Layout 프로토콜은 iOS 16과 함께 새롭게 등장한 view를 배치하는 기술.
(WWDC22, 10056: Compose custom layouts with SwiftUI)
내가 만들어본 앱(Scrap)은 SwiftUI로 만들었는데, 스토리보드와 달리 view를 내 마음대로 옮기고 배치시킬 수 없다는게 조금 불편했다. 이번 기회에 배치에 대해서 공부하고 또 어떻게 구현되는지 알아보고 직접 만들어볼 것이다. 이번 포스팅은 공부편으로 Apple Developer Document를 사용해서 공부해봤다!
overviews
뷰에는 subview(서브뷰)들을 넣어서 구성할 수 있는데 이들을 배치하는 방법이 여러 가지 있다. 가장 쉬운 게 바로 List 형태다. 1열 N행으로 view를 밑으로 쭉 나열하는 것이다. 이후에 N행으로 넘어가는 것은 Grid를 사용해서 구현할 수 있다. Grid는 ForEach 반복문 안에 GridRow를 가지고 있고, GridRow는 행 cell을 생성하는 각각의 view다. 첫 번째 뷰는 1행, 두 번째 뷰는 2행… 이런 식으로.
간단한 Grid 예제를 보자.
struct Animal : Identifiable {
let id = UUID()
let number: Int
let name: String
let icon: String
init(number: Int, name: String, icon: String) {
self.number = number
self.name = name
self.icon = icon
}
}
struct LayoutStudy: View {
@State private var animals = [
Animal(number: 1, name: "rabbit", icon: "🐰"),
Animal(number: 2, name: "cat", icon: "🐱"),
Animal(number: 10, name: "bear", icon: "🐻"),
Animal(number: 11, name: "tiger", icon: "🐯"),
Animal(number: 4, name: "fox", icon: "🦊")]
var body: some View {
Grid(alignment: .leading) {
ForEach(animals) { animal in
GridRow { //grid의 row에 들어갈 객체들
Text("\\(animal.number)")
Text(animal.name)
Text(animal.icon)
.gridColumnAlignment(.trailing)
}
Divider()
}
}
}
}
+ gridColumnAlignment(): 특정 column에만 정렬 따로 해주는 view modifier
이렇게 구현된다. 이렇게 배치되는 객체들을 Layout 프로토콜을 적용해서 커스텀할 수 있다. 지금부터 WWDC22 강의에서 제공된 예제를 통해서 이 Layout 프로토콜을 적용한 것을 알아보자. 소스 파일은 여기서!
Layout 프로토콜에 꼭 필요한 메소드 두 개가 있는데, 컨테이너의 사이즈를 측정하고 서브뷰를 위치시키는 메소드로, 커스텀 파일에서 구현해줘야 한다.
1. sizeThatFits(proposal:subviews:cache:)
주어진 proposed size와 subviews을 통해 컨테이너가 서브뷰를 배치하려면 얼마큼의 공간이 필요한지 그 크기를 반환한다.
- proposal: 컨테이너의 사이즈. 이 메소드를 호출하는 컨테이너의 부모 뷰는 다른 proposal을 가지고 한 번 이상 이 메소드를 호출할 수 있다. 이를 통해 배치를 위해 사용되는 proposal을 결정하기 전에 컨테이너의 flexibility(유연성)을 알 수 있다.
- zero: 레이아웃의 최소 사이즈
- infinity: 레이아웃의 최대 사이즈
- unspecified: 레이아웃의 이상적인 사이즈
- subviews: 컨테이너가 배치하는 서뷰 뷰. 컨테이너가 서브뷰들을 나타내기 위해 필요한 공간이 얼마큼 필요한지 결정하기 위해 서브 뷰에 대한 정보를 얻을 수 있다.
- cache: 커스텀 레이아웃 컨테이너 메소드 사이에서 공유할 수 있는 계산된 데이터를 위한 옵셔널 저장소.
spacing을 두지 않고, 열에 배치하는 기본적인 vertical stack의 사이즈를 계산하려면:
private struct BasicVStack: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
subviews.reduce(CGSize.zero) { result, subview in
let size = subview.sizeThatFits(.unspecified)
return CGSize(
width: max(result.width, size.width),
height: result.height + size.height)
}
}
}
subviews를 순회하면서 vertical stack의 사이즈를 구한다. (고차 함수 reduce)
각 subview의 사이즈는 ".unspecified" → 이상적인 사이즈를 찾도록 한다. 그 후, subview 높이의 총합과 subview의 최대 너비 값을 나타내는 size를 반환한다. SwiftUI view는 자신의 사이즈를 선택하고 레이아웃 엔진은 항상 이 메서드에서 반환한 값을 합성 뷰의 실제 크기로 사용한다. 이 사이즈는 placeSubviews(in:proposal:subviews:cache:) 메소드에 bounds 파라미터에 들어간다.
2. placeSubviews(in:proposal:subviews:cache:)
레이아웃의 서브뷰를 각 위치에 배치시킨다.
- bounds: 컨테이너 뷰의 부모가 컨테이너 뷰에 할당하는 영역으로, 부모의 좌표 공간에 지정된다. 모든 컨테이너의 subviews는 이 영역 안에 위치한다. 이 영역의 사이즈는 sizeThatFits(proposal:subviews:cache:) 메소드를 호출해서 반환받은 사이즈와 매칭된다.
이 메소드는 subviews를 배치할 때 사용된다. 여기서는 place(at:anchor:proposal:) 메소드를 사용해서 각 subview에게 UI에 나타날 위치를 알려준다.
struct BasicVStack: Layout {
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var point = bounds.origin
for subview in subviews {
subview.place(at: point, anchor: .topLeading, proposal: .unspecified)
point.y += subview.dimensions(in: .unspecified).height
}
}
}
이 메소드는 구체적인 bounds 입력값에서 시작하는 위치 포인트를 만들고 subview를 위치시키는 데에 사용된다.
dimensions(in:) 메소드를 사용해서 읽어낸 subview의 높이를 point.y에 추가해준다. (모든 subview는 unspecified proposal) 여기서 더 복잡한 레이아웃 컨테이너를 만들려면 subview 사이에 공간(spacing)을 추가해 준다.
위의 코드에서 공간(spacing) 값을 추가해서 적절한 subviews 사이에 적절한 거리를 연산해서 저장해 줄 수 있다. distance(to:along:) 메소드는 서로 인접한 뷰가 만나는 가장자리에 주어진 edge에 대한 두 뷰의 기본 설정을 모두 만족하는 최소 거리를 반환한다. 예를 들어, 한 뷰 bottom edge에 최소 2 포인트를 원하고, 다음 view는 top edge에 최소 8 포인트를 원한다면 distance method는 8을 반환한다. 왜냐하면 두 선호를 모두 만족하는 가장 작은 값이 8이기 때문이다.
var point = bounds.origin
for (index, subview) in subviews.enumerated() {
if index > 0 { point.y += spacing[index - 1] } // Add spacing.
subview.place(at: point, anchor: .topLeading, proposal: .unspecified)
point.y += subview.dimensions(in: .unspecified).height
}
placeSubviews(in:proposal:subviews:cache:)
👩💻 두 메소드는 항상 같이 사용되어야 한다. 서브뷰 배치시킬 땐 꼭 사이즈를 구하기
예를 들어, 배치하던 중 간격(spacing)을 추가할 경우 sizeThatFits(proposal:subviews:cache:) 메소드를 구현해서 추가적인 공간에 대한 연산이 필요하다. 마찬가지로, 만약 사이즈 메소드가 다른 사이즈의 proposal에 대한 다른 값을 반환한다면, placement 메소드를 통해 그에 맞는 서브뷰 배치를 다시 해야 한다.
필수 메소드를 알아봤으니, 예제를 통해서 직접 만들어보자.
Create a custom equal-width layout: 커스텀 동일 너비 레이아웃 만들기
이번 예제에서는 모두 같은 너비를 가진 객체를 만들어볼 것이다.
버튼의 너비는 동일하되, 안에 들어있는 텍스트의 길이는 다를 수 있는 경우. 가장 긴 텍스트를 기준으로 너비를 정해야 한다!
Layout protocol을 사용해서 커스텀 레이아웃 컨테이너 타입 (custom layout container type) 을 만들 수 있다.
→ Layout은 모든 subview의 ideal size를 측정해서 각 subview에 가장 넓은 ideal size를 적용한다.
[전체 코드]
import SwiftUI
struct MyEqualWidthHStack: Layout {
/// Returns a size that the layout container needs to arrange its subviews
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let totalSpacing = spacing.reduce(0) { $0 + $1 }
return CGSize(
width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
height: maxSize.height)
}
/// Places the subviews in a horizontal stack.
/// - Tag: placeSubviewsHorizontal
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
guard !subviews.isEmpty else { return }
let maxSize = maxSize(subviews: subviews) //가장 큰 사이즈
let spacing = spacing(subviews: subviews) //subviews 사이 간격 배열
let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)
var nextX = bounds.minX + maxSize.width / 2
for index in subviews.indices {
subviews[index].place( //subviews[index]를 at 위치에 배치
at: CGPoint(x: nextX, y: bounds.midY),
anchor: .center, //앵커(중심점)
proposal: placementProposal)
nextX += maxSize.width + spacing[index] //다음 subview의 x 좌표 값
}
}
/// Finds the largest ideal size of the subviews. subviews의 가장 큰 이상적인 사이즈를 찾는 함수
private func maxSize(subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) } //subviews의 이상적인 사이즈 배열
let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width), //subviews 사이즈 중 가장 큰 width
height: max(currentMax.height, subviewSize.height) //subviews 사이즈 중 가장 큰 height
)
}
return maxSize
}
/// Gets an array of preferred spacing sizes between subviews in the
/// horizontal dimension.
/// 수평 차원에서 subviews 사이의 적절한 spacing 사이즈 배열
private func spacing(subviews: Subviews) -> [CGFloat] {
subviews.indices.map { index in
guard index < subviews.count - 1 else { return 0 } //마지막 subview는 제외
return subviews[index].spacing.distance( //다음(오른쪽) subview와의 거리
to: subviews[index + 1].spacing,
along: .horizontal)
}
}
}
Layout을 사용하려면 두 개의 메소드를 꼭 작성해야 한다고 했다. 여기서 우리는 subviews 중 가장 긴 텍스트의 길이로 버튼의 길이를 정할 것이다. 그러므로 maxSize에 최대 사이즈를 넣어주고 이에 적절한 사이즈를 반환해주는 sizeThatFits 메소드를 사용한다.
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else { return .zero } //subview가 없으면 0 반환
let maxSize = maxSize(subviews: subviews) //subviews 중 최대 사이즈
let spacing = spacing(subviews: subviews) //subviews 사이의 간격
let totalSpacing = spacing.reduce(0) { $0 + $1 } //전체 spacing 값
return CGSize(
width: maxSize.width * CGFloat(subviews.count) + totalSpacing, //가장 큰 사이즈의 너비 * subviews 개수 + 전체 spacing 합
height: maxSize.height //가장 큰 사이즈의 높이
)
}
이제 사이즈를 구했으니 각 subview를 배치시키자! spacing과 가장 큰 사이즈로 배치시킨다.
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard !subviews.isEmpty else { return } //subview가 없으면 종료
let maxSize = maxSize(subviews: subviews) //subviews 중 최대 사이즈
let spacing = spacing(subviews: subviews) //subviews 사이의 간격
let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)
var nextX = bounds.minX + maxSize.width / 2 //다음 subview의 x 좌표 값
for index in subviews.indices {
subviews[index].place( //subviews[index]를 at 위치에 위치시킨다.
at: CGPoint(x: nextX, y: bounds.midY),
anchor: .center,
proposal: placementProposal)
nextX += maxSize.width + spacing[index] //다음 subview의 x 좌표 값
}
}
전체 코드를 보면 maxSize(), spacing() 메소드도 구현해 놨다. 각각 가장 큰 사이즈 구하는 메소드, subviews 사이 간격을 구하는 메소드다.
struct LayoutStudy: View {
let buttons = ["Hi", "there", "👋"]
var body: some View {
MyEqualWidthHStack() {
ForEach(buttons, id: \\.self) { button in
Button(action: {
//nothing
}) {
Text(button)
.frame(maxWidth: .infinity) // Expand to fill the offered space.
}
.buttonStyle(.bordered)
}
}
}
}
Choose the view that fits: 적절한 view 선택하기
button의 사이즈는 버튼이 가진 text의 너비에 따라 변한다. ViewThatFits를 사용해서 SwiftUI가 버튼의 horizontal 정렬과 vertical 정렬 사이 중 하나를 그에 맞게 선택할 수 있게 한다.
ViewThatFits { //choose the first view that fits
MyEqualWidthHStack { //arrange horizontally if it fits...
Buttons()
}
MyEqualWidthVStack { //or vertically, otherwise
Buttons()
}
}
Improve layout efficiency with cache: 캐시로 레이아웃 효율을 높이자
Layout protocol의 메소드는 cache 파라미터를 갖는다.
cache는 특정 layout instance의 모든 메소드 사이에 공유되는 옵셔널 저장소에 접근할 수 있다. 예를 들어, MyEqualWidthVStack은 sizeThatFits()와 placeSubviews() 사이에 구현되는 size와 spacing 계산 결과를 공유하기 위해 저장소를 생성했다.
1. 저장소에 CacheData 타입을 정의한다
struct CacheData {
let maxSize: CGSize
let spacing: [CGFloat]
let totalSpacing: CGFloat
}
2. 프로토콜의 옵셔널 makeCache(subviews:) 메소드를 구현해서 subviews를 계산하고 CacheData 타입으로 값을 반환한다.
func makeCache(subviews: Subviews) -> CacheData {
let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let totalSpacing = spacing.reduce(0) { $0 + $1 }
return CacheData(
maxSize: maxSize,
spacing: spacing,
totalSpacing: totalSpacing)
}
만약 subviews가 변하면, SwiftUI는 layout의 updateCache(_:subviews:) 메소드를 호출한다. (SwiftUI가 알아서 호출)
- 이 메소드의 기본 구현은 makeCache(subviews:) 메소드를 재호출해서 데이터를 다시 계산하는 것이다.
- 그 후, sizeThatFits()와 placeSubviews() 메소드는 데이터를 얻는데에 cache 파라미터를 사용한다.
- 예를 들어, placeSubviews()는 cache로부터 size와 spacing array를 읽어낸다.
let maxSize = cache.maxSize
let spacing = cache.spacing
cache를 사용하는 것은 딱히 효율적이지 않다.
[전체 코드 - VStack으로 구현]
struct MyEqualWidthVStack: Layout {
/// Returns a size that the layout container needs to arrange its subviews
/// vertically with equal widths.
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
// Load size and spacing information from the cache. 옵셔널 저장소인 cach에서 데이터 가져오기!
let maxSize = cache.maxSize
let totalSpacing = cache.totalSpacing
return CGSize(
width: maxSize.width,
height: maxSize.height * CGFloat(subviews.count) + totalSpacing)
}
/// Places the subviews in a vertical stack.
/// - Tag: placeSubviewsVertical
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) {
guard !subviews.isEmpty else { return }
// Load size and spacing information from the cache.
let maxSize = cache.maxSize
let spacing = cache.spacing
let placementProposal = ProposedViewSize(width: maxSize.width, height: bounds.height)
var nextY = bounds.minY + maxSize.height / 2
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: bounds.midX, y: nextY),
anchor: .center,
proposal: placementProposal)
nextY += maxSize.height + spacing[index]
}
}
/// A type that stores cached data.
/// - Tag: CacheData
struct CacheData {
let maxSize: CGSize
let spacing: [CGFloat]
let totalSpacing: CGFloat
}
/// Creates a cache for a given set of subviews.
/// When the subviews change, SwiftUI calls the updateCache(_:subviews:) method.
/// The ``MyEqualWidthVStack`` layout relies on the default
/// implementation of that method, which just calls this method again
/// to recreate the cache.
/// - Tag: makeCache
func makeCache(subviews: Subviews) -> CacheData {
let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let totalSpacing = spacing.reduce(0) { $0 + $1 }
return CacheData(
maxSize: maxSize,
spacing: spacing,
totalSpacing: totalSpacing)
}
/// Finds the largest ideal size of the subviews.
private func maxSize(subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
/// Gets an array of preferred spacing sizes between subviews in the
/// vertical dimension.
private func spacing(subviews: Subviews) -> [CGFloat] {
subviews.indices.map { index in
guard index < subviews.count - 1 else { return 0 }
return subviews[index].spacing.distance(
to: subviews[index + 1].spacing,
along: .vertical)
}
}
}
struct LayoutStudy: View {
let buttons = ["Hello World!", "👋"]
var body: some View {
MyEqualWidthVStack() {
ForEach(buttons, id: \\.self) { button in
Button(action: {
//nothing
}) {
Text(button)
.frame(maxWidth: .infinity) // Expand to fill the offered space.
}
.buttonStyle(.bordered)
}
}
}
}
Create a custom radial layout with an offset
proposal.replacingUnspecifiedDimensions()
replacingUnspecifiedDimension() 메소드는 제안(proposal)을 구체적인(concrete) 사이즈로 바꾼다.
subviews를 위치시키기 위해, layout은 vector를 회전시켜 위치의 중앙에 vector를 두고 subview의 앵커로 사용한다.
subviews의 subview들을 순회하면서
- 적절한 사이즈와 회전각도의 벡터를 찾는다.
- 위치의 중앙에 벡터를 둔다
- 그 위치에 맞게 앵커를 사용해서 subview를 위치시킨다.
for (index, subview) in subviews.enumerated() {
// Find a vector with an appropriate size and rotation.
var point = CGPoint(x: 0, y: -radius).applying(CGAffineTransform(rotationAngle: angle * Double(index) + offset)) //CGAffineTransform 메소드를 사용해 회전시킨다.
// Shift the vector to the middle of the region.
point.x += bounds.midX
point.y += bounds.midY
// Place the subview.
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
코드 속 offset은 현재 순위로, 상위 순위의 pet을 인터페이스 상단에 더 가깝게 배치하도록 한다. (높은 순위일수록 위로)
LayoutValueKey 프로토콜을 사용해 하위 뷰에 순위를 저장하고, 뷰를 위치시키기 전에 value를 읽어와서 offset을 계산한다.
[전체 코드]
struct MyRadialLayout: Layout {
/// 레이아웃 컨테이너가 하위 뷰를 원형으로 정렬하는 데 필요한 크기를 반환한다.
/// 이 구현은 컨테이너 뷰가 제안하는 모든 공간을 사용한다.
/// 컨테이너가 이 레이아웃의 이상적인 크기를 요청하면, 각 차원에 "nil"이 포함된 "unspecified" 제안을 제공한다.
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
proposal.replacingUnspecifiedDimensions() //proposal을 구체적인 사이즈로 만든다.
}
/// Places the stack's subviews in a circle.
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let radius = min(bounds.size.width, bounds.size.height) / 3.0 //원형으로 배치하니까 반지름을 구한다. 더 짧은 쪽
let angle = Angle.degrees(360.0 / Double(subviews.count)).radians //view 개수에 따라 각도가 달라진다.
// 각 view로부터 랭킹을 읽어와서 적절한 offset을 찾아야 한다.
// 이것은 오직 균일하지 않은 랭킹 값의 세가지 view의 특정 경우에서만 효과적이다. 그러지 않으면, offset은 0이고 어떠한 영향을 끼치지 않는다.
let ranks = subviews.map { subview in //ranks는 subview의 rank에 따라 배열로 만들어준다.
subview[Rank.self]
}
let offset = getOffset(ranks) //ranks가 높을수록 상단으로 가야한다.
for (index, subview) in subviews.enumerated() {
// Find a vector with an appropriate size and rotation.
var point = CGPoint(x: 0, y: -radius).applying(CGAffineTransform(rotationAngle: angle * Double(index) + offset)) //위치와 회전 값 구하기
// 영역의 중앙에 벡터 투기
point.x += bounds.midX
point.y += bounds.midY
// subview 배치시키기
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
}
}
extension MyRadialLayout {
/// 랭킹 순서로 view를 배치하기 위해 각도 offset를 구한다.
/// 이 메소드는 모든 subview를 랭킹에 따라 위에서 아래로 순서대로 표시되도록, 얼마나 회전시킬지 알려주는 offset을 만든다.
/// 상 - 우 - 하 - 좌 순서]
/// radian 각도 반환
private func getOffset(_ ranks: [Int]) -> Double {
guard ranks.count == 3,
!ranks.allSatisfy({ $0 == ranks.first }) else { return 0.0 } //최소 3개, 랭킹이 모두 같은 값이면 안됨
// 원을 3등분으로 나누고 offset을 구한다.
// 가장 높은 값을 leader로 맨 위에 두고, 나머지 두개가 하는 일에 따라 조절된다.
var fraction: Double
if ranks[0] == 1 {
fraction = residual(rank1: ranks[1], rank2: ranks[2])
} else if ranks[1] == 1 {
fraction = -1 + residual(rank1: ranks[2], rank2: ranks[0])
} else {
fraction = 1 + residual(rank1: ranks[0], rank2: ranks[1])
}
// radian 단위의 각도로 변환한다
return fraction * 2.0 * Double.pi / 3.0
}
/// 다른 두 랭킹이 수행하는 작업을 기준으로 나머지 값을 가져온다.
private func residual(rank1: Int, rank2: Int) -> Double {
if rank1 == 1 {
return -0.5
} else if rank2 == 1 {
return 0.5
} else if rank1 < rank2 {
return -0.25
} else if rank1 > rank2 {
return 0.25
} else {
return 0
}
}
}
/// subview의 랭킹을 읽어올 때 사용하는 key
private struct Rank: LayoutValueKey {
static let defaultValue: Int = 1
}
extension View {
/// view에 랭킹 레이아웃 값을 세팅한다.
func rank(_ value: Int) -> some View {
layoutValue(key: Rank.self, value: value)
}
}
struct LayoutStudy: View {
let buttons = ["Hello", "👋", "World", "!", "LoL"]
var body: some View {
MyRadialLayout() {
ForEach(buttons, id: \\.self) { button in
Button(action: {
//nothing
}) {
Text(button)
.frame(maxWidth: .infinity) // Expand to fill the offered space.
}
.buttonStyle(.bordered)
}
}
}
}
위에서도 말했지만 여기서 subview를 추가하거나 변경시키면 재연산으로 subview가 재배치된다.
Animate transitions between layouts
MyRadialLayout은 랭킹에 대한 적절한 배치를 만들어내는 offset을 계산한다.
이 세 가지 원이 겹치지 않도록 하기 위해, HStackLayout 타입(HStack에 Layout 프로토콜을 준수한 것)을 사용하는 대신 MyRadialLayout을 사용해서 라인 위에 아바타(객체)를 놓는다. 이 레이아웃 타입 간을 전환하기 위해 AnyLayout type을 사용한다.
let layout = model.isAllWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout())
Podium()
.overlay(alignment: .top) {
layout {
ForEach(model.pets) { pet in
Avatar(pet: pet)
.rank(model.rank(pet))
}
}
.animation(.default, value: model.pets)
}
views의 구조적 동일성이 전체적으로 동일하게 유지되기 때문에, animation view modifier는 레이아웃 타입 간에 애니메이션 전환을 생성한다. 또한 modifier는 계산된 offsets이 같은 pet data에 의존적이기 때문에 랭킹의 변화로 radial layout 변경을 활성화한다.
애플 도큐먼트를 보면서 코드를 분석하니 좀 이해한 것 같다! 역시 이론보다는 직접 만들어보는게 학습에 더욱 도움이 되니까 다음에는 실전편으로 돌아오겠습니다. 코드를 직접 파보니 재미있었고 애플의 개발자에게 감탄 또 감탄~
추가적으로 공부한 내용과 수정은 다음에 복습하면서 추가하도록 해야겠다. 얏호! 🌱
'iOS' 카테고리의 다른 글
[iOS] AutoLayout 정복하기 - Constraints (0) | 2023.02.13 |
---|---|
[iOS/SwiftUI] MVVM에 대해 알아보자! (0) | 2023.02.09 |
[iOS] Native app과 Web view (0) | 2023.01.02 |
[SwiftUI] .listRowBackground(_:) : List의 전체/특정 row 커스텀하기 (0) | 2022.09.29 |
[SwiftUI] View Modifier - customize your object! (1) | 2022.09.29 |