iOS 16부터 도입된 Layout 프로토콜을 활용해 CSS의
flex-wrap과 같은 자동 줄바꿈 레이아웃을 구현하는 방법을 알아봅니다.
SwiftUI의 HStack과 VStack은 강력하지만, 한 가지 아쉬운 점이 있습니다. 바로 자동 줄바꿈이 지원되지 않는다는 것입니다.
태그 목록, 필터 칩, 카테고리 버튼처럼 가로 공간이 부족하면 자연스럽게 다음 줄로 넘어가야 하는 UI는 어떻게 구현할까요?
// 이런 UI가 필요할 때
// [Swift] [iOS] [SwiftUI] [Layout]
// [Protocol] [Tutorial]
iOS 16 이전에는 GeometryReader와 PreferenceKey를 조합한 복잡한 방법을 사용해야 했습니다. 하지만 iOS 16에서 도입된 Layout 프로토콜을 사용하면 훨씬 깔끔하게 구현할 수 있습니다.
Layout 프로토콜은 WWDC22에서 소개된 SwiftUI의 새로운 레이아웃 시스템입니다. HStack, VStack, ZStack처럼 자식 뷰들을 배치하는 커스텀 컨테이너를 만들 수 있게 해줍니다.
Layout 프로토콜을 채택하려면 두 가지 메서드만 구현하면 됩니다:
protocol Layout {
// 1. 레이아웃 전체 크기 계산
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize
// 2. 자식 뷰 배치
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
)
}
ProposedViewSize는 부모 뷰가 제안하는 사용 가능한 공간입니다. 특별한 값들이 있습니다:
| 값 | 의미 |
|---|---|
.zero | 최소 크기 요청 |
.infinity | 최대 크기 요청 |
.unspecified | 이상적인(ideal) 크기 요청 |
| 구체적인 값 | 해당 크기 내에서 맞추기 |
이제 실제로 FlowLayout(Wrap)을 구현해보겠습니다.
import SwiftUI
struct Wrap: Layout {
let spacing: CGFloat // 아이템 간 가로 간격
let runSpacing: CGFloat // 줄 간 세로 간격
init(spacing: CGFloat = 4, runSpacing: CGFloat = 8) {
self.spacing = spacing
self.runSpacing = runSpacing
}
}
이 메서드는 모든 자식 뷰를 배치했을 때 필요한 전체 크기를 계산합니다.
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
// 부모가 제안한 너비 (없으면 무한대)
let proposalWidth: CGFloat = proposal.width ?? .infinity
var rowWidth: CGFloat = 0 // 현재 줄의 너비
var rowHeight: CGFloat = 0 // 현재 줄의 높이 (가장 높은 아이템 기준)
var totalHeight: CGFloat = 0
for subview in subviews {
// 각 자식 뷰의 이상적인 크기 측정
let size = subview.sizeThatFits(.unspecified)
// ⚠️ 핵심: 추가하기 "전에" 오버플로우 체크
if rowWidth + size.width + spacing > proposalWidth && rowWidth > 0 {
// 이전 줄 높이 확정 후 새 줄 시작
totalHeight += rowHeight + runSpacing
rowWidth = 0
rowHeight = 0
}
// 현재 줄에 아이템 추가
rowWidth += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
// 마지막 줄 높이 추가
if rowHeight > 0 {
totalHeight += rowHeight
}
return CGSize(width: proposalWidth, height: totalHeight)
}
처음 구현할 때 흔히 하는 실수가 있습니다:
// ❌ 잘못된 순서
rowWidth += size.width // 1. 먼저 추가
if rowWidth > proposalWidth { // 2. 그 다음 체크
totalHeight += rowHeight // 현재 아이템 높이가 이전 줄에 포함됨!
rowWidth = 0 // 현재 아이템이 새 줄에서 누락됨!
}
// ✅ 올바른 순서
if rowWidth + size.width > proposalWidth { // 1. 먼저 체크
totalHeight += rowHeight // 이전 줄만 확정
rowWidth = 0
}
rowWidth += size.width // 2. 그 다음 추가
이 메서드는 각 자식 뷰를 실제로 배치합니다.
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let proposalWidth: CGFloat = proposal.width ?? .infinity
var posX: CGFloat = 0
var posY: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
// 오버플로우 체크 (sizeThatFits와 동일한 로직)
if posX + size.width + spacing > proposalWidth && posX > 0 {
posX = 0
posY += rowHeight + runSpacing
rowHeight = 0
}
// 뷰 배치
subview.place(
at: CGPoint(
x: bounds.minX + posX, // ⚠️ bounds.minX 사용!
y: bounds.minY + posY // ⚠️ bounds.minY 사용!
),
proposal: ProposedViewSize(width: size.width, height: size.height)
)
posX += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
bounds의 origin이 항상 (0, 0)이 아닐 수 있습니다. 반드시 bounds.minX와 bounds.minY를 기준으로 좌표를 계산해야 합니다.
// ❌ 잘못된 방법
subview.place(at: CGPoint(x: posX, y: posY), ...)
// ✅ 올바른 방법
subview.place(at: CGPoint(x: bounds.minX + posX, y: bounds.minY + posY), ...)
struct TagListView: View {
let tags = ["Swift", "iOS", "SwiftUI", "Layout", "Protocol", "Tutorial", "WWDC22"]
var body: some View {
Wrap(spacing: 8, runSpacing: 12) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
.padding()
}
}
struct CategoryFilterView: View {
@State private var selectedCategory: String?
let categories = ["전체", "업무", "개인", "건강", "학습", "취미"]
var body: some View {
Wrap(spacing: 8, runSpacing: 8) {
ForEach(categories, id: \.self) { category in
CategoryChip(
name: category,
isSelected: selectedCategory == category
) {
selectedCategory = category
}
}
}
}
}
복잡한 레이아웃의 경우, cache를 활용해 계산 결과를 저장할 수 있습니다:
struct Wrap: Layout {
struct CacheData {
var sizes: [CGSize] = []
}
func makeCache(subviews: Subviews) -> CacheData {
CacheData(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) -> CGSize {
// cache.sizes 사용
}
}
Layout 프로토콜로 구현한 레이아웃은 Eager Evaluation을 수행합니다. 즉, 모든 자식 뷰의 크기를 미리 계산합니다.
따라서:
LazyVStack + 수동 그룹핑 고려)SwiftUI의 Layout 프로토콜은 복잡한 커스텀 레이아웃을 깔끔하게 구현할 수 있게 해줍니다. 핵심 포인트를 정리하면:
sizeThatFits와 placeSubviewsbounds.minX, bounds.minY 사용이 패턴을 응용하면 원형 레이아웃, 격자 레이아웃, 폭포수 레이아웃 등 다양한 커스텀 레이아웃을 만들 수 있습니다.
import SwiftUI
struct Wrap: Layout {
let spacing: CGFloat
let runSpacing: CGFloat
init(spacing: CGFloat = 4, runSpacing: CGFloat = 8) {
self.spacing = spacing
self.runSpacing = runSpacing
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
let proposalWidth: CGFloat = proposal.width ?? .infinity
var rowWidth: CGFloat = 0
var rowHeight: CGFloat = 0
var height: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if rowWidth + size.width + spacing > proposalWidth && rowWidth > 0 {
height += rowHeight + runSpacing
rowWidth = 0
rowHeight = 0
}
rowWidth += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
if rowHeight > 0 {
height += rowHeight
}
return CGSize(width: proposalWidth, height: height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let proposalWidth: CGFloat = proposal.width ?? .infinity
var posX: CGFloat = 0
var posY: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if posX + size.width + spacing > proposalWidth && posX > 0 {
posX = 0
posY += rowHeight + runSpacing
rowHeight = 0
}
subview.place(
at: CGPoint(x: bounds.minX + posX, y: bounds.minY + posY),
proposal: ProposedViewSize(width: size.width, height: size.height)
)
posX += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}