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

FramiOS
20 min readDec 9, 2023

--

Intro

1편에서 SwiftUI의 기본적인 layout 매커니즘을 살펴 봤습니다. (이전 글) 그럼에도 아직 SwiftUI 코드를 작성하며 View들이 화면에 어떻게 그려질지 예상하는 건 어려우실거에요! 그래서 이번 글에서 편법을 작성하려고 합니다. 애플에서 공식적으로 인정한 것은 아니지만 1편 보다 훨씬 View를 예상하고 그리시기 쉬울거에요. 그전에 1편에서 알아본 SwiftUI 에서의 레이아웃 단계를 다시 리마인드 해볼게요.

SwiftUI에서 Layout 단계

  1. 부모 뷰가 자식뷰에게 사이즈를 제안합니다. 이때 제안하는 사이즈를 Proposed Size라고 합니다.
  2. 자식 뷰는 자신의 사이즈를 선택 할 수 있으며(이를 behavior라고 합니다), 이때 자식 뷰가 선택한 사이즈를 Required Size라고 합니다.
  3. 부모 뷰는 부모 뷰 내의 좌표 공간에 자식뷰로 부터 전달 받은 Required Size를 바탕으로 자식 뷰를 배치합니다.
  4. View의 둘레를 가장 가까운 픽셀로 반올림 하여 좀더 부드럽고 유연한 UI를 제공합니다.

Behavior

이전 글에서도 자식 뷰의 Behavior에 대해서 다뤄 봤어요. 바로 위에 글에서 자식 뷰가 자신의 사이즈를 선택 할 수 있다고 했습니다. 이를 다시 간단하게 알아보면 부모 뷰가 자식 뷰를 배치 할때 얼마 정도의 사이즈를 가지고 배치 될지 제안을 할 수 있는데 이 부모 뷰가 제안한 사이즈는 정말 ‘제안' 정도 입니다.

결국 자신의 사이즈는 자식 뷰가 정하게 되요. 자식 뷰는 부모로 부터 제안을 듣고 그 제안을 받을지, 혹은 자신이 원하는 사이즈를 선택할 지 정할 수 있습니다. 이를 behavior라고 하는데요, 그래서 이 섹션의 제목이 behavior 입니다.

애플의 공식 document를 보면 크기와 관련된 modifier에 behavior라는 단어가 종종 등장합니다.

수학시간에 공식에 대한 증명을 하지만 실제로 문제를 풀때는 그 공식을 다시 증명하는게 아니라 그 공식을 암기하고 필요할 때 사용합니다. 그것과 마찬가지로 공식적인 공식은 아니지만 4가지 behavior를 정의할게요!

최소 사이즈

Text("")
.frame(minWidth: 100, minHeight: 100)

최소 크기를 지정했을 때를 의미합니다. frame에서 minWidth나 minHeight를 지정한 경우에요.

최대 사이즈

Text("")
.frame(maxWidth: .infinity, maxHeight: .infinity)

View를 최대 크기로 지정했을 때를 말해요. 이때 frame modifier의 maxWidth, maxHeight 파라미터에 값을 infinity로 지정해 줍니다. infinity는 자신이 차지할 수 있는 모든 공간을 차지하려 하기 때문에 부모가 제안한 사이즈를 최대한 채우게 됩니다. 따라서 주로 최대 사이즈로 설정한 경우 required size == proposed size가 됩니다.

명시적인 사이즈

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

구체적인 값을 지정할 때를 의미합니다. width, height 파라미터로 지정된 값을 전달해요.

구체적이지 않은 사이즈

Text("")

말 그대로 아무것도 지정되지 않은 상태입니다.

View에 따른 Behavior

이제 View에 따라서 어떻게 자신의 사이즈를 정하는지 확인해 볼게요. 순서는 Shape, Text, Container, Control 입니다.

Shape

Shape는 한 마디로 자신이 차지할 수 있는 모든 공간을 차지합니다. Shape는 명시적 사이즈를 제외하면 최대로 커질 수 있을 만큼 커집니다.

최소 사이즈 & 최대 사이즈 & 구체적이지 않은 사이즈

Circle()
.frame(minWidth: 100, minHeight: 100)

이 이미지는 부모뷰에 Cirecle과 Circle을 label 파라미터로 전달한 버튼입니다. 버튼은 Control에서 다룰거지만 미리 코드를 보면 이렇습니다.

Button { 
// action
} label: {
Circle()
}
.buttonStyle(.borderedProminent)

두 개 모두 Circel로 봐도 되고 위에 검은색 Circle만 보셔도 됩니다.

상위 뷰가 최대 100 x 100의 공간이 있다고 했을 때 자식 뷰에게 Proposed size로 100 x 100을 전달할 거에요. 이때 자식 뷰가 Shape 하나라면 자신이 최대로 가질 수 있는 사이즈인 100 x 100 이 Required Size가 될거에요. 지금 화면에서는 두 개의 Shape가 있고, 이 뷰의 상위 뷰인 Container에 의해 뷰와 뷰 사이에 패딩이 들어가 있어요. 그래서 패딩을 제외한 공간을 두 개의 Shape가 채울 수 있을 만큼 채워 1:1 비율로 차지하고 있는걸 볼 수 있어요.

Circle()
.frame(maxWidth: .infinity, maxHeight: .infinity)

최대 사이즈도 동일합니다. Circle은 자기 자신을 제외한 나머지 뷰가 차지하고 남은 공간을 최대로 꽉 채웁니다. Propsed size가 shape의 Required size가 됩니다.

명시적인 사이즈

Shape에서는 명시적인 사이즈만 알아두시면 됩니다. 명시적인 사이즈만 동작이 달라요!

Circle()
.frame(width: 50, height: 50)

상위 뷰에 border를 지정해서 Circle이 잘려 보이는데 지금 Circle의 크기는 50 x 50 입니다. frame으로 지정한 사이즈 만큼 뷰를 차지합니다.

이전과 다르게 확실히 줄어든 것을 확인할 수 있는데, 반면에 Shape 를 하위 뷰로 가지는 Button의 크기는 늘어났습니다. 왜냐하면 Button의 하위 뷰로 지정한 Shpae는 자신이 차지할 수 있는 공간을 가득 채우는 Shape이고, 같은 상위 뷰에 있었던 검은색 Circel이 차지하는 영역이 줄어들었기 때문에 줄어든 그 만큼의 영역을 채우게 됬기 때문이에요!

frame을 넘어가지 않는 Text

Text의 동작은 다른 View에 비해 꾀나 복잡합니다. 여러 modifier에 의해 예외 사항이 많기 때문에 자주 사용되는 상황을 살펴 볼게요.

최소 사이즈

Text("테스트1")
.frame(minWidth: 100, minHeight: 100)
.background(.green)

최소 사이즈를 지정하게 되면 지정된 사이즈 만큼 텍스트 영역이 잡히게 됩니다.

명시적인 사이즈

Text("테스트3")
.frame(width: 100, height: 100)
.background(.black.opacity(0.2))

명시적인 사이즈 또한 명시한 크기 만큼의 공간을 차지합니다.

최대 사이즈

Text("테스트2")
.frame(maxWidth: .infinity)
.background(.yellow)

최대 사이즈를 지정하면 자신이 채울 수 있는 공간을 모두 채웁니다. 이 뜻이 무엇이냐면 예를 들어 부모 뷰가 최대 320 넓이 만큼의 공간을 가지고 있다고 할 게요. 그리고 이 320 넓이를 자식 뷰에게 전달합니다. 이것이 바로 Proposed Size 입니다. 자식 뷰는 maxWidth가 infinity로 지정되어 자신이 제안 받은 최대 사이즈인 320을 required size로 정하고 부모 뷰에게 알려주게 되요. 즉 최대로 채운다는 것은 제안 받은 proposed size와 자식 뷰가 정한 사이즈인 required size가 같다는 것을 의미합니다.

구체적이지 않은 사이즈

Text("테스트4")
.background(.purple.opacity(0.5))

구체적인 사이즈를 지정하지 않은 경우 텍스트 영역 만큼의 사이즈를 required size로 반환하게 되요

frame을 넘어가는 긴 텍스트

자 이제 부터 혼란이 시작됩니다. Text는 개인적으로 모든 상황을 이해하기 보다는 그때 그때 상황에 따라 적절한 modifier을 취해주는게 맞지 않을까 싶기도 합니다.

SwiftUI는 layout 시스템을 제공해서 개발자가 직접 레이아웃을 계산하고 지정해 줄 필요 없게 해줍니다. 대신에 구체적으로 layout을 제한해야 할 때 한계가 생기기도 합니다. 간단한 UI는 쉽게 금방 그려도 복잡한 UI를 그릴 때는 오히려 쉬움이 방해가 될 때도 종종 있습니다. 우선은 지정한 영역을 넘어가는 긴 텍스트를 가진 Text view의 behavior을 살펴 보겠습니다.

frame, lineLimit, fixedSize, minimumScaleFactor modifier을 사용할 거에요!

                VStack {
Text("테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트1")
.frame(minWidth: 100, minHeight: 100)
.background(.black.opacity(0.2))

Text("테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트2")
.frame(width: 100, height: 100)
.background(.green)

Text("테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트3") // 명시하지 않은 경우 다른 뷰가 채우고 남은 공간을 채우며, 최소 1 라인의 크기는 필요로 함
.background(.purple.opacity(0.5))
}
.frame(height: 290)

위에서 부터 순서대로 최소 사이즈, 명시적인 사이즈, 구체적이지 않은 사이즈를 지정한 Text 입니다. 각 Text View는 같은 길이의 string을 화면에 보여주고 있어요.

.frame

minHeight를 지정할 경우 Text는 minHeight 만큼의 영역을 차지합니다. 만약 minHeight를 200으로 변경하면 200 만큼의 공간을 필요로 하고 이를 부모 뷰에게 알리게 됩니다.

부모 뷰 영역을 넘어서 200 높이 만큼 View를 그려주고 있어요. 반면에 사이즈를 지정하지 않은 보라색 텍스트는 자신이 최소로 필요로 하는 높이(한 줄의 텍스트 높이)만큼을 그려주고 있습니다. 만약에 minHeight를 50으로 변경하면 어떻게 될까요?

첫 번째 텍스트인 최소 사이즈와 세 번째 텍스트인 구체적이지 않은 사이즈의 우선 순위는 같습니다. 남은 공간을 두 텍스트가 사이좋게 나눠 써야 하고 그 공간에서 최대한 표시할 수 있을 만큼 텍스트를 표시합니다! 만약 텍스트가 2줄로 표시될 양이라면 1줄로 표현되고 나머지 텍스트가 남은 공간을 차지합니다.

이 둘의 동작의 변화에도 아랑곳 하지 않고 넓이와 높이를 유지하는 Text가 있습니다!

.frame(width: 100, height: 100)

크기를 명시하면 텍스트 View는 해당 크기로 고정되요!

우선순위를 정해 볼 수 있을 것 같습니다. 명시적인 사이즈 > 최소 사이즈(최소 사이즈도 사이즈다!) = 구체적이지 않은 사이즈

.lineLimit

SwiftUI에서 줄 수를 제한할 때 lineLimit modifier을 사용합니다. nil로 지정하면 최대로 표시할 수 있는 만큼의 줄 수를 가집니다. 즉, 제한을 두지 않겠다는 뜻이에요!

.lineLimit(nil)

모든 텍스트에 lineLimit 제한을 없애면

이렇게 됩니다. 구체적인 사이즈는 지정한 사이즈 만큼의 영역을 가지게 됩니다. 나머지 공간을 최소사이즈와 구체적이지 않은 사이즈로 지정된 텍스트가 채우게 됩니다.

                VStack {
Text("생략1")
.lineLimit(2)
.frame(minWidth: 100, minHeight: 50)
.background(.black.opacity(0.2))

Text("생략2")
.lineLimit(2)
.frame(width: 100, height: 100)
.background(.green)

Text("생략3")
.lineLimit(6)
}
.frame(height: 290)

보라색 뷰는 모든 글자를 표시하기 위해 최대 5줄이 필요합니다. 첫 번째 뷰의 lineLimit을 2로 설정하고 세 번째 뷰를 6으로 설정할 때, 첫 번째 뷰가 2 줄을 표시하고 남은 영역이 충분하기에 보라색 텍스트는 최대 5줄을 표시해 줄 수 있어요. 여기에 또 다른 예외 상황이 나옵니다. 만약에 minHeight를 50에서 100으로 변경하면 어떻게 될까요?

당연히 사이즈를 명시하지 않은 Text는 minHeight를 명시한 Text View의 우선 순위에 밀려 최대로 표시할 수 있는 공간인 3줄만 화면에 그려집니다. (이렇기 때문에 상황에 따라 적절한 modifier로 그때 그때 대응하는 것이 좋을 듯 합니다… 🥲 )

.fixedSize

fixedSize는 자식 뷰의 사이즈를 고정한다는 뜻으로 부모 뷰에서 제안한 사이즈를 사용하지 않고 자식 뷰 자신의 사이즈로 고정하게 됩니다.

                VStack {
Text("생략1")
.fixedSize()
.frame(minWidth: 100, minHeight: 50)
.background(.black.opacity(0.2))

Text("생략2")
.fixedSize()
.frame(width: 100, height: 100)
.background(.green)

Text("생략3")
.fixedSize()
}
.frame(height: 290)

frame이 지정되기 이전에 Text에 fixedSize를 지정해 주면 View가 이렇게 그려집니다.

이때 Text의 width는 1554.0 이고 height는 20.3 이에요. 텍스트 한줄일 때 넓이와 높이를 반환합니다! 이는 최소 사이즈, 명시적인 사이즈, 구체적이지 않은 사이즈 모두 동일하게 동작해요.

.minimumScaleFactor

VStack {
Text("생략1")
.minimumScaleFactor(0.001)
.frame(minWidth: 100, minHeight: 50)
.background(.black.opacity(0.2))

Text("생략2")
.minimumScaleFactor(0.001)
.frame(width: 100, height: 100)
.background(.green)

Text("생략3")
.minimumScaleFactor(0.001)
}
.frame(height: 290)

fixedSize를 지정하면 minimumScaleFactor가 적용되지 않기 때문에 다른 modifier는 제거하고 minimumScaleFactor를 적용해 줍니다. 텍스트를 어느 정도의 비율 만큼 줄일 것인지 정하는 modifier입니다. 텍스트를 표시할 공간이 충분하지 않다면 지정한 비율만큼 글자를 줄이게 됩니다. 0.001로 지정하면 텍스트를 표시할 공간이 충분하지 않은 경우 모든 텍스트가 보일 수 있을 때까지 텍스트 크기를 줄입니다. 이 코드를 실행하면

이렇게 나옵니다 ㅎㅎㅎ 이걸 어디다 쓰냐구요?

한글을 영어로 변경하면 이렇게 나옵니다. 안타깝게도 Text는 한글 line break를 지원할 때 글자 단위가 아닌 어절 단위로 줄 바꿈이 들어가기 때문에 frame이 지정된 경우 한 줄로 표시됩니다. 영어는 글자 단위로 줄 바꿈이 들어가고 자동으로 단어가 끊어지게 된 경우 슬래시(-)도 붙습니다. (아마 추후에 지원해 주지 않을까 기대해 봅니다)

한국어일 때의 동작을 살펴보면 minimuScaleFactor를 지정한 View는 frame이 지정된 경우 해당 frame 내에서 글자를 한줄 상태에서 텍스트 크기를 줄입니다.
frame이 지정되지 않은 경우와 최소 사이즈를 지정한 경우에는 표시할 수 있을 만큼 뷰를 그린 뒤 그 안에 텍스트를 표시하게 됩니다. 이 두가지 경우에 대해 텍스트를 ‘늘려가며' 사이즈를 측정해 본 결과 정확한 사이즈는 알 수 없었어요. 늘어났다 줄어들었다 합니다…

테스트해본걸 정리해보면 이렇습니다. 상황에 따라 달라서 텍스트는 따로 한번 딥하게 다뤄 봐야겠어요 🥲

Container

컨테이너는 VStack, HStack, ZStack만 알아볼게요. VStack, HStack은 각각 자식 뷰를 수평 혹은 수직 방향으로 배치해요. ZStack은 레이어를 쌓을 수 있도록 Z 축 방향으로 뷰를 쌓습니다. 자식 뷰에게 자신이 최대로 커질 수 있는 사이즈인 Proposed Size를 전달합니다. 자식 뷰는 부모 뷰로 부터 제안을 받지만 이를 무시하거나 채택할 수 있기 때문에 결국 Container의 최종 크기는 자식 뷰에 의해 정해집니다.

다만 frame으로 구체적인 크기를 명시하면 해당 크기로 고정됩니다.

Control

앞에서 Shapre, Text, Container에 대해 알아봤는데요, 이를 알면 Control의 required size를 예상하는 것은 쉬운일입니다 😌

Control은 특수한 Container다! 라고 생각하시면 됩니다.

Button

우선 Button입니다. Button은 Container 처럼 동작해요.

public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)

Button의 initializer 중 하나를 살펴 보면 label 파라미터로 @ViewBuilder 를 이용해서 View를 전달해 주고 있어요.

Button {
// action
} label: {
Text("test")
}
.buttonStyle(.borderedProminent)

Button이라는 Container가(공식적으로 Container라고 부르지는 않습니다) Text라는 자식 뷰를 감싸고 있는 형태입니다.

이전에 Text에서 구체적이지 않은 사이즈 즉, frame modifier을 사용해서 사이즈를 지정하지 않았을 때 어떻게 된다고 했었죠? 네 맞습니다! Text 글자 사이즈 만큼의 사이즈를 가집니다.

Container는 자식뷰의 사이즈를 반환 받아 자신의 최종 사이즈를 표시 하므로 preview를 확인해 보면 저희가 예상한 대로 동작합니다.

글자 주변의 조금의 여백이 들어간 것을 확인할 수 있는데 그건 buttonStyle로 지정한 borderedProminent 때문입니다. buttonStyle modifier을 background로 바꿔주세요.

                Button {
// action
} label: {
Text("test")
}
.background(.red)
// .buttonStyle(.borderedProminent)

Button의 사이즈는 Text의 사이즈가 됬습니다. 이제 이 Button의 label 파라미터로 Shape를 지정해 줄게요.

                Button { 
// action
} label: {
Circle()
}
.background(.black)

Button의 사이즈는 어떻게 될까요? 위에서 Shape의 frame을 정의하지 않으면 어떻게 되는지 알아 봤었습니다. Shape는 자신이 채울 수 있는 공간을 최대한 가득 채우려 합니다.

Circle은 최대로 채울 수 있는 넓이 만큼 커지고 그 크기 만큼 비례해서 높이도 지정됩니다.

Toggle

Toggle("test", isOn: $isOn) 

토글은 주로 이렇게 정의해서 사용하실거에요. 토글도 버튼 처럼 label 파라미터를 전달해 줄 수 있어요.

Toggle(isOn: $isOn, label: {
Text("Label")
})

위와 아래 코드는 동일한 토글을 그리게 되요. 즉 Toggle도 자식 뷰를 감싸고 있는 어느 상위 뷰라고 할 수 있습니다. Toggle은 특수한 컨테이너처럼 동작합니다. 넓이는 제안된 사이즈 즉, 넓어 질 수 있을 만큼 넓어지고 높이는 자식 뷰의 크기로 지정됩니다.

Toggle(isOn: $isOn, label: {
Circle()
})
.background(.yellow)

항상 커질 수 있을 만큼 커지는 shape를 하위 뷰로 넣으면 어떻게 될까요?

이렇게 됩니다. Shape 의 동작으로 Toggle의 전체적인 사이즈가 정해져요.

TextField

TextField도 Toggle과 동일합니다. 텍스트 영역 만큼의 높이, 최대한 채울 수 있을 만큼의 넓이를 가집니다.

Shape, Text, Container, Control의 behavior을 간단히 알아봤는데 어떠신가요? SwiftUI로 UI를 그리는 것이 익숙하지 않다면 초반에 View가 어떻게 그려질지 감을 잡는게 어려우실 거에요. 사실 1편과 2편에서 다룬 내용이 끝이 아닙니다! alignment와 offset 등이 들어가면 또 다른 결과가 나타나요! 그럼에도 기본적으로 SwiftUI의 layout 시스템을 이해하는데 조금이나마 도움이 되었으면 좋겠네요! 혹시나 잘 못된 내용이나 공유하고 싶은 정보가 있다면 언제든지 알려주시면 감사하겠습니다!

모두 스유하세요 🙌

Photo by Joshua J. Cotten on Unsplash

--

--