고급 Layout
Chris Eidhof와 Florian Kugler의 『Thinking in SwiftUI』 iOS 17 개정판, 원문 131–152쪽의 한국어 번역입니다.
고급 Layout
원문 131쪽은 7장 「Advanced Layout」의 장 표지입니다.
원문 131쪽과 도표

SwiftUI의 기본 layout primitive를 사용할 때는 View에 어떤 크기가 제안되는지 알 수 없으며, sibling이나 subview의 크기에 관한 정보도 얻을 수 없습니다. 따라서 이런 정보에 의존하는 layout을 만들려면 이 장에서 다루는 더 낮은 수준의 기법을 사용해야 합니다.
iOS 16과 macOS 13부터는 Layout protocol로 custom layout container를 만들 수 있습니다. 이 protocol은 두 단계로 작동합니다.
- 먼저
sizeThatFitsmethod로 container의 크기를 결정합니다. 이 method 안에서는 proxy를 통해 subview에 접근할 수 있습니다. 각 proxy에 여러 크기를 제안하여 subview를 측정할 수 있습니다. - 두 번째 단계에서는 container의 크기 안에 subview를 배치합니다. 이때도 proxy를 통해 subview에 접근할 수 있습니다.
현재 Layout protocol에서는 subview를 측정하고 배치할 수만 있으며, subview를 걸러 내거나 View를 추가할 수는 없습니다. 예를 들어 항목 사이에 separator를 추가할 수 없습니다. 따라서 Layout protocol로 ViewThatFits를 쉽게 구현할 수는 없습니다. 이를 가능하게 하는 몇 가지 요령은 있지만, 이러한 한계를 알아 두는 편이 좋습니다.
또한 Layout protocol은 2022년 이전에 출시된 platform에서는 사용할 수 없습니다. 이 platform들을 지원해야 한다면 geometry reader, preference, overlay로도 많은 custom layout을 만들 수 있습니다. 이러한 기법은 이 장의 뒷부분에서 살펴봅니다.
Layout protocol
Layout protocol을 사용하면 직접 작성한 algorithm에 따라 subview를 배치하는 custom container View를 만들 수 있습니다. 예를 들어 masonry layout, flow layout, circular layout 같은 custom layout을 만들 수 있습니다. 기존 layout을 감싸고 조정하는 데도 이 protocol을 사용할 수 있습니다.
이 절에서는 flow layout을 만듭니다. HStack은 언제나 subview를 한 줄에 배치하지만, flow layout은 다음 View가 현재 줄에 더 이상 들어가지 않으면 줄을 바꿉니다. Text View가 다음 단어를 현재 줄에 넣을 수 없을 때 줄을 바꾸는 방식과 비슷합니다.
Layout protocol을 사용하여 다음 순서로 flow layout을 구현합니다.
- 각 subview에
nil×nil을 제안하여 ideal size를 요청합니다. - container 자체의 크기를 바탕으로 모든 subview의 위치를 계산합니다.
- 2단계에서 계산한 위치에 각 subview를 배치합니다.
2단계의 줄 단위 layout algorithm은 크기 목록(1단계에서 구한 ideal size), spacing, container의 너비를 받아 frame 배열을 반환합니다. 입력으로 받은 크기마다 하나의 rectangle이 대응합니다. 작동 방식은 다음과 같습니다.
원문 132쪽과 도표

- 현재 위치를
(0, 0)에서 시작하는CGPoint로 관리합니다.x성분은 줄 안에서의 현재 수평 위치이며 View가 추가될 때마다 바뀝니다.y성분은 현재 줄의 위쪽 위치이며 새로운 줄을 시작할 때만 바뀝니다. - 모든 subview를 순회합니다.
- subview가 현재 줄에 들어가지 않으면 새 줄을 시작합니다. 현재 위치의
x성분을 0으로 되돌리고,y성분에는 현재 줄의 높이와 spacing을 더합니다. - 현재 위치를 이 subview의 rectangle의 origin으로 사용합니다.
- 현재 위치의
x성분에 subview의 너비와 spacing을 더합니다.
- subview가 현재 줄에 들어가지 않으면 새 줄을 시작합니다. 현재 위치의
이 algorithm은 Layout protocol과 독립적으로 작성할 수 있습니다.
func layout(
sizes: [CGSize],
spacing: CGSize = .init(width: 10, height: 10),
containerWidth: CGFloat
) -> [CGRect] {
var result: [CGRect] = []
var currentPosition: CGPoint = .zero
func startNewline() {
if currentPosition.x == 0 { return }
currentPosition.x = 0
currentPosition.y = result.union().maxY + spacing.height
}
for size in sizes {
if currentPosition.x + size.width > containerWidth {
startNewline()
}
result.append(CGRect(origin: currentPosition, size: size))
currentPosition.x += size.width + spacing.width
}
return result
}이 algorithm을 사용하면 실제 layout 구현은 매우 짧아집니다. sizeThatFits method는 모든 ideal size와 제안된 container 너비로 layout을 호출하고, 모든 frame의 union의 크기를 반환합니다. ZStack이 모든 subview의 union의 크기를 자신의 크기로 보고하는 것과 비슷합니다.
원문 133쪽과 도표

union method는 저희가 작성한 custom extension입니다.
subview를 배치할 때는 먼저 모든 frame을 계산한 뒤, 각 subview를 해당 frame의 origin에 배치합니다.
struct FlowLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
-> CGSize
{
let containerWidth = proposal.replacingUnspecifiedDimensions().width
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
return layout(sizes: sizes, containerWidth: containerWidth).union().size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
subviews: Subviews, cache: inout ())
{
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let frames = layout(sizes: subviewSizes, containerWidth: bounds.width)
for (f, subview) in zip(frames, subviews) {
let offset = CGPoint(x: f.origin.x + bounds.minX, y: f.origin.y + bounds.minY)
subview.place(at: offset, proposal: .unspecified)
}
}
}여기서 흥미로운 점은 이 layout의 너비가 언제나 제안된 너비와 정확히 같아지지는 않는다는 것입니다. 여러 줄로 줄바꿈되는 Text와 비슷합니다. spacing이 10 point이고 ideal width가 100인 subview가 세 개 있다고 가정하고, 너비로 250을 제안하면 어떻게 되는지 생각해 보겠습니다. 이 layout은 세 항목을 모두 한 줄에 넣을 수 없으므로 세 번째 항목을 두 번째 줄에 배치합니다. 따라서 가장 긴 줄의 너비인 210을 너비로 보고합니다.
원문 134쪽과 도표

FlowLayout {
ForEach(Array(0...10), id: \.self) { idx in
Rectangle()
.fill(Color.random)
.frame(width: .random(in: 10...35))
}
}
.border(.blue)
.frame(width: 125)
.border(.black)이 layout은 여러 방법으로 확장할 수 있습니다. 다음과 같은 아이디어를 생각해 볼 수 있습니다.
- 줄 안에서의 alignment를 위한 parameter를 추가합니다. 현재 한 줄의 모든 subview는 위쪽으로 정렬되지만, 임의의 vertical alignment를 지원할 수도 있습니다. 이를 위해 proxy를 통해 각 subview의 alignment 값을 요청할 수 있습니다.
- horizontal alignment를 지원합니다. 현재 각 줄은 leading으로 정렬됩니다.
- 외부에서 spacing을 조절할 수 있게 합니다.
- 항상 자신만의 줄을 차지하는 특별한 View를 지원합니다. heading 등이 그 예입니다.
- 한 축 또는 두 축 모두에서 proposed size가
nil일 때 더 나은 기본 동작을 마련합니다.
대부분의 기능은 추가하기가 그리 어렵지는 않지만, 책에 싣기에 적당한 분량보다 더 많은 코드가 필요합니다. 따라서 현재 사용할 수 있는 API를 살펴보고, flow layout을 확장할 때 어떤 도움을 주는지 알아보겠습니다. 먼저 Layout protocol의 API부터 살펴보겠습니다.
- custom
Cachetype을 정의하여 서로 다른 method 사이에서 계산 결과를 공유할 수 있습니다. 예를 들어sizeThatFits에서 계산한 subview의 frame을 cache에 저장하고placeSubviews에서 재사용할 수 있습니다.makeCachemethod를 override하면 cache를 초기화하거나 생성할 수 있습니다. 다만 문서에 나와 있듯 직접 cache를 만들 필요가 없는 경우가 많으므로, 불필요하게 이 방법을 택하기 전에 성능 문제가 있는지 확인해야 합니다.

원문 135쪽과 도표

explicitAlignment(...)API로 명시적인 alignment guide를 제공할 수 있습니다..alignmentGuideAPI와 달리, proxy를 통해 subview에 질의하여 alignment guide를 계산할 수도 있습니다. 예를 들어explicitAlignmentAPI의 기본 구현을 override하여firstFlowBaseline과lastFlowBaselinealignment의 alignment guide를 노출할 수 있습니다.
LayoutSubview type도 subview에 질의하는 데 쓸 수 있는 여러 유용한 API를 제공합니다.
sizeThatFits를 한 번만 호출해야 하는 것은 아닙니다. 실제로 여러 번 호출할 수 있습니다. Stack View도 이러한 probing을 통해 subview의 flexibility를 알아냅니다.sizeThatFits대신 크기를 제안하고 subview의ViewDimensions를 얻을 수도 있습니다.ViewDimensionstype은 너비와 높이가 있다는 점에서CGSize와 비슷하지만, subview의 alignment guide를 질의하는 데도 사용할 수 있습니다. 예를 들어 subview의 custom alignment guide를 반영하면서 subview들을 수평선 위에 정렬하는 데 사용할 수 있습니다.- subview가 선호하는 spacing 값을 요청하고, spacing이 명시적으로 지정되지 않았다면 그 값을 사용할 수 있습니다.
- View의 priority를 요청할 수 있습니다. flow layout에서는 크게 의미가 없지만, custom stack layout 등을 구현할 때 유용합니다.
- subview는 custom layout value도 정의할 수 있습니다. container는 View proxy의 subscript를 통해 이 값을 읽을 수 있습니다. 예를 들어 subview가 Boolean 값인
headinglayout value를 선언하고 container View에서 이를 읽게 할 수 있습니다.
AnyView와 비슷하게, layout의 구체적인 type을 지우는 AnyLayout type이 있습니다. 서로 다른 layout 사이를 동적으로 전환하는 데도 사용할 수 있습니다. 이 전환을 animation의 일부로 수행하면 subview의 frame도 animate됩니다.
한계
Layout protocol은 iOS 16과 macOS 13에서 추가되었으므로, 이전 platform을 대상으로 한다면 사용할 수 없습니다. 하지만 다음 절에서 살펴보듯 Layout protocol 없이도 preference와 geometry reader로 거의 모든 custom layout을 만들 수 있습니다.
원문 136쪽과 도표

또 다른 한계는 subview와 직접 소통할 수 없다는 점입니다. 예를 들어 modifier를 적용하거나 preference를 읽거나 View protocol이 필요한 다른 작업을 수행할 수 없습니다. 도입부에서 설명했듯 모든 subview가 배치되며, subview 목록에서 항목을 제거하거나 추가할 수 없습니다. 예를 들어 separator 같은 장식용 View를 삽입할 수 없고, 특정 subview를 건너뛰도록 filter를 적용할 수도 없습니다.
Preference 기반 Layout
앞 절에서 보았듯 Layout protocol을 사용하면 여러 크기를 제안하고 container 안에 View를 배치할 수 있습니다. preference, geometry reader, ZStack을 사용하면 거의 모든 동작을 재현할 수 있고, 그 이상의 일도 할 수 있습니다.
Geometry reader
GeometryReader를 사용하면 proposed size를 측정할 수 있습니다. GeometryReader는 proposed size를 무조건 수락하고, GeometryProxy를 통해 자신의 View builder closure에 그 크기를 전달합니다. geometry proxy를 사용하면 geometry reader의 크기를 CGSize로 얻을 수 있습니다. 이는 proposed size에서 nil을 기본값인 10 point로 바꾼 크기입니다. safe area inset을 읽거나, 특정 coordinate space에서의 frame을 읽거나, anchor를 resolve할 수도 있습니다.
geometry reader를 절대 사용하지 말라고 권하는 사람을 종종 보게 됩니다. 저희는 이러한 인식의 상당 부분이 geometry reader에 관한 오해에서 비롯된다고 생각합니다. 예를 들어 Text View 세 개가 있는 HStack을 생각해 보겠습니다. Text View 하나를 geometry reader로 감싸는 순간 layout이 완전히 달라집니다. 감싼 Text View가 이제 자신의 content를 바탕으로 크기를 정하는 대신 proposed size를 수락하기 때문입니다.
geometry reader로 View의 크기를 측정할 때는 overlay나 background 안에 넣는 것을 권장합니다. 「Layout」 장에서 설명했듯 overlay의 secondary subview에는 primary subview의 크기가 제안됩니다. 그 overlay 안에 geometry reader를 넣으면 layout에 영향을 주지 않으면서 primary subview의 크기를 측정할 수 있습니다.
크기를 얻고 나면 여러 작업을 할 수 있습니다. 예를 들어 background에 rounded rectangle을 표시하고, geometry reader의 크기에 따라 corner radius를 조정한다고 가정해 보겠습니다.
원문 137쪽과 도표

myView
.padding()
.background {
GeometryReader { proxy in
let radius: CGFloat = proxy.size.width > 50 ? 10 : 5
RoundedRectangle(cornerRadius: radius)
.fill(.blue)
}
}하지만 View의 ancestor 중 하나에서 그 View의 크기를 사용하고 싶다면 이 방법만으로는 할 수 없습니다. geometry reader도 하나의 View이므로, View builder closure 안에서 state property에 값을 대입하거나 다른 방법으로 state를 수정할 수 없기 때문입니다.
View 하나를 측정하는 경우에는 onAppear와 onChange(of:)를 함께 사용하여 이 제약을 우회할 수 있습니다. 예를 들어 View를 ideal size로 만들되 너비가 100 point인 frame 안에 표시하고, 그 frame 밖으로 그려지는지 감지한다고 가정해 보겠습니다. Text를 만들고 fixedSize modifier를 적용하여 ideal size가 되도록 합니다. 그런 다음 너비가 100인 frame을 적용하여 layout size가 항상 정확히 100 point가 되도록 합니다. Text의 크기는 geometry reader가 들어 있는 overlay로 측정할 수 있습니다. geometry reader의 content에는 어떤 형태로든 View가 필요하므로 clear color를 사용합니다. render tree에 node가 생성되어 onAppear가 호출되거나, Text의 크기가 100 point 기준을 넘어가거나 다시 그 아래로 내려와 onChange가 호출될 때 새 값을 state property에 대입합니다.

원문 138쪽과 도표

struct ContentView: View {
var text: String
@State private var overflows: Bool = false
var body: some View {
Text(text)
.fixedSize()
.overlay {
GeometryReader { proxy in
let tooWide = proxy.size.width > 100
Color.clear
.onAppear {
overflows = tooWide
}
.onChange(of: tooWide) { _ in
overflows = tooWide
}
}
}
.frame(width: 100)
.border(overflows ? Color.red : Color.clear)
}
}onAppear와 onChange(of:)가 모두 필요하다는 점에 주의해야 합니다. 전자는 node가 처음 생성될 때만 실행되고, 후자는 값이 실제로 바뀔 때만 실행됩니다. View가 처음 render될 때는 후자가 실행되지 않습니다. 이 pattern이 자주 필요하다면 두 modifier를 결합한 helper를 만들 수 있습니다.
extension View {
func onAppearOrChange<Value: Equatable>(of value: Value,
perform: @escaping (Value) -> ()) -> some View
{
self
.onAppear { perform(value) }
.onChange(of: value, perform: perform)
}
}iOS 17/macOS 14를 대상으로 한다면 initial parameter를 받는 새로운 onChange 형태가 있습니다. 이 parameter는 값이 바뀔 때뿐 아니라 처음에도 perform closure를 호출할지를 나타냅니다.

원문 139쪽과 도표

Color.clear
.onChange(of: tooWide, initial: true) {
overflows = tooWide
}많은 경우 이것이 View 하나의 크기를 측정하고 측정값을 ancestor에 전달하는 가장 간단한 방법입니다. 하지만 서로 관련된 여러 View를 측정해야 한다면 onAppear와 onChange(of:)를 사용하는 방법은 규모가 커질수록 잘 맞지 않습니다. 그 대신 preference를 사용할 수 있습니다.
Preference
preference는 environment와 반대 방향으로 작동합니다. 값을 tree 아래로 전달하는 대신 tree 위로 전달합니다. 예를 들어 flow layout에서는 각 subview의 크기를 측정하고 preference로 container에 전달할 수 있습니다. 또한 preference에서는 이 값들을 결합하는 방법도 정의할 수 있습니다. flow layout이라면 개별 측정값을 크기의 배열이나 dictionary로 결합하려고 할 것입니다.
environment와 마찬가지로 preference도 type safety를 보장하기 위해 다소 복잡한 문법으로 정의합니다. PreferenceKey protocol에는 preference가 설정되지 않았을 때 사용할 기본값이 필요하고, reduce method도 필요합니다. 이 method는 두 값을 결합하는 데 사용합니다. 예를 들어 여러 View의 최대 너비를 측정하고 싶다면 CGFloat 값을 사용하고, 최댓값을 취하도록 reduce를 구현할 수 있습니다. subtree의 어느 위치에나 있을 수 있는 단일 값에 관심이 있다면, optional로 모델링하고 nil이 아닌 첫 번째 값을 취할 수 있습니다.
여기서는 모든 subview의 크기를 측정하려고 합니다. 이를 CGSize의 배열로 모델링합니다. 따라서 preference key는 다음과 같습니다.
struct SizeKey: PreferenceKey {
static var defaultValue: [CGSize] = []
static func reduce(value: inout [CGSize], nextValue: () -> [CGSize]) {
value.append(contentsOf: nextValue())
}
}View 하나를 측정하려면 앞 절에서 배운 내용을 활용하여 overlay 안의 geometry reader로 측정합니다. 그 안에서는 preference를 사용해 이 값을 view tree 위로 전달합니다.
원문 140쪽과 도표

extension View {
func measureSize() -> some View {
overlay {
GeometryReader { proxy in
Color.clear
.preference(key: SizeKey.self, value: [proxy.size])
}
}
}
}여러 View의 크기를 측정하려면 ForEach로 여러 subview를 생성하고 각 subview에서 measureSize를 호출합니다. 또한 view tree에서 크기를 측정하는 위치보다 위쪽 어딘가에 onPreferenceChange(_:perform:) modifier를 추가하고 preference 값을 출력합니다.
ZStack(alignment: .topLeading) {
ForEach(0..<5) { ix in
Text("Item \(ix)" + String(repeating: "\n", count: ix/2))
.padding()
.measureSize()
}
}
.onPreferenceChange(SizeKey.self) { print($0) }이를 macOS에서 실행하면 print 문이 모든 크기를 담은 배열을 출력합니다.
[(70.5, 48.0), (68.5, 48.0), (70.0, 64.0), (70.5, 64.0), (70.5, 80.0)]각 .preference modifier의 값은 view tree 위로 전달되며, key의 reduce method가 여러 subview의 값을 결합하는 데 사용됩니다. 같은 key를 사용하는 .onPreferenceChange(_ key:perform:) modifier를 만나면, 그 .onPreferenceChange 안의 subtree에서 모은 값으로 perform closure를 호출합니다. preference에 영향을 주는 state 변경이 발생하면 이 과정은 다시 처음부터 시작됩니다.
flow layout을 만드는 다음 단계는 이 크기들을 사용하여 실제로 subview를 배치하는 것입니다.
전체 구현의 결합
크기가 주어지면 이 장의 앞부분에서 작성한 layout method를 재사용할 수 있습니다. 이 method는 여러 크기를 입력으로 받아 각 입력 크기에 대응하는 frame을 반환했습니다. 또한 container의 너비도 필요합니다.
원문 141쪽과 도표

func layout(
sizes: [CGSize],
spacing: CGFloat = 10,
containerWidth: CGFloat
) -> [CGRect]역주: 이 선언의
spacing: CGFloat = 10은 원문 그대로입니다. 앞서 133쪽의 구현에서는spacing: CGSize를 사용합니다.
첫 단계로 container의 너비를 측정해야 합니다. Layout protocol을 사용하지 않으면 proposed size를 측정하고 이를 바탕으로 자신의 크기를 계산하기가 쉽지 않으므로, 몇 가지 우회 방법이 필요합니다. 먼저 높이가 0인 geometry reader와 container View인 ZStackFlowLayout을 결합하는 바깥쪽 ZStack을 만듭니다. geometry reader의 높이를 0으로 설정하는 이유는 수직 layout size에 영향을 주지 않기 위해서입니다. geometry reader 안에서는 proposed size를 측정하여 state property에 저장합니다. container에서는 minWidth를 0으로, maxWidth를 .infinity로 설정합니다. 이는 “정확히 제안된 너비가 됩니다”를 표현하는 다소 특이한 방법입니다. 자세한 내용은 「Layout」 장을 참고하세요. 다시 말해 이 바깥쪽 ZStack, GeometryReader, ZStackFlowLayout의 너비는 모두 정확히 제안된 너비가 됩니다. 따라서 바깥쪽 View의 높이는 사실상 ZStackFlowLayout의 높이가 됩니다.
struct ZStackFlowLayoutExample: View {
@State private var containerWidth: CGFloat?
var body: some View {
ZStack {
GeometryReader { proxy in
let width: CGFloat = proxy.size.width
Color.clear
.onAppearOrChange(of: width) { containerWidth = $0 }
}
.frame(height: 0)
ZStackFlowLayout(containerWidth: containerWidth ?? 0)
.frame(minWidth: 0, maxWidth: .infinity)
}
}
}감싸는 View를 만들었으므로 이제 ZStackFlowLayout View를 구현할 수 있습니다. subview들의 크기를 저장하는 state property를 정의하고 container의 너비를 parameter로 받습니다. 또한 subview(for:) method는 flow layout의 각 index에 대해 background가 있는 Text를 생성합니다.
원문 142쪽과 도표

struct ZStackFlowLayout: View {
@State private var sizes: [CGSize]? = nil
var containerWidth: CGFloat
let subviewCount = 5
func subview(for index: Int) -> some View {
Text("Item \(index)" + String(repeating: "\n", count: index/2))
.padding()
.background {
RoundedRectangle(cornerRadius: 5)
.fill(Color(hue: .init(index)/10, saturation: 0.8, brightness: 0.8))
}
}
var body: some View {
...
}
}body property 안에서는 먼저 주어진 크기로 모든 offset을 계산합니다. 그런 다음 topLeading alignment의 ZStack을 만들고, alignment guide로 각 subview를 이동합니다. ZStack의 layout size는 모든 subview frame의 union이므로, layout system에 올바른 크기를 보고합니다. 이는 flow layout을 다른 View와 결합할 때 중요합니다. 예를 들어 scroll view 안에 넣는 경우가 그렇습니다. sizes parameter가 nil이면 각 View를 .zero에 배치합니다. 각 subview에는 fixedSize를 적용하여 render합니다. 이렇게 하면 subview의 크기가 측정하는 어떤 값에도 의존하지 않으므로 무한 layout loop를 막을 수 있습니다. 그런 다음 subview의 크기를 측정하고 alignment guide를 설정합니다. subview를 예를 들어 100×50 위치로 이동하려면 leading alignment guide를 -100으로, top alignment guide를 -50으로 설정해야 합니다. 마지막으로 가장 위쪽 View에 onPreferenceChange를 추가하여 모든 preference 값을 읽습니다.
var body: some View {
let offsets = sizes.map {
layout(sizes: $0, containerWidth: containerWidth).map { $0.origin }
} ?? Array(repeating: .zero, count: subviewCount)
ZStack(alignment: .topLeading) {
ForEach(0..<subviewCount) { ix in
subview(for: ix)
.fixedSize()
.measureSize()
.alignmentGuide(.leading) { _ in -offsets[ix].x }
.alignmentGuide(.top) { _ in -offsets[ix].y }
}
}
.onPreferenceChange(SizeKey.self) { sizes = $0 }
}크기를 저장하는 대신 onPreferenceChange 안에서 곧바로 layout을 계산하는 편이 더 효율적이라고 생각할 수도 있습니다.
원문 143쪽과 도표

그렇게 하는 것은 가능하지만 문제가 하나 생깁니다. 그렇게 하면 View가 containerSize의 변경에 반응하지 않습니다. 위와 같이 body를 작성하면 container 크기가 바뀌거나 다른 크기가 측정될 때마다 View가 invalidate됩니다. subview가 정적이더라도 environment의 어떤 값이 바뀌면 크기가 달라질 수 있습니다. dynamic type이 그 예입니다.
역주: 원문은 이 문단에서
containerSize라고 표기하지만, 위 코드에서 container의 너비를 나타내는 property 이름은containerWidth입니다.
ZStack을 사용하여 flow layout을 만드는 algorithm은 처음에 보여 드린 Layout protocol 버전보다 훨씬 복잡합니다. 또한 항목이 하나뿐이어도 항상 제안된 너비가 됩니다. 하지만 SwiftUI의 첫 버전부터 있었던 도구만으로도 복잡한 layout을 만들 수 있음을 보여 줍니다.
몇몇 미묘한 사항은 곧바로 드러나지 않으며, 직접 코드를 작성할 때 놓치기 쉽습니다. 예를 들어 위 View가 처음 render될 때 onPreferenceChange는 layout phase 도중 어느 시점에 실행되고, @State property가 invalidate됩니다. 현재 frame이 render되기도 전에 View의 body가 다시 실행됩니다. 이는 깜박임을 막아 주며 일반적으로 매우 유용합니다. 하지만 layout에 의도치 않은 loop가 생기지 않도록 해야 합니다. ForEach 안에서 fixedSize를 제거하면 subview를 측정하고, subview가 다른 크기를 보고하고, 이로 인해 layout이 반복해서 invalidate되는 loop에 실제로 빠질 수 있습니다. 이런 문제가 발생하면 보통 console에 “Bound preference x tried to update multiple times per frame.”이라는 경고가 표시됩니다.
마찬가지로 onPreferenceChange 안에서 위치를 계산하려고 하면 처음에는 코드가 올바르게 작동하는 것처럼 보이지만, container 너비가 바뀌는 상황은 지원하지 못합니다. 이를 쉽게 확인하는 방법은 macOS application 안에서 이 코드를 실행하는 것입니다. window의 크기를 조절하면 항목의 줄 배치가 바뀌어야 합니다.
Variadic View
ZStack과 preference를 사용한 flow layout의 마지막 단점은 단일 type의 subview만 지원할 수 있다는 것입니다. 물론 ForEach를 더 복잡한 것으로 바꿀 수도 있습니다. 예를 들어 Identifiable 항목의 배열을 순회하도록 바꿀 수 있습니다. 하지만 Layout을 사용할 때는 어떤 View 목록이든 전달할 수 있습니다. 예를 들어 다음 코드에서는 subview 여섯 개를 추가합니다. 처음 다섯 개는 ForEach에서 나오고, 마지막 View는 View builder의 일부로 추가합니다. preference 기반 layout에서는 이를 쉽게 구현할 수 없습니다.
FlowLayout() {
ForEach(0..<5) { ix in
Text("Item \(ix)")
}
RoundedRectangle(cornerRadius: 10)
.frame(width: 100, height: 100)
}원문 144쪽과 도표

preference 기반 layout에서는 variadic view라는 반공개 API로 이런 문법을 구현할 수 있습니다. 이 API의 이름에는 underscore가 붙어 있습니다. variadic view를 사용하면 내부 View들의 View proxy에 ForEach에서 사용할 수 있는 collection 형태로 접근할 수 있습니다. 이를 통해 Layout 기반 버전과 매우 비슷한 API를 만들 수 있습니다. 전체 코드는 이 책의 범위를 벗어나지만, 완성된 버전을 볼 수 있습니다.
preference의 용도는 layout에만 한정되지 않으며, 다른 목적으로도 사용할 수 있습니다. 예를 들어 navigation view를 대체할 자체 구현을 만든다면 preference로 navigationTitle API를 만들 수 있습니다. 일반적으로 다른 View를 감싸는 재사용 가능한 component를 만들 때, 그 View들은 preference를 사용하여 component에 다시 정보를 전달할 수 있습니다.
Coordinate space
geometry reader를 사용할 때는 서로 다른 coordinate space에서 View의 geometry를 측정할 수 있습니다. 기본 제공되는 coordinate space에는 global과 local 두 가지가 있습니다. 이와 별도로 직접 coordinate space를 정의할 수도 있습니다. stack 주위에 명시적인 coordinate space를 둔 다음 View를 살펴보겠습니다.
struct ContentView: View {
var body: some View {
VStack {
Text("Hello")
Text("Second")
.overlay { GeometryReader { proxy in
let _ = print([
proxy.frame(in: .global).pretty,
proxy.frame(in: .local).pretty,
proxy.frame(in: .named("Stack").pretty)
].joined(separator: "\n"))
Color.clear
}}
}
.coordinateSpace(name: "Stack")
}
}역주: 원문 코드의
proxy.frame(in: .named("Stack").pretty)에는 닫는 괄호 위치의 오기가 있습니다. 의도한 표현은proxy.frame(in: .named("Stack")).pretty입니다.
위 코드를 iOS simulator에서 실행하면 서로 다른 세 가지 값을 출력합니다.
원문 145쪽과 도표

- global coordinate space에서 origin은
(167, 438)입니다. 화면의 top-leading edge로부터의 거리입니다. - local coordinate space에서 origin은
(0, 0)입니다. local은 항상 해당 View를 기준으로 한다는 뜻입니다. 여기서 해당 View는 geometry reader이며, 이는 두 번째 Text label과 같은 coordinate space입니다. container의 coordinate space가 아닙니다. - stack 안에서 origin은
(0, 20)입니다. geometry reader가 있는 View가 stack의 두 번째 subview이기 때문입니다.
위 View에서 출력되는 크기는 모두 같습니다. 하지만 scale effect를 적용하면 어떻게 되는지 살펴보겠습니다.
Text("Hello")
.overlay { GeometryReader { proxy in
let _ = print([
proxy.frame(in: .global).size.pretty,
proxy.frame(in: .local).size.pretty,
].joined(separator: "\n"))
Color.clear
}}
.scaleEffect(0.5)「Layout」 장에서 설명했듯 effect는 View가 그려지는 방식만 바꾸며, View의 layout은 바꾸지 않습니다. 하지만 geometry proxy에 특정 coordinate space를 기준으로 한 View의 frame을 요청하면 그 coordinate space 안의 모든 effect가 반영됩니다. 위 예제에서 global coordinate space로 측정한 크기는 local coordinate system으로 측정한 크기의 절반입니다. 더 정확히 말하면 너비와 높이가 각각 local에서 측정한 값의 절반입니다.
Anchor
anchor는 geometry reader, preference, coordinate space를 바탕으로 만들어집니다. 본질적으로 anchor는 global coordinate space에서 측정한 geometry 값(CGRect, CGSize, CGPoint)을 감싸는 wrapper입니다. geometry proxy는 anchor를 특별히 지원하며, geometry 값을 local coordinate space로 자동 변환할 수 있게 해 줍니다.
예를 들어 현재 항목 주위에 ellipse를 그려 interface의 특정 부분을 강조하는 onboarding flow를 만든다고 생각해 보겠습니다.
원문 146쪽과 도표

VStack {
Button("Sign Up") {}
Button("Log In") {}
.overlay {
Ellipse()
.strokeBorder(Color.red, lineWidth: 1)
.padding(-5)
}
}
.border(Color.green)일반적으로 이 ellipse는 login button의 sibling이나 view tree에서 더 위에 있는 다른 View에 가려질 수 있습니다. 이 경우 ellipse의 위치는 hierarchy 깊숙한 곳에 있을 수도 있는 View가 결정하더라도, ellipse 자체는 맨 위에 그려야 합니다.
이를 위해 login button의 frame을 preference로 전달하고, 전체 view tree 위의 overlay에 ellipse를 그릴 수 있습니다. 첫 단계로 anchor를 전달하는 key를 만듭니다.
struct HighlightKey: PreferenceKey {
typealias Value = Anchor<CGRect>?
static var defaultValue: Value
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value ?? nextValue()
}
}두 번째로 ellipse를 그리던 부분을 anchor preference를 설정하는 코드로 바꿉니다. 이렇게 하면 global coordinate space에서 측정한 두 번째 button의 위치와 크기가 view tree 위로 전달됩니다.
VStack {
Button("Sign Up") {}
Button("Log In") {}
.anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
}이제 VStack 바깥에 그리는 코드를 추가합니다. 이 장의 앞부분에서는 preference 값을 얻기 위해 onPreferenceChange를 사용했지만, 여기서는 overlay 안에서 preference 값에 접근할 수 있는 더 간단한 API를 사용합니다. 이 방법을 사용하면 별도의 state property를 두지 않아도 됩니다. overlay 안에서는 geometry reader와 그 proxy로 anchor를 resolve합니다. Anchor<CGRect>를 local coordinate space 안의 CGRect로 변환하는 것입니다.

원문 147쪽과 도표

VStack {
...
}
.border(Color.green)
.overlayPreferenceValue(HighlightKey.self) { value in
if let anchor = value {
GeometryReader { proxy in
let rect = proxy[anchor]
Ellipse()
.strokeBorder(Color.red, lineWidth: 1)
.padding(-5)
.frame(width: rect.width, height: rect.height)
.offset(x: rect.origin.x, y: rect.origin.y)
}
}
}anchor는 편의를 위한 기능이라는 점을 기억하세요. custom coordinate space를 정의하고 모든 값을 그 coordinate space를 기준으로 측정하거나, 모든 값을 global coordinate space에서 측정하는 방식으로도 같은 코드를 작성할 수 있습니다. 하지만 두 대안 모두 더 많은 코드가 필요하고 오류가 생길 가능성도 더 높습니다.
저희의 경험상 anchor는 서로 다른 coordinate space 사이에서 좌표나 크기를 변환해야 할 때 가장 유용합니다. 이는 View hierarchy에서 서로 다른 깊이에 있는 여러 View를 다룰 때 자주 발생합니다. 이 장 앞부분의 flow layout 예제에서는 모든 subview가 같은 parent를 공유했으므로, 서로 다른 coordinate space 사이에서 변환할 필요가 없었습니다.
Matched geometry effect
matchedGeometryEffect API는 하나 이상의 View인 target에 다른 View인 source와 같은 위치, 크기 또는 frame(위치와 크기)을 부여하는 데 사용합니다. source View의 크기와 위치를 측정한 뒤, target View들에 같은 크기를 제안하고 offset을 설정하는 방식으로 작동합니다. 하나의 matched geometry group에 속하는 source와 target View는 view tree의 어느 곳에나 있을 수 있습니다. 이들은 namespace와 보조 identifier로 식별하며, 이 identifier에는 어떤 hashable 값이든 사용할 수 있습니다.
원문 148쪽과 도표

예를 들어 앞의 ellipse 강조 표시 코드를, preference로 anchor를 직접 전달하는 대신 matched geometry effect로 다시 구현할 수 있습니다.
struct ContentView: View {
@Namespace var namespace
let groupID = "highlight"
var body: some View {
VStack {
Button("Sign Up") {}
Button("Log In") {}
.matchedGeometryEffect(id: groupID, in: namespace)
}
.overlay {
Ellipse()
.strokeBorder(Color.red, lineWidth: 1)
.padding(-5)
.matchedGeometryEffect(id: groupID, in: namespace, isSource: false)
}
}
}stack 안의 button과 padding이 적용된 ellipse outline에는 같은 namespace와 공통 identifier를 지정했으므로, 두 View는 같은 geometry group에 속합니다. 여기서는 identifier로 문자열을 사용했지만, 정수나 UUID처럼 다른 hashable 값도 사용할 수 있습니다.
하나의 geometry group에서는 View 하나만 그 group의 source가 될 수 있습니다. isSource parameter의 기본값은 true입니다. button을 기준으로 ellipse를 맞춰야 하므로 overlay의 ellipse에는 명시적으로 false를 지정해야 합니다.
사실 이 장에서 지금까지 설명한 기법으로 matched geometry effect API를 직접 구현할 수도 있습니다. 위쪽 어딘가에 명시적인 View modifier를 두고, preference에서 source geometry를 읽어 environment를 통해 target에 전달하면 됩니다. SwiftUI는 framework이기 때문에 이를 암시적으로 수행할 수 있다는 이점이 있습니다. 그 점을 제외하면 matched geometry effect를 만드는 데 특별한 마법이 있는 것은 아닙니다.

원문 149쪽과 도표

ellipse가 button의 frame에 맞춰질 때는 frame과 offset이 삽입된 것처럼 생각할 수 있습니다. 실제 구현이 반드시 이렇다는 뜻은 아니지만, mental model로는 잘 맞습니다.
VStack {
...
}
.overlay {
Ellipse()
.strokeBorder(Color.red, lineWidth: 1)
.padding(-10)
.frame(width: <matchedWidth>, height: <matchedHeight>)
.offset(x: <offsetX>, y: <offsetY>)
}따라서 source의 크기가 target View에 제안됩니다. 앞서 설명했듯 proposed size를 어떻게 처리할지는 전적으로 frame의 subview가 결정합니다. 예를 들어 target View의 크기가 고정되어 있다면, 즉 resizable이 아닌 image나 또 다른 fixed frame이라면 matched geometry effect는 target View의 크기에 영향을 주지 않습니다.
한 View의 크기를 다른 View에 맞추려면 두 View의 flexibility가 같은 것이 중요합니다. 그렇지 않으면 matching이 작동하지 않을 수 있습니다. 물론 target의 동작을 제한하려고 의도적으로 target View의 flexibility를 제한할 수도 있습니다. 예를 들어 최대 너비가 있는 flexible frame을 사용할 수 있습니다.
Matched geometry effect와 Transition
matched geometry effect는 View가 삽입되기 전과 제거된 후에 적용됩니다. 삽입되거나 제거되는 View에 transition의 active state가 적용되는 방식과 비슷합니다. 덕분에 아주 적은 코드로 한 View에서 다른 View로 부드럽게 전환할 수 있습니다.
geometry group에 속한 새로운 View를 View hierarchy에 삽입하면, 그 View의 isSource parameter가 false로 설정된 것처럼 initial geometry를 계산합니다. 즉, state가 바뀌기 전에 view tree에 있는 source View에 geometry를 맞춥니다. View를 view tree에서 제거하면 그 View의 final geometry는 state가 바뀐 뒤 view tree에 있게 될 source View에 맞춰집니다.
이를 이용하면 한 View에서 다른 View로 전환하는 코드를 매우 쉽게 작성할 수 있습니다. 다음은 30×30 circle과 fullscreen circle 사이를 전환하는 예제입니다.
원문 150쪽과 도표

struct ContentView: View {
@State private var hero = false
@Namespace var namespace
var body: some View {
let circle = Circle().fill(Color.green)
ZStack {
if hero {
circle
.matchedGeometryEffect(id: "image", in: namespace)
} else {
circle
.matchedGeometryEffect(id: "image", in: namespace)
.frame(width: 30, height: 30)
}
}
.onTapGesture {
withAnimation(.default) { hero.toggle() }
}
}
}위 예제에서 hero flag를 토글하면 circle이 30×30 크기와 fullscreen 크기 사이에서 부드럽게 animate되는 모습을 볼 수 있습니다.
이 animation은 두 부분으로 이루어집니다. matched geometry effect가 위치와 크기를 담당하고, 기본 transition인 opacity가 두 View 사이를 blend합니다. 다시 말해 transition 도중에는 두 View가 모두 화면에 있습니다. hero를 false에서 true로 바꾸면 첫 번째 View는 opacity가 0이고 두 번째 View에 맞춘 frame으로 삽입됩니다. 두 번째 View는 state가 바뀌기 전 geometry group의 source View였습니다. transition 도중 첫 번째 View는 fade in하면서, matching된 initial position에서 자신의 final position으로 animate됩니다. 동시에 제거된 두 번째 View는 fade out하면서, state 변경 전의 이전 위치에서 state 변경 후 차지하게 될 위치로 animate됩니다. 이제 새로 삽입된 source View에 matching되기 때문입니다.
modifier의 순서는 매우 중요하면서도 틀리기 쉽다는 점에 주의해야 합니다. 예를 들어 else branch에서 matchedGeometryEffect와 frame의 순서를 바꾸면 작은 circle은 언제나 30×30 크기로 남습니다.
if hero {
...
} else {
circle
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "image", in: namespace)
}앞서 설명했듯 .matchedGeometryEffect modifier는 사실상 .frame과 .offset modifier로 바뀝니다.
원문 151쪽과 도표

너비와 높이가 30 point인 가장 안쪽 frame이 언제나 circle의 크기를 결정하므로, matched geometry effect는 적어도 View의 크기에는 영향을 주지 않습니다.
View를 view tree에서 완전히 제거하거나 삽입하는 경우 외에도, matched geometry effect를 창의적으로 활용하여 View hierarchy에 계속 남아 있는 View를 animate할 수 있습니다. View의 namespace나 identifier를 바꾸면 geometry group에 View를 추가하거나 제거할 수 있습니다.
원문 152쪽과 도표
