[UIKit] - viewDidLayoutSubviews()로 뷰의 레이아웃을 안전하게 설정하기 (with. SafeAreaInsets)

Benedicto·2023년 9월 2일
0

iOS

목록 보기
7/30
post-thumbnail

Trouble-shooting 💥

아래의 UIView 확장을 통해 UIButton의 크기와 위치를 viewDidLoad()에서 frame 기반으로 설정하였을 때, UIButton이 잘못 그려지는 문제가 있었다.

import Foundation
import UIKit

// MARK: - Extension UIView
extension UIView {
    
    var width: CGFloat { return frame.size.width }
    var height: CGFloat { return frame.size.height }
    var left: CGFloat { return frame.origin.x }
    var right: CGFloat { return left + width }
    var top: CGFloat { return frame.origin.y }
    var bottom: CGFloat { return top + height }
}

// MARK: - ViewController
final class WelcomeViewController: UIViewController {

    private let signInButton: UIButton = { ...(생략)... }()
    
    override func viewDidLoad() {
        view.addSubview(signInButton)
        
        signInButton.frame = CGRect(x: 20, 
                                    y: view.height-50-view.safeAreaInsets.bottom, 
                                    width: view.width-40, 
                                    height: 50)
    }
}

버튼이 위와 같이 화면의 가장 아래에 밀착되어 노출되는 이유는 view.safeAreaInsets.bottom의 값을 가져오는 방식 때문이다.

  • Safe Area와 Safe Area Insets의 차이

ref. https://devmjun.github.io/archive/SafeArea_1


문제 원인

  • view.safeAreaInsets.bottom 값은 뷰컨트롤러의 viewDidLoad() 시점에서는 0으로 설정된다.

  • view.safeAreaInsets는 레이아웃이 완전히 설정된 후에 정확한 값을 갖게 된다.

  • 따라서 viewDidLoad()에서 UIButton의 frame을 설정할 때, view.safeAreaInsets.bottom이 0으로 처리되면서 버튼의 위치가 아래로 밀착된다.


UIViewController & UIView의 LifeCycle

다이어그램을 통해 safeAreaInsets 값이 viewDidLoad() 시점에서는 0인 이유를 알 수 있다.


safeAreaInsets 값이 설정되는 시점

  1. init()으로 뷰컨트롤러가 생성됨

  2. viewDidLoad()에 의해 뷰가 로드됨

    • 이 단계에서 뷰의 크기(view.frame)는 설정되지만, 레이아웃과 안전 영역(safe area)은 아직 반영되지 않는다.
    • safeAreaInsets 값은 기본적으로 0
  3. viewWillLayoutSubviews() → viewDidLayoutSubviews() 과정을 통해 뷰의 크기가 정해지고 레이아웃이 적용됨

    • 이 시점에서 safeAreaInsets 값이 제대로 반영된다.

즉, viewDidLoad()에서는 safeAreaInsets.bottom이 아직 0이므로,

y: view.height - 50 - view.safeAreaInsets.bottom

이 부분이 결국 view.height - 50 과 동일하게 계산되어 버튼이 화면 맨 아래로 밀려버리는 것이다.


조금 더 자세하게 설명하자면 아래와 같다.

1️⃣ viewDidLoad() 시점에는 safeAreaInsets 값이 유효하지 않음

  • viewDidLoad()는 말 그대로, 뷰가 메모리에 로드된 후 실행된다.
  • 그러나 이 시점에서는, 뷰의 크기만 설정되었을 뿐, 레이아웃이 아직 완료되지 않는다.
  • safeAreaInsets 값은 뷰가 화면에 추가되고 레이아웃이 완료된 후(viewDidLayoutSubviews()) 정확한 값이 반영된다.

2️⃣ viewDidLayoutSubviews()에서 레이아웃이 적용됨

  • viewWillLayoutSubviews()layoutSubviews()viewDidLayoutSubviews() 순서로 호출된다.
  • 이 시점에서 뷰의 크기와 안전 영역(safe area)이 반영되므로,
    viewDidLayoutSubviews()에서 safeAreaInsets.bottom을 올바르게 가져올 수 있다.

해결 방법

// MARK: - ViewController
final class WelcomeViewController: UIViewController {

    private let signInButton: UIButton = { ...(생략)... }()
    
    override func viewDidLoad() {
        view.addSubview(signInButton)
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        signInButton.frame = CGRect(x: 20, 
                                    y: view.height-50-view.safeAreaInsets.bottom, 
                                    width: view.width-40, 
                                    height: 50)
    }
}

Safe Area 영역을 가리키는 임의의 UIView (borderView)를 통해 viewDidLoad()viewDidLayoutSubviews()에서의 뷰컨트롤러의 view 속성의 위치 및 크기를 확인하면 아래와 같다.

override func viewDidLoad() {
    super.viewDidLoad()
        
    print("********** viewDidLoad() **********")
    
    view.backgroundColor = .systemGreen
    view.addSubview(signInButton)
    view.addSubview(borderView)
    
    NSLayoutConstraint.activate([
    //	...(borderView 제약조건 생략)...
    ])
        
    /// `view`
    print("width: \(view.width)")
    print("height: \(view.height)")
    print("left: \(view.left)")
    print("right: \(view.right)")
    print("top: \(view.top)")
    print("bottom: \(view.bottom) \n")
    
    /// `view.safeAreaInsets`
    print("top: \(view.safeAreaInsets.top)")
    print("bottom: \(view.safeAreaInsets.bottom)")
    print("left: \(view.safeAreaInsets.left)")
    print("right: \(view.safeAreaInsets.right)")
}

viewDidLoad()에서는 view의 safeAreaInsets 값이 모두 0임을 확인

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    print("********** viewDidLayoutSubviews() **********")
        
    /// `view.safeAreaInsets`
    print("top: \(view.safeAreaInsets.top)")
    print("bottom: \(view.safeAreaInsets.bottom)")
    print("left: \(view.safeAreaInsets.left)")
    print("right: \(view.safeAreaInsets.right)")
    
    //  borderView 자기 자신이 시작되는 y좌표의 위치 == safeArea가 시작되는 위치
    print("borderView의 상단 y좌표: \(borderView.frame.origin.y)")
    
    // == borderView.height
    print("borderView의 전체 높이 (Height): \(borderView.safeAreaLayoutGuide.layoutFrame.maxY)")
    
    //  view (즉, 슈퍼뷰)가 끝나는 지점의 y좌표로부터 borderView가 끝나는 지점의 y좌표를 뺀 값은
    //	safeAreaInsets의 bottom 값과 같음
    print("borderView의 하단 y좌표: \(view.frame.maxY - borderView.frame.maxY)")
    
    signInButton.frame = CGRect(x: 20,
                                y: view.height-50-view.safeAreaInsets.bottom,
                                width: view.width-40,
                                height: 50)
}

viewDidLayoutSubviews()에서는 safeAreaInsets의 값이 설정되고, borderView의 y좌표를 통해 safe Area의 위치를 확인

profile
 Developer

0개의 댓글