-
SwiftUI - AnyLayoutSwiftUI 2024. 8. 29. 18:28
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 SwiftUI의 AnyLayout에 대해 학습해보겠습니다 🙋🏻
AnyLayout
우선 AnyLayout은 레이아웃 프로토콜의 타입 소거된 인스턴스입니다.
여기서 Type erasure란, 컴파일 시간에 알려진 타입 정보의 일부를 런타임에 제거하는 과정을 말하며 주로 제네릭 프로그래밍에서 유용하게 사용됩니다.
즉, 구체 타입은 숨기고 더 일반적인 인터페이스를 제공할 수 있죠.
AnyLayout도 Any라는것이 붙었으니 구체적인 타입을 숨기고 Layout이라는 인터페이스를 제공함을 추론해볼 수 있습니다.
SwiftUI에선 AnyView도 동일한거라고 보시면 됩니다 😃
@frozen struct AnyLayout
선언 구조 자체는 구조체로 간단합니다.
AnyLayout은 iOS 16.0 이상부터 제공되며 해당 인스턴스를 사용해 하위 뷰를 변형하지 않고 레이아웃 컨테이너의 타입을 동적으로 변경할 수 있어요.
예시로, 아래와 같은 코드가 있습니다.
struct DynamicLayoutExample: View { @Environment(\.dynamicTypeSize) var dynamicTypeSize var body: some View { let layout = dynamicTypeSize <= .medium ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout()) layout { Text("First label") Text("Second label") } } }
다이나믹 타입에 따라서 해당 Text 두개를 세로 스택으로 보여줄건지 가로 스택으로 보여줄건지 AnyLayout을 이용해 레이아웃을 변환시켜줄 수 있죠.
즉, Text 2개를 또 중복으로 VStack, HStack으로 담아 코드의 보일러 플레이트를 만들 필요없이 해당 뷰 코드의 레이아웃만 구분지어주면 되는것이죠.
AnyLayout은 기본적으로 Animatable과 Layout 프로토콜을 준수합니다.
Layout 프로토콜이 Animatable 프로토콜을 채택하고 있으니 뭐 당연한 소리겠지만요 😃
그렇기에 더 이어서 보겠지만, layout이 변화할때 자연스러운 애니메이션 효과가 나타납니다.
AnyLayout에는 기본적으로 4가지 구현되어 있는 인스턴스를 활용할 수 있습니다.
우리가 흔히 아는 가로, 세로 스택 및 겹쳐서 표현하거나 그리드로 표현하는 일반적인 레이아웃들이 존재하죠.
해당 구조체들은 모두 Layout 프로토콜을 준수하고 있기에 AnyLayout으로 감싸져 사용할 수 있는것입니다.
사용하는건 VStack, HStack처럼 일반적으로 동일하다고 보면 됩니다.
그럼 개념을 알았으니, 구현을 통해 어떻게 동작하는지 한번 볼까요?
import SwiftUI struct ContentView: View { @State var isTapped: Bool = false var layout: AnyLayout { self.isTapped ? AnyLayout(HStackLayout(spacing: 30)) : AnyLayout(VStackLayout(spacing: 30)) } var body: some View { VStack(spacing: 50) { Spacer() layout { Rectangle() .fill(.green) .frame(width: 70, height: 70) Rectangle() .fill(.yellow) .frame(width: 70, height: 70) Rectangle() .fill(.red) .frame(width: 70, height: 70) } Button( action: { withAnimation { self.isTapped.toggle() } }, label: { Text("Change") } ) Spacer() } } }
이렇게 코드를 짜고 살펴볼께요.
간단히, 색상이 다른 사각형 3개가 존재하고 버튼을 누르면 레이아웃이 변경됩니다.
이때 버튼 액션에 withAnimation을 걸어 레이아웃 변경 시 자연스러운 애니메이션 효과를 가져갑니다.
돌려볼까요?
자연스럽게 레이아웃이 변화하죠?
만약 AnyLayout을 이용하는게 아니라, body에서 if else 구문을 통해 V/HStack으로 분기를 처리하고 담아줬다면, 즉 아래와 같이~
struct ContentView: View { @State var isTapped: Bool = false var body: some View { VStack(spacing: 50) { Spacer() if isTapped { VStack(spacing: 30) { Rectangle() .fill(.green) .frame(width: 70, height: 70) Rectangle() .fill(.yellow) .frame(width: 70, height: 70) Rectangle() .fill(.red) .frame(width: 70, height: 70) } } else { HStack(spacing: 30) { Rectangle() .fill(.green) .frame(width: 70, height: 70) Rectangle() .fill(.yellow) .frame(width: 70, height: 70) Rectangle() .fill(.red) .frame(width: 70, height: 70) } } Button( action: { withAnimation { self.isTapped.toggle() } }, label: { Text("Change") } ) Spacer() } } }
이렇게 되어 있다면 애니메이션 효과가 어떻게 적용될까요?
애니메이션이 일어나긴 하지만, 그냥 뷰가 아예 변화될때 갈아치워주는 효과 정도의 애니메이션이지 자연스럽게 이동된다는 느낌이 없죠.
이런 차이가 존재합니다.
이건 앞서 말했듯이, AnyLayout이 Animatable을 채택하면서 하나의 뷰인 layout 경계에서 다뤄주기에 가능한것이죠.
이런 AnyLayout 너무 좋죠?
그런데 매번 이걸 사용한다면 성능 오버헤드가 나타날 수 있어요.
그렇기에 정적인 레이아웃에서는 기존처럼 V/HStack을 사용하고 이건 동적인 레이아웃에서 고려될 사항이라고 보면 됩니다.
동적으로 이런 Bool 프로퍼티 감지도 물론이며 기기 사이즈 및 기기 방향 등 다양한 옵션을 통해 제어할 수 있죠.
자 이렇게 AnyLayout이 무엇인지 살펴봤는데요.
기본적으로 제공되는 레이아웃 형태 외에도 우리는 충분히 커스텀하게 Layout을 만들어서 AnyLayout 타입 인스턴스로 사용할 수가 있어요.
Layout 프로토콜을 채택하여 커스텀한 자신만의 레이아웃을 만들어서 사용하는 방법이죠.
Layout 프로토콜을 채택하면 기본적으로 이 두가지 메서드를 구현해줘야 합니다.
struct BasicVStack: Layout { func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) -> CGSize { // Calculate and return the size of the layout container. } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { // Tell each subview where to appear. } }
sizeThatFits은 합성 레이아웃 뷰의 크기를 나타내주고, placeSubviews는 컨테이너의 하위 뷰에 위치를 할당해주는 역할을 하죠.
그럼 한번 간단히 커스텀한 레이아웃 만들어 볼까요?
struct RandomXVStackLayout: Layout { var spacing: CGFloat func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout [CGFloat]) -> CGSize { let sizes = subviews.map { $0.sizeThatFits(.unspecified) } let maxWidth = sizes.map { $0.width }.max() ?? 0 let totalHeight = sizes.map { $0.height }.reduce(0) { $0 + $1 } + spacing * CGFloat(subviews.count - 1) return CGSize(width: proposal.width ?? maxWidth, height: totalHeight) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout [CGFloat]) { var y = bounds.minY for (index, subview) in subviews.enumerated() { let size = subview.sizeThatFits(.unspecified) let randomX = bounds.minX + (bounds.width - size.width) * cache[index] let point = CGPoint(x: randomX, y: y) subview.place(at: point, anchor: .topLeading, proposal: ProposedViewSize(size)) y += size.height + spacing } } func makeCache(subviews: Subviews) -> [CGFloat] { subviews.map { _ in CGFloat.random(in: 0...1) } } }
이런걸 만들어 봤습니다.
간단히 설명해보면 우리가 알고 있는 VStack의 구현에서 조금 변형한건데요.
VStack으로 나타날때 x 좌표를 랜덤하게 뿌려줍니다.
즉, 간격은 주어진만큼 반영해 VStack으로 나타나지만 정렬되지 않고 x 좌표 중구난방하게 나타나는것이죠.
한번 적용해볼까요?
struct ContentView: View { @State var isTapped: Bool = false var layout: AnyLayout { self.isTapped ? AnyLayout(RandomXVStackLayout(spacing: 30)) : AnyLayout(HStackLayout(spacing: 30)) } var body: some View { VStack(spacing: 50) { Spacer() layout { Rectangle() .fill(.green) .frame(width: 70, height: 70) Rectangle() .fill(.yellow) .frame(width: 70, height: 70) Rectangle() .fill(.red) .frame(width: 70, height: 70) } Button( action: { withAnimation { self.isTapped.toggle() } }, label: { Text("Change") } ) Spacer() } } }
바뀐 코드 부분이라곤 VStackLayout을 우리가 만든 커스텀한 RandomXVStackLayout으로 치환한것뿐이죠 😃
돌려볼까요?
아주 의도대로 잘 동작하는걸 볼 수 있습니다.
핵심은 랜덤하게 뿌려지게 구현했다가 아니라, 원하면 자유롭게 Layout을 채택해 우리만의 커스텀한 레이아웃을 구현할 수 있다입니다.
마무리
이렇게 SwiftUI의 AnyLayout에 대해 학습해봤습니다 😀
iOS 16부터지만 이제 슬슬 미니멈 타겟들도 올라가고 있으니, 동적인 레이아웃을 구성할때 충분히 활용해볼 수 있겠네요!
레퍼런스
'SwiftUI' 카테고리의 다른 글
SwiftUI - ScrollPosition (2) 2024.10.14 SwiftUI - ScaledMetric (3) 2024.09.09 Enhance your UI animations and transitions (feat. WWDC 2024) (80) 2024.07.29 Create custom visual effects (feat. WWDC 2024) (84) 2024.07.22 SwiftUI - ViewThatFits (73) 2024.07.15