SwiftUI Study – Layout 프로토콜과 커스텀 레이아웃으로 복잡한 화면을 선언적으로 구성하는 방법 (GeometryReader 대체 관점)
Dev Study/SwiftUI 2025. 12. 9. 10:30반응형
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을 적극적으로 도입하는 것이 장기 유지보수에 큰 도움이 된다.
반응형

