RoundedButton: UIButton, CardView: UIView, 그리고 UIView extension에 모아 둔 헬퍼들. SwiftUI에는 상속이 없다. 대신 작은 뷰를 쪼개고, 모디파이어로 꾸미고, Style로 한 번에 갈아 끼운다. 오늘은 거대한 body 하나를 조립 가능한 부품으로 나누는 도구 네 가지를 익힌다.오늘의 목표 · 30분
- 100줄짜리
body를 보고 계산 프로퍼티 /@ViewBuilder함수 / 별도 struct 중 무엇으로 쪼갤지 기준에 따라 고른다. @ViewBuilder로 자식을 받는 제네릭 컨테이너(Card<Content>)를 만든다.- 반복되는 모디파이어 묶음을
ViewModifier+Viewextension으로 바꾼다. - 버튼마다 붙은 꾸밈 코드를
ButtonStyle하나로 모아 컨테이너에 한 번 적용한다.
1. 쪼개는 세 가지 방법 — 프로퍼티, 함수, struct 🔥 핵심
먼저 비용 걱정부터 지운다. WWDC24 · SwiftUI essentials: "Because views are just declarative descriptions, breaking up one view into multiple doesn't hurt the performance of your app." 뷰는 설명서라서 여러 개로 나눠도 성능이 나빠지지 않는다. 그러니 기준은 성능이 아니라 역할이다.
struct Pet: Identifiable {
let id = UUID()
var name: String
var trick: String
var hasAward: Bool
}
struct PetRowBig: View {
let pet: Pet
var body: some View {
HStack {
Image(systemName: "pawprint.fill")
.font(.title)
.frame(width: 44, height: 44)
.background(.orange.opacity(0.2), in: Circle())
VStack(alignment: .leading) {
Text(pet.name).font(.headline)
Text(pet.trick).font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
if pet.hasAward {
Image(systemName: "trophy.fill").foregroundStyle(.yellow)
}
}
}
}struct PetRow: View {
let pet: Pet
var body: some View {
HStack {
profileImage // ① 프로퍼티
titles(name: pet.name, trick: pet.trick) // ② 함수 (인자가 필요할 때)
Spacer()
if pet.hasAward { awardBadge }
}
}
private var profileImage: some View { // ① 단일 표현식이면 @ViewBuilder 불필요
Image(systemName: "pawprint.fill")
.font(.title)
.frame(width: 44, height: 44)
.background(.orange.opacity(0.2), in: Circle())
}
private func titles(name: String, trick: String) -> some View {
VStack(alignment: .leading) {
Text(name).font(.headline)
Text(trick).font(.subheadline).foregroundStyle(.secondary)
}
}
private var awardBadge: some View {
Image(systemName: "trophy.fill").foregroundStyle(.yellow)
}
}WWDC24 데모도 정확히 이렇게 한다: "I've refactored out the profile image construction into a private view property." 같은 파일 안에서 읽기 좋게 나누는 가장 가벼운 방법이다.
struct AwardBadge: View {
let hasAward: Bool
@State private var isAnimating = false // 자기 상태는 struct만 가질 수 있다
var body: some View {
Image(systemName: hasAward ? "trophy.fill" : "trophy")
.foregroundStyle(hasAward ? .yellow : .gray)
.scaleEffect(isAnimating ? 1.2 : 1)
.onTapGesture { isAnimating.toggle() }
}
}
#Preview { AwardBadge(hasAward: true) } // 부품 하나만 따로 미리보기| 기준 | ① 계산 프로퍼티 | ② @ViewBuilder 함수 | ③ 별도 struct |
|---|---|---|---|
자기 @State | 불가 | 불가 | 가능 |
| 입력 | 부모 프로퍼티를 그대로 읽음 | 인자로 받음 | init 입력으로 받음 |
| 다른 화면에서 재사용 | 같은 타입 안에서만 | 같은 타입 안에서만 | 어디서나 |
| 단독 Preview | 불가 | 불가 | 가능 |
| 언제 평가되나 | 부모 body의 일부 — 부모가 평가될 때마다 같이 | 자기 body를 가진 독립 단위 — 자기가 읽은 의존성 기준 | |
마지막 행이 중요하다. WWDC24는 "SwiftUI keeps track of any views that depend on this pet, such as the row view"라고 설명한다. 의존성은 뷰 단위로 추적되니, 별도 struct는 자기 body가 읽은 값에만 반응한다. 프로퍼티·함수는 그냥 부모 body 코드를 옮겨 적은 것이다.
UIKit의 "셀을 xib로 뺄까" 고민과 달리 ③은 몇 줄이면 끝난다. WWDC19 · SwiftUI Essentials의 원칙 그대로 "prefer smaller, single-purpose views"다.
2. @ViewBuilder와 some View — 자식을 받는 컨테이너 🔥 핵심
ViewBuilder는 "A custom parameter attribute that constructs views from closures" — 클로저의 여러 문장을 뷰 하나로 합쳐 주는 result builder다. VStack { … }에 쉼표 없이 나열하고 if를 쓸 수 있는 이유가 이것이다.
빌더가 만드는 실제 타입
struct BuilderPeek: View {
var flag = true
var body: some View {
VStack {
Text("제목") // Text
if flag { Text("부제") } // Optional<Text>
if flag { Image(systemName: "star") }
else { Text("없음") } // _ConditionalContent<Image, Text>
}
}
}
// print(type(of: BuilderPeek().body)) 출력:
// VStack<TupleView<(Text, Optional<Text>, _ConditionalContent<Image, Text>)>>body에만 자동으로 붙는다
body는 프로토콜 선언에 빌더가 붙어 있어 if/else를 그냥 쓸 수 있다. 내가 만든 프로퍼티·함수에는 직접 붙여야 한다. 안 붙이면 평범한 Swift 함수라서 갈래마다 반환 타입이 달라 컴파일이 안 된다.
struct StatusView: View {
let isOnline: Bool
var body: some View { statusIcon }
// ❌ error: branches have mismatching types 'Image' and 'Text'
// private var statusIcon: some View {
// if isOnline { Image(systemName: "wifi") } else { Text("오프라인") }
// }
// ✅ 빌더가 두 갈래를 한 타입으로 합친다
@ViewBuilder
private var statusIcon: some View {
if isOnline {
Image(systemName: "wifi")
} else {
Text("오프라인")
}
}
}자식을 받는 제네릭 컨테이너
WWDC24: HStack은 "ViewBuilder content parameter"를 받는 init을 쓰고, "This is a pattern used by all container views in SwiftUI." 내 컨테이너도 같은 패턴으로 만든다. UIKit에서 CardView에 contentView를 두고 addSubview 하던 자리다.
struct Card<Content: View>: View {
let title: String
let content: Content
init(title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content() // 클로저는 init에서 바로 실행해 값으로 저장
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline)
content
}
.padding()
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
}
struct CardDemo: View {
var body: some View {
Card(title: "오늘의 트릭") {
Text("Whiskers — 하이파이브")
Text("Roofus — 홈런")
}
}
}| 쓰는 곳 | 누가 타입을 정하나 | 예 | 비고 |
|---|---|---|---|
some View (반환) | 구현하는 쪽 | var body: some View | 타입은 숨기되 컴파일러는 안다 |
<Content: View> (제네릭) | 호출하는 쪽 | Card { … } | 컨테이너의 기본 패턴 |
AnyView | 런타임에 지움 | AnyView(Text("")) | 타입 정보 손실 — Day 4에서 비용 |
Group과 EmptyView — 보이지 않는 부품
where Footer == EmptyView 편의 init)의 기본 타입으로 쓴다(문서).struct StartScreen: View {
let isLoggedIn: Bool
var body: some View {
NavigationStack {
Group {
if isLoggedIn {
Text("환영합니다")
} else {
Text("로그인이 필요합니다")
}
}
.navigationTitle("Start") // 어느 갈래든 제목이 붙는다
}
}
}컨테이너가 받은 자식을 하나씩 꺼내 배치하고 싶으면 iOS 18의 ForEach(subviews:content:), Group(subviews:transform:)를 쓴다. iOS 17 타깃에서는 위의 Card처럼 content를 통째로 배치하는 방식까지만 가능하다.
현재 Apple 문서는 Xcode 27부터 ContentBuilder(ViewBuilder의 타입 별칭)를 쓰라고 안내하고, body 선언에도 @ContentBuilder가 붙어 있다. 별칭이라 오늘 코드의 @ViewBuilder는 그대로 동작한다.
3. 커스텀 ViewModifier + extension
같은 모디파이어 묶음이 세 번 이상 반복되면 이름을 붙일 때다. WWDC19 · Building Custom Views with SwiftUI의 정의: view modifier는 뷰처럼 계층의 한 조각을 정의하지만 "as a function of some other view" — 다른 뷰(content)를 받아 새 뷰를 돌려주는 함수다.
태그마다 .font(.caption2).padding(10).overlay(…).foregroundStyle(.blue)를 복붙하면 한 곳을 고쳐도 나머지는 그대로다. 묶음에 이름을 붙인다.
struct BorderedCaption: ViewModifier {
func body(content: Content) -> some View {
content
.font(.caption2)
.padding(10)
.overlay(RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 1))
.foregroundStyle(.blue)
}
}
extension View {
func borderedCaption() -> some View {
modifier(BorderedCaption()) // 호출부는 .borderedCaption() 한 줄
}
}
struct TagsAfter: View {
var body: some View {
HStack {
Text("Swift").borderedCaption()
Text("iOS").borderedCaption()
}
}
}ViewModifier 문서는 .modifier(_:)를 직접 불러도 되지만 extension으로 감싸는 쪽이 "more common and idiomatic"이라고 말한다. 결과 타입은 ModifiedContent<Text, BorderedCaption> — Day 1의 "감싸기"가 내 타입 이름으로 보인다.
상태를 가진 모디파이어
그냥 모디파이어를 이어 붙이는 거라면 extension View 함수 하나로 충분하다. ViewModifier 타입이 필요한 순간은 모디파이어가 자기 상태를 가질 때다. WWDC19 표현대로 "a little bit like a view"라서 @State를 둘 수 있다.
struct SpoilerModifier: ViewModifier {
@State private var isRevealed = false
func body(content: Content) -> some View {
content
.blur(radius: isRevealed ? 0 : 8)
.onTapGesture { isRevealed = true }
.accessibilityHint(isRevealed ? "" : "탭하면 내용이 보입니다")
}
}
extension View {
func spoiler() -> some View { modifier(SpoilerModifier()) }
}
// 사용: Text("범인은 집사다").spoiler()| UIKit | SwiftUI |
|---|---|
extension UIView { func applyCardShadow() } — 기존 뷰의 속성을 바꿈 | extension View { func cardShadow() -> some View } — 감싼 새 뷰를 반환 |
| 상태가 필요하면 서브클래스 + 저장 프로퍼티 | ViewModifier 안의 @State |
| 적용 순서가 코드에 안 드러남 | 체인 순서 = 적용 순서 |
4. Style 프로토콜 — 한 번 정의, 계층 전체에 적용
버튼 모양을 바꾸려고 Button을 감싼 커스텀 뷰를 만들면 목적 기반 설계를 버리게 된다. WWDC19 SwiftUI Essentials는 Button을 "action + label" 두 속성으로 정의하고, 모양은 context와 style이 맡는다고 설명한다. 스타일이 바뀌어도 "no matter what the style, still having accessibility support built right in".
| 프로토콜 | 바꾸는 것 | configuration에서 받는 것 | 적용 모디파이어 |
|---|---|---|---|
ButtonStyle | 모양 (상호작용은 표준) | label, isPressed | .buttonStyle(_:) |
PrimitiveButtonStyle | 모양 + 상호작용 | label, trigger() | .buttonStyle(_:) |
LabelStyle | Label의 아이콘·제목 배치 | icon, title | .labelStyle(_:) |
ToggleStyle | 토글 모양·동작 | label, isOn, $isOn | .toggleStyle(_:) |
❌ Text(title).background(.blue, in: Capsule()).onTapGesture(perform: action)로 버튼을 흉내 내면 눌림 표시가 없고 VoiceOver가 "버튼"으로 읽지 않는다. 모양만 바꾸려면 ButtonStyle이다.
struct PillButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.horizontal, 16).padding(.vertical, 10)
.background(.blue, in: Capsule())
.foregroundStyle(.white)
.opacity(configuration.isPressed ? 0.6 : 1) // 눌림 상태를 받아 쓴다
.scaleEffect(configuration.isPressed ? 0.97 : 1)
}
}
extension ButtonStyle where Self == PillButtonStyle {
static var pill: PillButtonStyle { PillButtonStyle() } // .buttonStyle(.pill) 로 쓰기
}
struct ActionsView: View {
var body: some View {
VStack {
Button("저장") { }
Button("공유") { }
HStack {
Button("삭제", role: .destructive) { }
Button("취소") { }
.buttonStyle(.bordered) // 안쪽에서 덮어쓰기
}
}
.buttonStyle(.pill) // 한 번 적용 → 안쪽 버튼 전부
}
}struct VerticalLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(spacing: 4) {
configuration.icon.font(.title2)
configuration.title.font(.caption)
}
}
}
struct TabLikeRow: View {
var body: some View {
HStack(spacing: 24) {
Label("홈", systemImage: "house")
Label("검색", systemImage: "magnifyingglass")
Label("설정", systemImage: "gear")
}
.labelStyle(VerticalLabelStyle())
}
}ToggleStyle도 같은 모양이다. makeBody에서 configuration.isOn(Binding)을 토글하는 버튼을 그리면 체크박스가 된다.
커스텀 모양이 필요하면 Button을 버리지 말고 ButtonStyle을 쓴다. 역할(버튼)과 라벨이 유지돼 VoiceOver·Voice Control이 그대로 동작한다(WWDC19 · SwiftUI Essentials).
UIKit에서 UIButton.Configuration이나 UIButton 서브클래스로 하던 일이 ButtonStyle이다. 차이는 적용 단위다 — 버튼 인스턴스마다가 아니라 계층 한 지점에 한 번.
5. 흔한 실수 모음
① 조건부 모디파이어 .if 확장
"조건일 때만 모디파이어를 붙이는" 확장은 편해 보이지만 if/else로 서로 다른 두 타입을 오간다.
extension View {
@ViewBuilder
func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
if condition { transform(self) } else { self } // _ConditionalContent<T, Self>
}
}
// ❌ 회전 대신 크로스페이드가 나온다
struct FlipIconWrong: View {
@State private var flipped = false
var body: some View {
Image(systemName: "app.fill")
.if(flipped) { $0.rotationEffect(.degrees(180)) }
.onTapGesture { withAnimation { flipped.toggle() } }
}
}
// ✅ 회전 애니메이션이 된다
struct FlipIcon: View {
@State private var flipped = false
var body: some View {
Image(systemName: "app.fill")
.rotationEffect(.degrees(flipped ? 180 : 0))
.onTapGesture { withAnimation { flipped.toggle() } }
}
}"you should try to push your conditions into your modifiers as much as possible. Because that will help SwiftUI detect those changes and give you better animations." — WWDC19 · SwiftUI Essentials
풀이: 조건을 모디파이어 인자 안으로 넣어라. "회전으로 감싼 뷰"와 "그냥 뷰"는 서로 다른 뷰라 페이드가 나오고, 인자로 넣으면 한 뷰가 값만 바꿔 회전한다.
② 타입을 맞추려고 AnyView
갈래 타입이 달라 에러가 나면 AnyView로 감싸고 싶어진다. 컴파일은 되지만 타입 정보를 지워 SwiftUI가 구조를 볼 수 없다. 먼저 @ViewBuilder로 푼다 — 비용과 걷어내는 법은 Day 4 2부 3절에서 자세히 다룬다.
③ 스타일을 버튼마다 반복
.buttonStyle(.pill)을 버튼 10개에 각각 붙이지 않는다. 공통 컨테이너(화면 루트, 섹션)에 한 번 붙이고 예외만 안쪽에서 덮어쓴다(4절 도해).
✅ 복습 · 5분
행 안의 "즐겨찾기 별" 조각에 탭하면 켜지는 자기 상태가 필요하다. 계산 프로퍼티, 함수, struct 중 무엇으로 떼어 내나?
@State 저장소를 관리해 주는 건 View·ViewModifier(와 App·Scene)에 선언했을 때뿐이고, 계산 프로퍼티·함수는 부모 body의 일부일 뿐이다. 덤으로 단독 Preview와 재사용도 얻는다.body에서는 되던 if/else가 내가 만든 var header: some View에서는 왜 에러가 나나?
body에는 프로토콜 선언에 뷰 빌더가 붙어 있지만, 내가 만든 프로퍼티는 평범한 Swift 프로퍼티다. 갈래마다 Image/Text처럼 다른 타입을 돌려주면 some View 하나로 정할 수 없다. @ViewBuilder를 붙이면 _ConditionalContent로 합쳐진다.@ViewBuilder가 그런 두 칸짜리 상자를 만들어 준다.ButtonStyle과 PrimitiveButtonStyle은 언제 각각 쓰나?
ButtonStyle은 표준 상호작용(탭, 눌림 상태)을 유지하고 모양만 바꾼다 — 대부분 이것. PrimitiveButtonStyle은 trigger()를 언제 부를지까지 직접 정해 상호작용을 바꿀 때 쓴다(Apple 문서).struct Gate: View {
@State private var isLoggedIn = false
var body: some View {
Group {
if isLoggedIn { Text("Welcome") } else { Text("Login") }
}
.onAppear { print("appear") }
.onTapGesture { isLoggedIn.toggle() }
}
}🔮 코드 예측: 화면이 뜬 뒤 텍스트를 두 번 탭하면 appear는 총 몇 번 찍히나?
onAppear는 "executes every time isLoggedIn changes". 갈래가 바뀔 때마다 새 텍스트가 나타나기 때문이다. 이 "갈래가 바뀌면 새 뷰"가 Day 4의 주제다.🔁 지난 Day 복습: Text("A").borderedCaption()의 타입이 ModifiedContent<Text, BorderedCaption>인 이유를 Day 1의 개념으로 설명하면?
.modifier(BorderedCaption())도 똑같이 Text를 감싸고, 그 감싼 결과가 ModifiedContent다. 순서 규칙도 그대로 적용된다.🛠 미니 과제 (선택 · 10분)
설정 화면의 반복 코드를 오늘 배운 부품으로 정리한다. Preview 하나로 확인한다.
import SwiftUI
struct SettingsBefore: View {
var body: some View {
VStack(spacing: 16) {
VStack(alignment: .leading) {
Text("알림").font(.headline)
Text("푸시 알림을 받습니다")
Text("켜기").padding(8).background(.blue, in: Capsule()).foregroundStyle(.white)
.onTapGesture { print("알림") }
}
.padding().background(.gray.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading) {
Text("백업").font(.headline)
Text("매일 밤 백업합니다")
Text("지금 백업").padding(8).background(.blue, in: Capsule()).foregroundStyle(.white)
.onTapGesture { print("백업") }
}
.padding().background(.gray.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
}
.padding()
}
}
#Preview { SettingsBefore() }- 요구 1: 제목과
@ViewBuilder내용을 받는SettingCard<Content: View>컨테이너를 만든다. - 요구 2: 파란 캡슐 모양을
ButtonStyle로 만들고,onTapGesture를 진짜Button으로 바꾼다. - 요구 3: 스타일은 가장 바깥
VStack에 한 번만 적용한다.
확인 방법: 카드가 전체 폭으로 정렬된 것 외에는 Preview 모양이 시작 코드와 같고, Live Preview에서 버튼을 누르는 동안 흐려지는(눌림 상태) 효과가 보이면 성공이다.
정답 코드 펼치기
import SwiftUI
struct SettingCard<Content: View>: View {
let title: String
let content: Content
init(_ title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading) {
Text(title).font(.headline)
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.gray.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
}
}
struct CapsuleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(8)
.background(.blue, in: Capsule())
.foregroundStyle(.white)
.opacity(configuration.isPressed ? 0.6 : 1)
}
}
struct SettingsAfter: View {
var body: some View {
VStack(spacing: 16) {
SettingCard("알림") {
Text("푸시 알림을 받습니다")
Button("켜기") { print("알림") }
}
SettingCard("백업") {
Text("매일 밤 백업합니다")
Button("지금 백업") { print("백업") }
}
}
.buttonStyle(CapsuleButtonStyle()) // 한 번만
.padding()
}
}
#Preview { SettingsAfter() }