← SwiftUI 14일 마스터 2 / 14

Day 2 · 뷰 조립

거대한 body를 계산 프로퍼티·함수·struct로 나누고, 제네릭 컨테이너·커스텀 모디파이어·Style 프로토콜로 재사용 가능한 부품을 만든다.
진행률
0 / 0 완료
UIKit에서 재사용은 서브클래싱이었다. RoundedButton: UIButton, CardView: UIView, 그리고 UIView extension에 모아 둔 헬퍼들. SwiftUI에는 상속이 없다. 대신 작은 뷰를 쪼개고, 모디파이어로 꾸미고, Style로 한 번에 갈아 끼운다. 오늘은 거대한 body 하나를 조립 가능한 부품으로 나누는 도구 네 가지를 익힌다.

오늘의 목표 · 30분

  • 100줄짜리 body를 보고 계산 프로퍼티 / @ViewBuilder 함수 / 별도 struct 중 무엇으로 쪼갤지 기준에 따라 고른다.
  • @ViewBuilder로 자식을 받는 제네릭 컨테이너(Card<Content>)를 만든다.
  • 반복되는 모디파이어 묶음을 ViewModifier + View extension으로 바꾼다.
  • 버튼마다 붙은 꾸밈 코드를 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." 뷰는 설명서라서 여러 개로 나눠도 성능이 나빠지지 않는다. 그러니 기준은 성능이 아니라 역할이다.

출발점 — 한 body에 전부 들어 있는 행(row)
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 안에서 정리
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." 같은 파일 안에서 읽기 좋게 나누는 가장 가벼운 방법이다.

방법 ③ 별도 View struct — 자기 상태·재사용·단독 Preview가 필요할 때
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 코드를 옮겨 적은 것이다.

body의 이 조각을 떼어 내고 싶다 자기 @State가 필요? 다른 화면에서도 씀? 혹은 단독 Preview로 다듬고 싶음? 예 ③ struct AwardBadge 아니오 부모 프로퍼티 말고 다른 인자가 필요? 같은 모양을 값만 바꿔 여러 번 그림 예 ② 함수 titles(name:) 아니오 ① 계산 프로퍼티 profileImage 가장 가볍다 → 커지면 ③으로 승격
위에서부터 묻는다. 기본값은 가장 가벼운 ①이고, 상태나 재사용이 생기는 순간 ③으로 올린다.

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를 쓸 수 있는 이유가 이것이다.

빌더가 만드는 실제 타입

나열·if·if-else가 각각 어떤 타입이 되는지
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>)>>
코드 (VStack 클로저) ViewBuilder가 만든 타입 Text("제목") if flag { Text } if flag { Image } else { Text } VStack<TupleView<( … )>> Text Optional<Text> _ConditionalContent<Image, Text> if/else는 "숨김 플래그"가 아니라 타입 안의 두 갈래다 갈래가 바뀌면 다른 뷰로 취급된다 — Day 4 identity의 출발점
빌더는 문장마다 타입을 정해 튜플로 묶는다. 이 구조가 SwiftUI가 뷰를 구분하는 근거가 된다.

body에만 자동으로 붙는다

body는 프로토콜 선언에 빌더가 붙어 있어 if/else를 그냥 쓸 수 있다. 내가 만든 프로퍼티·함수에는 직접 붙여야 한다. 안 붙이면 평범한 Swift 함수라서 갈래마다 반환 타입이 달라 컴파일이 안 된다.

🔁 @ViewBuilder 없이 갈래마다 다른 타입(❌ 컴파일 에러) → @ViewBuilder를 붙여 _ConditionalContent로(✅)
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 하던 자리다.

Card<Content> — 호출하는 쪽이 내용 타입을 정한다
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 — 보이지 않는 부품

Group
레이아웃에 영향 없이 여러 뷰를 한 단위로 묶는다. 붙인 모디파이어는 그룹이 아니라 각 멤버에 적용된다(문서).
EmptyView
"뷰가 없음"을 나타내는 타입. 직접 만들 일은 드물고, 생략 가능한 제네릭 자식 자리(예: where Footer == EmptyView 편의 init)의 기본 타입으로 쓴다(문서).
Group — if/else 결과 전체에 공통 모디파이어 하나 (Apple 문서 패턴)
struct StartScreen: View {
    let isLoggedIn: Bool

    var body: some View {
        NavigationStack {
            Group {
                if isLoggedIn {
                    Text("환영합니다")
                } else {
                    Text("로그인이 필요합니다")
                }
            }
            .navigationTitle("Start")     // 어느 갈래든 제목이 붙는다
        }
    }
}
🆕 최신 API iOS 18+

컨테이너가 받은 자식을 하나씩 꺼내 배치하고 싶으면 iOS 18의 ForEach(subviews:content:), Group(subviews:transform:)를 쓴다. iOS 17 타깃에서는 위의 Card처럼 content를 통째로 배치하는 방식까지만 가능하다.

🔬 더 깊이 Xcode 27

현재 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)를 복붙하면 한 곳을 고쳐도 나머지는 그대로다. 묶음에 이름을 붙인다.

✅ ViewModifier + View extension (Apple 문서 BorderedCaption 패턴)
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의 "감싸기"가 내 타입 이름으로 보인다.

Text("Swift") 원래 뷰 .borderedCaption() extension = 이름표 body(content:) content = Text font·padding·overlay 새 뷰 화면에 그려짐 타입: ModifiedContent<Text, BorderedCaption> View가 "속성 → 뷰"라면, ViewModifier는 "뷰 → 뷰" 함수다
extension은 호출을 짧게 해 주는 이름표일 뿐이고, 실제 일은 body(content:)가 한다.

상태를 가진 모디파이어

그냥 모디파이어를 이어 붙이는 거라면 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()
UIKitSwiftUI
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(_:)
LabelStyleLabel의 아이콘·제목 배치icon, title.labelStyle(_:)
ToggleStyle토글 모양·동작label, isOn, $isOn.toggleStyle(_:)

❌ Text(title).background(.blue, in: Capsule()).onTapGesture(perform: action)로 버튼을 흉내 내면 눌림 표시가 없고 VoiceOver가 "버튼"으로 읽지 않는다. 모양만 바꾸려면 ButtonStyle이다.

✅ ButtonStyle — Button의 의미는 그대로, 모양만 교체
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)                                     // 한 번 적용 → 안쪽 버튼 전부
    }
}
VStack .buttonStyle(.pill) 저장 · pill 공유 · pill HStack 삭제 · pill 취소 · bordered 가까운 쪽 설정이 이긴다 스타일은 아래로 전파된다 버튼마다 붙일 필요 없음
ButtonStyle 문서 표현대로 "all buttons within a view hierarchy"에 적용된다. 예외만 안쪽에서 덮어쓴다.
LabelStyle — 아이콘 위, 제목 아래로 배치 바꾸기
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 대응

UIKit에서 UIButton.Configuration이나 UIButton 서브클래스로 하던 일이 ButtonStyle이다. 차이는 적용 단위다 — 버튼 인스턴스마다가 아니라 계층 한 지점에 한 번.

5. 흔한 실수 모음

① 조건부 모디파이어 .if 확장

"조건일 때만 모디파이어를 붙이는" 확장은 편해 보이지만 if/else로 서로 다른 두 타입을 오간다.

🔁 .if 확장(❌ 조건이 바뀌면 다른 뷰로 교체) → 조건을 모디파이어 입력으로(✅ 같은 뷰, 값만 변경)
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() } }
    }
}
📝 Apple 원문

"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

풀이: 조건을 모디파이어 인자 안으로 넣어라. "회전으로 감싼 뷰"와 "그냥 뷰"는 서로 다른 뷰라 페이드가 나오고, 인자로 넣으면 한 뷰가 값만 바꿔 회전한다.

❌ .if(flipped) — 뷰 두 개 ✅ rotationEffect(조건 ? 180 : 0) — 뷰 하나 Image false 갈래 rotation(Image) true 갈래 하나는 사라지고, 하나는 나타남 → 기본 전환 = 페이드, 내부 상태도 새로 rotation(Image) angle: 0° → 180° 같은 뷰의 입력값만 바뀜 → 각도를 보간해 회전 애니메이션 "같은 뷰인가"를 가르는 규칙 = identity → Day 4에서 정식으로 다룬다
조건이 뷰 구조(타입)를 바꾸면 교체, 조건이 값만 바꾸면 변경이다.

② 타입을 맞추려고 AnyView

갈래 타입이 달라 에러가 나면 AnyView로 감싸고 싶어진다. 컴파일은 되지만 타입 정보를 지워 SwiftUI가 구조를 볼 수 없다. 먼저 @ViewBuilder로 푼다 — 비용과 걷어내는 법은 Day 4 2부 3절에서 자세히 다룬다.

③ 스타일을 버튼마다 반복

.buttonStyle(.pill)을 버튼 10개에 각각 붙이지 않는다. 공통 컨테이너(화면 루트, 섹션)에 한 번 붙이고 예외만 안쪽에서 덮어쓴다(4절 도해).

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
행 안의 "즐겨찾기 별" 조각에 탭하면 켜지는 자기 상태가 필요하다. 계산 프로퍼티, 함수, struct 중 무엇으로 떼어 내나?
별도 struct다. SwiftUI가 @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 문서).
쉽게 말하면 옷만 갈아입히면 ButtonStyle, 누르는 방식(예: 두 번 눌러야 동작)까지 바꾸면 PrimitiveButtonStyle.
🔮 예측용 코드
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는 총 몇 번 찍히나?
3번이다(처음 1 + 탭마다 1). Group 문서: 모디파이어는 그룹 자체가 아니라 멤버에 적용되고, onAppear는 "executes every time isLoggedIn changes". 갈래가 바뀔 때마다 새 텍스트가 나타나기 때문이다. 이 "갈래가 바뀌면 새 뷰"가 Day 4의 주제다.
쉽게 말하면 Group은 투명한 봉투라서, 봉투에 붙인 "등장하면 알려줘" 스티커가 안의 내용물에 붙는다. 내용물이 바뀔 때마다 새로 등장한 셈이다.
🔁 지난 Day 복습: Text("A").borderedCaption()의 타입이 ModifiedContent<Text, BorderedCaption>인 이유를 Day 1의 개념으로 설명하면?
Day 1: 모디파이어는 원래 뷰를 감싼 새 뷰를 돌려준다. .modifier(BorderedCaption())도 똑같이 Text를 감싸고, 그 감싼 결과가 ModifiedContent다. 순서 규칙도 그대로 적용된다.
쉽게 말하면 내가 만든 포장지도 결국 포장지다. 선물(Text)을 한 겹 더 싼다.

🛠 미니 과제 (선택 · 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() }

확인 방법: 카드가 전체 폭으로 정렬된 것 외에는 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() }

출처 · 참고자료