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의 빌더 구현이 클로저의 결과를 결정하게 됩니다. 이 컴포넌트에서는 사용하지 않는 영역을 생략하고 편의 이니셜라이저가 문서화된 부재 타입을 제공하도록 하세요.

이 접근 방식에는 규모의 한계가 있습니다. 콘텐츠를 항상 필수로 두면 영역 세 개에서 유용한 조합은 네 가지가 나옵니다. 서로 독립적으로 선택할 수 있는 영역이 많은 컴포넌트라면 훨씬 더 많은 오버로드가 필요합니다. 오버로드를 추가하기 전에 독자가 실제로 필요한 조합이 무엇인지 정하세요. 의미 있는 컴포넌트 변형의 수를 줄이면 사용과 유지 관리가 더 쉬워질 수 있습니다.

모호한 호출은 어떻게 진단하나요?

문제를 재현하는 가장 작은 패널 호출부터 살펴보세요. 그런 다음 다음 순서를 따르세요. 이 순서로 인자 대응 문제와 빌더 내부의 오류를 구분할 수 있습니다.

  1. 모든 클로저 인자를 괄호 안에 작성합니다. 선언 순서대로 content:, header:, footer:를 모두 적으세요. 이렇게 해서 호출이 해결되면 후행 클로저 구문이 어떤 구분을 제거했는지 살펴보세요.
  2. 각 영역을 임시로 Text 표현식 하나로 바꿉니다. 그러면 호출이 작동하는 경우 원래 표현식을 하나씩 되돌려 문제가 있는 빌더 본문을 찾으세요.
  3. 이니셜라이저 형태를 비교합니다. 서로 경쟁하는 기본값, 순서가 뒤바뀐 클로저, 첫 번째 빌더 레이블만으로 구분되는 오버로드가 있는지 살펴보세요.
  4. 생략된 영역의 제약을 확인합니다. 선택된 이니셜라이저에서 생략된 모든 제네릭 영역에는 의도된 구체 타입이 있어야 합니다.
  5. 도구 체인 경계를 확인합니다. Xcode 27로 옮긴 뒤에는 이니셜라이저 계열을 재설계하기 전에 명시적인 빌더 결과 제약과 충돌하는 타입 이름을 살펴보세요.

괄호 안에 레이블을 적으면 첫 번째 후행 클로저에서 생략되는 인자 이름을 보존할 수 있습니다. Swift는 두 형태를 모두 지원하므로, 레이블을 모두 쓴 형태는 진단에 유용하며 특정 호출을 더 읽기 쉽게 만든다면 최종 표기로 사용해도 좋습니다. The Swift Programming Language: 클로저

새 SDK에서 발생하는 진단의 경우, Apple은 모듈 간에 충돌하는 타입에 모듈을 명시하고 이전 튜플 출력 타입에 의존하는 제약을 업데이트하라고 구체적으로 권장합니다. 이런 문제는 잘 설계된 이니셜라이저 호출 안에서도 발생할 수 있습니다. 오버로드를 더 추가해도 근본 원인은 해결되지 않습니다. Apple: TN3211

명확한 이니셜라이저 계약은 컴포넌트가 지원해야 할 호출 형태에서 시작합니다. 기본 콘텐츠를 앞에 두고, 액세서리 빌더에 의미 있는 레이블을 붙이며, 없는 영역에는 명시적인 타입을 지정하세요. 그런 다음 인터페이스를 확장하기 전에 선택한 컴파일러, 언어 모드, SDK로 지원되는 각 호출 형태를 빌드하세요.

출처