뷰가 화면에 그려지는 과정 - Constraints, Layout

코드로 버튼을 생성하고 cornerRadius를 적용해 둥글게 만드려고 했는데, 적용되지 않았던 이슈가 있었다. 내가 알고 있던 것은 cornerRadius가 적용 안 되는 이유는 레이아웃이 아직 완전히 잡히질 않아서인데 ViewDidAppear에서 버튼을 add 하고, cornerRadius 세팅하는 함수를 호출하고 있어서 레이아웃이 아직 잡히질 않았을 리가 없어서 의아해서 해결 방법을 찾아보니, 레이아웃이 모두 잡혔을 때에서 호출되는

viewDidLayoutSubviews()에 호출하니 정상적으로 적용 되었다. ViewDidAppear일 때도 레이아웃이 모두 잡혀있을 텐데 왜 적용이 안될까 라는 궁금증이 생겨, 뷰가 화면에 그려지는 과정에 대해 알아보았다.

 

뷰가 화면에 그려지는 과정은 크게 3가지로 나뉜다.

  • Constraints
  • Layout
  • Draw

Constraints

뷰의 제약조건에 관한 작업을 담당

updateConstraints

뷰의 제약조건을 업데이트 합니다. 상위뷰와 하위뷰가 있을 때, 하위뷰의 updateConstraints부터 호출됩니다. 해당 함수를 직접적으로 호출하면 안 됩니다. 뷰의 제약조건을 업데이트하려면 setNeedsUpdateConstraints()를 호출 해 다음 루프 때, 업데이트가 되도록 예약을 할 수 있다.

 

setNeedsUpdateConstraints()

해당 함수를 호출하면, 다음 루프에서 제약조건을 업데이트한다. 제약 조건 변경이 여러 개가 있을 경우, 다음 루프 때 모두 한꺼번에 업데이트한다. 

붉은 색이 FirstView, 파란색이 SecondView

class FirstView: UIView {
    
    override func updateConstraints() {
        print("updateConstraints FirstView")
        super.updateConstraints()
    }
}
class SecondView: UIView {
    
    override func updateConstraints() {
        print("updateConstraints SecondView")
        super.updateConstraints()
    }
}

하위 뷰의 updateConstraints가 먼저 호출된다.

intrinsicContentSize

뷰가 내부적으로 가지고 있는 콘텐츠의 크기를 반환하는 프로퍼티이다. 예시로 UILabel은 내부적으로 텍스트를 그리기 위한 paragraph가 존재한다. 이 paragraph는 폰트의 크기에 따라 달라지고, UILabel 역시 pagraph의 크기에 따라 크기가 달라진다. UILabel은 이에 따라 뷰의 크기가 달라지며, 이때 intrinsicContentSize를 사용한다. 

updateViewConstraints

UIView의 메소드가 아닌 뷰컨트롤러의 메서드이다. 뷰 컨트롤러의 뷰가 제약조건을 업데이트하려고 할 때 호출된다. Constraint는 하위뷰의 constraint가 먼저 업데이트되므로, 마지막에 가장 상위 뷰인 뷰컨트롤러의 뷰가 호출이 된다.

뷰 컨트롤러에서 오버로딩해서 실행되는것을 찍어보면 FirstView의 updateConstraints가 호출된 후 호출된 것을 볼 수 있다.

Layout

Layout은 이전 Constraint 단계에서 업데이트 된 제약조건을 이용해 뷰의 크기와 위치를 결정한다.

viewWillLayoutSubViews()

뷰컨트롤러의 뷰가 하위뷰(자식뷰)를 레이아웃 하려고 할 때 호출된다. 레이아웃이 시작되기 전에 호출되는 함수. 뷰의 레이아웃이 변경될 경우에도 호출된다.

layoutSubViews()

UIView의 하위뷰(자식뷰)의 레이아웃을 업데이트 하기 위해 호출된다. 하위뷰의 위치, 크기등을 계산한다. 해방 메서드는 상위에서 하위 순으로 호출된다. 이 메서드를 직접 호출하지 말아야 한다. 뷰의 레이아웃을 업데이트하려면 setNeedLayout()을 이용해 다음 루프 때 뷰의 레이아웃이 업데이트 되게 예약하거나, layoutIfNeeded()를 이용해 즉시 업데이트 할 수 있다. 

즉 자기 자신의 레이아웃을 업데이트 하는게 아닌, 하위뷰의 레이아웃을 업데이트한다! 그동안 애니메이션을 적용할 때, 왜 애니메이션이 적용되는 뷰의 layoutIfNeeded()를 호출하는 게 아니라 뷰컨트롤러의 뷰의 layoutIfNeeded()를 호출했는지 이해가 간다.

하위뷰의 autoResizing이나 제약조건을 기반으로한 레이아웃이 원하는 대로 동작하지 않는 경우에만 이 메서드를 재정의 해야 한다. CornerRadius를 설정하는 코드 같은 경우는 제약조건이나 autoResizing 기반이 아니라 CALayer이 기반이므로 신중하게 오버라이딩 해야 한다. layoutSubViews는 자신의 하위 뷰의 레이아웃을 잡는것이지, 하위뷰의 하위뷰의 layoutSubViews()가 호출되어 하위뷰의 하위뷰의 레이아웃을 잡힌것이 아니다.

 

대표적인 예시로 

https://www.inflearn.com/questions/822857/cornerradius%EA%B0%80-%EC%A0%81%EC%9A%A9%EC%9D%B4-%EC%95%88%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C%EA%B0%80-%EC%9E%88%EC%96%B4-%EC%A7%88%EB%AC%B8%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4

 

cornerRadius가 적용이 안되는 문제가 있어 질문드립니다. - 인프런 | 질문 & 답변

- 학습 관련 질문을 남겨주세요. 상세히 작성하면 더 좋아요! - 먼저 유사한 질문이 있었는지 검색해보세요. - 서로 예의를 지키며 존중하는 문화를 만들어가요. - 잠깐! 인프런 서비스 운영 관련

www.inflearn.com

해당 링크에서 커스텀 뷰의 layoutSubViews()를 오버라이딩 할 때, super.layoutSubViews()이후, 버튼의 cornerRadius를 설정하는 코드를 실행했지만 적용되지 않았는데, 그 이유는 superLayoutSubViews()가 실행된것은 해당 뷰의 하위뷰인 colorButtonStackView, backgroundView, save Button의 레이아웃이 잡힌것이지 colorButtonStackView의 하위뷰인 버튼들의 레이아웃이 잡힌것이 아니다. 

 

viewDidLayoutSubviews()

뷰컨트롤러의 뷰의 bound가 변경 되었을때, 뷰는 하위뷰의 position을 조정하고, 해당 메서드를 호출한다. 여기서 다음줄에 헷갈리는 문장이 있다. 그러나, 이 메서드가 호출됐다고 해서 하위뷰의 레이아웃이 조정된 게 아니다(??). 레이아웃을 조정하는 책임은 각 하위 뷰에 있다. 그러면 이 함수가 호출된 시점에서 레이아웃이 아직 안 잡혔을 수 있다는 말인가? 하지만 해당 함수의 한 줄 설명에는 하위뷰를 배치했을 때 호출되는 함수라 나와있다.

 

좀 더 알아보니, layoutSubViews()를 호출하는 동안, 다른 코드에 의해 뷰의 레이아웃이 변경 될 수 있다. 즉 이번 루프에서 잡힌 view의 레이아웃은 적용이 되지만, 어떤 코드로 인해, layoutSubViews가 호출되는 동안 변경된 레이아웃에 관해서는 적용되지 않기에, 이 메서드가 호출됐다고 해서 하위뷰의 레이아웃이 조정된 게 아니다는 말이 나온 게 아닐까 생각된다.

 

일단 코드로 각 뷰의 layoutSubViews와 viewDidLayoutSubViews등을 오버라이딩해 프린트 문을 찍어서 어떤 순서로 호출되는지 알아보았다.

override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func updateViewConstraints() {
        print("updateViewConstraints")
        super.updateViewConstraints()
    }
    
    override func viewWillLayoutSubviews() {
        print("viewWillLayoutSubViews")
        super.viewWillLayoutSubviews()
    }
    
    override func viewDidLayoutSubviews() {
        print("viewDidLayoutSubviews")
        super.viewDidLayoutSubviews()
    }

실행 결과 viewDidLayoutSubviews에서 먼저 프린트가 찍혔고, 그 후에 해당 뷰컨트롤러의 하위뷰인 layoutSubview가 찍힌 것을 볼 수 있다. breakPoint를 걸어 디버깅을 해 보았지만, viewDidLayoutSubview부분에서 먼저 걸렸고, 그 후에 firstView, secondView 순으로 layoutSubviews가 호출되었다.

출력된 대로 하면, viewDidLayoutSubViews가 호출되는 시점에서는 뷰의 레이아웃이 잡힌 것을 보장해 주지 않는다는 것인데... 

 

이곳저곳에서 구글링을 해본 결과 viewDidLayoutSubViews는 뷰 컨트롤러의 뷰의 layoutSubviews가 완료된 시점에서 호출되는 함수가 맞다고 한다. 그런데 왜 저런 결과가 나오는지는 모르겠다. 구글링을 해봐도 안 나온다.  일단 패스하기로 했다.

 

그렇다면, 이제 내가 맞닥뜨린 문제를 해결해 보자.

 

let buttonStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.axis = .horizontal
        stackView.spacing = 40
        return stackView
    }()
    
    let closeButton: UIButton = {
        let button = UIButton()
        .
        .
        .
        return button
    }()
    
    let userTrackingButton: UIButton = {
        let button = UIButton()
        .
        .
        .
        return button
    }()
    
    let cameraButton: UIButton = {
        let button = UIButton()
        .
        .
        .
        return button
    }()
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        setButtonStackView()
        setButtonLayout()
    }
    
    private func setButtonStackView() {
        buttonStackView.addArrangedSubview(userTrackingButton)
        buttonStackView.addArrangedSubview(cameraButton)
        buttonStackView.addArrangedSubview(closeButton)
        
        view.addSubview(buttonStackView)
        NSLayoutConstraint.activate([
            buttonStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            buttonStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -60)
        ])
    }
    
  private func setButtonLayout() {
        userTrackingButton.layer.cornerRadius = userTrackingButton.frame.height/2
        closeButton.layer.cornerRadius = closeButton.frame.height/2
        cameraButton.layer.cornerRadius = cameraButton.frame.height/2
    }

viewDidAppear의 경우, 화면에 뷰가 그려지는 과정이 모두 끝난 후 호출된다. 즉 StackView와 button들은 ViewDidAppear에서 화면에 add될 때, 위치와 크기가 정의 되지 않은 상태다. 그런데, 위치와 크기가 정의 되지 않은 시점에서 frame을 이용해 cornerRadius를 설정하려고 하니, 값을 모르는 상태이니 적용되지 않는 상태인것.

 

해결 방법은 여러가지가 있다.

우선은 layoutIfNeeded()를 호출하는 것.

버튼들은 buttonStackView에 들어가 있다. buttonStackView 역시 viewDidAppear 이후에 view에 추가되었으므로, 아직 레이아웃이 잡히지 않았지만, buttonStackView.layoutIfNeeded()를 호출 할 시, buttonStackView에 대한 하위 뷰의 레이아웃이 잡히게 되므로, cornerRadius를 적용 할 수 있다.

private func setButtonLayout() {
        buttonStackView.layoutIfNeeded()
        userTrackingButton.layer.cornerRadius = userTrackingButton.frame.height/2
        closeButton.layer.cornerRadius = closeButton.frame.height/2
        cameraButton.layer.cornerRadius = cameraButton.frame.height/2
    }

두번째 방법으로는 viewDidLayoutSubviews()에서 해당 함수를 실행 시키는 것이다. viewDidAppear 이후에 뷰가 add 되면, 다음 루프때, 뷰를 그리는 과정을 반복하게 된다. 이때, viewDidLayoutSubview는 뷰컨틀로러의 뷰의 레이아웃부터 추가된 뷰의 레이아웃이 잡혔을 때 호출이 되므로, 해당 함수에서 cornerRadius를 적용하면, 버튼들의 레이아웃이 잡힌 시점에 호출될때가 있을 것이므로, 적용이 된다.

 override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        setButtonLayout()
    }

 

버튼의 cornerRadius가 안되는 문제에서 시작해서 꽤나 깊게 들어온것 같다. 그래도 어떤식으로 뷰가 그려지고, 어떤 타이밍에 함수가 호출되고, 어떤식으로 컨트롤 해야 할 지 알게 되어서 꽤나 유용한 공부가 되었다.

그리고 이건 여담인데, 공부할때, 챗지피티를 이용해보았는데, 틀린것을 가르쳐 주었다.

updateConstraints와 layoutSubViews의 뷰계층구조의 호출 순서를 물어봤는데, 거꾸로 알려주었다.

이걸 보면, 챗지피티에 완전히 의존하는것은 아직 이른게 아닐까...