LODY/정리

애플 플랫폼 백과사전 / Variadic Views in SwiftUI

Variadic Views in SwiftUI

원문: Moving Parts — Variadic Views in SwiftUI 관련 공식 세션: WWDC24 #10146 — Demystify SwiftUI Containers

이 시리즈의 앞선 편들이 "뷰가 언제 다시 그려지는가" 를 다뤘다면, 이 편은 한 단계 옆의 질문을 다룬다.

VStack 안에 Text 세 개를 넣으면, SwiftUI는 그걸 어떤 자료구조로 들고 있는가?

그리고 그 자료구조를 가로채면 List처럼 행 사이에 자동으로 구분선을 끼워 넣는 컨테이너를 직접 만들 수 있다. Apple은 iOS 18에서 이걸 공식 API로 공개했지만 (ForEach(subviews:), Group(subviews:)), 그 이전부터 _VariadicView라는 underscore-prefixed API가 같은 일을 해 오고 있었다.


Part 1: TupleView (ViewBuilder의 출력)

VStack의 실제 타입

let stack = VStack {
    Text("First")
    Text("Second")
}

이 코드에서 stack의 타입은 직관과 다르다.

VStack<TupleView<(Text, Text)>>

@ViewBuilder는 여러 뷰를 TupleView로 포장한 뒤 컨테이너에 넘긴다. 2편에서 본 _ConditionalContent와 같은 부류 — SwiftUI가 정적 타입으로 뷰 구성을 표현하기 위해 쓰는 generic 래퍼다.

수정자는 TupleView 하위에 각각 적용된다

TupleView((Text("A"), Text("B")))
    .border(.blue)

직관으로는 "두 Text를 함께 감싸는 테두리"를 상상하지만, 실제는 각 Text에 개별 테두리가 생긴다.

┌───┐
│ A │
└───┘
┌───┐
│ B │
└───┘

TupleView는 그 자체가 시각적 컨테이너가 아니라 "여러 뷰를 한 자리에 묶은 수식적 표현" 이다. modifier는 묶음 단위로 펼쳐져 각각에 적용된다. 이 성질이 Group에도 그대로 이어진다.


Part 2: _VariadicView.Tree (컨테이너의 실제 구조)

타입 시그니처

public enum _VariadicView {
    public struct Tree<Root, Content> {
        public var root: Root       // 자식들을 어떻게 배치할지 결정
        public var content: Content // 실제 자식 뷰들 (보통 TupleView)
    }
}
구성요소역할
TreeRoot와 Content를 묶는 컨테이너
Root"이 자식들을 어떻게 레이아웃할 것인가"의 로직
Content실제 자식 뷰 표현 (거의 항상 TupleView)

VStack의 내부 (단순화)

public struct VStack<Content: View>: View {
    let _tree: _VariadicView.Tree<_VStackLayout, Content>
 
    public init(@ViewBuilder content: () -> Content) {
        _tree = _VariadicView.Tree(_VStackLayout()) {
            content()
        }
    }
}

_VStackLayoutRoot 역할을 맡고, @ViewBuilder가 만든 TupleViewContent 역할을 맡는다. 런타임에는 _VStackLayout이 자식들을 세로로 배치하는 레이아웃 규칙을 가진 객체로 동작한다.

Mirror로 직접 확인

let stack = VStack {
    Text("A")
    Text("B")
}
 
let mirror = Mirror(reflecting: stack)
for child in mirror.children {
    print("\(child.label ?? ""): \(type(of: child.value))")
}
 
// 출력:
// _tree: _VariadicView.Tree<_VStackLayout, TupleView<(Text, Text)>>

Apple이 _ 접두사로 숨겨 두긴 했지만, 리플렉션으로 구조가 그대로 보인다.

용어 설명

  • Variadic: 가변 인자. 여러 개의 자식을 한 번에 다루는 컨테이너의 성격
  • Root: _VariadicView.Tree의 배치 로직 담당자. _VStackLayout, _HStackLayout 등이 이 자리
  • Content: 실제 자식 뷰들. @ViewBuilder 결과로 대부분 TupleView<...>

Part 3: _VariadicView_ViewRoot 프로토콜

Root 자리에 들어가는 타입이 채택해야 하는 프로토콜은 두 가지 변종으로 제공된다.

// 공통 형태
public protocol _VariadicView_ViewRoot {
    associatedtype Body: View
 
    @ViewBuilder
    func body(children: _VariadicView.Children) -> Body
}
 
// 실제로 쓸 때 채택할 두 가지
public protocol _VariadicView_UnaryViewRoot: _VariadicView_ViewRoot {}
public protocol _VariadicView_MultiViewRoot: _VariadicView_ViewRoot {}

핵심: body(children:) 메서드가 자식 뷰 목록을 받아서 "어떻게 조립할지"를 돌려준다. 이 메서드 안에서 자식 순서를 바꾸거나, 사이에 Divider를 끼워 넣거나, 홀수/짝수 행마다 다르게 꾸밀 수 있다.

_VariadicView.Children의 성격

func body(children: _VariadicView.Children) -> some View {
    children.count      // 개수
    children.first
    children.last
    children[0]         // 인덱스 접근
 
    ForEach(children) { child in
        child            // 각 child는 View
    }
}
특성의미
View 채택Children 자체를 그냥 body 결과로 쓸 수도 있다
RandomAccessCollection 채택배열처럼 접근
Element: IdentifiableForEach(children)에 그대로 전달 가능

Part 4: UnaryViewRoot vs MultiViewRoot (가장 중요한 구분)

UnaryViewRoot: 전체가 하나의 뷰

VStack {
    Text("A")
    Text("B")
}
.border(.red)
// 전체를 감싸는 하나의 테두리
//
// ┌───────┐
// │  A    │
// │  B    │
// └───────┘

VStack·HStack·ZStack이 여기 속한다. 컨테이너 자신이 하나의 레이아웃 뷰로 취급되므로, 외부에서 붙은 modifier는 컨테이너 전체에 적용된다.

MultiViewRoot: 투명하게 자식을 노출

Group {
    Text("A")
    Text("B")
}
.border(.red)
// 각각에 테두리
//
// ┌───┐
// │ A │
// └───┘
// ┌───┐
// │ B │
// └───┘

Group·ForEach·Section이 여기 속한다. 컨테이너 자신은 시각적으로 존재하지 않고 자식들을 부모에게 그대로 넘긴다. modifier는 각 자식에 개별 적용된다.

한 줄 비교

구분UnaryViewRootMultiViewRoot
외부 시선하나의 뷰투명
modifier 적용컨테이너 전체자식 각각
대표 예VStack, HStack, ZStackGroup, ForEach, Section
용도레이아웃그룹핑·변환·반복

List가 행 사이에 Divider를 끼울 수 있는 이유

List는 내부적으로 MultiViewRoot 관점에서 자식을 들여다보기 때문에, 각 자식 사이에 시스템 구분선을 끼워 넣을 수 있다. 개별 행으로 "분해"되는 구조이기 때문.


Part 5: 실전 (자동으로 Divider를 끼우는 DividedVStack)

사용 목표.

DividedVStack {
    Text("첫 번째")
    Text("두 번째")
    Text("세 번째")
}
 
// 렌더 결과:
// 첫 번째
// ────────
// 두 번째
// ────────
// 세 번째

UnaryViewRoot 버전: 전체를 하나의 VStack으로 감싸기

struct DividedVStack<Content: View>: View {
    var content: Content
 
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
 
    var body: some View {
        _VariadicView.Tree(DividedVStackLayout()) {
            content
        }
    }
}
 
struct DividedVStackLayout: _VariadicView_UnaryViewRoot {
    @ViewBuilder
    func body(children: _VariadicView.Children) -> some View {
        let lastID = children.last?.id
 
        VStack {
            ForEach(children) { child in
                child
                if child.id != lastID {
                    Divider()
                }
            }
        }
    }
}

동작 흐름.

  1. _VariadicView.Tree@ViewBuilder의 content를 받아 Root에 전달
  2. DividedVStackLayout.body(children:) 가 호출되어 Children 컬렉션을 받음
  3. ForEach(children)로 자식을 순회하며 각 자식 뒤에 Divider를 삽입 (마지막만 제외)
  4. 전체를 VStack으로 감쌌으므로 외부 관점에선 하나의 뷰

MultiViewRoot 버전: 투명하게 노출

struct Divided<Content: View>: View {
    var content: Content
 
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
 
    var body: some View {
        _VariadicView.Tree(DividedLayout()) {
            content
        }
    }
}
 
struct DividedLayout: _VariadicView_MultiViewRoot {
    @ViewBuilder
    func body(children: _VariadicView.Children) -> some View {
        let lastID = children.last?.id
 
        ForEach(children) { child in
            child
            if child.id != lastID {
                Divider()
            }
        }
    }
}

차이는 Root 프로토콜 뿐이다. 동작이 크게 달라진다.

수정자가 다르게 적용된다

// UnaryViewRoot 버전
DividedVStack {
    Text("A")
    Text("B")
}
.background(.yellow)
// 외부 `.background`는 컨테이너 전체에 한 번 적용 → 전체 노란 배경
 
// MultiViewRoot 버전
Divided {
    Text("A")
    Text("B")
}
.background(.yellow)
// `.background`가 자식 각각에 적용 → Text마다 노란 배경, Divider는 별개

컨테이너를 설계할 때 가장 먼저 정해야 하는 것은 "외부 modifier가 어떻게 번져야 하는가"다. 그 답이 Unary vs Multi의 선택.


Part 6: _VariadicView의 쓰임새 정리

개념역할
TupleViewViewBuilder가 여러 뷰를 묶는 정적 표현
_VariadicView.TreeRoot(배치 로직) + Content(자식 뷰)의 컨테이너
_VariadicView.ChildrenRoot가 받는 자식 목록. 배열처럼 순회 가능
_VariadicView_UnaryViewRoot컨테이너 자신이 하나의 뷰로 취급됨
_VariadicView_MultiViewRoot컨테이너가 투명. 자식이 부모에 직접 노출됨

이 네 가지만 알면 List·Form·Menu 같은 SwiftUI 네이티브 컨테이너가 왜 각 자식을 개별적으로 꾸밀 수 있는지의 근거가 설명된다.


Part 7: 주의사항과 마이그레이션

underscore-prefixed API

_VariadicView 계열은 _ 접두사가 붙은 private/SPI API다.

  • 앱스토어 리젝 위험: 공식적으로 권장되지 않으므로 불가능하진 않으나, 리스크 있음. 실제로 VStack 자신이 이 API를 쓰기 때문에 시스템 전반에 깊이 묻혀 있어 사실상 심사 문제로 직결된 사례는 드물다는 것이 커뮤니티 경험
  • 향후 깨질 가능성: API가 공식이 아니므로 시그니처가 바뀔 수 있음. iOS 버전별 테스트 필수

iOS 18의 공식 API

Apple은 iOS 18에서 같은 메커니즘을 공식 API로 공개했다.

새 프로젝트라면 공식 subviews: API 쪽이 안전한 선택. 구버전 지원이 필요하거나 더 섬세한 제어가 필요할 때만 _VariadicView를 경유.


관련 읽을거리

이 시리즈 다른 편