[iOS] - Layout Update


``


iOS 앱의 메인 run loop

iOS 앱의 메인 run loop은 유저에게서 input 이벤트를 받고, 적절한 응답을 해주는 것을 담당한다. 사용자가 만든 상호작용은 event 큐에 추가된다. 아래 그림에서처럼, Application object는 event 큐에서 이벤트를 하나씩 꺼내 애플리케이션의 다른 객체들에게 전달한다. Application object는 본질적으로 이 input event를 해석해서 그에 대응되는 core object안에 있는 handler를 호출한다. 이 메서드들이 반환되면 다시 컨트롤은 main run loop으로 돌아가서 Update Cycle이 시작된다. Update Cycle은 View들을 배치하고 다시 그리는 역할을 한다.

image

Update Cycle

Update Cycle은 위에서 언급했듯이 애플리케이션이 이벤트 핸들링 코드를 수행하고 다시 main run loop으로 컨트롤을 반환할 때 수행된다. 여기서 시스템은 View들을 배치하고(layout), 출력하고(display), 제약한다.(constraint) 만약 이벤트 핸들러를 처리하는 과정에서 어떤 View에 변화를 준다면, 이 View는 다시 그려져야(redraw) 한다고 표시된다. 이렇게 표시된 View들은 다음 Update cycle에서 시스템이 변화시킨다.

사용자의 input에 대응되는 event handler를 처리하고 view들을 배치, 출력, 제약한다고 했는데, 이 사용자의 input이 발생한 시점과 레이아웃이 변하는 시간의 갭은 사용자가 인지하지 못할 정도로 짧아야 한다. iOS 앱은 초당 60프레임을 보여주기 때문에, 한 번의 update cycle은 1/60밖에 안 걸린다. 이만큼 빨리 업데이트가 되기 때문에 사용자가 상호작용과 UI의 변화의 시간 차이를 느끼지 못하는 것이다. 하지만, 사용자가 체감을 못하는 것과는 별개로 이벤트가 실제로 처리되는 시점과 View가 다시 그려지는 시점이 차이가 있기 때문에, View는 우리가 View를 업데이트 하기 원하는 run loop의 특정 시점에 덥데이트가 되지 않을 수 있다.

만약 View의 마지막 Layout이나 Content에 기반한 연산을 가지고 있다면, 뷰의 이전 정보를 가지고 연산해야 하는 위험을 가지게 된다. Run loop, update cycle, UIView의 메서드를 이해하는 것이 이런 이슈를 피하거나 디버깅하는데 도움을 줄 것이다.

아래 그림은 update cycle이 run loop의 마지막에 일어나는 것을 표현한 것이다.

image

Layout

UIView의 Layout이란 것은 화면에서 UIView의 크기와 위치를 의미한다. 모든 View는 frame을 가지고 있고, 이는 부모 뷰의 Coordinate System(좌표계)에서 어디에 위치하고, 얼마나 크기를 차지하는지를 나타낸다. UIView는 시스템에게 UIView의 레이아웃이 변했다고 알려주는 메서드, View의 레이아웃이 다시 계산되는 시점에 특정 작업을 실행할 수 있게 오버라이딩할 수 있는 콜백 메서드도 제공한다.

layoutSubviews()

layoutSubviews()는 UIView의 메서드로, View와 자식 View들의 위치와 크기를 재조정한다. 이 메서드는 현재 view와 모든 subview에 위치와 크기를 설정한다. 이 메서드는 재귀적으로 모든 자식 뷰의 layoutSubviews()를 호출해야 하므로 실행할 때 부하가 큰 메서드이다. 시스템은 뷰의 frame을 다시 계산해야 할 때 layoutSubviews를 호출하기 때문에 layoutSubviews를 오버라이딩해서 frame이나 위치, 크기를 조절할 수 있다.

하지만 레이아웃을 업데이트해야 할 때 layoutSubviews()를 직접 호출하는 것은 금지되어 있다. 대신 run loop의 다른 시점에서 layoutSubviews()를 시스템이 호출하도록 유도할 수 있는 여러 방법이 있고, 이는 layoutSubviews()자체를 호출하는 것보다 부하가 적다.

layoutSubviews()가 완료될 때, viewDidLayoutSubviews가 view를 소유한 ViewController에서 호출된다. layoutSubviews()는 view의 layout이 변했다는 유일한 콜백이기 때문에, 레이아웃의 크기나 위치와 연관된 로직을 viewDidLoadviewDidAppear가 아닌 viewDidLayoutSubviews에 호출해야 한다. 이는 오래된 레이아웃이나 위치 변수를 다른 계산에 사용하는 실수를 막는 유일한 방법이다.

Automatic refresh triggers

아래 이벤트들은 자동으로 view의 레이아웃이 변했다는 마크를 자동으로 해줘서 개발자가 직접 할 필요 없이 다음에 layoutSubviews()가 호출되도록 한다.

  • View를 resizing 할 때
  • Subview 추가할 때
  • UIScrollView를 스크롤할 때. (layoutSubviewsUIScrollView와 그 부모뷰에서 호출된다.)
  • 사용자가 기기를 회전시킬 때
  • View의 constraint를 업데이트 할 때

위 이벤트들은 시스템에게 view의 포지션이 다시 계산되어야 한다는 것을 알려주고, 이는 결과적으로 layoutSubviews 호출로 자동으로 이어진다. 하지만, layoutSubviews를 직접 호출하는 방법도 있다.

setNeedsLayout()

layoutSubviews를 가장 적은 부하로 호출할 수 있는 메서드는 setNeedsLayout 메서드다. setNeedsLayout은 시스템에게 view의 레이아웃이 다시 계산되어야 한다고 알려준다. setNeedsLayout은 즉시 반환되고, 실제로 view를 업데이트해주는 것은 아니다. 대신, 다음 update cycle에서 시스템이 이 뷰에서 layoutSubviews를 호출하고 자식 뷰들에서도 layoutSubviews를 호출할 때 view들을 업데이트 한다. 이는 시각적으로 아무런 영향을 끼치지 않는다. setNeedsLayout이 호출되는 시점과 view가 다시 그려지는 시점이 완전히 일치하지는 않지만, 사용자가 인지할 수 없을 만큼 차이가 적기 때문이다.

layoutIfNeeded()

layoutIfNeeded는 view가 layoutSubviews를 호출하게 하는 또 다른 명시적인 메서드다. layoutIfNeededlayoutSubviews가 다음 update cycle에서 호출되게 하는 것이 아니라, 만약 view의 layout이 재조정되어야 한다면 즉시 layoutSubviews를 호출해버린다. 만약 setNeedsLayout을 호출한 직후나 자동으로 layoutSubviews를 호출하는 방법들을 하고 난 직후에 layoutIfNeeded를 호출했다면, layoutSubviews는 즉시 호출된다. 하지만 layoutIfNeeded를 호출했는데 view가 재조정되어야 하는 이유가 없다면, layoutSubviews는 호출되지 않는다. 만약 동일한 run loop에서 레이아웃 업데이트 없이 layoutIfNeeded를 두 번 호출했다면, 두 번째 호출은 layoutSubviews를 호출하지 않을 것이다.

layoutIfNeeded를 사용한다면, setNeedsLayout과는 다르게 자식 뷰들을 배치하고 다시 그리는 것은 즉시 실행될 것이고, 이 메서드가 반환되기 전에 끝난다.(애니메이션이 있는 상황을 제외하고) 이 메서드는 내가 새로운 레이아웃에 의존해야 하고 뷰들이 다음 업데이트 사이클에 업데이트 되기까지 기다릴 수 없을 때 유용하다. 하지만, 이런 경우가 아니라면 setNeedsLayout을 대신 호출하고 다음 업데이트 사이클까지 기다려서 한 run loop당 한 번 뷰를 업데이트 하도록 해야 한다.

이 메서드는 constraint의 변화를 애니메이션을 보여줄 때 특히 유용하다. 애니메이션 블럭의 시작 전에 layoutIfNeeded를 호출해서 모든 레이아웃 업데이트가 애니메이션 시작 전에 진행된 것을 보장해야 한다. 새로운 constraint를 구성하고, 애니메이션 블럭안에서 layoutIfNeeded를 다시 호출해서 새로운 상태로 애니메이션 되는 것을 보여준다.

Display

Layout이 뷰의 위치와 크기를 의미한다면, Display는 view나 subview의 사이즈나 포지셔닝을 포함하지 않는 프로퍼티들을 포함한다. 색, 텍스트, 이미지, Core Graphics가 여기 해당된다. Display는 Layout이 update를 하기 위해 호출하는 메서드와 비슷한 메서드들을 호출한다. 모두 변화가 감지되었을 때 시스템에 의해 호출되는 메서드, 우리가 임의로 새로고침하게 위해 호출하는 것들이 있다.

draw(_:)

draw는 layout 업데이트 과정에서 layoutSubviews와 같은 역할을 한다. 하지만 draw는 자식 view들의 draw까지 호출해주지는 않는다. layoutSubviews와 마찬가지로 draw를 직접 호출하는 것은 좋지 않다.

setNeedsDisplay()

setNeedsLayout과 유사하다. View의 content가 업데이트 되게 하는 내부 플래그를 활성화시키고 실제로 view가 다시 그려지기 전에 메서드가 반환된다. 이러면 다음 update cycle에 시스템은 이 플래그가 활성화되어있는 view들에 draw를 호출해서 다시 그려준다. 만약 view의 일부분만 다시 그려지길 원한다면 setNeedsDisplay 메서드의 인자로 rect를 전달할 수 있다.

대부분 뷰의 UI 컴포넌트를 업데이트 하는 것은 view의 dirty flag를 활성화시켜서 개발자가 명시적으로 setNeedsDisplay를 호출하지 않아도 다음 update cycle에 뷰가 다시 그려지도록 유도한다. 하지만 UI 컴포넌트와 직접적으로 연관되어 있지는 않지만 매 update cycle마다 다시 뷰를 그려줘야 하는 속성이 있다면 didSet 프로퍼티 감시자를 설정해서 setNeedsDisplay를 명시적으로 호출할 수 있다.

아래와 같이 속성을 설정할때마다 커스텀하게 view를 그리게 할 수 있다.

class MyView: UIView {
    var numberOfPoints = 0 {
        didSet {
            setNeedsDisplay()
        }
    }

    override func draw(_ rect: CGRect) {
        switch numberOfPoints {
        case 0:
            return
        case 1:
            drawPoint(rect)
        case 2:
            drawLine(rect)
        case 3:
            drawTriangle(rect)
        case 4:
            drawRectangle(rect)
        case 5:
            drawPentagon(rect)
        default:
            drawEllipse(rect)
        }
    }
}

numberOfPoints가 변하면 draw()안에서 view를 그리는 방식이 달라지기 때문에 didSet 블록 안에 setNeedsDisplay를 명시적으로 호출한 것이다.

Layout과는 다르게 Display는 draw를 호출해주는 메서드는 존재하지 않는다. 이는 뷰가 다시 그려지기 위해 다음 update cycle을 기다리는 것이 일반적이기 때문이다.

Constraints

Auto Layout에서는 layout하고 draw하는 데 3단계가 존재한다.

  1. Constraints를 업데이트 한다. 시스템이 view에 필요한 constraints들을 계산하고 설정한다.
  2. Layout 단계 : 레이아웃 엔진이 view와 자식 view들의 frame을 계산하고 배치한다.
  3. Display 단게 : View의 컨텐츠를 다시 그리고 필요하다면 draw 메서드를 호출한다.

updateConstraints()

이 메서드는 Auto Layout을 이용하는 view의 constraints를 동적으로 바꿀때 사용할 수 있다. layout 단계에서의 layoutSubviews나 display에서 draw 같이, updateConstraints는 오버라이딩 되어야 하고 명시적으로 호출하면 안된다. 우리는 보통 updateConstraints에서 동적으로 변해야 하는 constraint를 구현한다. 정적 constraint들은 Interface Builder나 view의 생성자, 혹은 viewDidLoad에서 정의되어야 한다.

일반적으로 constraints를 활성화/비활성화하거나 우선순위, constant를 변경하거나, view를 view 계층에서 삭제하는 것은 updateConstraints를 다음 update cycle에서 호출하게 한다. 하지만 updateConstraints를 명시적으로 호출하는 방법이 있다.

setNeedsUpdateConstraints()

setNeedsUpdateConstraints를 호출하면 다음 update cycle에서 constraint가 업데이트 되는 것을 보장해준다. 이 메서드는 setNeedsLayout, setNeedsDisplay와 비슷하게 동작한다.

updateConstraintsIfNeeded()

이 메서드는 layoutIfNeeded와 유사하다. 하지만 Auto Layout을 사용하는 뷰에서만 유효하다. 이 메서드는 Constraint Update Flag(이 flag는 자동으로 설정되거나, setNeedsUpdateConstraints를 통해 설정되거나, invalidateIntrinsicContentSize를 통해 설정될 수 있다.)를 검사한다. 만약 Constraints가 업데이트 되어야 한다면, updateConstraints를 즉시 호출한다.

invalidateInstrinsicContentSize()

Auto Layout을 사용하는 몇 view들은 intrinsicContentSize 속성을 가지고 있다. 이는 View가 갖고 있는 Content의 크기이다. intrinsicContentSize는 일반적으로 view가 갖고 있는 요소들의 constraints로 결정되지만, 이 또한 오버라이딩하여 다른 동작을 하도록 할 수 있다. invalidateIntrinsicContentSize()를 호출하면 view가 가지고 있는 intrinsicContentSize가 오래된 것이어서 다음 update cycle에서 다시 계산되어야 한다고 플래그를 활성화시켜준다.

How it all connects

View의 layout과 display, constraints는 run loop의 다른 지점에서 업데이트 되고 강제로 업데이트 되는데 비슷한 패턴을 가지고 있다. 각 컴포넌트들은 layoutSubviews, draw, updateConstraints와 같이 업데이트를 전파하는 메서드들을 가지고 있고, 얘네들은 명시적으로 호출되면 안되기 때문에 이를 호출되게 유도하는 메서드들이 있다. 유도하는 메서드들은 run loop의 마지막에 view의 해당 flag가 활성화되어 있으면 시스템이 업데이트 하는 메서드를 호출하게 한다. Layout과 Constraints는 다음 update cycle까지 기다릴 수 없을 때, 즉시 업데이트가 되도록 요청하는 메서드들이 있다. 아래의 표는 이런 메서드들이 작동하는 방식이다.

image

아래 표는 update cycle과 event loop, 그리고 위에서 설명한 메서드들이 cycle에서 어떻게 호출되는지 보여준다. layoutIfNeeded, updateConstraintsIfNeeded를 run loop의 아무 시점에서 호출할 수 있다. loop의 끝은 update cycle이다. Update Cycle은 constraints, layout, display가 플래그가 활성화되어 있다면 업데이트해준다. 업데이트가 완료되면 run loop가 다시 시작된다.

image

아래 사진의 순서대로 viewController와 view의 메서드들이 호출된다.

image

출처

  • https://daeun28.github.io/%EC%9D%B4%EB%A1%A0/post22/
  • https://tech.gc.com/demystifying-ios-layout/