그대들은 어떻게 layout할 것인가 (1) feat.SwiftUI

FramiOS
13 min readNov 13, 2023

--

현재 개발 중인 앱에 여러 인터렉션과 애니메이션이 들어간 UI가 추가 되고 있어요 앱이 점점 예뻐진다는 것은 iOS 개발자로서 참을 수 없는 즐거움입니다 😌

SwiftUI는 기본적으로 UI를 쉽고 빠르게 그릴 수 있도록 제공해 주는 기능들이 많습니다. 그렇기 때문에 오히려 기본적인 것 외에 커스텀을 해야 한다던가 복잡한 UI를 그려야 할 때 애를 먹는 경우도 종종 생깁니다.

그래서 오늘은 SwiftUI 개발시 도움이 되는 layout system에 대해 다뤄 보려고 합니다! 끝까지 참고 읽어 주시면 도움되는 내용이 많을 거에요! 특히 이제 막 SwiftUI로 View를 그리는 재미를 느끼셨다면 꼭 한번 읽어 보셨으면 합니다

이 글에서 알 수 있는 것

  • layout protocol
  • proposed size & required size

UI를 그릴 때 실제적으로 도움이 되는 내용은 다음 글에서 다루겠습니다 🥲 글이 너무 길어져서…

Layout Protocol

proposed size에 대해 알아보기 이전에 layout 프로토콜에 대해서 알아볼게요!

SwiftUI로 UI를 그릴 때 빠지지 않고 자주 사용 되는 Container (HStack, VStack, ZStack etc…)들은 내부 적으로 layout 시스템을 가지고 있습니다. container 내부의 하위뷰들의 크기를 계산하고 어떻게 배치할 것인가에 대한 layout 규칙이 이미 정해져 있기 때문에 개발자는 단순히 그걸 가져다 사용하면 됩니다! 하지만 직접 이 layout 시스템을 만들고 싶다? 할 때에는 layout 프로토콜을 채택해 custom layout을 구현해주면 된답니다

custom layout을 직접 구현하여 사용하지 않아도 되지만, 이 layout 프로토콜을 보면 layout을 이해하는데 도움이 되기 때문에 한번 살펴볼게요~

struct CustomLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
<#code#>
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
<#code#>
}
}

Layout 프로토콜에는 필수로 구현해 주어야 하는 델리게이트가 두 개 있습니다. Layout 프로토콜을 채택한 CustomLayout은 하위 뷰(ChildView라고 하기도 하는데 이 글에서는 하위 뷰로 하겠습니다~)를 가질 수 있어요. 즉 Container의 역활을 합니다. 이 Container는 당연히 다른 Container의 하위 뷰가 될 수도 있습니다!

우선은 두 개의 델리게이트 중에 sizeThatFits을 볼게요!

proposal이라고 하는 값은 컨테이너의 부모 뷰로 부터 받는 값입니다. 상위 뷰는 하위 뷰에게 값을 ‘제안'하게 됩니다. “내가 최대로 차지할 수 있는 공간은 이 정도야" 라고 하위 뷰에게 알려줘요! 하위 뷰는 부모 뷰로 부터 얼마 정도의 공간을 차지할 수 있는지 알게 됩니다.

그러나 여기에는 기억해야 할 하나의 룰이 있는데 하위 뷰는 부모 뷰에게 제안된 사이즈와 위치 정보를 받더라도 자신이 직접 자신의 사이즈를 결정할 수 있어요. 그리고 부모 뷰는 하위 뷰의 결정을 존중해 줍니다. 애플 문서에서는 하위뷰가 자신의 사이즈를 결정하는 것을 behavior라고 표현합니다. 이 글에서는 결정한다라는 표현으로 사용할게요!

func sizeThatFits(
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
) -> CGSize

sizeThatFits의 반환타입을 볼게요! CGSize 입니다. 앞에서 이야기 했던 자신이 결정한 사이즈를 상위뷰에게 반환합니다.

하위 뷰 또한 Container이기 때문에 여러 하위 뷰를 가지게 되요. 자신이 제안 받은 사이즈에서 하위 뷰가 사용가능한 공간을 계산한 뒤 하위뷰에게 전달합니다. 이 하위 뷰는 제안 받은 사이즈를 바탕으로 자신의 사이즈를 계산해서 CGSize 타입의 사이즈 정보를 상위뷰로 반환합니다. 하위뷰의 사이즈 정보를 반환 받은 상위뷰는 자신의 상위뷰에게 반환합니다.

파라미터 중에 subviews를 볼 수 있는데 해당 Container의 하위 뷰들이에요. 이 하위 뷰로 부터 정보를 반환받겠죠? 그리고 최종적으로 상위뷰에게 CGSize를 전달할거에요!

결국 가장 마지막 최종 상위뷰가 하위뷰의 size들을 토대로 layout을 그리게 됩니다.

Proposed Size

layout 프로토콜에서 proposed size에 대해 이야기 해봤으니 이제 실제 코드를 보면서 확인할게요!

struct BasicLayoutView: View {
@State private var isOn: Bool = false

var body: some View {
HStack {
Toggle(isOn: $isOn, label: {
Text("Label")
})
}
}
}

가장 최상위 레이아웃을 이루고 있는 것은 body인 ConentView입니다. 이 body 프로퍼티 내부에는 HStack이 있고 HStack은 Toggle을 하위 뷰로 가지고 있어요. Toggle은 Text를 하위뷰로 가집니다.

Toggle이라고 하는 View가 하나의 View 처럼 느껴질 수도 있는데 사실 label 이라고 하는 파라미터에 하위 뷰들을 포함시켜 줄수 있어요! (기본은 Text View로 이루어집니다) label 파라미터 코드를 마우스로 찍어보면 Text View의 영역이 잡히는 걸 확인할 수 있어요!

여기서 코드를 살짝 바꾸겠습니다 영역을 확인할 수 있게 백그라운드를 추가하고 HStack에 padding을 추가해 줄게요!

struct BasicLayoutView: View {
@State private var isOn: Bool = false

var body: some View {
HStack {
Toggle(isOn: $isOn, label: {
Text("Label")
.background(.green)
})
.background(.yellow)
}
.padding()
.background(.red)
}
}

#Preview {
BasicLayoutView()
}

이제 하나 하나 살펴 봅시다

저희가 코드로 작성해 준 BasicLayoutView는 상위 뷰를 가지는 하위 뷰일 수도 있고 앱의 RootView 일 수도 있습니다. 여기서는 앱을 처음 실행하면 보이는 첫 화면이라고 할게요!

Padding

우선 첫 번째로 고려할 대상은 .padding modifier 입니다! HStack도 아니고 padding이라니! 할 수 있지만 뷰의 계층 구조를 표현하면 이렇습니다

몇 블로그 글을 보면 뷰 계층 구조를 표현하고 공식적인 것은 아니라고 적어 놓으시던데 이것도 공식적인 것은 아닙니다. 단순 뷰의 구조를 이해하기 위한 그림이니 참고만 하셔요!

padding은 View에 추가적인 공간을 더해 반환합니다. 즉 layout 결정할 때 HStack에 여백 공간이 필요함을 의미합니다. ContentView는 padding에게 사용할 수 있는 사이즈인 디바이스 사이즈를 propose (제안) 합니다.

그러면 padding은 자신이 차지해야 하는 값 (여기서는 default값을 15로 하겠습니다) 15를 뺀다음 HStack에게 전달합니다. HStack이 전달받는 proposed size는 디바이스 높이 — 15, 디바이스 넓이 — 15가 되는 거죠!

(background modifier의 경우 전달 받은 View의 크기 만큼의 영역을 차지하기 때문에 proposed size와 required에 직접적인 영향을 끼치지 않습니다. required size는 곧 알아볼 예정 :))

HStack에는 Toggle이라는 하위 뷰가 있습니다. HStack이 상위 뷰로 부터 전달 받은 사이즈를 토대로 하위 뷰가 사용할 수 있는 공간 값을 propose 하게 됩니다. 위에서 다뤘다 싶이 디바이스의 크기에서 padding 값을 제외한 영역이에요

Toggle은 Control 로써 자신이 최대한으로 차지할 수 있는 크기 만큼 커집니다. 즉! 제안 받은 사이즈를 사용합니다. 여기서는 HStack으로 부터 전달 받은 디바이스 사이즈 — 15가 됩니다.

토글은 이제 Text에게 값을 제안합니다. 제안 받은 영역을 최대한으로 차지할 것이라고 label 파라미터에 지정한 하위 뷰에게 전달합니다.

Toggle에게 사이즈 정보를 전달 받은 텍스트는 이제 사이즈 정보를 전달할 하위 뷰가 없으니 자신의 사이즈를 결정(behavior)하고 상위뷰에게 그 정보를 전달하게 됩니다. 이것이 바로 required size 입니다.

Required Size

상위 뷰가 하위 뷰에게 사이즈를 제안하고 그 사이즈를 하위 뷰가 무조건 적용한다면 왼쪽 이미지 처럼 될거에요!

하지만 작성한 코드를 실행해 보면 오른쪽과 같이 나와요! 그 이유는 바로 하위 뷰가 자신의 사이즈를 결정할 수 있기 때문입니다~

하위 뷰는 상위 뷰에서 전달해 준 제안된 사이즈를 이용해 자신의 사이즈를 정하고 상위 뷰에게 전달합니다. 그래서 이제는 아래에서 위로 향하는 흐름입니다.

Text("Label")
.background(.green)

Text는 Toggle View로 부터 최대한 채울 수 있는 사이즈 (디바이스 사이즈 — 15) 를 전달 받았어요. SwiftUI에서 Text View의 layout 사이즈는 frame으로 지정하지 않는 이상 one single line 텍스트 사이즈를 반환합니다.

                Text("Label")
.background(.green)
.background( GeometryReader { geo in
Color.clear.onAppear {
print(geo.frame(in: .global).size)
}
})

geometry를 사용해 보면 Text는 width 41.3, height 20.3 을 가집니다. Text의 String이 달라지면 그 글자 크기 만큼의 사이즈를 반환하겠죠?

이제 Text는 상위 뷰에게 41.3 x 20.3을 반환해요! 이 사이즈가 바로 required size 입니다!

그럼 이제 Toggle을 감싸고 있던 상위 뷰인 Toggle이 이 required size를 전달 받습니다

            Toggle(isOn: $isOn, label: {
Text("Label")
.background(.green)
})
.background(.yellow)

Toggle은 Control로서 자신의 하위 뷰로 부터 required size를 전달 받고 자신의 required size를 계산합니다. Control의 넓이는 부모 뷰의 proposed size를 따릅니다. 상위 뷰에서 제공해준 넓이를 최대한 차지합니다. 높이는 하위 뷰를 토대로 계산합니다. 하위 뷰인 Text의 높이 20.3과 토글 버튼을 토대로 계산합니다. 만약 하위 뷰로 Text가 아닌 다른 하위 뷰들을 가졌다면 높이는 그 하위 뷰에 따라 달라지게 되겠죠?!!

            Toggle(isOn: $isOn, label: {
Text("Label")
.background(.green)
})
.background(.yellow)
.background( GeometryReader { geo in
Color.clear.onAppear {
print(geo.frame(in: .global).size)
}
})

// (361.0, 31.0)

상위 뷰로 부터 전달 받은 최대 값 361.0 을 width로, height 31을 상위 뷰에게 전달하게 됩니다.

    var body: some View {
HStack {
Toggle(isOn: $isOn, label: { 생략 })
}
.border(.black) // 추가
.padding()
.background(.red)
}

HStack의 proposed size는 padding에 의해 device에서 패딩 값을 제외한 영역입니다. 패딩을 제외한 영역을 꽉 채워 자신을 그릴 수 있지만 하위 뷰가 결정한 사이즈를 존중합니다. Toggle은 최대한 넓어 질 수 있을 만큼 넓이를 차지하고자 하고 자신의 하위 뷰를 토대로 높이를 결정합니다. HStack은 이를 반영해서 하위 뷰에 fit 하게 자신의 사이즈를 결정합니다.

검은색 border가 실제 HStack의 사이즈 입니다. padding을 제외한 영역 내에서 자식 뷰에 맞추어 사이즈가 정해진 걸 확인할 수 있어요

padding은 HStack에게 자신의 영역을 제외하고 proposed size를 전달했고, HStack은 하위 뷰와 자신의 layout rule에 의해 padding에게 자신의 required size를 전달합니다. 이미 padding이 자신의 영역을 제외하고 하위 뷰에게 제안했기 때문에 자신의 영역을 제외한 공간에 하위뷰를 배치합니다.

가장 최상위 뷰라고 할 수 있는 Content View는 padding 까지 적용된 이 하위 뷰를 기본적인 규칙으로 중간에 배치합니다.

그 결과 최종적으로 이 결과를 얻게 됩니다. 그렇다면 이 모든 과정들을 생각하면서 UI와 layout을 그려야 할 까요?

일일이 이 모든 것을 고려해야 한다면 굉장히 힘들거에요 (사실 UI가 복잡해 질 수록 굉장히 힘들긴 합니다 허헣 🥲)

사실 무슨 규칙하나 없이 자기 멋대로 하는것 같은 SwiftUI에도 (공식적인 건 아니지만) 몇 가지 규칙에 따라 움직인답니다.

그래서 다음 글에서는 모든 규칙의 위의 최강 규칙 .frame modifier와 Behavior에 따른 View를 분리해 보겠습니다~ 많관부

Photo by Nagara Oyodo on Unsplash

--

--