변경되는 드롭다운 레이블이 부드럽게 다시 줄바꿈되도록 하려면 각 컨트롤을 이상적인 크기로 측정하고, 공통 줄바꿈 알고리즘으로 배치한 다음, 컨테이너에서 선택 변경을 애니메이션으로 처리합니다. 이 튜토리얼에서는 가상의 옵션을 사용해 작은 macOS 제어 패널을 만듭니다.
줄바꿈과 애니메이션 분리하기
독자가 수행할 작업은 구체적입니다. 더 긴 드롭다운 레이블을 선택하면 주변 컨트롤이 다른 행으로 이동할 수 있는 데스크톱 패널을 만듭니다. 컨트롤은 위치가 바뀌어도 동일한 정체성을 유지해야 하며, 패널은 주변 인터페이스에 새 높이를 알려야 합니다.
서로 다른 두 가지 책임이 있습니다. 사용자 정의 Layout은 목적지 크기와 위치를 계산합니다. 애니메이션은 이전 기하 구조에서 새 기하 구조로 어떻게 이동할지 SwiftUI에 알려줍니다. Apple은 기본 제공 컨테이너보다 더 복잡한 동작이 필요한 배열을 위한 확장 지점으로 Layout을 문서화합니다. 필수 메서드 두 개는 컨테이너의 크기를 보고하고 하위 뷰를 배치합니다. Layout
이 예제에서 각 드롭다운은 선택 항목을 업데이트하는 동작이 포함된 Menu입니다. Apple은 사용자 정의 레이블 빌더를 사용하는 메뉴를 지원하므로, 표시되는 레이블에 카테고리와 선택된 옵션을 모두 포함할 수 있습니다. 레이아웃은 창 안에 닫힌 상태의 컨트롤을 배열합니다. 이 예제는 메뉴가 표시된 콘텐츠의 기하 구조를 제어하려고 하지 않습니다. Menu
이 설계에는 세 가지 명시적인 정책이 있습니다.
- 컨트롤은 한 행을 유지하기 위해 레이블을 압축하지 않고 이상적인 크기를 유지합니다.
- 다음 컨트롤을 추가하면 제안된 너비를 초과하는 경우, 컨트롤을 기존 순서대로 줄바꿈합니다.
- 사용 가능한 너비보다 넓은 컨트롤은 자체 행을 차지하고, 레이아웃은 그보다 넓은 크기를 보고합니다.
마지막 정책이 중요합니다. 컬렉션을 줄바꿈해도 나눌 수 없는 컨트롤 하나를 더 좁게 만들 수는 없습니다. 애플리케이션에서 임의로 긴 레이블을 좁은 패널에 맞춰야 한다면, 의도적으로 경계를 정한 레이블 디자인과 같은 별도의 레이블 정책도 필요합니다. Apple은 fixedSize()가 이상적인 크기를 유지하므로 뷰가 부모의 경계를 넘을 수 있다고 설명합니다. fixedSize()
측정과 배치에 하나의 계산 사용하기
SwiftUI의 레이아웃 시스템은 크기 제안을 계층 아래로 전달합니다. 크기가 지정되지 않은 제안은 뷰의 이상적인 크기를 요청하며, 0이나 무한대를 포함한 제안과는 다릅니다. 여기서는 이상적인 크기를 측정해 각 컨트롤의 공간을 정하고, 부모가 제안한 너비로 행을 나눕니다. ProposedViewSize
핵심은 일관성입니다. 컨테이너는 배치 알고리즘이 사용하는 것과 동일한 공간을 확보해야 합니다. Apple은 간격과 제안에 대한 응답을 포함해 크기 계산과 배치 계산이 일관되어야 한다고 명시적으로 요구합니다. 또한 배치 경계는 부모의 좌표 공간을 기준으로 하므로, 배치 시 bounds.minX과 bounds.minY을 고려해야 합니다. placeSubviews(in:proposal:subviews:cache:)
Xcode에서 macOS SwiftUI 앱을 만들고 배포 대상을 macOS 14 이상으로 설정합니다. 예제는 macOS 13부터 사용할 수 있는 Layout과 macOS 14부터 사용할 수 있는 geometryGroup()를 사용합니다. 다음 섹션의 레이아웃 선언과 앱 코드를 하나의 Swift 파일에 넣고, 생성된 앱 진입점을 대체합니다. 두 코드 블록을 합치면 완전한 예제가 됩니다. Layout availability, geometryGroup() availability
import SwiftUI
struct WrappingControls: Layout {
var spacing: CGFloat = 8
struct Plan {
var size: CGSize
var origins: [CGPoint]
}
func plan(
proposal: ProposedViewSize,
subviews: Subviews
) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, origins: [])
}
let gap = max(0, spacing)
let limit: CGFloat
if let width = proposal.width, width.isFinite {
limit = max(0, width)
} else {
limit = .infinity
}
var origins: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var usedWidth: CGFloat = 0
var rowHasItems = false
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let nextX = rowHasItems ? x + gap : 0
if rowHasItems && nextX + size.width > limit {
y += rowHeight + gap
x = 0
rowHeight = 0
rowHasItems = false
}
if rowHasItems {
x += gap
}
origins.append(CGPoint(x: x, y: y))
x += size.width
rowHeight = max(rowHeight, size.height)
usedWidth = max(usedWidth, x)
rowHasItems = true
}
let width = limit.isFinite ? max(limit, usedWidth) : usedWidth
return Plan(
size: CGSize(width: width, height: y + rowHeight),
origins: origins
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
plan(proposal: proposal, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = plan(proposal: proposal, subviews: subviews)
for (index, subview) in subviews.enumerated() {
let origin = result.origins[index]
subview.place(
at: CGPoint(
x: bounds.minX + origin.x,
y: bounds.minY + origin.y
),
anchor: .topLeading,
proposal: .unspecified
)
}
}
}
두 레이아웃 메서드는 모두 SwiftUI가 제공한 제안을 사용해 plan을 호출합니다. 측정과 배치에서도 동일한 하위 뷰 제안인 .unspecified를 사용합니다. Apple의 하위 뷰 측정 API는 전달된 제안에 따라 하위 뷰가 선택한 크기를 반환합니다. 이 예제는 문자 수로 너비를 추정하는 대신 그 응답을 사용합니다. sizeThatFits(_:)
제안된 너비가 유한하면 레이아웃은 최소한 그 너비만큼 공간을 채우고, 하위 뷰가 더 큰 경우 이를 그대로 보고합니다. 너비가 지정되지 않았거나 무한대이면, 크기가 유한한 컨트롤에 대해 한 행으로 배치했을 때의 유한한 이상적 크기를 반환합니다. 높이는 콘텐츠에 따라 결정됩니다. 레이아웃은 제안된 높이를 의도적으로 무시하고 행에 필요한 공간을 보고합니다.
이 구현은 Void 캐시를 사용하고 작은 레이아웃 계획을 다시 계산합니다. Apple은 프로파일링 결과 이점이 확인된 경우에만 캐시를 추가하라고 권장합니다. 나중에 측정값을 캐시하더라도 캐시를 다시 만들었을 때 레이아웃 결과가 달라지지 않아야 합니다. makeCache(subviews:)
변경되는 선택을 안정적인 컨트롤에 연결하기
가상의 패널에는 Shape, Pattern, Arrangement라는 세 가지 카테고리가 있습니다. 각 카테고리는 자체 선택 문자열을 가지며 모든 옵션은 예제에 정의되어 있습니다. 레이블은 기존 Menu 안에서 바뀝니다. 선택이 바뀔 때마다 프로그램이 메뉴를 교체하지는 않습니다.
레이아웃 선언 뒤에 이 코드를 추가합니다. 프로젝트에는 @main 앱 선언이 하나만 있어야 합니다.
struct ControlChoice: Identifiable, Equatable {
let id: Int
let title: String
let options: [String]
var selected: String
}
struct DropdownPanel: View {
@State var choices: [ControlChoice] = [
ControlChoice(
id: 1,
title: "Shape",
options: ["Circle", "Rounded rectangle"],
selected: "Circle"
),
ControlChoice(
id: 2,
title: "Pattern",
options: ["Solid", "Alternating diagonal lines"],
selected: "Solid"
),
ControlChoice(
id: 3,
title: "Arrangement",
options: ["Rows", "Evenly spaced columns"],
selected: "Rows"
)
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Layout options")
.font(.headline)
WrappingControls(spacing: 8) {
ForEach(choices) { choice in
Menu {
ForEach(choice.options, id: \.self) { option in
Button(option) {
select(option, for: choice.id)
}
}
} label: {
Text("\(choice.title): \(choice.selected)")
.lineLimit(1)
}
.fixedSize()
.geometryGroup()
}
}
.animation(
.easeInOut(duration: 0.25),
value: choices.map(\.selected)
)
Text("Choose a longer option, then narrow the window.")
.foregroundStyle(.secondary)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 320, minHeight: 240)
}
func select(_ option: String, for id: Int) {
guard let index = choices.firstIndex(where: { $0.id == id }),
choices[index].options.contains(option) else {
return
}
choices[index].selected = option
}
}
@main
struct RewrappingMenusApp: App {
var body: some Scene {
WindowGroup {
DropdownPanel()
}
}
}
애니메이션 수정자는 선택된 문자열의 배열을 관찰합니다. Apple은 관찰하는 값이 Equatable을 준수해야 한다고 요구하며, 이 배열은 그 조건을 충족합니다. 선택 값을 관찰하므로 창 너비만 바뀌었다는 이유로 이 수정자가 새 애니메이션을 요청하지도 않습니다. 제안이 바뀌면 레이아웃은 여전히 줄바꿈을 다시 계산합니다. animation(_:value:)
안정적인 카테고리 ID는 업데이트 사이에도 각 메뉴를 연결합니다. ForEach는 식별된 데이터로부터 뷰를 만들며, 뷰의 명시적인 .id를 변경하면 정체성과 관련 상태가 초기화됩니다. 따라서 .id(choice.selected)을 추가하면 이 예제에 필요한 연속성을 해치게 됩니다. 옵션 문자열은 각 가상 카테고리 안에서 고유하므로 메뉴 동작에 id: \.self을 사용하기에 적합합니다. ForEach, id(_:)
Apple의 사용자 정의 레이아웃 예제는 구조적 정체성을 유지하면 animation(_:value:)가 레이아웃 변경을 애니메이션으로 처리할 수 있음을 보여줍니다. 이 예제는 하나의 레이아웃 유형 안에서 측정값이 바뀌는 경우에 그 원칙을 적용합니다. 선택 문자열은 즉시 바뀝니다. 의도된 애니메이션은 두 레이블 문자의 보간이 아니라 그에 따른 이동과 크기 조정입니다. Composing custom layouts with SwiftUI
geometryGroup() 수정자는 각 메뉴 전체에 기하 경계를 제공합니다. Apple은 이 경계가 하위 항목에 전달되기 전에 부모 기하 구조를 해결하고 애니메이션한다고 설명합니다. 여기서는 이 문서화된 메커니즘을 사용해 각 컨트롤의 내부 콘텐츠가 함께 이동하도록 합니다. 이 수정자는 행을 나누는 기준을 정하거나 잘못된 줄바꿈 계산을 바로잡지는 않습니다. geometryGroup()
애니메이션을 조정하기 전에 행 나눔 확인하기
간단한 예제를 통해 줄바꿈 정책을 점검할 수 있습니다. 이는 설명을 위한 크기이며 macOS 컨트롤을 측정한 값이 아닙니다. 컨트롤 세 개의 너비가 각각 80, 100, 90포인트이고 모두 높이가 24포인트이며 간격이 8포인트이고 사용 가능한 너비가 300포인트라고 가정합니다.
처음 행에는 286포인트가 필요합니다. 80 + 8 + 100 + 8 + 90입니다. 가운데 컨트롤의 너비가 150포인트로 늘어나면 행 전체에 336포인트가 필요합니다. 처음 두 컨트롤에는 여전히 238포인트가 필요하므로 함께 들어갑니다. 세 번째 컨트롤은 다음 행으로 이동합니다. 새 콘텐츠 높이는 56포인트입니다. 24 + 8 + 24입니다.
| 조건 | 이 알고리즘의 예상 결과 |
|---|---|
| 너비 합계가 한계와 정확히 같음 | 컨트롤을 해당 행에 그대로 둡니다. |
| 다음 컨트롤을 추가하면 한계를 넘음 | 해당 컨트롤 앞에서 새 행을 시작합니다. |
| 컨트롤 높이가 서로 다름 | 완료된 행에서 가장 높은 컨트롤을 기준으로 다음 행을 배치합니다. |
| 컨트롤 하나가 제안된 너비를 초과함 | 해당 컨트롤만 배치하고 필요한 더 넓은 크기를 보고합니다. |
| 너비가 지정되지 않았거나 무한대임 | 컨트롤의 이상적인 너비를 합친 한 행을 반환합니다. |
| 컨트롤이 없음 | 크기가 0인 레이아웃을 반환합니다. |
더 긴 레이블, 너비를 초과하는 컨트롤, 지정되지 않은 너비를 포함해 결정적인 가상 크기로 패킹 계산을 별도로 확인했습니다. SwiftUI 앱 자체는 이 환경에서 컴파일하거나 시각적으로 테스트하지 않았습니다. macOS에서 빌드하고 실행해 기본 컨트롤 모양과 애니메이션을 확인하세요.
앱을 실행한 뒤 먼저 창 너비를 고정한 상태에서 더 긴 옵션을 선택합니다. 선택 변경으로 행 경계가 넘어가도록 창 크기를 조정한 다음, 짧은 옵션과 긴 옵션을 번갈아 선택합니다. 컨트롤 아래 설명 텍스트가 새 행 높이에 맞춰 이동하고 짧은 레이블로 돌아가면 사용하지 않는 세로 공간이 사라지는지 확인합니다.
그다음 선택은 그대로 두고 창 크기를 바꿉니다. 이렇게 하면 줄바꿈 문제와 애니메이션 문제를 구분할 수 있습니다. 최종 위치가 잘못되면 측정이나 배치를 점검하고, 최종 위치는 올바르지만 이동이 바람직하지 않으면 애니메이션 범위와 기하 그룹화를 점검합니다.
남은 실패 조건 진단하기
레이블은 바뀌는데 행 나눔이 그대로라면 레이아웃이 실제로 측정하는 값을 확인합니다. 모든 메뉴를 고정 너비 래퍼로 감싸면 짧은 레이블과 긴 레이블이 동일한 바깥 너비로 측정될 수 있습니다. 이 구현에서는 각 메뉴가 의도적으로 이상적인 크기를 유지하며, 레이아웃은 별도의 텍스트 추정값이 아니라 전체 컨트롤을 측정합니다. fixedSize(), sizeThatFits(_:)
컨트롤이 겹치거나 다음 섹션이 두 번째 행을 덮으면 먼저 크기 계산과 배치를 비교합니다. 간격, 하위 뷰 제안, 줄바꿈 조건, 행 높이 계산을 두 메서드에서 동일하게 유지합니다. 크기는 한 너비를 기준으로 계산하고 배치는 다른 너비를 기준으로 하지 마세요. 공통 plan 함수는 이런 불일치가 생기기 어렵게 합니다. placeSubviews(in:proposal:subviews:cache:)
선택 변경 중 컨트롤이 사라졌다가 다시 나타나면 애니메이션 수정자를 더 추가하기 전에 정체성을 확인합니다. 카테고리 ID는 그대로 두고 선택 항목만 업데이트합니다. Apple은 명시적인 뷰 ID를 변경하면 뷰의 정체성이 초기화된다고 문서화했으므로, 바뀌는 레이블은 지속되는 컨트롤의 정체성 키로 적절하지 않습니다. id(_:)
macOS 13 배포를 위해서는 .geometryGroup()를 제거하고 Layout 구현과 선택에 따른 애니메이션을 유지합니다. 줄바꿈 방식은 그대로지만 macOS 14부터 사용할 수 있는 문서화된 기하 경계는 제거됩니다. 지원하는 시스템에서 결과적인 움직임을 평가하세요. Layout, geometryGroup()
완성된 동작은 세 가지 구체적인 결정에 달려 있습니다. 실제 컨트롤을 측정하고, 보고 크기와 배치에 동일한 행 계획을 사용하고, 레이블이 바뀌어도 컨트롤의 정체성을 유지합니다. 이러한 결정으로 최종 기하 구조가 올바르게 나오면 선택에 따른 애니메이션으로 줄바꿈을 더 쉽게 따라갈 수 있습니다.
출처
- Layout — Apple 개발자 문서.
- ProposedViewSize — Apple 개발자 문서.
- placeSubviews(in:proposal:subviews:cache:) — Apple 개발자 문서.
- sizeThatFits(_:) — Apple 개발자 문서.
- makeCache(subviews:) — Apple 개발자 문서.
- Menu — Apple 개발자 문서.
- fixedSize() — Apple 개발자 문서.
- animation(_:value:) — Apple 개발자 문서.
- ForEach — Apple 개발자 문서.
- id(_:) — Apple 개발자 문서.
- Composing custom layouts with SwiftUI — Apple 개발자 문서.
- geometryGroup() — Apple 개발자 문서.