반응형

SwiftUI Study – Layout 프로토콜과 커스텀 레이아웃으로 복잡한 화면을 선언적으로 구성하는 방법 (GeometryReader 대체 관점)

 

1. 왜 중요한가 (문제 배경)

iOS 16 이후 SwiftUI에는 Layout 프로토콜이 도입되었다.
이는 기존의 HStack, VStack, ZStack으로 표현하기 어려운 복잡한 배치를
보다 선언적이면서도 재사용 가능한 방식으로 구현할 수 있게 해준다.

기존에는 다음과 같은 문제들이 있었다.

  • GeometryReader + position/offset 조합으로 억지 배치
  • 뷰마다 반복되는 동일한 레이아웃 계산
  • 특정 패턴(태그 레이아웃, 워터폴 그리드 등)을 매번 수작업 구현
  • 레이아웃 로직이 View 내부에 뒤섞여 가독성 저하

Layout 프로토콜을 사용하면 레이아웃을 하나의 정책 객체로 분리할 수 있고,
이를 통해 다음을 달성할 수 있다.

  • 뷰와 레이아웃 로직 분리
  • 재사용 가능한 레이아웃 컴포넌트
  • GeometryReader 의존도 감소
  • 테스트와 유지보수 용이성 향상

 

2. 잘못된 패턴 예시

❌ 예시 1: GeometryReader + offset으로 태그 레이아웃 흉내 내기

struct WrongTagCloud: View {
    let tags: [String]

    var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .topLeading) {
                var currentX: CGFloat = 0
                var currentY: CGFloat = 0

                ForEach(tags, id: \.self) { tag in
                    Text(tag)
                        .padding(8)
                        .background(Color.blue.opacity(0.2))
                        .offset(x: currentX, y: currentY)   // ❌ 직접 좌표 계산
                    // 각 태그의 크기를 알 수 없어 정확한 배치가 어려움
                }
            }
        }
    }
}

문제점
- 각 뷰의 실제 크기를 알 수 없어 정확한 계산이 복잡
- GeometryReader 내부에서 수동 배치를 시도하면 코드가 금방 난해해짐
- 반응형 대응이 어렵고 유지보수가 힘듦

❌ 예시 2: 동일한 레이아웃 계산이 여러 View에 중복

func rowItems(for width: CGFloat) -> [[Item]] {
    // ❌ 각 View마다 비슷한 계산 로직을 중복 구현
}

var body: some View {
    GeometryReader { geo in
        let rows = rowItems(for: geo.size.width)
        VStack {
            ForEach(0..<rows.count, id: \.self) { row in
                HStack {
                    ForEach(rows[row]) { item in
                        ItemView(item: item)
                    }
                }
            }
        }
    }
}

문제점
- 레이아웃 로직이 View에 박혀 있음
- 다른 화면에서 같은 레이아웃을 쓰려면 복붙이 필요함
- 레이아웃 규칙을 수정하면 여러 파일을 동시에 손봐야 함

 

3. 올바른 패턴 예시

✅ 예시 1: Layout 프로토콜을 활용한 간단한 수평 래핑 레이아웃

struct WrappingHLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var x: CGFloat = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x + size.width > maxWidth {
                x = 0
                y += rowHeight
                rowHeight = 0
            }
            x += size.width
            rowHeight = max(rowHeight, size.height)
        }

        return CGSize(width: maxWidth, height: y + rowHeight)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let maxWidth = bounds.width
        var x: CGFloat = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x + size.width > maxWidth {
                x = 0
                y += rowHeight
                rowHeight = 0
            }
            subview.place(
                at: CGPoint(x: bounds.minX + x + size.width / 2,
                            y: bounds.minY + y + size.height / 2),
                proposal: ProposedViewSize(size)
            )
            x += size.width
            rowHeight = max(rowHeight, size.height)
        }
    }
}

사용:

struct TagCloudView: View {
    let tags: [String]

    var body: some View {
        WrappingHLayout {
            ForEach(tags, id: \.self) { tag in
                Text(tag)
                    .padding(8)
                    .background(Color.blue.opacity(0.2))
                    .clipShape(Capsule())
            }
        }
        .padding()
    }
}

장점
- 태그 레이아웃 규칙이 WrappingHLayout 하나에 모인다
- 다른 화면에서도 그대로 재사용 가능
- GeometryReader 없이도 복잡한 배치 구현 가능

✅ 예시 2: Layout을 통해 UI 규칙을 “정책 객체”로 분리

struct EqualWidthHLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let count = CGFloat(subviews.count)
        let width = (proposal.width ?? 0) / max(count, 1)
        let height = subviews
            .map { $0.sizeThatFits(.unspecified).height }
            .max() ?? 0
        return CGSize(width: proposal.width ?? 0, height: height)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let count = CGFloat(subviews.count)
        guard count > 0 else { return }
        let cellWidth = bounds.width / count

        for (index, subview) in subviews.enumerated() {
            let x = bounds.minX + cellWidth * (CGFloat(index) + 0.5)
            let size = subview.sizeThatFits(
                ProposedViewSize(width: cellWidth, height: bounds.height)
            )
            subview.place(
                at: CGPoint(x: x, y: bounds.midY),
                proposal: ProposedViewSize(size)
            )
        }
    }
}

사용 예:

EqualWidthHLayout {
    Text("하나")
    Text("둘")
    Text("셋")
}
.frame(maxWidth: .infinity)

장점
- 각 버튼/텍스트가 동일 폭을 가지는 레이아웃을 한 번에 구성
- 폼, 툴바, 탭형 UI 구성에 유용

 

4. 실전 적용 팁

✔ 팁 1 – GeometryReader로 억지 배치하기 전에 Layout 사용 가능 여부부터 검토

복잡한 배치일수록 Layout 프로토콜이 더 잘 맞는다.

✔ 팁 2 – “여러 View에서 반복되는 배치 규칙”은 Layout으로 승격

같은 레이아웃 로직이 보이면 정책 객체로 분리하는 것이 좋다.

✔ 팁 3 – Layout은 뷰와 독립적인 순수 로직으로 유지

비즈니스 로직이 아닌 “배치 규칙”만 포함하도록 설계.

✔ 팁 4 – Preview에서 Layout만 따로 테스트해보기

더미 뷰를 넣고 레이아웃 규칙이 기대대로 동작하는지 쉽게 검증 가능.

✔ 팁 5 – iOS 16 이상 타겟이라면 Layout을 우선 도입

GeometryReader 중심 설계보다 확장성과 예측 가능성이 높다.

 

5. 정리

  • Layout 프로토콜은 GeometryReader로 억지 배치하던 많은 패턴을 대체할 수 있는 강력한 도구다.
  • 복잡한 배치 규칙은 Layout으로 분리해 재사용성과 가독성을 동시에 확보하는 것이 좋다.
  • 뷰는 “무엇을 보여줄지”에만 집중하고, “어떻게 배치할지”는 Layout에 위임하는 구조가 가장 이상적이다.
  • iOS 16+ 프로젝트라면, 커스텀 Layout을 적극적으로 도입하는 것이 장기 유지보수에 큰 도움이 된다.
반응형
Posted by 까칠코더
,