Layout

Chris Eidhof와 Florian Kugler의 『Thinking in SwiftUI』 iOS 17 개정판 4장 한국어 번역입니다. 원문 PDF 56–90쪽을 다룹니다.

Layout

원문 56쪽은 4장 「Layout」의 표지입니다.

원문 56쪽과 도표원문 56쪽: Layout 장 표지

SwiftUI의 layout algorithm은 간단합니다. parent view가 subview에 크기를 제안하면 subview는 그 제안에 따라 자신의 크기를 결정하고 parent에 그 크기를 보고합니다. 그러면 parent는 자신의 좌표계 안에 subview를 배치합니다. 본질적으로 layout 과정의 목표는 각 view에 위치와 크기를 부여하는 것입니다.

하지만 view tree가 왜 지금과 같은 모습으로 렌더링되는지 이해하기 어려울 때, algorithm의 동작을 이해하는 일이 언제나 간단하지는 않습니다. 이 장에서는 SwiftUI의 layout 단계를 머릿속으로 따라가며 그 동작을 이해하는 데 필요한 세부 사항을 모두 살펴봅니다.

먼저 기억해야 할 점은 SwiftUI의 layout algorithm이 view tree를 따라 위에서 아래로 진행된다는 것입니다. 따라서 view builder 코드가 만들어 내는 view tree를 이해하는 것이 중요합니다. 이에 관한 자세한 내용은 view tree 장을 다시 참고해 주세요. 실제 view tree에 layout algorithm을 어떻게 적용하는지 더 잘 이해할 수 있도록 예제를 살펴보겠습니다.

VStack {
    Image(systemName: "globe")
    Text("Hello, World!")
}

이 예제에서 VStack은 root view이므로 화면의 safe area를 proposed size로 받습니다. 자신의 크기를 결정하기 위해 stack은 먼저 subview에 재귀적으로 크기를 제안합니다. image는 globe symbol의 크기에 따라 자신의 크기를 보고하고, text는 proposed size와 렌더링해야 하는 문자열에 따라 크기를 보고합니다. Image와 Text가 정확히 어떻게 크기를 결정하는지는 뒤에서 자세히 살펴봅니다. 이제 stack은 두 subview를 위아래로 배치하고, 둘 사이에 기본 spacing을 넣습니다. stack은 subview의 frame들을 합집합한 영역의 크기를 자신의 크기로 계산해 window에 보고합니다.

좀 더 형식적으로 layout algorithm을 설명하면 다음과 같습니다.

  1. parent가 subview에 크기를 제안합니다.
  2. subview는 제안에 따라 자신의 크기를 결정합니다. 자신에게도 subview가 있다면 1단계부터 재귀적으로 시작합니다.
  3. subview가 parent에 자신의 크기를 보고합니다.
  4. parent가 subview를 배치합니다.
원문 57쪽의 도표 1
원문 57쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 57쪽과 도표원문 57쪽: VStack 예제의 코드와 view tree

3단계에서 subview가 보고한 크기는 그 subview의 최종 크기라는 점에 유의해야 합니다. parent는 이 크기를 일방적으로 바꿀 수 없습니다. 2단계로 돌아가 다른 제안을 할 수는 있지만, 결국 제안을 적절히 고려해 자신의 크기를 결정하는 주체는 subview입니다.

이 과정에 쓰이는 API는 공개된 View protocol의 일부가 아니지만, 각 view에 다음과 같은 method가 있다고 생각할 수 있습니다.

extension View {
    func sizeThatFits(in: ProposedViewSize) -> CGSize {
        ...
    }
}

ProposedViewSize type은 iOS 16과 macOS 13부터 공개되었습니다. 이 type은 고급 Layout 장에서 다룰 Layout protocol에 사용됩니다.

struct ProposedViewSize {
    var width, height: CGFloat?
}

ProposedViewSize와 CGSize의 차이는 ProposedViewSize에서는 두 component 모두 optional이라는 점입니다. 한 component에 nil을 제안하면 해당 차원에서 view가 ideal size를 취할 수 있다는 뜻입니다. ideal size는 view마다 다르며, 아래에서 자세히 살펴봅니다.

API의 관점에서 layout algorithm을 표현하면 다음과 같습니다.

  1. parent가 subview의 sizeThatFits를 호출하며 proposed size를 전달합니다.
  2. subview가 proposed size에 따라 자신의 크기를 결정합니다. subview가 있다면 그 subview의 sizeThatFits를 호출할 수도 있습니다. 어떤 view는 proposed size를 완전히 무시합니다. 예를 들어 Image의 기본 동작이 그렇습니다. 반면 어떤 view는 proposed size를 실제 크기로 반환합니다. Rectangle이 그 예입니다. view가 proposed size를 반환하면, 그 view가 proposed size를 받아들인다고 표현합니다.
  3. subview가 sizeThatFits method의 반환값으로 parent에 자신의 크기를 보고합니다.
  4. parent가 자신의 alignment와 subview의 alignment guide에 따라 subview를 배치합니다. alignment가 어떻게 동작하는지는 이 장의 뒤에서 자세히 살펴봅니다.

다음은 모든 단계와 관련 view의 구체적인 크기까지 포함한 좀 더 자세한 예제입니다.

원문 58쪽과 도표원문 58쪽: ProposedViewSize와 layout algorithm
Text("Favorite")
    .padding(10)
    .background(Color.teal)

이 예제에서는 window의 safe area가 320×480이라고 가정하겠습니다.

  1. system이 background에 320×480을 제안합니다.
  2. background가 primary subview인 padding에 같은 320×480을 제안합니다.
  3. padding이 각 edge에서 10 point씩 빼고 text에 300×460을 제안합니다.
  4. text가 자신의 크기를 51×17로 보고합니다.
  5. padding이 각 edge에 10 point씩 더하고 자신의 크기를 71×37로 보고합니다.
  6. background가 padding을 적용한 text의 크기인 71×37을 secondary subview인 Color에 제안합니다.
  7. color가 제안된 71×37을 받아들이고 보고합니다.
  8. background가 primary subview의 크기인 71×37을 보고합니다.

SwiftUI의 layout system이 view tree를 순회하는 전반적인 방식을 이해했으니, 다음으로 여러 SwiftUI built-in view의 구체적인 layout 동작을 살펴보겠습니다.

view가 어떻게 배치되는지 이해하려 할 때 저희가 가장 즐겨 쓰는 방법은 view에 border를 넣는 것입니다. 여기에 geometry reader를 포함한 overlay를 추가하면 view의 크기도 렌더링할 수 있습니다. 하지만 저희 경험으로는 border만으로도 거의 모든 layout 문제를 debug하는 데 도움이 됩니다.

전반적인 layout algorithm은 한두 문장으로 요약할 수 있지만, 모든 built-in view의 구체적인 layout 동작을 이해하는 일은 쉽지 않습니다. 그래도 장기적으로 SwiftUI를 사용하면서 지나치게 추측에 의존하지 않으려면 이를 이해하는 것이 중요합니다.

원문 59쪽의 도표 1
원문 59쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 59쪽과 도표원문 59쪽: padding과 background의 layout 단계

이 절에서는 layout과 관련된 여러 SwiftUI view와 view modifier를 살펴보고, 이들이 자신의 크기를 어떻게 결정하는지, subview에 어떤 크기를 제안하는지, ideal size는 무엇으로 결정되는지 설명합니다. leaf view, 즉 subview가 없는 view부터 시작해 view modifier를 살펴본 다음 container view를 다룹니다.

Leaf view

Text

기본적으로 Text view는 어떤 proposed size에도 자신을 맞춥니다. 이를 위해 Text는 순서대로 여러 전략을 사용합니다. text를 여러 줄로 나누고(word wrapping), 단어를 나누고(line wrapping), text를 truncate한 다음, 마지막으로 clip합니다. Text는 언제나 content를 렌더링하는 데 필요한 정확한 크기를 보고합니다. 너비는 proposed width보다 작거나 같고, 높이는 적어도 한 줄의 높이입니다. 다만 0×0을 제안하는 경우는 예외입니다. 다시 말해 Text는 0부터 content 전체를 렌더링하는 데 필요한 크기까지 어떤 너비든 취할 수 있습니다.

원문 도표에는 proposed size에 따라 Text("Hello, World!")가 어떻게 렌더링되는지 보여 주는 몇 가지 예가 있습니다. 점선 사각형은 proposed size를, 실선 사각형은 reported size를 나타냅니다.

Text의 동작을 바꾸기 위해 사용할 수 있는 modifier가 몇 가지 있습니다.

  • .lineLimit(_ number:)는 세로 공간이 더 많이 제안되었는지와 관계없이 렌더링할 최대 줄 수를 지정합니다. nil을 지정하면 줄 수 제한이 없습니다.
  • .lineLimit(_ limit:reservesSpace:)는 렌더링할 최대 줄 수를 지정하면서, 해당 줄이 비어 있는지와 관계없이 그 줄들을 위한 공간을 항상 reported size에 포함할지도 선택할 수 있게 합니다.
  • .truncationMode(_ mode:)는 truncation을 적용할 위치를 지정합니다.
  • .minimumScaleFactor(_ factor:)는 proposed size에 text를 맞추기 위해 Text가 font size를 얼마나 줄일 수 있는지 지정합니다.
원문 60쪽의 도표 1
원문 60쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 60쪽과 도표원문 60쪽: proposed size에 따른 Text의 렌더링과 크기

Text에 .fixedSize()를 적용하면 ideal size를 취합니다. fixedSize가 text에 nil×nil을 제안하기 때문입니다. text의 ideal size는 wrapping과 truncation 없이 content를 렌더링하는 데 필요한 크기입니다. 원문 도표에는 서로 다른 proposed size에서 Text("Hello, World!").fixedSize()가 어떻게 렌더링되는지 보여 주는 예가 있습니다.

실선 사각형(reported size)이 점선 사각형(proposed size) 바깥으로 렌더링되는 모습에서 알 수 있듯이, fixedSize modifier는 어떤 크기가 제안되든 subview인 text가 항상 ideal size로 렌더링되도록 합니다. 이는 SwiftUI에서 parent view가 subview의 proposed size 준수를 기대할 수 없다는 좋은 예입니다. 최종적으로 자신의 크기를 결정하는 주체는 subview입니다.

Shape

대부분의 built-in shape인 Rectangle, RoundedRectangle, Capsule, Ellipse는 0부터 무한대까지 어떤 proposed size든 받아들이고 사용 가능한 공간을 채웁니다. Circle은 예외입니다. 어떤 proposed size든 그 안에 자신을 맞추고 circle의 실제 크기를 보고합니다. shape에 nil을 제안하면, 즉 .fixedSize로 감싸면, 10×10이라는 기본 크기를 취합니다.

built-in shape뿐 아니라 custom shape도 구현할 수 있습니다. 현재 이곳은 SwiftUI에서 proposed size에 따라 크기를 계산하는 logic을 직접 작성할 수 있는 몇 안 되는 지점 중 하나입니다. 예제로 bookmark shape를 만들어 보겠습니다.

원문 61쪽의 도표 1
원문 61쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 61쪽과 도표원문 61쪽: fixedSize가 적용된 Text와 Shape의 크기 결정
struct Bookmark: Shape {
    func path(in rect: CGRect) -> Path {
        Path { p in
            p.move(to: rect[.topLeading])
            p.addLines([
                rect[.bottomLeading],
                rect[.init(x: 0.5, y: 0.8)],
                rect[.bottomTrailing],
                rect[.topTrailing],
                rect[.topLeading]
            ])
            p.closeSubpath()
        }
    }
}

이 shape를 더 쉽게 작성하기 위해 다음과 같은 CGRect extension을 사용합니다. UnitPoint를 받아 rectangle 안의 해당 지점을 반환합니다.

extension CGRect {
    subscript(_ point: UnitPoint) -> CGPoint {
        CGPoint(x: minX + width*point.x, y: minY + height*point.y)
    }
}

이 shape를 항상 2×3의 aspect ratio로 렌더링해야 한다면, 사용하는 곳에서 .aspectRatio modifier를 적용할 수 있습니다. 하지만 API의 다른 사용자가 실수로 bookmark를 다른 aspect ratio로 렌더링할 수도 있습니다. bookmark shape가 항상 2×3의 aspect ratio를 유지하도록 sizeThatFits를 구현할 수 있습니다. shape의 기본 구현은 proposed size를 반환합니다. 이를 aspect ratio를 2×3으로 고정한 구현으로 override할 수 있습니다.

func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize {
    var result = proposal.replacingUnspecifiedDimensions()
    let ratio: CGFloat = 2/3
    let newWidth = ratio * result.height
    if newWidth <= result.width {
        result.width = newWidth
    } else {
        result.height = result.width / ratio
    }
    return result
}
원문 62쪽의 도표 1
원문 62쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 62쪽과 도표원문 62쪽: Bookmark shape와 sizeThatFits 구현

proposed size의 component가 nil일 수 있으므로 먼저 replacingUnspecifiedDimensions를 호출해 CGSize를 얻어야 합니다. 그런 다음 shape가 2×3의 aspect ratio를 유지하면서 proposed size 안에 들어가도록 너비와 높이를 계산합니다.

이 sizeThatFits method를 container view를 더 잘 이해하는 데 사용할 수도 있습니다. 예를 들어 shape에 proposed size와 reported size를 logging하는 코드를 추가하고 이 shape를 HStack 안에 사용하면, HStack이 subview에 무엇을 제안하는지 console에서 확인할 수 있습니다. sizeThatFits는 View protocol이 아니라 Shape protocol의 일부라는 점에 유의해 주세요.

Color

Color.red처럼 color를 직접 view로 사용하면 layout의 관점에서 Rectangle().fill(...)과 똑같이 동작합니다.

하지만 특별한 경우가 있습니다. non-safe area에 닿는 background에 color를 넣으면, color가 자동으로 non-safe area까지 번집니다. 이 동작은 layout에 영향을 주지는 않지만 누구나 언젠가는 마주칠 가능성이 높으므로 여기서 언급합니다. 이를 막으려면 .background의 ignoresSafeAreaEdges parameter를 사용하거나 Color 대신 Rectangle().fill(...)을 사용할 수 있습니다.

Image

기본적으로 Image view는 원본 image의 크기라는 고정된 값을 보고합니다. image에 .resizable()을 호출하면 view가 완전히 flexible해집니다. 그러면 Image는 어떤 proposed size든 받아들여 그대로 보고하고, image를 그 크기에 맞춥니다. 실제로는 거의 모든 resizable image에 .aspectRatio(contentMode:) 또는 .scaleToFit() modifier를 함께 적용해 image가 왜곡되지 않도록 합니다. 이 modifier들은 다음 절에서 다룹니다.

원문 63쪽의 도표 1
원문 63쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 63쪽과 도표원문 63쪽: Bookmark 크기 예제와 Color, Image의 layout

resizable modifier에는 두 parameter가 있습니다. 첫 번째 parameter인 capInsets로 크기를 바꾸지 않을 edge 쪽 부분을 지정할 수 있습니다. 두 번째 parameter로 resizing mode를 지정할 수 있습니다. image를 stretch하거나 tile할 수 있습니다.

Divider

Divider를 horizontal stack 바깥에서 사용하면 어떤 proposed width든 받아들이고 divider 선의 높이를 보고합니다. horizontal stack 안에서는 proposed height를 받아들이고 divider 선의 너비를 보고합니다. nil을 제안하면 context에 따라 flexible한 축에서 기본 크기 10을 취합니다.

Spacer

horizontal stack이나 vertical stack 바깥에서 Spacer는 minimum length부터 무한대까지 어떤 proposed size든 받아들입니다. 하지만 stack 안에 놓으면 동작이 달라집니다. vertical stack 안의 Spacer는 minimum length부터 무한대까지 어떤 높이든 받아들이지만, 너비는 0으로 보고합니다. horizontal stack 안에서도 축만 바뀔 뿐 같은 방식으로 동작합니다. spacer의 minimum length는 initializer의 minLength parameter로 따로 지정하지 않는 한 기본 padding의 길이입니다.

spacer의 흔한 용도는 view를 정렬하는 것입니다. 예를 들어 다음과 같은 코드를 자주 볼 수 있습니다.

HStack {
    Spacer()
    Text("Some right-aligned text")
}

저희는 대신 alignment를 지정한 flexible frame을 권합니다. flexible frame은 다음 절에서 자세히 다룹니다.

Text("Some right-aligned text")
    .frame(maxWidth: .infinity, alignment: .trailing)

HStack과 Spacer를 쓰는 방법보다 이 방법을 선호하는 이유는 HStack을 사용하는 방법에 바로 드러나지 않을 수 있는 edge case가 있기 때문입니다. Spacer에는 기본 spacing과 같은 기본 minimum length가 있습니다. 따라서 Spacer도 HStack의 proposed width 일부를 차지하므로, text가 필요 이상으로 일찍 wrapping되거나 truncate될 수 있습니다.

크기 변경 동작의 차이를 빠르게 확인하는 방법은 기본적으로 크기를 조절할 수 있는 Mac application의 content view 안에 VStack을 두고 위의 두 view를 함께 넣는 것입니다.

원문 64쪽과 도표원문 64쪽: Image, Divider와 Spacer의 layout 동작

View modifier

view modifier는 언제나 기존 view를 한 겹 더 감쌉니다. modifier가 적용 대상 view의 parent가 되는 것입니다. SwiftUI에는 ViewModifier protocol을 따르는 값을 적용하는 .modifier API가 있지만, SwiftUI의 built-in modifier는 모두 View extension으로 공개되어 있습니다. 직접 만드는 view modifier도 이 방식을 따르는 것이 좋습니다. 이 절에서는 layout에 영향을 주는 view modifier를 설명합니다.

Padding

.padding modifier는 padding 값을 사용해 지정한 edge에서 padding을 빼는 방식으로 proposed size를 변경합니다. 이렇게 변경한 크기를 .padding의 subview에 제안합니다. subview가 자신의 크기를 보고하면 padding은 그 크기에 지정한 edge의 padding을 더하고, 늘어난 크기를 자신의 크기로 보고합니다.

.padding에는 유용한 두 가지 형태가 있습니다.

  • .padding(_ inset:)은 EdgeInset 값을 사용해 한 번의 호출로 edge마다 서로 다른 padding 값을 지정할 수 있습니다.
  • .padding(_ edges:_ length:)는 edge 집합에 하나의 padding 값을 지정할 수 있습니다. edge를 지정할 때 .horizontal과 .vertical이 특히 유용합니다.

역주: 원문에는 type 이름이 EdgeInset으로 표기되어 있습니다. 실제 SwiftUI type 이름은 EdgeInsets입니다. 위 본문은 원문 표기를 유지합니다.

구체적인 값을 지정하지 않으면, 예를 들어 인자 없이 .padding()을 작성하면, 현재 platform의 기본 padding이 사용됩니다.

Fixed frame

fixed frame modifier인 .frame(width:height:alignment)의 layout 동작은 매우 간단합니다. subview에 지정한 크기를 정확히 제안하고, subview가 보고한 크기와 관계없이 지정한 크기를 정확히 자신의 크기로 보고합니다. 다시 말해 fixed frame은 지정한 크기를 정확히 갖는 보이지 않는 view이며, 이를 통해 다른 view에 특정 크기를 제안할 수 있습니다.

width나 height 중 하나만 지정하고 다른 parameter를 nil로 지정하거나 아예 생략하면, fixed frame은 지정하지 않은 차원에 손대지 않습니다. 그 차원에서는 frame의 proposed size가 subview로 그대로 전달되고, frame은 subview의 크기를 자신의 크기로 보고합니다.

dynamic type과 다양한 화면 크기를 지원해야 하므로 production code에서 fixed frame을 사용하는 일은 실제로 꽤 드뭅니다. 본질적으로 scale할 수 없거나 scale해서는 안 되는 특정 디자인 요소처럼 꼭 필요한 경우가 아니라면, fixed frame에 magic number를 하드코딩하지 않는 것이 좋습니다.

원문 65쪽과 도표원문 65쪽: view modifier와 Padding, Fixed frame

Flexible frame

flexible frame API에는 parameter가 아주 많습니다. frame의 width와 height 각각에 minimum, maximum, ideal 값을 지정할 수 있을 뿐 아니라 alignment도 지정할 수 있습니다. flexible frame의 동작은 그다지 직관적이지 않지만 유용한 도구이므로, 그 동작을 익히는 것이 중요합니다. 먼저 minimum과 maximum 경계 parameter만 살펴보고, ideal width와 height parameter는 잠시 제외하겠습니다.

flexible frame은 minimum과 maximum 경계를 두 번 적용합니다. 한 번은 subview에 제안할 크기를 정할 때이고, 다른 한 번은 자신의 reported size를 정할 때입니다.

들어오는 크기, 즉 frame의 subview에 제안할 크기를 계산할 때의 clamping이 둘 중 더 간단합니다. flexible frame은 proposed size를 받아 지정된 minimum과 maximum parameter로 clamp합니다. 그런 다음 이 크기를 frame의 subview에 제안합니다.

subview의 크기 계산이 끝나면, flexible frame은 경계를 다시 적용해 proposed size에 따라 자신의 크기를 결정합니다. 하지만 이번에는 빠진 경계 값, 예를 들어 minWidth만 지정하고 maxWidth는 지정하지 않은 경우의 값이 subview의 reported size로 대체됩니다. 그런 다음 이 경계로 proposed size를 clamp하고, 그 결과를 frame의 크기로 보고합니다.

실제로 이는 minWidth만 지정하면 flexible frame의 너비가 최소 minWidth, 최대 subview의 너비가 된다는 뜻입니다. maxWidth만 지정하면 flexible frame의 너비는 최소 subview의 너비, 최대 maxWidth가 됩니다. height에도 같은 규칙이 적용됩니다.

이 동작 덕분에 SwiftUI에서 흔히 쓰이는 두 가지 패턴이 가능합니다. 처음에는 이해하기 어렵지만 몇 번 사용하면 익숙해집니다.

첫 번째로 흔히 쓰는 flexible frame 사용법은 다음과 같습니다.

Text("Hello, World!")
    .frame(maxWidth: .infinity)
    .background(.quaternary)
원문 66쪽의 도표 1
원문 66쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 66쪽과 도표원문 66쪽: Flexible frame의 clamping과 maxWidth 예제

.frame(maxWidth: .infinity) 패턴은 flexible frame이 적어도 proposed width만큼 넓어지도록 하며, subview가 그보다 넓으면 subview의 너비를 취하도록 합니다. 흔히 사용 가능한 전체 너비를 차지하는 view를 만들 때 사용합니다.

이 패턴을 View extension으로 추상화할 수 있습니다.

extension View {
    func proposedWidthOrGreater() -> some View {
        frame(maxWidth: .infinity)
    }
}

흔히 쓰는 또 다른 패턴은 다음과 같습니다.

Text("Hello, World!")
    .frame(minWidth: 0, maxWidth: .infinity)
    .background(Color.teal)

이렇게 하면 subview의 크기와 관계없이 frame이 항상 proposed width와 정확히 같은 너비를 갖습니다. 이 경우에도 helper function을 작성할 수 있습니다.

extension View {
    func acceptProposedWidth() -> some View {
        frame(minWidth: 0, maxWidth: .infinity)
    }
}

두 패턴은 height에도 적용할 수 있습니다.

앞선 예제를 바탕으로 다음 view가 320×480 화면에서 렌더링되면 어떤 일이 일어나는지 살펴보겠습니다.

원문 67쪽과 도표원문 67쪽: Flexible frame의 두 가지 너비 패턴
Text("Hello, World!")
    .frame(minWidth: 0, maxWidth: .infinity)
    .background(Color.teal)
    .padding(10)
  1. system이 padding에 320×480을 제안합니다.
  2. padding이 background에 300×460을 제안합니다.
  3. background가 primary subview인 frame에 같은 300×460을 제안합니다.
  4. frame이 subview인 text에 같은 300×460을 제안합니다.
  5. text가 자신의 크기를 76×17로 보고합니다.
  6. frame의 너비는 max(0, min(.infinity, 300)) = 300이 됩니다. 여기서 0과 .infinity는 flexible frame에 지정한 인자입니다.
  7. background가 flexible frame의 크기인 300×17을 secondary subview인 Color에 제안합니다.
  8. color가 proposed size를 받아들이고 보고합니다.
  9. background가 primary subview의 크기인 300×17을 보고합니다.
  10. padding이 각 edge에 10 point씩 더하고 자신의 크기를 320×37로 보고합니다.

flexible frame API는 SwiftUI에서 ideal size를 명시적으로 지정할 수 있는 유일한 API입니다. ideal size란 한 차원 또는 두 차원 모두에 nil을 제안했을 때 취하는 크기입니다. idealWidth나 idealHeight parameter를 지정하면 이 크기를 frame의 subview에 제안하고, subview의 크기와 관계없이 frame 자신의 크기로도 보고합니다.

Aspect ratio

aspectRatio modifier는 완전히 flexible한 view를 다룰 때 가장 유용합니다. 예를 들어 다음 코드로 aspect ratio가 4/3인 rectangle을 그릴 수 있습니다.

원문 68쪽의 도표 1
원문 68쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 68쪽과 도표원문 68쪽: Flexible frame과 background, padding의 layout 단계
Color.secondary
    .aspectRatio(4/3, contentMode: .fit)

aspectRatio modifier는 proposed size 안에 들어가는 4/3의 aspect ratio를 가진 rectangle을 계산해 subview에 제안합니다. parent view에는 proposed size나 지정한 aspect ratio와 관계없이 항상 subview의 크기를 보고합니다.

이 예제를 좀 더 구체적으로 살펴보기 위해 proposed size가 200×200이라고 가정하겠습니다. 그러면 aspect ratio는 proposed size 안에 들어가면서 너비와 높이의 비율이 4/3인 가장 큰 rectangle을 계산합니다. 이 경우에는 200×150입니다. 이 크기를 Color에 제안하면 Color는 제안을 받아들입니다. 그런 다음 aspectRatio는 자신의 크기를 200×150으로 보고합니다.

contentMode에 .fit 대신 .fill을 사용하면 어떤 일이 일어나는지 살펴보겠습니다.

Color.secondary
    .aspectRatio(4/3, contentMode: .fill)

이번에도 proposed size가 200×200이라고 가정하고 시작합니다. 이를 바탕으로 .aspectRatio는 너비와 높이의 비율이 4/3이면서 proposed size 전체를 덮는 가장 작은 rectangle을 계산합니다. 그 크기는 약 266.6×200입니다. 이 크기를 Color에 제안하면 Color는 제안을 받아들입니다. 그런 다음 .aspectRatio는 subview의 크기인 266.6×200을 자신의 크기로 반환합니다.

.aspectRatio의 흔한 사용 사례는 image입니다. 앞에서 살펴봤듯이 image에 .resizable을 호출해 flexible하게 만들면 어떤 크기에도 맞추기 위해 image를 왜곡합니다. 하지만 대부분은 원본 image의 aspect ratio를 유지하면서 사용 가능한 공간에 맞추거나 그 공간을 채우도록 image를 scale하고 싶습니다. 원본 image의 aspect ratio를 몰라도 다음과 같이 작성할 수 있습니다.

Image("header")
    .resizable()
    .aspectRatio(contentMode: .fit)

.scaleToFit과 .scaleToFill modifier는 각각 .aspectRatio(contentMode: .fit)과 .aspectRatio(contentMode: .fill)의 축약형입니다.

역주: 원문에는 .scaleToFit과 .scaleToFill로 표기되어 있습니다. 실제 SwiftUI API 이름은 .scaledToFit()과 .scaledToFill()입니다. 위 본문은 원문 표기를 유지합니다.

원문 69쪽의 도표 1
원문 69쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 69쪽과 도표원문 69쪽: Aspect ratio의 fit과 fill 및 image 예제

구체적인 aspect ratio를 지정하지 않았으므로 .aspectRatio modifier는 subview에 nil×nil을 제안해 ideal size를 알아보고, 그 크기를 바탕으로 aspect ratio를 계산합니다. proposed size가 200×200이고 image의 ideal size가 100×30이라고 가정하면 이 예제의 layout 단계는 다음과 같습니다. resizable modifier는 tree에 새로운 layer를 추가하는 대신 Image 자체를 변경한다는 점에 유의해 주세요.

Image("header")
    .resizable()
    .aspectRatio(contentMode: .fit)
  1. aspectRatio에 200×200의 크기가 제안됩니다.
  2. aspectRatio가 image에 nil×nil을 제안합니다.
  3. image가 자신의 ideal size인 100×30을 보고합니다.
  4. aspectRatio가 100/30의 aspect ratio를 가진 rectangle을 200×200 안에 맞춥니다. 그 결과인 200×60을 image에 제안합니다.
  5. image가 자신의 크기를 200×60으로 보고합니다.
  6. aspectRatio가 subview의 크기인 200×60을 자신의 크기로 보고합니다.

aspectRatio view의 최종 aspect ratio가 반드시 지정한 값이 되는 것은 아닙니다. modifier는 그 aspect ratio를 가진 크기를 subview에 제안하지만, 늘 그렇듯 제안을 바탕으로 자신의 크기를 결정하는 일은 subview의 몫입니다. subview가 지정된 aspect ratio에 맞출 만큼 flexible하지 않다면 aspectRatio modifier 자체도 지정된 비율이 되지 않습니다. subview의 크기를 자신의 크기로 취하기 때문입니다.

Overlay와 Background

overlay와 background는 SwiftUI에서 가장 유용한 modifier에 속합니다. layout 측면에서는 정확히 같은 방식으로 동작합니다. 유일한 차이는 overlay가 secondary view를 primary view 위에 그리는 반면, background는 secondary view를 primary view 뒤에 그린다는 것입니다. 예를 들어 text 뒤에 background를 그리려면 다음과 같이 작성할 수 있습니다.

원문 70쪽의 도표 1
원문 70쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 70쪽과 도표원문 70쪽: Image의 ideal size를 이용한 aspectRatio의 layout 단계
Text("Hello")
    .background(Color.teal)

동작 방식은 꽤 간결합니다. background는 먼저 primary subview, 위 예제에서는 Text의 크기를 정합니다. 그런 다음 primary subview의 크기를 secondary subview에 제안합니다. 따라서 teal color가 text와 정확히 같은 크기가 됩니다. text에 padding을 포함하도록 바꿀 수도 있습니다.

Text("Hello")
    .padding(10)
    .background(Color.teal)

이제 teal background의 크기는 text에 각 edge의 padding 10 point를 더한 크기와 정확히 같아집니다.

background와 overlay는 primary subview의 layout에 영향을 주지 않습니다. overlay나 background의 reported size는 항상 primary subview의 reported size입니다. 예를 들어 view 뒤에 view 자체보다 약간 큰 orange background를 그리는 highlight modifier를 만들 수 있습니다.

extension View {
    func highlight(enabled: Bool = true) -> some View {
        background {
            if enabled {
                Color.orange
                    .padding(-3)
            }
        }
    }
}

enabled flag를 toggle해도 view의 sibling은 움직이지 않습니다. background modifier는 background 안의 view인 secondary subview와 관계없이 언제나 primary subview의 크기를 취합니다.

원문 71쪽의 도표 1
원문 71쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 71쪽의 도표 2
원문 71쪽의 도표 2. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 71쪽과 도표원문 71쪽: Background의 primary subview 크기와 highlight 예제

overlay나 background 안에서 secondary view가 여러 view를 포함하는 view list이면, 이 view들은 implicit ZStack 안에 배치됩니다.

Fixed size

fixedSize() modifier는 자신에게 제안된 크기와 관계없이 subview의 width와 height에 nil을 제안합니다. 이렇게 하면 subview는 ideal size를 취합니다. fixedSize(horizontal:vertical:) overload를 사용하면 subview가 한 차원에서만 ideal size를 취하도록 만들 수 있습니다. 예를 들어 width에는 nil을 제안하고 height에는 그러지 않을 수 있습니다.

fixedSize의 가장 흔한 사용 사례 중 하나는 Text view입니다. 앞에서 설명했듯이 Text는 truncation이나 word wrapping 등을 사용해 proposed size 안에서 렌더링되려고 합니다. 이를 피하는 한 가지 방법은 text에 nil×nil을 제안해 ideal size를 취하도록 하는 것입니다. 다만 view tree에 따라 text가 bounds 바깥에 그려질 수 있습니다. 그 예가 원문 도표에 나와 있습니다.

이 예제에서는 text가 frame modifier의 bounding rect 바깥에 그려지는 모습을 볼 수 있습니다. 여기서는 text에 특정 크기를 제안하기 위해 frame modifier를 사용합니다. 이렇게 그려지는 이유는 SwiftUI의 view가 기본적으로 clip되지 않기 때문입니다. clipping이 필요하면 clipped modifier로 활성화할 수 있습니다.

방금 살펴본 것처럼 overlay와 background에는 유용한 성질이 있습니다. 둘 다 primary subview의 크기를 secondary subview에 제안합니다. 하지만 secondary subview의 크기가 언제나 primary subview에 의존하기를 원하는 것은 아닐 수 있습니다. 예를 들어 view의 top trailing corner에 badge를 렌더링하는 custom badge modifier를 만든다고 생각해 보겠습니다.

원문 72쪽의 도표 1
원문 72쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 72쪽과 도표원문 72쪽: fixedSize로 인해 proposed bounds를 벗어나는 Text
extension View {
    func badge<Badge: View>(@ViewBuilder contents: () -> Badge)
        -> some View {
        self.overlay(alignment: .topTrailing) {
            contents()
                .padding(3)
                .background {
                    RoundedRectangle(cornerRadius: 5).fill(.teal)
                }
        }
    }
}

위 modifier는 badge의 ideal size가 적용 대상 view의 크기보다 작으면 잘 동작합니다. 예를 들어 큰 button에 text가 "1"뿐인 badge를 표시하려면 잘 동작합니다. 하지만 Text("Hi") 같은 view에 큰 badge를 적용하면 badge는 필요한 크기를 취하지 못합니다.

Text("Hi").badge {
    Text("2023").font(.caption)
}

이를 해결하려면 overlay 안에 fixedSize를 넣으면 됩니다. 그러면 badge가 적용 대상 view의 크기 대신 자신의 ideal size를 취하도록 할 수 있습니다.

extension View {
    func badge<Badge: View>(@ViewBuilder contents: () -> Badge) -> some View {
        self.overlay(alignment: .topTrailing) {
            contents()
                .padding(3)
                .background(RoundedRectangle(cornerRadius: 5))
                .fixedSize()
        }
    }
}
원문 73쪽의 도표 1
원문 73쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 73쪽의 도표 2
원문 73쪽의 도표 2. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 73쪽과 도표원문 73쪽: Badge modifier의 크기 문제와 fixedSize 적용

badge의 alignment는 이 장의 뒤에서 alignment guide를 살펴볼 때 개선하겠습니다.

Container view

HStack과 VStack

저희 경험으로 HStack과 VStack은 대체로 직관적으로 동작하지만, 그렇지 않을 때도 있습니다. 그런 경우 정확히 무슨 일이 일어나는지 알아내기는 꽤 복잡할 수 있습니다. stack의 layout algorithm이 상당히 복잡하기 때문입니다.

horizontal stack과 vertical stack은 주축만 다를 뿐 같은 방식으로 subview를 배치합니다. 따라서 아래에서는 HStack에 한정해 설명하겠습니다.

다음 stack을 살펴보겠습니다.

HStack(spacing: 0) {
    Color.cyan
    Text("Hello, World!")
    Color.teal
}

충분히 큰 너비를 제안하면 예상대로 렌더링됩니다. "Hello, World!" text의 왼쪽에는 파란색, 오른쪽에는 초록색이 나타납니다. 하지만 너비가 작아지면 흥미로운 일이 일어납니다. text를 전부 표시하고 두 color를 위한 공간도 남길 수 있을 만큼 충분한 공간이 있는데도 text가 wrapping됩니다.

이 동작은 HStack algorithm이 사용 가능한 너비를 subview 사이에 나누는 방식 때문에 발생합니다.

  • 먼저 stack은 subview의 flexibility를 파악합니다. 두 color는 무한히 flexible하므로 제안한 어떤 크기든 취합니다. 반면 Text에는 상한이 있습니다. 0부터 ideal size까지 어떤 크기든 취할 수 있지만, 그보다 커질 수는 없습니다.
원문 74쪽의 도표 1
원문 74쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 74쪽과 도표원문 74쪽: HStack의 proposed width에 따른 Text wrapping
  • 그런 다음 stack은 flexibility가 가장 작은 것부터 가장 큰 것 순으로 subview를 정렬합니다. 아직 처리하지 않은 subview와 남은 너비를 계속 추적합니다.
  • 남은 subview가 있는 동안 stack은 남은 너비를 남은 subview의 수로 나눈 값을 제안합니다.

설명을 간단히 하기 위해 text의 ideal width가 100 point라고 가정하겠습니다. HStack에 180×180을 제안하면, 먼저 flexibility가 가장 작은 subview인 text에 너비 180/3을 제안합니다. 그러면 text는 필요에 따라 wrapping되거나 truncate됩니다. 줄바꿈을 넣어 50×40 point가 되었다고 가정하겠습니다. 그런 다음 두 rectangle에는 각각 130/2와 65/1 point가 제안됩니다. 이 algorithm 때문에 text를 위한 공간이 충분한데도 text는 줄바꿈 없이 표시될 만큼 커지지 않습니다.

이 동작을 바꾸는 방법이 몇 가지 있습니다. 먼저 text에 fixedSize() modifier를 적용할 수 있습니다. 그러면 text는 제안을 무시하고 항상 ideal size를 취합니다. 잘 동작하는 방법이지만, HStack에 text의 ideal width보다 작은 너비를 제안해도 text는 여전히 전체 너비로 렌더링됩니다. 다시 말해 제안보다 넓게, bounds 바깥으로 렌더링됩니다.

다른 방법은 .layoutPriority modifier로 text에 layout priority를 부여하는 것입니다. 그러면 HStack은 먼저 남은 전체 너비를 text에 제안하고, 그 이후에 남은 너비로 두 color에 크기를 제안합니다. 이렇게 하면 text를 ideal size로 렌더링할 공간이 부족해지는 시점부터 word wrapping이 시작됩니다.

stack layout의 algorithm을 더 잘 이해할 수 있도록 비공식적인 설명을 제시하면 다음과 같습니다.

  1. 각 subview에 0×proposedHeight와 .infinity×proposedHeight를 모두 제안해 모든 subview의 flexibility를 파악합니다. 이렇게 subview에 여러 크기를 제안하는 과정을 probing이라고도 합니다. flexibility는 두 결과의 width 차이입니다.
  2. 모든 subview를 layout priority에 따라 group으로 묶습니다.
  3. proposed width에서 모든 subview의 minimum width와 그 사이의 spacing을 뺍니다. 그 결과가 remainingWidth입니다. proposed width가 nil이었다면 이 값도 nil일 수 있습니다.
  4. 남은 group이 있는 동안 다음 과정을 반복합니다.
원문 75쪽의 도표 1
원문 75쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 75쪽과 도표원문 75쪽: HStack의 flexibility, fixedSize와 layoutPriority

위 4단계에서 각 group을 처리하는 과정은 다음과 같습니다.

  1. layout priority가 가장 높은 group을 선택합니다.
  2. 이 group에 속한 subview의 minimum width 합계를 remainingWidth에 다시 더합니다.
  3. group에 남은 view가 있는 동안 다음 과정을 반복합니다.
    1. flexibility가 가장 작은 view를 선택합니다.
    2. (remainingWidth/numberOfRemainingViews)×proposedHeight를 제안합니다.
    3. view의 reported width를 remainingWidth에서 뺍니다.

이 algorithm은 공식적으로 문서화되어 있지는 않지만, 2020년 이 책의 초판에서 처음 설명한 이후로 바뀌지 않았으며 저희는 안정적이라고 봅니다. sizeThatFits를 구현해 proposed size와 reported size를 logging하는 custom shape를 stack 안에 넣으면, 이 동작을 적어도 부분적으로 검증할 수 있습니다.

ZStack

언뜻 ZStack은 overlay나 background와 같은 일을 하는 것처럼 보일 수 있지만, 동작 방식은 다릅니다. 앞서 설명했듯이 overlay와 background는 primary subview의 크기를 취하고 secondary subview의 크기는 무시합니다. ZStack을 사용하면 subview의 frame들을 합집합한 영역을 사용해 stack 자신의 크기를 계산합니다.

다음 view를 살펴보겠습니다.

ZStack {
    Color.teal
    Text("Hello, world")
}

이를 새로운 iOS application의 root view로 사용하면 color가 safe area 전체 크기로 늘어납니다. ZStack에 safe area 전체가 제안되고, ZStack이 그 제안을 받아 각 subview에 같은 값을 제안하기 때문입니다.

overlay와 background 절의 badge 예제를 overlay 대신 ZStack으로 구현하면, badge는 적용 대상 view의 layout에 영향을 줍니다. ZStack의 크기에 badge가 포함되기 때문입니다. badge를 적용한 view 하나만 보면 같아 보이지만, 다른 view와의 관계에서 layout 동작은 달라집니다.

원문 76쪽과 도표원문 76쪽: HStack layout algorithm의 나머지 단계와 ZStack

원문 도표의 두 view는 HStack 안에서 bottom alignment로 정렬되어 있습니다. 둘 다 top trailing alignment의 badge가 있습니다. 왼쪽 view는 badge에 overlay를 사용하므로 badge view의 크기가 layout에 영향을 주지 않습니다. 반면 오른쪽 view는 badge에 ZStack을 사용하므로 이 view의 bottom은 badge의 bottom이 됩니다.

Scroll view

scroll view에서는 실제 scroll view의 layout 동작과 그 안의 content의 layout 동작을 구분해야 합니다. content는 scroll view의 보이는 영역 안에서 scroll됩니다.

scroll view 자체는 scroll 축에서 proposed size를 받아들이고, 다른 축에서는 content의 크기를 취합니다. 예를 들어 vertical scroll view를 root view로 배치하면 높이는 safe area 전체 높이가 되고, 너비는 scroll content의 너비가 됩니다.

scroll 축에서 scroll view는 사실상 무제한의 공간을 가지며, content는 그 축에서 원하는 만큼 커질 수 있습니다. 따라서 scroll view는 scroll 축 또는 축들에 nil을 제안하고, 다른 축에는 크기를 변경하지 않고 제안합니다. 다음 예제를 살펴보겠습니다.

ScrollView {
    Image("logo")
        .resizable()
        .aspectRatio(.fit)
    Text("This is a longer text")
}

역주: 원문 코드의 .aspectRatio(.fit) 표기를 유지합니다. 실제 SwiftUI 호출에서는 .aspectRatio(contentMode: .fit)처럼 parameter label을 지정해야 합니다.

기본적으로 위 예제처럼 scroll view의 content로 여러 view를 지정하면, scroll 방향과 관계없이 이 subview들은 implicit VStack 안에 배치됩니다. 이 예제에서는 scroll view에 320×480이 제안된다고 가정하겠습니다. 그러면 두 subview 모두에 320×nil을 제안하고, 그에 따라 scroll container 안에 배치합니다. scroll view 자체는 항상 proposed height인 480을 취합니다.

text를 포함한 scroll view에서는 때때로 text가 word wrapping을 수행하면서 proposed width보다 너비가 조금 작아질 수 있습니다. scroll view 안에 proposed width를 취하는 다른 view가 없다면, scroll view의 너비도 제안보다 작아질 수 있습니다. 이를 해결하려면 text에 .frame(maxWidth: .infinity)를 추가하면 됩니다. proposed width를 반드시 받아들이지는 않는 Text 이외의 view에도 같은 내용이 적용됩니다.

원문 77쪽의 도표 1
원문 77쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 77쪽과 도표원문 77쪽: Badge의 overlay와 ZStack 비교 및 Scroll view의 크기

scroll view 안에 shape를 넣으면 의외의 결과가 나올 수 있습니다. SwiftUI의 built-in shape는 모두 ideal size가 10×10입니다. ProposedViewSize의 .replacingUnspecifiedDimensions(by:)의 기본 인자가 10×10이기 때문입니다. scroll view는 scroll 축에 nil을 제안하므로 shape는 그 축에서 ideal size인 10 point를 취합니다. 이를 바꾸려면 .frame(height:) 또는 .frame(idealHeight:)로 height를 명시적으로 지정하거나 .aspectRatio를 사용할 수 있습니다.

GeometryReader

geometry reader는 proposed size에 접근하는 데 사용합니다. 고급 Layout 장에서 사용해 보겠습니다. geometry reader는 언제나 proposed size를 받아들이고, GeometryProxy를 통해 그 크기를 view builder closure에 전달합니다. 이를 통해 geometry reader의 크기에 접근할 수 있습니다. geometry proxy로는 현재 safe area inset과 특정 coordinate space에서의 view frame에도 접근할 수 있으며, anchor를 resolve할 수도 있습니다. 예를 들어 다음 geometry reader는 proposed size를 표시합니다.

GeometryReader { proxy in
    Text(verbatim: "\(proxy.size)")
}

하지만 Stack Overflow나 social media를 읽다 보면 GeometryReader는 늘 문제를 일으키는 것처럼 보이는 view 중 하나입니다. geometry reader는 항상 proposed size를 취하므로, 예를 들어 Text view의 너비를 측정하려고 text 바깥을 geometry reader로 감싸면 text 주변의 layout에 영향을 줍니다.

SwiftUI의 layout system을 잘 이해하면 많은 곳에서 geometry reader 사용을 피할 수 있습니다. 그렇다고 해도 필요한 경우는 있습니다. geometry reader가 layout에 영향을 주지 않도록 하는 방법은 두 가지입니다.

  • 완전히 flexible한 view를 GeometryReader 안에 감싸면 layout에 영향을 주지 않습니다. 예를 들어 어차피 proposed size를 취하는 scroll view가 있다면, geometry reader로 감싸 proposed size에 접근할 수 있습니다.
  • geometry reader를 background나 overlay modifier 안에 넣으면 primary view의 크기에 영향을 주지 않습니다. background나 overlay 안에서 proxy를 사용해 view의 geometry와 관련된 여러 값을 읽을 수 있습니다. primary subview의 크기가 secondary subview인 geometry reader에 제안되므로 view의 크기를 측정하는 데 유용합니다. 고급 Layout 장에서 더 많은 예제를 살펴봅니다.

geometry reader는 다른 SwiftUI container view와 달리 alignment parameter를 제공하지 않으며, 기본적으로 subview를 top leading에 배치합니다. 반면 ScrollView를 제외한 다른 모든 container view는 기본적으로 center alignment를 사용합니다.

원문 78쪽과 도표원문 78쪽: Scroll view 안의 Shape와 GeometryReader의 사용

List

List는 SwiftUI에서 UITableView 또는 NSTableView에 해당합니다. 집필 시점의 List는 여전히 매우 제한적이며 설정할 수 있는 부분이 많지 않습니다. List view 자체는 proposed size를 취하고, ScrollView와 비슷하게 subview에 자신의 width와 height nil을 제안합니다.

list item이 lazy하게 배치되기 때문에 list content의 세로 크기 계산은 복잡합니다. row height가 고정되지 않은 UITableView처럼, List는 이미 배치한 item을 바탕으로 content의 전체 높이를 추정합니다.

LazyHStack과 LazyVStack

LazyHStack과 LazyVStack은 주축과 교차축만 다를 뿐 같은 방식으로 동작합니다. lazy stack이 모든 subview의 frame을 합집합한 크기를 취한다는 점은 non-lazy stack의 layout 동작과 같습니다. 하지만 lazy stack은 주축의 사용 가능한 공간을 subview 사이에 분배하려고 하지 않습니다. 예를 들어 LazyHStack은 subview에 그저 nil×proposedHeight를 제안합니다. 즉 subview는 ideal width를 취합니다.

lazy vertical stack은 scroll view 안에 들어가면 subview를 lazy하게 생성하므로 height를 계산하기가 복잡합니다. width에 대해서는 proposed width를 받아들입니다. 예를 들어 다음 view를 살펴보겠습니다.

ScrollView {
    LazyVStack {
        ForEach(0..<100) { i in
            Text("View \(i)")
                .padding(.vertical)
                .onAppear {
                    print("View \(i) appeared")
                }
        }
    }
}

scroll할수록 더 많은 print message가 나타나고, 필요에 따라 render tree에 view가 생성됩니다. 따라서 List처럼 LazyVStack도 이미 배치한 subview를 바탕으로 자신의 height를 추정해야 하며, 새로운 subview가 화면에 나타나면 자신의 크기를 갱신해야 합니다.

원문 79쪽과 도표원문 79쪽: List와 LazyHStack, LazyVStack의 layout

LazyVGrid와 LazyHGrid

LazyVGrid와 LazyHGrid는 column 또는 row의 크기를 계산하는 데 같은 algorithm을 사용하므로, 이 절에서는 LazyVGrid에 집중하겠습니다.

LazyVGrid layout의 첫 번째 단계는 grid의 proposed width를 바탕으로 column의 width를 계산하는 것입니다. column type은 fixed column, flexible column, adaptive column의 세 가지입니다. fixed width column은 무조건 지정된 width를 취합니다. flexible column은 flexible하지만 width에 하한과 상한이 있습니다. adaptive column은 실제로는 여러 subcolumn을 담는 container입니다.

grid는 먼저 proposed width에서 모든 fixed column의 width와 spacing을 뺍니다. 나머지 column에는 column 순서대로 남은 width를 남은 column 수로 나눈 값을 제안합니다. flexible column은 자신의 경계인 minimum width 또는 maximum width, 혹은 둘 다를 사용해 이 제안을 clamp합니다. 다만 adaptive column은 특별합니다. grid는 proposed column width를 adaptive column에 지정한 minimum width로 나누어, adaptive column 안에 가능한 한 많은 subcolumn을 넣으려고 합니다. 그런 다음 이 subcolumn을 지정한 maximum width까지 늘려 남은 공간을 채울 수 있습니다.

column width 계산이 끝나면 grid는 column width의 합계에 column 사이의 spacing을 더한 값을 자신의 width로 계산합니다. height를 구할 때는 subview에 columnWidth×nil을 제안해 row height를 계산한 다음, row height의 합계에 spacing을 더해 자신의 height를 계산합니다.

HStack과 VStack과 달리 grid는 column layout algorithm을 두 번 거칩니다. 한 번은 layout pass에서, 다음 한 번은 render pass에서입니다. layout 중에는 grid의 proposed width로 시작합니다. 하지만 render pass에서는 layout pass에서 계산한 width로 시작해 그 width를 column 사이에 다시 나눕니다. 이 때문에 매우 의외의 결과가 나올 수 있습니다. 예를 들어 다음 grid를 살펴보겠습니다.

LazyVGrid(columns: [
    GridItem(.flexible(minimum: 60)),
    GridItem(.flexible(minimum: 120))
], spacing: 10, content: {
    ...
})
.frame(width: 200)
.border(.teal, ...)
원문 80쪽의 도표 1
원문 80쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 80쪽과 도표원문 80쪽: LazyVGrid의 column layout과 예상 밖의 렌더링

column의 minimum width는 사용 가능한 200 point 안에 충분히 들어갈 수 있는데도, grid는 bounds 바깥으로 렌더링됩니다. 그뿐 아니라 자신을 감싸는 width 200 point의 frame 안에서도 center를 벗어나 렌더링됩니다. 무슨 일이 일어나는지 이해하기 위해 grid layout algorithm의 단계를 따라가 보겠습니다.

남은 width는 200 point에서 spacing 10 point를 뺀 190 point로 시작합니다. 첫 번째 column의 width는 190을 남은 column 수인 2로 나눈 95 point로 계산됩니다. 첫 번째 column의 minimum width는 60 point이므로 width 95 point는 clamping의 영향을 받지 않습니다. 따라서 남은 width는 95 point입니다. 두 번째 column은 95 point를 minimum인 120 point로 clamp한 값, 즉 width 120 point가 됩니다.

하지만 실제 grid 렌더링에서는 그렇게 보이지 않습니다. 저희가 계산한 값은 95 point와 120 point인데, 첫 번째 column은 width 108 point, 두 번째 column은 width 120 point로 렌더링됩니다. 여기서 두 번째 layout pass가 등장합니다.

첫 번째 pass에서 계산한 grid 전체 width는 95 + 10 + 120 = 225 point입니다. grid를 감싸는 width 200 point의 fixed frame은 grid를 center에 맞추면서 (225 − 200) / 2 = 12.5 point만큼 왼쪽으로 옮깁니다. grid가 렌더링될 차례가 되면 column layout을 다시 거칩니다. 하지만 이번에는 남은 width 225 point에서 시작합니다.

두 번째 pass는 225 point에서 spacing 10 point를 뺀 215 point로 시작합니다. 첫 번째 column은 215 point를 남은 column 수인 2로 나누고 반올림한 108 point가 됩니다. 남은 width는 이제 215에서 108을 뺀 107 point입니다. 두 번째 column은 107 point를 minimum 120 point로 clamp한 값이 됩니다. 이는 위 예제에서 보이는 모습과 정확히 같습니다.

의외의 column width뿐 아니라 grid가 fixed frame의 center를 벗어나 렌더링되는 이유도 설명할 수 있습니다. grid를 감싸는 frame은 원래 width인 225 point를 바탕으로 grid의 origin을 계산했지만, grid는 이제 전체 width 108 + 10 + 120 = 238 point로 렌더링됩니다. 따라서 grid는 center에서 약 7 point 벗어나 보입니다.

grid layout의 더 많은 예제는 이 블로그 글을 참고해 주세요.

Grid

Grid는 iOS 16과 macOS 13에서 도입되었으며, 두 차원으로 동작한다는 점을 제외하면 HStack과 VStack과 비슷합니다. grid의 subview는 flexibility에 따라 정렬됩니다. proposed size에 따라 2차원 flexibility 또는 1차원 flexibility가 사용될 수 있으며, 사용 가능한 공간이 두 차원에 걸쳐 subview 사이에 분배됩니다.

집필 시점에는 grid의 layout 동작이 문서화되어 있지 않습니다. iOS 17 이전에는 몇 가지 bug가 있었습니다. iOS 17 이상에서는 동작이 달라졌지만 여전히 bug가 있습니다. 따라서 여기서는 더 자세히 다루지 않겠습니다.

원문 81쪽과 도표원문 81쪽: LazyVGrid의 두 layout pass 계산과 Grid

ViewThatFits

proposed size에 따라 서로 다른 view를 표시하고 싶을 때는 ViewThatFits를 사용할 수 있습니다. 여러 subview를 받아 그중 크기가 맞는 첫 번째 subview를 표시합니다. 이를 위해 nil을 제안하여 각 subview의 ideal size를 알아내고, 코드에 나타나는 subview 순서대로 ideal size가 proposed size 안에 들어가는 첫 번째 subview를 표시합니다. 맞는 subview가 하나도 없으면 마지막 subview를 선택합니다.

Rendering modifier

SwiftUI에는 view가 렌더링되는 방식에는 영향을 주지만 layout 자체에는 영향을 주지 않는 view modifier가 여럿 있습니다. 예를 들어 offset, rotationEffect, scaleEffect 등이 있습니다. 이 modifier들은 view가 그려지는 위치를 바꾸기 위해 CGContext.translate와 비슷한 작업을 수행한다고 생각할 수 있습니다. 하지만 layout system의 관점에서 view는 여전히 원래 위치에 있습니다.

Alignment

SwiftUI는 alignment를 사용해 view를 서로에 대해 배치합니다. 기본적으로 거의 모든 view는 subview를 center에 둡니다. 예를 들어 text나 rectangle 하나만 view로 가지는 iOS app을 만들면, 그 view는 safe area 안에서 가로와 세로 모두 center에 배치됩니다.

alignment가 어떻게 동작하는지 더 잘 이해하기 위해 다음 예제를 살펴보겠습니다.

Text("Hello")
    .frame(width: 100, height: 100)

frame modifier의 alignment parameter는 기본값이 .center입니다. 앞 절에서 설명했듯이 fixed frame은 언제나 지정한 크기를 정확히 취합니다. 이 경우에는 100×100 point입니다. 그 100×100 point 안에서 text가 center에 배치됩니다. 설명을 간단히 하기 위해 text의 reported size가 50×20 point라고 가정하겠습니다. frame modifier는 subview를 배치할 때 다음 단계를 거칩니다.

  1. subview에 horizontal center를 묻습니다. subview의 width는 50 point이므로, subview의 local view coordinate에서 horizontal center를 25라고 보고합니다. 이 값을 subview의 horizontal center alignment guide라고 합니다.
  2. subview에 vertical center를 묻습니다. subview의 height는 20 point이므로 vertical center를 10이라고 보고합니다. 이것이 subview의 vertical center alignment guide입니다.
  3. frame이 자신의 horizontal center와 vertical center를 (50, 50)으로 계산합니다.
원문 82쪽과 도표원문 82쪽: ViewThatFits, Rendering modifier와 center alignment
  1. 이제 frame은 두 center의 차이인 (50-25, 50-10)을 구하고, frame의 coordinate space에서 그 지점인 (25, 40)에 subview의 origin을 배치해 subview를 center에 맞출 수 있습니다.

SwiftUI의 alignment system을 이해하는 데 가장 중요한 점은 alignment가 항상 parent와 subview 사이의 협의를 통해 결정된다는 것입니다. parent view가 혼자서 subview를 배치하는 것이 아닙니다. 관련 alignment guide를 subview에 물어본 다음, 자신의 크기 또는 다른 subview를 기준으로 subview의 위치를 정합니다.

같은 예제에 다른 alignment인 .bottomTrailing을 적용해 보겠습니다.

Text("Hello")
    .frame(width: 100, height: 100, alignment: .bottomTrailing)

algorithm은 center alignment일 때와 정확히 같습니다.

  1. subview에 trailing alignment guide를 묻습니다. subview는 50을 반환합니다. subview의 local view coordinate에서 trailing edge의 위치가 50이기 때문입니다.
  2. subview에 bottom alignment guide를 묻습니다. subview는 20을 반환합니다.
  3. frame이 자신의 trailing alignment guide를 100, bottom alignment guide를 100으로 계산합니다.
  4. frame이 subview의 origin을 (50, 80)에 배치합니다. 이 역시 차이인 (100-50, 100-20)을 계산한 결과입니다.
원문 83쪽의 도표 1
원문 83쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 83쪽과 도표원문 83쪽: Text와 frame의 center를 맞추는 coordinate 계산

frame 같은 view modifier에서는 alignment가 두 방향으로 동작합니다. frame의 alignment parameter type은 Alignment로, HorizontalAlignment와 VerticalAlignment를 결합한 composite struct입니다. 두 차원에서 정렬하는 다른 type으로는 .overlay, .background, ZStack이 있습니다. 반면 VStack에는 horizontal alignment만 있고 HStack에는 vertical alignment만 있습니다. Alignment type 자체가 alignment guide인 것은 아닙니다. 어떤 alignment guide를 사용할지 결정하는 type입니다.

HorizontalAlignment와 VerticalAlignment struct에는 built-in alignment guide를 위한 static constant가 있습니다. horizontal 방향에는 .leading, .center, .trailing이 있고, vertical 방향에는 .top, .center, .bottom, .firstTextBaseline, .lastTextBaseline이 있습니다. composite Alignment struct는 이들을 결합해 .topLeading이나 .bottomTrailing 같은 constant를 제공합니다.

HorizontalAlignment와 VerticalAlignment type에는 모두 view의 크기가 주어졌을 때 기본값을 계산하는 method가 있습니다. 따라서 각 view는 모든 built-in alignment guide를 자동으로 정의합니다. 예를 들어 원문 도표에는 세 줄짜리 text view의 주요 vertical alignment guide가 나와 있습니다.

text를 포함하지 않는 view의 first text baseline과 last text baseline을 계산할 때는 .bottom과 비슷하게 view의 height를 사용합니다.

원문 84쪽의 도표 1
원문 84쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 84쪽과 도표원문 84쪽: Bottom trailing alignment와 Text의 vertical alignment guide

다음으로 여러 subview를 포함하는 간단한 ZStack 예제를 살펴보겠습니다. ZStack을 비롯해 frame을 제외한 모든 container view는 모든 subview에 alignment guide를 물어보고, 서로를 기준으로 정렬해야 합니다. 이는 앞선 frame 예제보다 복잡한 과정입니다. ZStack은 먼저 subview의 크기와 alignment guide를 바탕으로 자신의 크기를 계산해야 하며, 그 뒤에 alignment를 사용해 subview를 배치할 수 있기 때문입니다.

ZStack {
    Rectangle()
        .fill(.teal)
        .frame(width: 50, height: 50)
    Text("Hello, World!")
}

ZStack의 alignment 기본값은 .center이므로 다음 단계에 따라 두 subview의 center를 맞춥니다.

  1. ZStack 자신의 크기를 결정합니다.
    1. 첫 번째 subview인 파란 rectangle을 감싼 frame에 크기와 center alignment guide를 묻습니다. 크기는 50×50, alignment point는 (25, 25)라고 응답합니다.
    2. 두 번째 subview인 text에 크기와 center alignment guide를 묻습니다. text의 크기는 100×20이라고 가정합니다. 크기는 100×20, alignment point는 (50, 10)이라고 응답합니다.
    3. 두 alignment point의 차이를 구해 rectangle에 대한 text의 상대 origin을 계산합니다. (25, 25) - (50, 10) = (-25, 15)입니다.
    4. 각 subview의 frame을 결정합니다. frame은 subview의 origin과 size를 결합한 것입니다.
    5. 두 subview frame의 합집합을 계산합니다. 그 영역의 origin은 (-25, 0), 크기는 100×50입니다. 이 rectangle의 크기가 ZStack 자신의 크기가 됩니다.
  2. ZStack의 subview를 배치합니다.
    1. 1단계에서 계산한 크기를 바탕으로 ZStack이 자신의 center를 (50, 25)로 계산합니다.
    2. ZStack의 center에서 rectangle의 alignment point를 빼서 rectangle의 origin을 계산합니다. (50, 25) - (25, 25) = (25, 0)입니다.
원문 85쪽의 도표 1
원문 85쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 85쪽과 도표원문 85쪽: ZStack에서 rectangle과 text의 center alignment

마지막으로 ZStack의 center에서 text의 alignment point를 빼서 text의 origin을 계산합니다. (50, 25) - (50, 10) = (0, 15)입니다.

더 일반적으로 표현하면, 모든 container view가 subview를 정렬하는 방식은 다음과 같습니다.

  1. 자신의 크기를 결정합니다.
    1. subview의 크기를 결정합니다. 구체적인 방식은 이 장의 앞부분에서 다룬 각 view type에 따라 달라집니다.
    2. container의 alignment에 해당하는 alignment guide를 subview에 묻습니다.
    3. 임의의 subview 하나를 기준으로 삼아 subview frame의 origin을 계산합니다.
    4. subview frame들의 합집합을 계산합니다. 그 rectangle의 크기가 container의 크기입니다.
  2. subview를 배치합니다.
    1. container 자신의 크기를 바탕으로 container의 alignment guide를 계산합니다.
    2. container의 alignment guide에서 각 subview의 alignment guide를 빼서 각 subview의 origin을 계산합니다.

다루는 container view에 따라 첫 번째 단계를 일부 또는 전부 생략할 수 있습니다. 예를 들어 두 차원을 모두 지정한 fixed frame은 이미 자신의 크기를 알고 있습니다. overlay의 크기는 primary subview에만 기반하므로 alignment는 overlay의 크기를 결정하는 데 관여하지 않습니다.

Alignment guide의 변경

built-in alignment guide로 여러 view를 정렬할 때는 각 view에 같은 alignment를 사용해야 합니다. 예를 들어 한 view의 top edge를 다른 view의 top edge에 맞출 수 있습니다. 또는 한 view의 bottom trailing corner를 다른 view의 bottom trailing corner에 맞출 수 있습니다. 하지만 한 view의 center를 다른 view의 top trailing corner에 맞추는 식의 정렬은 할 수 없습니다. 다행히 built-in implicit alignment guide를 override할 수 있고, 자신만의 alignment를 만들 수도 있습니다.

SwiftUI에서는 특정 alignment의 explicit alignment guide를 제공해 view의 implicit alignment guide를 바꿀 수 있습니다. 예를 들어 image에서 first text baseline이 계산되는 방식을 override할 수 있습니다.

let image = Image(systemName: "pencil.circle.fill")
    .alignmentGuide(.firstTextBaseline, computeValue: { dimension in
        dimension.height/2
    })
원문 86쪽과 도표원문 86쪽: Container의 alignment 단계와 explicit alignment guide

explicit alignment guide를 제공하는 것만으로는 아무 일도 일어나지 않습니다. 그 alignment guide가 배치에 사용될 때에만 효과가 있습니다. 예를 들어 다음과 같습니다.

HStack(alignment: .firstTextBaseline) {
    image
    Text("Pencil")
}

하지만 stack의 alignment를 .center로 바꾸면 custom alignment guide는 정렬에 영향을 주지 않습니다.

.alignmentGuide API의 computeValue closure에 전달되는 parameter는 ViewDimensions type입니다. width와 height를 가진다는 점에서는 CGSize와 비슷하지만, 해당 view의 alignment guide에도 접근할 수 있습니다. 예를 들어 image 예제는 다음과 같이 작성할 수도 있습니다.

let image = Image(systemName: "pencil.circle.fill")
    .alignmentGuide(.firstTextBaseline, computeValue: { dim in
        $0[VerticalAlignment.center]
    })

역주: 원문 코드는 closure parameter를 dim으로 명명한 뒤 본문에서 $0을 사용합니다. 원문을 그대로 보존했으며, 실제로 사용할 때는 $0을 dim으로 바꾸거나 dim in을 생략해야 합니다.

서로 다른 두 alignment guide를 사용해 view를 정렬하고 싶을 때도 alignment guide를 수정하는 방법이 매우 유용할 수 있습니다. 예를 들어 badge view의 center를 다른 view의 top trailing corner 위에 겹치려면 다음 코드를 사용할 수 있습니다.

extension View {
    func badge<B: View>(@ViewBuilder _ badge: () -> B) -> some View {
        overlay(alignment: .topTrailing) {
            badge()
                .alignmentGuide(.top) { $0.height/2 }
                .alignmentGuide(.trailing) { $0.width/2 }
        }
    }
}

이 코드는 .topTrailing alignment guide가 badge의 시각적인 center에 오도록 수정합니다. overlay에 지정한 alignment인 .topTrailing이 badge에 지정한 explicit alignment guide인 .top, .trailing과 일치한다는 점에 유의해 주세요.

위 modifier는 다음과 같이 사용할 수 있습니다.

원문 87쪽의 도표 1
원문 87쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 87쪽과 도표원문 87쪽: Text baseline 정렬과 badge의 alignment guide
Text("Hello")
    .padding()
    .background(.teal)
    .badge {
        Circle()
            .fill(Color.orange)
            .frame(width: 20, height: 20)
    }

Custom alignment identifier

지금까지는 built-in implicit alignment를 사용하고, .alignmentGuide API로 built-in alignment에 explicit alignment guide를 지정했습니다. 여기서 한 단계 더 나아가 완전히 새로운 custom alignment를 정의할 수도 있습니다.

원문 도표와 같은 layout을 생각해 보겠습니다.

아래쪽의 더 큰 menu button은 개별 menu item의 circle들과 horizontal center가 맞아야 합니다. 이 menu의 대략적인 구조는 다음과 같습니다.

VStack {
    HStack {
        Text("Inbox")
        CircleButton(symbol: "tray.and.arrow.down")
            .frame(width: 30, height: 30)
    }
    HStack {
        Text("Sent")
        CircleButton(symbol: "tray.and.arrow.up")
            .frame(width: 30, height: 30)
    }
    CircleButton(symbol: "line.3.horizontal")
        .frame(width: 40, height: 40)
}
원문 88쪽의 도표 1
원문 88쪽의 도표 1. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 88쪽의 도표 2
원문 88쪽의 도표 2. 그림을 선택하면 원래 크기로 볼 수 있습니다.
원문 88쪽과 도표원문 88쪽: Badge 적용 예제와 menu의 custom alignment 목표

여기서 문제는 VStack의 horizontal alignment입니다. built-in alignment 중에는 원하는 동작을 하는 것이 없습니다. VStack에 .trailing alignment를 지정한 다음, 두 item의 CircleButton에 .trailing의 explicit alignment guide를 지정해 볼 수 있습니다. 하지만 이 방법도 동작하지 않습니다. VStack은 subview를 배치할 때 각 subview에 .trailing alignment guide를 묻습니다. HStack에는 자신의 .trailing alignment guide가 있으므로 CircleButton의 explicit alignment guide는 전혀 사용되지 않습니다.

이때 custom alignment가 필요합니다. custom alignment는 여러 container view를 거쳐 위로 전파될 수 있기 때문입니다. custom alignment를 위해 먼저 필요한 것은 AlignmentID protocol을 따르는 type입니다.

struct MenuAlignment: AlignmentID {
    static func defaultValue(in context: ViewDimensions) -> CGFloat {
        context.width/2
    }
}

AlignmentID protocol의 유일한 요구 사항은 static defaultValue(in:) method입니다. 여기서 해당 alignment의 기본값을 정합니다. 이후 explicit value를 지정하지 않으면 이 기본값이 사용됩니다. 이 예제의 목적에 맞게 view의 horizontal center를 기본값으로 지정합니다.

이제 SwiftUI가 built-in alignment에 제공하는 것처럼 HorizontalAlignment struct에 custom alignment의 static constant를 정의할 수 있습니다.

extension HorizontalAlignment {
    static let menu = HorizontalAlignment(MenuAlignment.self)
}

이제 .menu alignment는 MenuAlignment struct의 type으로 식별됩니다. menu의 VStack에서 이를 사용하고, CircleButton에 .menu의 explicit alignment guide를 지정할 수 있습니다.

VStack(alignment: .menu) {
    HStack {
        Text("Inbox")
        CircleButton(symbol: "tray.and.arrow.down")
            .frame(width: 30, height: 30)
            .alignmentGuide(.menu) { $0.width/2 }
    }
    HStack {
        Text("Sent")
        CircleButton(symbol: "tray.and.arrow.up")
            .frame(width: 30, height: 30)
            .alignmentGuide(.menu) { $0.width/2 }
    }
    CircleButton(symbol: "line.3.horizontal")
        .frame(width: 40, height: 40)
}
원문 89쪽과 도표원문 89쪽: AlignmentID와 MenuAlignment의 정의 및 사용

menu button 자체에는 .menu alignment의 explicit value를 지정하지 않습니다. .menu의 기본값이 이미 center이므로 목적에 맞기 때문입니다.

이제 VStack이 subview에 .menu alignment guide를 물으면, HStack은 기본값을 사용하기 전에 자신의 subview에 explicit alignment guide 값이 있는지 확인합니다. 따라서 HStack은 .menu alignment guide를 요청받으면 CircleButton에 지정한 explicit alignment guide를 반환합니다. 다시 말해 explicit alignment guide에 기본값과 같은 값을 사용하더라도, stack의 implicit alignment guide 대신 subview의 explicit alignment guide가 사용됩니다.

원문 90쪽과 도표원문 90쪽: Menu의 custom alignment 적용과 guide의 전파
이전State와 Binding다음Environment