본문 바로가기

iOS/Deep dive!!!

[iOS] Dynamic view를 추가하는 상황에서 layoutIfNeeded() 활용 및 개념 완벽 뿌수기 +_+ | UIView life cycle

 

안녕하세요.

 

이번 포스트는 UIView와 AutoLayout이라면 빠질 수 없는 layoutIfNeeded()의 개념과 언제, 왜 사용해야 하는지 등을 정리하려고 합니다. 또한 커스텀 뷰 내부에 특정 subview를 동적으로 추가 후 화면에 render 해야할 때, subview's layoutIfNeeded와 super view's layoutIfNeeded 중 무엇을 호출해야 하는지에 대해 간단한 프로젝트를 통해 다양하게 탐구하며 알게된 개념을 소개하려고 합니다.

 

기본적으로 main run loop와 update cycle 관련해서 설명하는 해외 포스트(관련 링크)를 우선 꼭 보셔야 좋습니다.



 

지금부터 버튼을 클릭하면 동적으로 애니메이션과 함께 고양이 사진이 나온 후에 간단한 레이블이 아래로 보여지는 프로젝트를 통해 layoutIfNeeded를 탐구할 것입니다. ( 프로젝트 링크 바로가기 )

 

 

LayoutIfNeeded() 기본 개념 부수기!!

layoutIfNeeded는 무엇이고 언제 쓰면 좋을까요? 그 전에 잠깐 알아야 할 개념은 main run loop와 update cycle입니다.

https://tech.gc.com/demystifying-ios-layout/

 

화면 구현을 위해 View를 추가하는 코드와 Auto layout 또는 Frame 등으로 위치, 크기를 지정하는 코드 작성 후 런타임 때 해당 코드들이 메인 스레드에서 실행된다면 화면에 보여지기까지 크게 두 가지 파트로 구분할 수 있습니다.

 

 

첫 번째 파트는 아래 [영상1] 처럼 메인 스레드(main run loop!)가 특정 뷰를 상위 뷰에 추가하는 코드를 실행해야 하고, Auto layout이나 frame 등으로 화면에 보여질 위치를 지정하는 코드들을 한 라인씩 수행해 나가는 것입니다.

 

[영상1]

 

 

(cf. 이때 [영상1] 처럼 메인스레드가 뷰를 상위 뷰에 추가 및 레이아웃 잡는 코드등을 실행할 때 화면에 바로 보여지지 않습니다. [영상1] 에서 27~ 33라인을 차례대로 실행했지만, 화면에 보여지지 않는다는 의미입니다.)

 

두 번째 파트는 메인 스레드(Thread1)에서 실행한 UI 관련 코드들을 기억했다가 main run loop 시점이 지나고 Update cycle! 시점이 될 때 기억해둔 UI관련 코드 정보들을 바탕으로 View 컴포넌트들을 Constraints(제약), Layout(배치), Render(화면에 display) 단계를 통해 화면에 보여집니다.

 

이때 Layout 단계의 layoutSubviews는 특정 뷰를 기준으로 top->down(top: superView)으로 호출됩니다. 이 시점에 각각의 뷰의 frame이 계산된 사각형과 함께 업데이트 됩니다. 즉 특정 뷰와 해당 뷰의 subviews의 위치와 크기를 재조정합니다. 그리고 이는 직접적인 호출을 권장하지 않습니다.

 

layoutIfNeeded()을 통해 지금 당장 Update cycle을 할 수 있고 아니면 setNeedsLayout()을 통해 next update cycle에서 layooutSubviews를 간접적으로 호출할 수 있습니다.

 

Main thread에서 특정한 View를 상위뷰에 추가하고 포지션까지 지정하는 code scope를 수행했을 때 암묵적으로 Update cycle 시점에 도달해야 화면에 새로운 특정한 View가 보여집니다. 그러나 만약 [영상1]에서 메인스레드가 27 ~ 33라인까지 실행을 했을 당시(뷰 관련 생성, 크기 및 위치 지정 코드 수행됬을 때) 바로 화면에 보여주기 위해 Update cycle의 특정 시점을 강제로 수행하는 여러 방법이 있습니다.

Update cycle을 즉시 호출하는 여러 함수 중 지금과 같은 상황에 호출해야할 함수는 layoutIfNeeded()입니다.

 

https://developer.apple.com/documentation/uikit/uiview/1622507-layoutifneeded

 

[영상1]에서 27~33라인의 코드가 실행됬을 때, Update cycle의 3 단계에서 수행해야 할 뷰들의 업데이트 사항들이 flag를 통해 update pending됩니다. 아직 Update cycle이 다가오지 않았기 때문입니다. 그러나 33번라인 바로 하단에 view.layoutIfNeeded()를 호출하게 된다면 Update cycle을 강제로 수행해서 화면에 보여줄 수 있습니다.

 

setNeedsLayout()는 flag를 통해 pending된 뷰들을 다음 update cycle에 수행할 수 있게해주는 함수입니다. 

 


layoutIfNeeded를 탐구하기 전에 앞에 언급한 프로젝트를 간단히 소개하겠습니다.

 

화면에서 "show card view" 버튼을 누를때 버튼의 액션 메서드에서 showCardView()를 호출합니다.

 

 

 

이때 RotationCardView를 생성합니다. RotationCardView는 이미지 컨텐츠 layer, label을 subview로 갖고 있습니다.

 

final class RotationCardView() {
  ...
  override var intrinsicContentSize: CGSize {
    return CGSize(width: 200, height: 200)
  }
}

 

 

이때 레이아웃을 더 쉽게 잡기 위해 디폴트로 intrinsicContentSize를 지정했습니다.

 

class ViewController: UIViewController {
  ...
  func showCardView() {
    // 1.
    let dynamicCardView = RotationCardView()
    view.addSubview(dynamicCardView)
    bottomConstraint = dynamicCardView.bottomAnchor.constraint(
      equalTo: view.safeAreaLayoutGuide.bottomAnchor,
      constant: dynamicCardView.intrinsicContentSize.height + 50)
    NSLayoutConstraint.activate([
      dynamicCardView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      bottomConstraint])
    dynamicCardView.setImageLayer(with: .init(x: 0, y: 0, width: 200, height: 200))
    // 2.
    UIView.animate(
      withDuration: 0.8,
      delay: 0,
      usingSpringWithDamping: 0.4,
      initialSpringVelocity: 0,
      animations: {
        // 3.
        self.bottomConstraint.constant = -50
        // 4.
        self.view.layoutIfNeeded()
      }, completion: { _ in
        dynamicCardView.setGradientLabel()
      })
  }
}

 

showCardView()가 호출될 때 dynamicCardView가 화면의 아래에 있다가 위로 올라오도록 하는 코드를 AutoLayout으로 위치를 정하고 애니메이션을 통해 아래서 위로 보여지는 동적인 화면을 구현했습니다. 위 코드는 실제 프로젝트의 showCardView() 내부 코드와 살짝 다릅니다. 프로젝트에서 showCardView() 함수 내부 구현된 코드를 주석 처리 후 이 코드를 붙여넣어주세요. 이를 기반으로 구현해나갈것입니다.

 

animations 클로저에서 bottomConstraint.constant = -50을 통해 화면위로 올라오도록 constant를 변경한 후에 view.layoutIfNeeded()를 해야 view의 safeareaLayoutGuide.bottomAnchor를 기준으로 250 -> -50으로 변경되는 Layout 단계가 즉시 실행되는 Update cycle에서 수행될 것이기 때문입니다.

 

실행 결과 화면

 

하지만 위에서 작성한 코드를 기반으로 showCardView() 함수를 호출하게 된다면 dynamicCardView의 애니메이션 초기 위치는 위 코드 주석 1에서 지정한 bottomAnchor 위치와 다르게 됩니다.

 

여기서 알 수 있는 중요한 정보는 showCardView()함수 내부 scope를 main thread에서 순차적으로 실행할 때, Update cycle이 수행되지 않았다는 점입니다. 카드뷰를 생성해서 메모리에 로드했지만, 여전히 화면에 그려지는 3단계를 거치지 않은 채로 애니메이션을 수행하기 때문에 기본 표준 값인 (0, 0) 에서 실행되는 것입니다.

 

그래서 애니메이션이 수행되지 전에 dynamicCardView의 constraints와 layout 및 draw를 하는 Update cycle을 즉시 수행해서 좌표를 미리 지정해두어야 합니다.

 

class ViewController: UIViewController {
  ...
  func showCardView() {
    // 1.
    let dynamicCardView = RotationCardView()
    view.addSubview(dynamicCardView)
    bottomConstraint = dynamicCardView.bottomAnchor.constraint(
      equalTo: view.safeAreaLayoutGuide.bottomAnchor,
      constant: dynamicCardView.intrinsicContentSize.height + 50)
    NSLayoutConstraint.activate([
      dynamicCardView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      bottomConstraint])
    dynamicCardView.setImageLayer(with: .init(x: 0, y: 0, width: 200, height: 200))
    view.layoutIfNeeded() // 즉시 Update cycle을 통해 dynamicCardView의 크기 및 위치 등 지정.
    // 2.
    ...
  } 
}

 

 

 

 

여기서 한 가지 퀴즈!가 있습니다.

 

dynamicCardView.setImageLayer(with:)는 이미지 레이어의 frame 크기를 지정해주는 함수입니다. RotationCardView는 intrinsicContentSize를 width 200, height 200으로 오버라이딩했습니다. 이때 view.layoutIfNeeded()함수 위에 ImageLayer 프레임을 지정해주는 setImageLayer(with:) 매개변수에 dynamicCardView.bounds를 대입하면 어떻게 될까요?

 

class ViewController: UIViewController {
  ...
  func showCardView() {
    // 1.
    ...
    dynamicCardView.setImageLayer(with: dynamicCardView.bounds)
    view.layoutIfNeeded() // 즉시 Update cycle을 통해 dynamicCardView의 크기 및 위치 등 지정.
    // 2.
    ...
  } 
}

 

네! 정상 동작됩니다. dynamicCardView의 constraints를 추가 후 active로 지정했어도 bounds는 .zero입니다. 그 이유는 Update cycle 시점이 오지 않아 Constraints, Layout, Render가 되지 않았기 때문입니다.

그래서 정상 동작되지 않습니다. 특히 layout의 컬백 메서드로 layoutSubviews를 불러서 특정 super view부터 subview들까지 호출완료 된 이후에 bounds에서 .zero가 아닌 intrinsicContentSize에 의해 지정된 크기를 사용할 수 있습니다.

 

class ViewController: UIViewController {
  ...
  func showCardView() {
    // 1.
    ...
    view.layoutIfNeeded() // 즉시 Update cycle을 통해 dynamicCardView의 크기 및 위치 등 지정.
    dynamicCardView.setImageLayer(with: dynamicCardView.bounds) // bounds 크기 200, 200.
    // 2.
    ...
  } 
}

 

이때 view.layoutIfNeeded()가 아니라 view.setNeedsLayout()를 한다면 아래의 dynamicCardView.bounds는 마찬가지로 .zero가 됩니다. 아직 다가올 Update cycle 호출 X.

 

 

여기까지 나온 개념을 간단히 정리했습니다.

  1. 동적으로 뷰를 추가하는 상황에서, 애니메이션을 통해 화면을 보여주기 위해서는 애니메이션 전에 특정 뷰.layoutIfNeeded()를 해야 합니다.
  2. main run loop 시점에 UI 상위뷰에 등록 및 레이아웃 추가하는 코드 실행된 직후 시점에서 layoutIfNeeded()를 호출하지 않는다면 크기, 위치도 지정되지 않아 화면에 보여지지 않습니다.
  3. AutoLayout에 의해 특정 뷰의 width, height를 지정했을 때 또는 intrinsicContentSize에 의해 초기 bounds를 얻기 위해서는 Update Cycle 특히 layoutSubviews를 컬백 메서드로 호출하는 Layout 단계를 반드시 거쳐야만 합니다.

 

그래서 layoutIfNeeded()를 호출하는 것은 정말 중요합니다. 그러나 이를 호출하는 뷰를 기준으로 자식뷰들의 layoutSubviews를 호출할 가능성이 있기에 부하가 큰 메서드입니다. 만약 layoutIfNeeded()를 호출했는데 Update cycle에서 호출할 layout이 없다면? 레이아웃 관련 컬백 메서드를 호출하지 않습니다. 헉,,,, 그래서 정말로 layout if needed 인 것입니다.

 

 

근데 여기서 한가지 의문점이 있었습니다.

 

layoutIfNeeded()는 UIView의 instance method입니다. 그렇다는 것은 layoutIfNeeded를 subview에서 호출할 수도 있고 superview에서 호출할 수 있습니다.

특정 뷰의 업데이트가 필요할 때 super view or subview의 layoutIfNeeded.

showCardView에서 고양이 이미지가 올라오는 애니메이션 이후 completion 시점에 태그 레이블을 추가로 설정합니다. 이젠 아래의 코드를 봐야합니다. 이 내부 스코프 역시 RotationCardView가 view에 추가될 때와 동일한 로직을 갖고 있습니다.

 

final class RotationCardView: UIView {
  ...
  func setGradientLabel() {
    let gradientLabel = GradientAnimatedLabel()
    let minimumTopSpacing = 5.0
    gradientLabel.text = "# 귀여운 고양이 카드"
    gradientLabel.alpha = 0
    addSubview(gradientLabel)
    let gradientLabelBottomConstraint = gradientLabel.topAnchor.constraint(
      equalTo: bottomAnchor,
      constant: minimumTopSpacing - gradientLabel.intrinsicContentSize.height/2)
    NSLayoutConstraint.activate([
      gradientLabelBottomConstraint,
      gradientLabel.trailingAnchor.constraint(equalTo: trailingAnchor)])
    // 1.
    layoutIfNeeded()
    UIView.animate(withDuration: 0.2, animations: {
      gradientLabelBottomConstraint.constant = minimumTopSpacing
      gradientLabel.alpha = 1
      // 2.
      self.layoutIfNeeded()
    })
  }
}

 

setGradientLabel()에서 gardientLabel을 생성 후에 RotationCardView에 등록했다면 self.layoutIfNeeded() 호출 vs gradientLabel.layoutIfNeeded()를 고민할 수 있습니다. 

 

layoutIfNeeded()의 설명을 살펴봐야 합니다.

https://developer.apple.com/documentation/uikit/uiview/1622507-layoutifneeded

 

layoutIfNeeded는 이를 호출해서 receive로 사용한 뷰를 rootView로 합니다. 그리고 이 뷰에서 서브 트리에 따라 뷰들을 lay out합니다. rootView의 constraints에 의해 오토레이아웃 앤진이 계산한 크기등을 기반으로 자식들의 크기, 위치를 정합니다.

 

그래서 주석1 아래의 코드를 gradientLabel.layoutIfNeeded()로 변경해 호출한다는 것은, gradientLabel.constraints 에 등록된 오토 레이아웃을 기반으로 하위뷰들의 크기들을 지정한다는 것을 의미할 수 있습니다. 근데 gradientLabel의 constraints는 아무런 등록된 제약이 없습니다. gradientLabel의 자식뷰들도 없기 때문입니다. 

 

 

그럼 이렇게 결과가 나옵니다. 즉, 위에서 gradientLabelBottomConstraint로 제약했던, bottomAnchor(이미지 하단) 기준 적용이 되지 않습니다.

 

결국 setGradientLabel()에서 gradientLabel, gradientLabelBottomConstraint를 곧바로 적용시키려면 위 코드에서 1. 주석 아래의 코드는 self.layoutIfNeeded()를 호출 해야합니다.

 

 

그래야 gradientLabel의 V(Vertical - 11) 및 trailing equal 제약조건이 곧바로 Update cycle에 적용됩니다. 물론 RotationCardView 인스턴스 self가 아니라 이 인스턴스의 상위뷰인 super.layoutIfNeeded()를 호출해도 됩니다.

 

 

 

func setGradientLabel() {
    let gradientLabel = GradientAnimatedLabel()
    let minimumTopSpacing = 5.0
    gradientLabel.text = "# 귀여운 고양이 카드"
    gradientLabel.alpha = 0
    addSubview(gradientLabel)
    let gradientLabelBottomConstraint = gradientLabel.topAnchor.constraint(
      equalTo: bottomAnchor,
      constant: minimumTopSpacing - gradientLabel.intrinsicContentSize.height/2)
    var gradientWidthConstraint = gradientLabel.widthAnchor.constraint(equalToConstant: 250) // 코드 추가.
    NSLayoutConstraint.activate([
      gradientLabelBottomConstraint,
      gradientLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
      gradientWidthConstraint // 코드 추가.])
    gradientLabel.layoutIfNeeded() // 추가. width 길이 gradientLabel에 반영 완료.
    //self.layoutIfNeeded()
    UIView.animate(withDuration: 0.2, animations: {
      gradientLabelBottomConstraint.constant = minimumTopSpacing
      gradientLabel.alpha = 1
      gradientWidthConstraint.constant = 170 // 코드 추가.
      self.layoutIfNeeded()
    })
  }

 

위와 같이 새롭게 코드들을 추가했습니다. gradientLabel의 자식 뷰는 없지만, 오토레이아웃으로 gradientLabel의 크기를 지정한다면, self.constraints에 제약이 추가되는게 아니라 gradientLabel.cosntraints에 제약이 추가됩니다.

 

 

그리고 gradientLabel.layoutIfNeeded()를 한다는 것은, 자기 자신부터 서브트리에 따라 lay out하며 뷰들의 크기를 반영합니다.

 

느리게 실행

 

이 코드를 실행하면, 위 영상처럼 gradientLabel.layoutIfNeeded를 통해 바로 반영된 width 크기가 반영되는 것을 볼 수 있습니다.

그러나 gradientLabelBottomConstraint 및 gradientLabel.trailing의 제약조건은 반영되지 않기에, 애니메이션 시점에선 슈퍼뷰 기준 0,0의 point를 갖게됩니다. 그래서 연이어 self.layoutIfNeeded()를 호출해야 합니다. 이는 예시를 위해 보여드린 것입니다.

 

이를 통해 활용할 수 있는 것은 스택 뷰를 떠올릴 수 있습니다. 스택뷰에 add된 arranged subviews는 따로 제약조건을 지정하지 않습니다. 그러나 자기 자신에 대한 constraints를 지정하면 그 크기가 스택뷰 내에서 반영될 수 있습니다. 이때 특정 subview.layoutIfNeeded()를 통해 원하는 높이를 바로 준 후 동적으로 스택뷰에 추가하면 또 괜찮을 것 같습니다.

 

 

혹시 잘못된 개념이나 내용이 있을 경우 댓글로 알려주신다면 감사합니다.

 

 

References:

Demystifying iOS Layout 해외 포스트

위 내용 번역해주신 MJ Studio님 포스트

Deferred Layout Pass 애플 공식문서

setNeedsLayout vs setNeedsUpdateConstriants... 스택오버플로우

왜 UIVIew.animate() 내부 animations 클로저 로직에서 setNeedsUpdateConstraints가 필요하지 않지?... 스택 오버플로우

UIView auto layout life cycle 해외 포스트

gradientLayer 커스텀 애니메이션 포스트 바로가기