원문: 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)
}
}| 구성요소 | 역할 |
|---|---|
Tree | Root와 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()
}
}
}_VStackLayout이 Root 역할을 맡고, @ViewBuilder가 만든 TupleView가 Content 역할을 맡는다. 런타임에는 _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: Identifiable | ForEach(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는 각 자식에 개별 적용된다.
한 줄 비교
| 구분 | UnaryViewRoot | MultiViewRoot |
|---|---|---|
| 외부 시선 | 하나의 뷰 | 투명 |
| modifier 적용 | 컨테이너 전체 | 자식 각각 |
| 대표 예 | VStack, HStack, ZStack | Group, 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()
}
}
}
}
}동작 흐름.
_VariadicView.Tree가@ViewBuilder의 content를 받아Root에 전달DividedVStackLayout.body(children:)가 호출되어Children컬렉션을 받음ForEach(children)로 자식을 순회하며 각 자식 뒤에Divider를 삽입 (마지막만 제외)- 전체를
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의 쓰임새 정리
| 개념 | 역할 |
|---|---|
TupleView | ViewBuilder가 여러 뷰를 묶는 정적 표현 |
_VariadicView.Tree | Root(배치 로직) + Content(자식 뷰)의 컨테이너 |
_VariadicView.Children | Root가 받는 자식 목록. 배열처럼 순회 가능 |
_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로 공개했다.
ForEach(subviews:)Group(subviews:)- 관련 세션: WWDC24 #10146 — Demystify SwiftUI Containers
새 프로젝트라면 공식 subviews: API 쪽이 안전한 선택. 구버전 지원이 필요하거나 더 섬세한 제어가 필요할 때만 _VariadicView를 경유.
관련 읽을거리
- Moving Parts — Variadic Views in SwiftUI (원문). movingparts.io
- WWDC24 #10146 — Demystify SwiftUI Containers. developer.apple.com — iOS 18 공식 API 소개
- Emerge Tools — How to use VariadicView. emergetools.com — 실전 예제
- Chris Eidhof's Gist — VariadicView 예시. gist.github.com
이 시리즈 다른 편
- index — SwiftUI 렌더 트리와 리렌더링 심화
- 2편 — WWDC21 Demystify SwiftUI — 이 글의
TupleView·_ConditionalContent개념이 거기서 먼저 등장 - 5편 — Chris Eidhof A Day in the Life of a SwiftUI View — view tree ↔ render tree 번역의 큰 그림