SwiftUI Layout

Isaac·2026년 1월 7일

SwiftUI

목록 보기
2/2

SwiftUI Layout Protocol로 FlowLayout(Wrap) 직접 구현하기

iOS 16부터 도입된 Layout 프로토콜을 활용해 CSS의 flex-wrap과 같은 자동 줄바꿈 레이아웃을 구현하는 방법을 알아봅니다.

들어가며

SwiftUI의 HStackVStack은 강력하지만, 한 가지 아쉬운 점이 있습니다. 바로 자동 줄바꿈이 지원되지 않는다는 것입니다.

태그 목록, 필터 칩, 카테고리 버튼처럼 가로 공간이 부족하면 자연스럽게 다음 줄로 넘어가야 하는 UI는 어떻게 구현할까요?

// 이런 UI가 필요할 때
// [Swift] [iOS] [SwiftUI] [Layout]
// [Protocol] [Tutorial]

iOS 16 이전에는 GeometryReaderPreferenceKey를 조합한 복잡한 방법을 사용해야 했습니다. 하지만 iOS 16에서 도입된 Layout 프로토콜을 사용하면 훨씬 깔끔하게 구현할 수 있습니다.


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 이해하기

ProposedViewSize는 부모 뷰가 제안하는 사용 가능한 공간입니다. 특별한 값들이 있습니다:

의미
.zero최소 크기 요청
.infinity최대 크기 요청
.unspecified이상적인(ideal) 크기 요청
구체적인 값해당 크기 내에서 맞추기

Wrap 레이아웃 구현

이제 실제로 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
    }
}

sizeThatFits 구현

이 메서드는 모든 자식 뷰를 배치했을 때 필요한 전체 크기를 계산합니다.

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. 그 다음 추가

placeSubviews 구현

이 메서드는 각 자식 뷰를 실제로 배치합니다.

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

bounds의 origin이 항상 (0, 0)이 아닐 수 있습니다. 반드시 bounds.minXbounds.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()
    }
}

카테고리 필터 UI

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 활용하기

복잡한 레이아웃의 경우, 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을 수행합니다. 즉, 모든 자식 뷰의 크기를 미리 계산합니다.

따라서:

  • ✅ 수십 개의 태그, 칩 UI에 적합
  • ❌ 수백~수천 개의 아이템에는 부적합 (이 경우 LazyVStack + 수동 그룹핑 고려)

마무리

SwiftUI의 Layout 프로토콜은 복잡한 커스텀 레이아웃을 깔끔하게 구현할 수 있게 해줍니다. 핵심 포인트를 정리하면:

  1. 두 가지 메서드만 구현: sizeThatFitsplaceSubviews
  2. 오버플로우 체크 순서: 아이템 추가 전에 체크
  3. bounds origin 고려: bounds.minX, bounds.minY 사용
  4. 동일한 로직 유지: 두 메서드에서 같은 배치 알고리즘 사용

이 패턴을 응용하면 원형 레이아웃, 격자 레이아웃, 폭포수 레이아웃 등 다양한 커스텀 레이아웃을 만들 수 있습니다.


참고 자료


전체 코드

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)
        }
    }
}
profile
이것 저것 즐겨하는 개발자입니다.

0개의 댓글