Mac 앱에서 재사용 가능한 SwiftUI 패널을 만들 때는 기본 콘텐츠 빌더를 맨 앞에 두고, 나머지 빌더에는 레이블을 붙이며, 생략된 영역에는 제약을 적용한 이니셜라이저를 사용하세요. 이렇게 하면 호출을 읽기 쉽게 유지하고, 헤더와 푸터가 없을 때 명시적인 타입을 지정할 수 있습니다.
이 튜토리얼에서는 콘텐츠, 헤더, 푸터 영역으로 이루어진 작은 컴포넌트를 만듭니다. 목표는 서로 기본값이 지정된 클로저에 의존하지 않고 콘텐츠만 있는 경우, 콘텐츠와 액세서리 중 하나만 있는 경우, 콘텐츠와 액세서리 모두 있는 경우까지 유용한 네 가지 호출 형태를 지원하는 것입니다. 표시되는 값은 모두 로컬에서 만든 예시 텍스트입니다.
여러 빌더를 사용하는 이니셜라이저를 모호하지 않게 만드는 방법
Swift의 여러 후행 클로저 구문에서는 첫 번째 후행 클로저의 인자 레이블을 생략하고, 그 다음 후행 클로저에는 레이블을 붙여야 합니다. 따라서 컴포넌트의 이름만으로 첫 번째 빌더의 용도를 알 수 있게 하고, 뒤따르는 빌더에는 서로 구별되는 레이블을 지정해야 합니다. 이 구문은 Swift 5.3에서 구현되었습니다. SE-0279: 여러 후행 클로저
이 패널의 전체 이니셜라이저는 init(content:header:footer:)입니다. 호출부에서 첫 번째 블록은 콘텐츠를 전달하고, header:와 footer:는 나머지 블록을 식별합니다. 선언 순서는 인자의 순서를 결정하고, 컴포넌트의 body는 화면에 표시되는 순서를 결정합니다. 따라서 헤더가 콘텐츠 위에 나타나더라도 콘텐츠를 첫 번째 인자로 둘 수 있습니다.
Apple의 현재 Section 인터페이스도 콘텐츠 우선 방식을 따릅니다. 전체 이니셜라이저는 빌더 클로저 세 개를 받으며, 일부 영역이 없는 조합을 지원하는 별도의 이니셜라이저도 있습니다. 이는 인터페이스 설계의 좋은 선례입니다. 아래의 사용자 정의 패널에는 자체 레이아웃과 동작이 있습니다. Apple: Section, Apple: init(content:header:footer:)
빌더 어트리뷰트와 매개변수 레이블은 서로 다른 문제를 해결합니다. 결과 빌더 어트리뷰트는 전달된 클로저의 본문을 변환하고, 인자 레이블은 호출자가 매개변수를 식별하는 방법을 결정합니다. @ViewBuilder를 추가해도 후행 클로저 구문에서 생략된 첫 번째 레이블이 되살아나지는 않습니다. SE-0289: 결과 빌더
이 구분에 따라 실용적인 설계 규칙을 세울 수 있습니다. 첫 번째 후행 클로저의 레이블을 생략하면 유용한 차이가 사라지는 유사한 이니셜라이저는 피하세요. 예를 들어, 그 외에는 동일한 init(content:header:) 및 init(body:header:) 이니셜라이저 두 개가 있으면 레이블 없는 첫 번째 블록 뒤에 header:가 오는 형태로 동일하게 보입니다. 이는 빌더 어노테이션을 더 추가해야 하는 이유가 아니라 후행 클로저 규칙에서 비롯되는 설계상의 결과입니다.
중요한 컴파일러 및 SwiftUI 경계는 무엇인가요?
컴파일러, 선택한 Swift 언어 모드, SwiftUI SDK의 세 가지 사항을 염두에 두세요. 여러 후행 클로저는 Swift 5.3에 도입되었고, 정식 결과 빌더 기능은 Swift 5.4에서 구현되었습니다. 이 이정표는 언어 지원 시점을 설명할 뿐, 모든 프레임워크 이니셜라이저의 사용 가능 시점을 보장하지는 않습니다. SE-0279, SE-0289
Apple의 현재 문서는 도구 체인에 따른 경계도 안내합니다. Xcode 26 이하에서는 기존 뷰 빌더 동작을 사용하고, Xcode 27 이상에서는 이 빌더로 타입에 구애받지 않는 콘텐츠를 생성합니다. 새 동작에서는 Apple이 @ContentBuilder 표기를 사용하도록 안내합니다. ContentBuilder는 ViewBuilder의 타입 별칭이며, 허용되는 콘텐츠는 여전히 이를 둘러싼 API의 제약으로 결정됩니다. Apple: ViewBuilder, Apple: ContentBuilder
아래 전체 예제는 Xcode 26을 기준으로 @ViewBuilder 표기를 사용하고 macOS 11 이상을 대상으로 합니다. 문서에 설명된 Xcode 27 인터페이스 스타일을 채택할 때는 이니셜라이저 매개변수의 어노테이션을 @ContentBuilder로 바꾸세요. 세 영역 모두 뷰 레이아웃 안에 배치되므로 제네릭 View 제약 세 개는 그대로 유지하세요.
컴포넌트 계약에 빌더가 생성하는 튜플 타입을 명시하지 마세요. Apple은 새 빌더 구현을 채택할 때 명시적인 TupleView 제약과 관련된 소스 호환성 문제를 문서화했습니다. 제네릭 영역 타입을 사용하면 이 패널은 그 의존성을 피할 수 있습니다. Apple: TN3211, State 및 ContentBuilder 관련 SwiftUI 소스 호환성 문제 해결
네 가지 명시적 이니셜라이저 형태로 패널 만들기
각 영역에 서로 독립적인 제네릭 타입을 사용하세요. 하나의 제네릭 매개변수를 재사용하면 영역들이 동일한 구체 타입을 가져야 합니다. 한 영역은 텍스트 뷰 하나를 담고 다른 영역은 여러 뷰를 담을 수 있으므로 이는 불필요한 제약입니다.
영역이 없는 경우 해당 제네릭 매개변수를 EmptyView로 구체화하세요. Apple은 제네릭 뷰 타입에서 자식이 없음을 나타내는 것이 EmptyView의 정확한 용도라고 설명합니다. 아래의 제약된 이니셜라이저는 호출자에게 지정되지 않은 액세서리 타입을 추론하도록 요구하지 않고 이를 명시적으로 선택합니다. Apple: EmptyView
이 섹션의 두 Swift 블록은 하나의 완전한 애플리케이션 파일을 구성합니다. macOS SwiftUI 앱 타깃에서 생성된 앱 및 콘텐츠 뷰 선언을 이 블록들로 바꾸고, @main 선언은 정확히 하나만 유지하세요. 첫 번째 블록은 컴포넌트와 네 가지 이니셜라이저 형태를 모두 정의합니다.
import SwiftUI
struct BuilderPanel<Content: View, Header: View, Footer: View>: View {
private let content: Content
private let header: Header
private let footer: Footer
init(
@ViewBuilder content: () -> Content,
@ViewBuilder header: () -> Header,
@ViewBuilder footer: () -> Footer
) {
self.content = content()
self.header = header()
self.footer = footer()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
content
footer
}
.padding()
}
}
extension BuilderPanel where Footer == EmptyView {
init(
@ViewBuilder content: () -> Content,
@ViewBuilder header: () -> Header
) {
self.init(
content: content,
header: header,
footer: { EmptyView() }
)
}
}
extension BuilderPanel where Header == EmptyView {
init(
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.init(
content: content,
header: { EmptyView() },
footer: footer
)
}
}
extension BuilderPanel where Header == EmptyView, Footer == EmptyView {
init(@ViewBuilder content: () -> Content) {
self.init(
content: content,
header: { EmptyView() },
footer: { EmptyView() }
)
}
}
전체 이니셜라이저는 해당 패널 값이 초기화되는 동안 각 빌더를 한 번씩 호출하고 반환된 값을 저장합니다. 편의 이니셜라이저는 전체 이니셜라이저에 위임합니다. 이는 이 구현의 특성입니다. 인터페이스의 전체 수명 동안 빌더가 한 번만 실행된다는 보장은 없습니다. 패널 값을 새로 만들면 이니셜라이저가 다시 호출되기 때문입니다.
두 번째 블록을 같은 파일에 추가하세요. 하나의 빌더 안에서 여러 문장을 사용하는 경우를 포함해 지원되는 모든 형태를 시험합니다.
struct PanelDemo: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
BuilderPanel {
Text("Canvas A")
}
BuilderPanel {
Text("Canvas B")
Text("Grid: 12 columns")
} header: {
Text("Layout")
}
BuilderPanel {
Text("Canvas C")
} footer: {
Text("Units: points")
}
BuilderPanel {
Text("Canvas D")
} header: {
Text("Preview")
} footer: {
Text("Scale: 100 percent")
}
}
.padding()
}
.frame(minWidth: 360, minHeight: 440)
}
}
@main
struct BuilderPanelDemoApp: App {
var body: some Scene {
WindowGroup {
PanelDemo()
}
}
}
Xcode에서 앱을 빌드하고 실행해 선택한 SDK에서 계약이 유효한지 확인하세요. 목록을 검토했지만 여기서는 컴파일하거나 실행하지 않았습니다. 조사 환경에 Swift 컴파일러나 macOS SwiftUI SDK가 없었습니다.
각 호출을 구체적인 타입 계약에 대응시키기
유용한 판단 도구는 호출 형태 표입니다. 어떤 이니셜라이저가 선택되어야 하는지와 생략된 영역에 어떤 구체 타입이 지정되는지를 보여줍니다. 이는 샘플 선언을 바탕으로 한 기대 결과이므로 프로젝트에서 확인할 수 있습니다.
| 제공된 영역 | 이니셜라이저 레이블 | 생략된 영역의 제약 |
|---|---|---|
| 콘텐츠만 | content: |
Header == EmptyView, Footer == EmptyView |
| 콘텐츠와 헤더 | content:header: |
Footer == EmptyView |
| 콘텐츠와 푸터 | content:footer: |
Header == EmptyView |
| 세 영역 모두 | content:header:footer: |
없음 |
각 짧은 이니셜라이저는 선언된 모든 클로저를 필수로 요구합니다. 기본 인자 때문에 세 빌더를 받는 이니셜라이저가 콘텐츠만 전달하는 호출과 경쟁하지 않습니다. 헤더만 있는 액세서리 형태와 푸터만 있는 형태는 두 클로저가 모두 Text를 반환하더라도 두 번째 레이블로 구분됩니다.
컴포넌트를 변경할 때 이 표를 활용하세요. 새 이니셜라이저가 기존 호출 형태를 받아들인다면 첫 번째 후행 레이블을 생략한 뒤에도 새 구분이 드러나는지 확인하세요. 이렇게 하면 더 큰 뷰에서 진단하기 어려운 문제가 되기 전에 인터페이스의 겹침을 찾아낼 수 있습니다.
기본값이 지정된 빌더 클로저를 여러 개 두지 않는 이유
기본 인자는 유용하지만, 기본값이 지정된 클로저 매개변수를 여러 개 사용하면 Swift 언어 모드에 따라 인자 대응 규칙이 달라질 수 있습니다. SE-0286은 기본값이 있는 매개변수를 건너뛰는 호환성 휴리스틱인 전방 대응과, 이전 언어 모드에서 사용되는 후방 대응 대체 동작을 설명합니다. 또한 Swift 5.8에서 구현된 ForwardTrailingClosures 예정 기능 플래그도 문서화합니다. SE-0286: 후행 클로저의 전방 검색 대응
여기서 권장하는 방식은 이 규칙들보다 의도적으로 범위를 좁게 잡았습니다. 작은 재사용 패널은 이런 규칙에 의존하지 않아도 됩니다. 기본 빌더는 필수로 두고, 지원할 생략 형태는 제약된 이니셜라이저로 표현하세요. 그러면 선언에서 지원 조합을 확인할 수 있고, 컴파일러 버전에 따라 달라지는 대응 동작을 컴포넌트의 의도된 의미에 포함하지 않게 됩니다.
생략된 액세서리와 비어 있는 액세서리 클로저는 서로 다른 인터페이스 선택입니다. 콘텐츠만 있는 이니셜라이저는 두 액세서리 모두에 EmptyView를 명시적으로 지정합니다. 비어 있는 빌더를 전달하면 SDK의 빌더 구현이 클로저의 결과를 결정하게 됩니다. 이 컴포넌트에서는 사용하지 않는 영역을 생략하고 편의 이니셜라이저가 문서화된 부재 타입을 제공하도록 하세요.
이 접근 방식에는 규모의 한계가 있습니다. 콘텐츠를 항상 필수로 두면 영역 세 개에서 유용한 조합은 네 가지가 나옵니다. 서로 독립적으로 선택할 수 있는 영역이 많은 컴포넌트라면 훨씬 더 많은 오버로드가 필요합니다. 오버로드를 추가하기 전에 독자가 실제로 필요한 조합이 무엇인지 정하세요. 의미 있는 컴포넌트 변형의 수를 줄이면 사용과 유지 관리가 더 쉬워질 수 있습니다.
모호한 호출은 어떻게 진단하나요?
문제를 재현하는 가장 작은 패널 호출부터 살펴보세요. 그런 다음 다음 순서를 따르세요. 이 순서로 인자 대응 문제와 빌더 내부의 오류를 구분할 수 있습니다.
- 모든 클로저 인자를 괄호 안에 작성합니다. 선언 순서대로
content:,header:,footer:를 모두 적으세요. 이렇게 해서 호출이 해결되면 후행 클로저 구문이 어떤 구분을 제거했는지 살펴보세요. - 각 영역을 임시로
Text표현식 하나로 바꿉니다. 그러면 호출이 작동하는 경우 원래 표현식을 하나씩 되돌려 문제가 있는 빌더 본문을 찾으세요. - 이니셜라이저 형태를 비교합니다. 서로 경쟁하는 기본값, 순서가 뒤바뀐 클로저, 첫 번째 빌더 레이블만으로 구분되는 오버로드가 있는지 살펴보세요.
- 생략된 영역의 제약을 확인합니다. 선택된 이니셜라이저에서 생략된 모든 제네릭 영역에는 의도된 구체 타입이 있어야 합니다.
- 도구 체인 경계를 확인합니다. Xcode 27로 옮긴 뒤에는 이니셜라이저 계열을 재설계하기 전에 명시적인 빌더 결과 제약과 충돌하는 타입 이름을 살펴보세요.
괄호 안에 레이블을 적으면 첫 번째 후행 클로저에서 생략되는 인자 이름을 보존할 수 있습니다. Swift는 두 형태를 모두 지원하므로, 레이블을 모두 쓴 형태는 진단에 유용하며 특정 호출을 더 읽기 쉽게 만든다면 최종 표기로 사용해도 좋습니다. The Swift Programming Language: 클로저
새 SDK에서 발생하는 진단의 경우, Apple은 모듈 간에 충돌하는 타입에 모듈을 명시하고 이전 튜플 출력 타입에 의존하는 제약을 업데이트하라고 구체적으로 권장합니다. 이런 문제는 잘 설계된 이니셜라이저 호출 안에서도 발생할 수 있습니다. 오버로드를 더 추가해도 근본 원인은 해결되지 않습니다. Apple: TN3211
명확한 이니셜라이저 계약은 컴포넌트가 지원해야 할 호출 형태에서 시작합니다. 기본 콘텐츠를 앞에 두고, 액세서리 빌더에 의미 있는 레이블을 붙이며, 없는 영역에는 명시적인 타입을 지정하세요. 그런 다음 인터페이스를 확장하기 전에 선택한 컴파일러, 언어 모드, SDK로 지원되는 각 호출 형태를 빌드하세요.
출처
- 여러 후행 클로저 — SE-0279 — Swift Project.
- 후행 클로저의 전방 검색 대응 — SE-0286 — Swift Project.
- 결과 빌더 — SE-0289 — Swift Project.
- 클로저 — The Swift Programming Language — Swift Project.
- Section — Apple.
- init(content:header:footer:) — Apple.
- ViewBuilder — Apple.
- ContentBuilder — Apple.
- EmptyView — Apple.
- TN3211: State 및 ContentBuilder 관련 SwiftUI 소스 호환성 문제 해결 — Apple.