https://tech.gc.com/demystifying-ios-layout/
이 글은 위 블로그 게시물을 해석하고, 정리하는 글입니다.
iOS 애플리케이션을 처음 빌드할 때 피하거나 디버깅하기 가장 어려운 문제 중 일부는 view 레이아웃 및 콘텐츠와 관련된 문제입니다. 이러한 문제는 view 업데이트 시점에 대한 오해로 인해 발생합니다. view가 업데이트되는 방법과 시점을 이해하려면 iOS 애플리케이션의 Main Run Loop와 UIView에서 제공하는 일부 메서드와의 관계에 대한 심층적인 이해가 필요합니다.
이 게시물에서는 이러한 상호 작용을 설명하여 UIView의 메서드를 사용하여 원하는 동작을 얻는 방법을 명확하게 설명합니다.
iOS 앱의 Main run loop는 모든 사용자 입력 이벤트를 처리하고, 적절한 응답을 Trigger하는 역할을 합니다.
앱의 모든 사용자 상호 작용은 이벤트 대기열(Event Queue)에 추가됩니다. 위 다이어그램에 표시된 Application Object는 이벤트를 Event Queue로부터 가져오고, Core Object에 Dispatch 합니다.
Core Object에서는 해당 입력의 Handler(개발자가 작성한 코드)를 호출하여 run loop를 실행합니다.
Handler의 호출이 반환되면, 제어가 Main run loop로 돌아가고 Update Cycle이 시작됩니다.
Update Cycle은 View를 Layout하고 redrawing 작업을 담당합니다.
- Main run loop는 사용자 이벤트를 처리하고, 적절한 응답을 보낸다.
- 이벤트가 발생하면, Event Queue에 들어가고, Application 객체에 의해 해당 이벤트의 핸들러가 실행된다.
- 핸들러 실행이 완료되면, Update Cycle에 의해 View가 업데이트 된다.
Update Cycle은 앱이 모든 이벤트 처리 코드 실행을 완료한 후 제어가 Main run loop로 돌아가는 시점입니다. 이 시점에서 시스템이 Layout, Dispay 및 Constraints 를 업데이트하기 시작합니다.
이벤트 핸들러를 처리하는 동안 View의 변경을 요청하면, 시스템에서 View를 다시 그려야 한다고 인지합니다. 다음 Update Cycle에서 시스템은 이러한 View에 대한 모든 변경 사항을 실행합니다.
사용자 상호 작용과 Layout 업데이트 사이의 지연은 사용자가 인지할 수 없을 정도로 짧아야 합니다. iOS 앱은 일반적으로 60fps로 애니메이션 되므로, 한 번의 새로 고침 주기는 1/60초밖에 걸리지 않습니다.
이 속도는 매우 빨라서 사용자는 UI가 업데이트가 지연되는 것을 느끼지 못합니다.
그러나 이벤트가 처리되는 시점과 해당 View가 다시 그려지는 시점 사이에 간격이 있기 때문에 run loop 중 특정 지점에서 뷰가 원하는 방식으로 업데이트되지 않을 수 있습니다.
뷰의 최신 콘텐츠 또는 레이아웃에 의존하는 계산이 있는 경우 뷰의 과거 정보로 계산될 위험이 있습니다.
run loop, 업데이트 주기 및 특정 UIView 메서드를 이해하면, 이러한 문제를 방지하거나 디버깅하는데 도움이 될 수 있습니다.
- Update Cycle은 앱이 모든 이벤트를 처리한 후 제어가 Main run loop로 돌아가는 시점이다.
- Update Cycle 동안 Layout, Display, Constraint 를 업데이트한다.
View의 Layout은 화면에서 View의 크기와 위치를 나타냅니다. 모든 View에는 SuperView(상위 뷰)의 좌표계에서 View가 존재하는 위치와 크기를 정의하는 frame이 있습니다.
UIView는 뷰의 레이아웃이 변경되었음을 시스템에 알릴 수 있는 메서드를 제공할 뿐만 아니라 뷰의 레이아웃이 다시 계산된 후 수행할 작업을 정의하기 위해 재정의할 수 있는 메서드를 제공합니다.
layoutSubviews()는 UIView의 인스턴스 메서드로, 뷰와 모든 하위 뷰의 위치 변경 및 크기 조정을 처리합니다.
현재 뷰와 모든 하위 뷰에 위치와 크기를 지정합니다. 이 메서드는 뷰의 모든 하위 뷰에 대해 작동하고, 해당 레이아웃 하위 뷰 메서드를 호출하기 때문에 비용이 많이 듭니다.
시스템은 뷰의 프레임을 다시 계산해야 할 때마다 이 메서드를 호출하므로 프레임을 설정하고 위치 및 크기를 지정하려는 경우 이 메서드를 재정의해야 합니다.
그러나 뷰 계층 구조에 레이아웃 새로 고침이 필요한 경우 이 메서드를 명시적으로 호출해서는 안 됩니다. 대신, run loop 중 여러 지점에서 layoutSubviews() 호출을 trigger 하는데 사용할 수 있는 여러 메커니즘이 있으며, 이는 layoutSubviews 자체를 호출하는 것보다 훨씬 비용이 적게 듭니다.
layoutSubviews() 가 완료되면 뷰를 소유한 ViewController에서 viewDidLayoutSubviews()가 호출됩니다. layoutSubviews()는 뷰의 레이아웃이 업데이트된 후 안정적으로 호출되는 유일한 메서드이므로 레이아웃 및 크기 조정에 의존하는 모든 로직은 viewDidLoad 또는 viewDidAppear가 아닌 viewDidLayoutSubviews 에 작성해야 합니다.
이렇게 해야만 과거의 레이아웃 및 위치 값을 사용하는 오류를 방지할 수 있습니다.
- layoutSubviews 는 현재 뷰와 모든 하위 뷰의 위치와 크기를 지정한다.
- 뷰의 모든 하위 뷰에 대해서 작동하기 때문에 비용이 많이 든다
- layoutSubviews 호출을 trigger하는 여러 메커니즘이 있고, 비용이 저렴하다.
- 뷰의 최신 상태값으로 어떤 다른 계산을 하려면, viewDidLayoutSubviews 에 로직을 작성해라. 그렇게 하면, 과거의 뷰 상태값을 사용하는 것을 방지할 수 있다.
뷰가 레이아웃을 변경한 것으로 자동으로 표시하는 여러 이벤트가 있으므로 개발자가 수동으로 이 작업을 수행하지 않고도 다음 update cycle에 layoutSubviews가 호출됩니다.
뷰의 레이아웃이 변경되었음을 시스템에 자동으로 알리는 몇 가지 방법이 있습니다.
이들은 모두 뷰의 위치를 다시 계산해야 한다는 것을 시스템에 전달하고, 자동으로 layoutSubviews 호출로 이어집니다.
그리고, 레이아웃 서브뷰를 직접 트리거하는 방법도 있습니다.
layoutSubviews 호출을 트리거하는 가장 비용이 적게 드는 방법은 뷰에서 setNeedsLayout을 호출하는 것입니다. 이렇게 하면, 뷰의 레이아웃을 다시 계산해야 한다는 것을 시스템에 전달합니다.
setNeedsLayout은 즉시 실행되고 반환되며 반환하기 전에 실제로 뷰를 업데이트하지 않습니다. 대신 다음 Update cycle 에서 시스템이 해당 뷰에서 layoutSubviews를 호출하고, 모든 하위 뷰에서 layoutSubviews 호출을 트리거할 때 뷰가 업데이트 됩니다.
setNeedsLayout이 반환되는 시점과 뷰가 다시 그려지고 레이아웃 되는 시점 사이에 임의의 시간 간격이 있더라도 앱에 지연을 일으킬 만큼 길지 않아야 하므로 지연으로 인한 사용자 영향이 없어야 합니다.
layoutIfNeeded는 UIView의 또 다른 메서드로, layoutSubviews 호출을 트리거할 것입니다. 그러나 다음 Update cycle에서 실행되도록 layoutSubviews를 대기열에 넣는 대신, 시스템은 뷰에 레이아웃 업데이트가 필요한 경우 즉시 layoutSubviews를 호출합니다. setNeedsLayout을 호출한 후, 또는 위에서 설명한 자동 layoutSubviews trigger 중 하나를 호출한 후에 layoutIfNeeded를 호출하면 뷰에서 layoutSubviews가 호출됩니다.
그러나 뷰를 다시 그려야 한다는 동작이 시스템에 전달되지 않은 경우 layoutSubviews가 호출되지 않습니다. 동일한 run loop 중간에 레이아웃을 업데이트하지 않고 뷰에 대해 layoutIfNeeded를 두 번 호출하는 경우 두 번째 호출은 layoutSubviews 호출을 트리거하지 않습니다.
layoutIfNeeded를 사용하면 setNeedsLayout과 달리 서브뷰를 배치하고 다시 그리는 작업이 즉시 수행되며, 이 메서드가 반환되기전에 완료됩니다. 이 메서드는 새 레이아웃에 의존해야 하고, 다음 update cycle에서 뷰가 업데이트될 때까지 기다릴 수 없는 경우에 유용합니다. 그러나 이러한 경우가 아니라면 setNeedsLayout을 호출해서 다음 update cycle에 뷰를 업데이트하는 것을 권장합니다.
이 메서드는 Constraints에 대한 변경 사항을 애니메이션할 때 특히 유용합니다. 모든 레이아웃 업데이트가 애니메이션 시작 전에 전파되도록 하려면 애니메이션 블록을 시작하기 전에 layoutIfNeeded를 호출해야 합니다. 새 Constraints를 구성한 다음 애니메이션 블록 내에서 layoutIfNeeded를 다시 호출하여 새 상태로 애니메이션을 적용합니다.
- layoutSubviews 호출을 자동으로 trigger하는 몇가지 방법이 있고, 직접 trigger하는 방법이 있다.
- setNeedsLayout은 다음 Update cycle에 뷰 업데이트를 하도록 요청하는 것이다. 즉, 즉시 업데이트 되지 않고, 다음 Update cycle에 해당 뷰에서 layoutSubviews가 호출되고, 하위뷰에서 연쇄적으로 layoutSubviews가 호출되면서 뷰가 업데이트 됨.
- 반면에, layoutIfNeeded는 layoutSubviews 호출을 즉시 trigger합니다.
View의 Display는 색상, 텍스트, 이미지 및 Core Graphics drawing 등 뷰 및 하위 뷰의 크기 및 위치와 관련이 없는 뷰의 속성이 포함됩니다.
draw 메서드는 layoutSubviews 처럼 동작하지만, 하위 view 들의 draw 메서드를 호출하지는 않습니다.
layoutSubviews와 마찬가지로 draw를 직접 호출해서는 안되며, 대신 run loop 중 다른 지점에서 draw 호출을 트리거하는 메서드를 호출해야 합니다.
이 메서드는 setNeedsLayout에 해당하는 Display 메서드 입니다. 즉, view에 콘텐츠 업데이트가 있었다는 flag 를 설정하지만, 실제로 view를 곧바로 다시 그리지는 않습니다.
다음 update cycle에서 이 flag가 설정된 모든 view를 검토하고, 해당 view에 draw를 호출합니다.
대부분의 경우 view의 UI 구성 요소를 업데이트하면, flag가 자동으로 설정되고, setNeedsDisplay를 호출할 필요 없이 다음 update cycle에서 view를 redraw 합니다.
그러나 UI 컴포넌트에 직접적으로 연결되지는 않았지만, 업데이트할 때마다 view를 다시 그려야하는 custom drawing은 setNeedsDisplay를 명시적으로 호출해야합니다.
다음 예제는 사용자의 숫자 입력값에 따라 보여지는 도형이 달라지는 로직입니다.
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의 값이 변할 때마다 다른 도형을 그려야 하므로 프로퍼티 관찰자에서 setNeedsDisplay를 호출해서 다음 update cycle에 뷰를 redraw 하도록 합니다.
layoutIfNeeded처럼 view에서 즉각적인 콘텐츠 업데이트를 트리거하는 Display 메서드는 없습니다.
일반적으로 view를 다시 그리려면 다음 update cycle에서 하는 것으로도 충분합니다.
- Display는 view의 색상, 텍스트, 이미지 등 view의 크기, 위치와 관련 없는 속성값을 포함한다.
- view의 속성값을 업데이트하는 draw 메서드가 있고, 이는 직접 호출하는 것이 아니라 draw를 트리거하는 메서드를 사용해야 한다.
- setNeedsDisplay 는 draw 호출을 트리거하기 위한 메서드이며, 즉시 view를 업데이트하는 것이 아니라 다음 update cycle에 flag를 통해 redraw할 view를 찾고, 해당 view의 draw를 호출한다.
Auto layout에서 view를 배치하고 다시 그리는 데는 3단계가 있습니다.
- Constraints 업데이트
: 시스템에서 view에 필요한 모든 constraint를 계산하고 설정- Layout
: Layout 엔진이 view 및 subview의 frame을 계산하고 레이아웃을 배치- Display
: Update cycle의 마지막 단계로, 필요에 따라 draw 메소드를 호출해서 view의 콘텐츠를 다시 그림
updateConstraints()는 Auto layout을 사용하는 view에서 constraints를 동적으로 변경하는데 사용할 수 있습니다. 직접 호출해서는 안되고, override해서 사용합니다.
정적인 constraints는 interface builder, view의 init 또는 viewDidLoad()에서 지정하고, 동적인 constraints는 updateConstarint 에서 구현해야합니다.
setNeedsUpdateConstraints 메소드를 호출하면, 다음 update cycle에서 constraints 업데이트가 보장됩니다. 이 메소드는 view의 constraints 중 하나가 업데이트 되었음을 표시하여 updateContraints()를 trigger 합니다.
이 메소드는 Auto Layout을 사용하는 View의 Constraints를 업데이트 해야한다고 판단되면, 다음 update cycle을 기다리지 않고, 즉시 updateContraints를 trigger 합니다.
View의 Constraints 업데이트는 "constraints update" flag를 통해 업데이트의 필요성을 판단합니다.
이 flag는 자동으로 설정되거나, setNeedsUpdateConstraints, invalidateIntrinsicContentSize 로 설정할 수 있습니다.
Auto Layout을 사용하는 일부 View에는 콘텐츠가 주어졌을 때 View의 자연스러운 크기인 intrinsiceContentSize 속성이 있습니다.View의 intrinsicContentSize는 일반적으로 View에 포함된 요소의 constraints에 의해 결정되지만, custom 동작을 제공하기 위해 override 할 수 있습니다.
invalidateIntrinsicContentSize() 를 호출하면, view의 contents가 변경되면 intrinsicContentSize 프로퍼티를 통해 크기 계산을 다시 할 수 있습니다.
잘 봤습니다. 좋은 글 감사합니다.