← SwiftUI 14일 마스터 부록 B

부록 B · 출시 가능한 앱

Text 현지화 규칙과 String Catalog, 시스템 색·Dynamic Type, 접근성 요소 설계와 4단계 점검, App/Scene과 UIApplicationDelegateAdaptor로 UIKit 앱 구조를 옮기는 방법을 다룬다.
진행률
0 / 0 완료
14일 동안 만든 화면은 "내 폰, 한국어, 라이트 모드, 기본 글자 크기"에서만 검증됐다. 출시하면 영어·아랍어 사용자, 다크 모드, 가장 큰 글자, VoiceOver 사용자가 같은 화면을 연다. UIKit에서 NSLocalizedString·UIColor(named:)·UIFontMetrics·accessibilityTraits·AppDelegate로 하던 일을 SwiftUI에서는 어디서 하는지 정리한다. 기본값을 믿되, 기본값이 깨지는 네 곳을 안다.

부록 목표 · 30분

  • Text(변수)가 번역되지 않는 코드를 찾아 LocalizedStringKey·String(localized:)로 고친다.
  • 하드코딩 색·고정 폰트 크기를 시스템 색·Asset Catalog 색·relativeTo:·@ScaledMetric로 바꾸고 극단 설정 Preview로 확인한다.
  • VoiceOver가 5번 멈추는 셀을 accessibilityElement(children:)로 1번에 읽히게 만들고, 4단계 점검 순서로 확인한다.
  • AppDelegate에 있던 푸시·SDK 초기화 코드를 @UIApplicationDelegateAdaptor로 옮긴다.

1. 현지화 — 리터럴은 번역되고 변수는 안 된다 🔥 핵심

Text는 인자 타입에 따라 다른 init을 고른다. 문자열 리터럴은 LocalizedStringKey로 해석되어 번들에서 번역을 찾는다. String 변수는 init(_:)으로 가서 번역 없이 그대로 나온다.

코드 선택되는 init 화면에 나오는 것 Text("장바구니") LocalizedStringKey 카탈로그 조회 → "Cart" Text(title) // String init(_: StringProtocol) "장바구니" 그대로 (번역 X) Text(verbatim: ver) init(verbatim:) "v1.2.0" 그대로 (의도)
같은 한글이 들어가도 타입이 경로를 정한다. 두 번째 줄이 출시 후 "여기만 한국어로 나와요" 버그의 정체다.
📝 Apple 원문

"Use this initializer to create a text view that displays — without localization — the text in a string variable." (Apple 문서 · Text init(_:))

풀이: String 변수를 받는 init은 번역하지 않는다. 번역하려면 먼저 LocalizedStringKey를 만들어 넘기라고 문서가 안내한다.

🔁 String 변수 → 번역 누락 / 키 타입으로 들고 다니면 해결
import SwiftUI

struct CartTitleWrong: View {
    let title: String = "장바구니"
    var body: some View {
        Text(title)   // ❌ init(_: StringProtocol) — 번역 안 됨
    }
}

struct CartTitle: View {
    let titleKey: LocalizedStringKey = "장바구니"
    let version = "v1.2.0"
    var body: some View {
        VStack {
            Text("장바구니")          // ✅ 리터럴 → LocalizedStringKey
            Text(titleKey)           // ✅ 키 타입으로 전달
            Text(verbatim: version)  // 번역하면 안 되는 값은 명시적으로
        }
    }
}

커스텀 뷰가 라벨을 String으로 받으면 같은 누락이 생긴다. WWDC21 발표도 "In custom SwiftUI views that require localization, we need to use LocalizedStringKey."라고 짚는다 (WWDC21 · Localize your SwiftUI app). 뷰 밖(모델·에러 메시지)에서는 String(localized:comment:)를 쓴다 (WWDC22 · Localization by example).

커스텀 뷰는 LocalizedStringKey로 받고, 번역가에게 comment를 남긴다
import SwiftUI

struct StepperRow: View {
    let label: LocalizedStringKey   // String이 아니라 키 타입
    @Binding var count: Int
    var body: some View {
        Stepper(value: $count, in: 0...10) { Text(label) }
    }
}

enum OrderError: Error {
    case soldOut
    var message: String {
        // 뷰 밖에서는 String(localized:)
        String(localized: "품절된 상품입니다", comment: "주문 실패 알림 본문")
    }
}

struct OrderView: View {
    @State private var count = 1
    var body: some View {
        StepperRow(label: "수량", count: $count)
        Text("담기", comment: "장바구니에 추가하는 버튼")
    }
}

String Catalog — Xcode 15 도구, OS 요구 아님

.xcstrings 파일(String Catalog)은 Xcode 15 이상이 필요한 도구 기능이다. 빌드 설정 "Use Compiler to Extract Swift Strings"를 켜면 빌드 때 위 리터럴·String(localized:)가 자동으로 카탈로그에 들어간다. 복수형은 카탈로그에서 문자열을 우클릭해 "Vary by Plural"로 언어별 규칙을 채운다 (WWDC23 · Discover String Catalogs).

📝 Apple 원문

"at build time, these files compile to .strings and .stringsdict files. … you can start using String Catalogs right away without having to update your minimum deployment target." (WWDC23 · Discover String Catalogs)

풀이: 카탈로그는 빌드할 때 예전 형식으로 바뀐다. 그래서 배포 타깃을 올릴 필요가 없다. 필요한 건 Xcode 15 툴체인뿐이다.

숫자·날짜는 포맷으로, 정렬은 leading으로

단위·날짜·퍼센트를 문자열로 이어 붙이지 않는다. "You just wrap your value in Text() and specify how you would like your number to be formatted." (WWDC22 · Localization by example) 레이아웃은 오른쪽→왼쪽 언어에서 자동으로 뒤집힌다. 그래서 정렬은 .leading/.trailing으로 쓴다 (WWDC21 · 10220).

포맷 + 복수형 키 + 로케일·RTL Preview
import SwiftUI

struct OrderSummary: View {
    let count: Int
    let total: Decimal
    let date: Date
    var body: some View {
        VStack(alignment: .leading) {      // .left가 아니라 .leading
            Text("상품 \(count)개")          // 카탈로그에서 Vary by Plural
            Text(total, format: .currency(code: "KRW"))
            Text(date, format: .dateTime.month().day().weekday())
            Text(0.42, format: .percent)
        }
    }
}

#Preview("아랍어 · RTL") {
    OrderSummary(count: 3, total: 12000, date: .now)
        .environment(\.locale, Locale(identifier: "ar"))
        .environment(\.layoutDirection, .rightToLeft)
}
UIKitSwiftUI
label.text = NSLocalizedString("key", comment:)Text("key", comment:) — 리터럴이면 자동
모델 속 NSLocalizedStringString(localized:comment:)
DateFormatter·NumberFormatterText(value, format:) · .formatted()
leadingAnchor (left 대신)alignment: .leading
.strings + .stringsdict.xcstrings 하나 (Xcode 15+)

2. 테마·다크 모드·Dynamic Type

색과 글자 크기는 시스템이 바꿀 수 있게 맡기는 것이 기본이다. 직접 분기하는 코드는 마지막 수단이다.

먼저 시도 ① 시스템 색 · .primary / .secondary 계층 라이트·다크·고대비 자동 ② Asset Catalog 색 · Color("Brand") Any / Dark 칸 채우면 자동 ③ @Environment(\.colorScheme) 분기 색 아닌 결정(그림자 등)만
위에서 해결되면 아래로 내려가지 않는다. colorScheme 문서도 색만 바꿀 거라면 Asset Catalog에서 하라고 적는다.
계층 스타일 + Asset Catalog 색 + 색이 아닌 결정만 colorScheme
import SwiftUI

struct ProfileCard: View {
    @Environment(\.colorScheme) private var colorScheme
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("도윤").foregroundStyle(.primary)
            Text("iOS 개발자").foregroundStyle(.secondary)  // 모드별 자동
            Text("팔로우").foregroundStyle(Color("Brand"))  // Any / Dark 칸
        }
        .padding()
        .background(Color(uiColor: .secondarySystemBackground),
                    in: .rect(cornerRadius: 12))
        .shadow(radius: colorScheme == .dark ? 0 : 4)       // 색 아닌 결정
    }
}

Xcode 15부터는 Asset Catalog 색·이미지마다 Swift 심볼이 생성되어 Color(.brand)처럼 문자열 없이 쓸 수 있다. 이름을 바꾸면 빌드 에러로 잡힌다 (WWDC23 · What's new in Xcode 15). 이 심볼의 타입 ColorResource는 문서상 iOS 17+다. 앱 전체를 한 모드로 고정하려면 루트에 .preferredColorScheme(.dark)를 건다. 문서대로 이 값은 가장 가까운 presentation(시트·윈도우)까지 위로 전파된다. colorScheme 환경값을 직접 세팅하면 아래 뷰만 바뀐다 (Apple 문서 · colorScheme).

글자는 .font(.body) 같은 텍스트 스타일이면 Dynamic Type을 따라간다. 커스텀 폰트는 relativeTo:로 기준 스타일을 정하고, 아이콘 크기·여백 같은 숫자는 @ScaledMetric으로 함께 키운다 (Apple 문서 · Applying custom fonts to text).

커스텀 폰트·아이콘을 Dynamic Type에 태우고 극단 설정으로 Preview
import SwiftUI

struct FavoriteLabel: View {
    @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 20
    var body: some View {
        HStack {
            Image(systemName: "star.fill")
                .resizable()
                .frame(width: iconSize, height: iconSize)  // 글자 따라 커진다
            Text("즐겨찾기")
                .font(.custom("Pretendard-Regular", size: 17, relativeTo: .body))
        }
    }
}

#Preview("최대 글자 · 다크") {
    FavoriteLabel()
        .dynamicTypeSize(.accessibility5)
        .preferredColorScheme(.dark)
}
UIKitSwiftUI
UIColor(named: "Brand")Color("Brand") · Color(.brand) (Xcode 15 심볼)
.secondaryLabel.foregroundStyle(.secondary)
traitCollection.userInterfaceStyle@Environment(\.colorScheme)
overrideUserInterfaceStyle.preferredColorScheme(_:)
UIFontMetrics(forTextStyle:).scaledFont(for:)Font.custom(_:size:relativeTo:)
UIFontMetrics.scaledValue(for:)@ScaledMetric

3. 접근성 심화 — 요소를 설계한다 🔥 핵심

VoiceOver는 화면을 접근성 요소 단위로 읽는다. "Accessibility technologies like VoiceOver only interact with apps through their elements" (WWDC24 · Catch up on accessibility in SwiftUI). 기본 뷰는 요소를 알아서 만든다. 할 일은 몇 개로 묶을지와 빠진 정보 채우기다.

.combine
자식들을 요소 1개로 합친다. 라벨은 이어 붙고, 버튼은 커스텀 동작이 된다.
.contain
자식은 그대로 두고 컨테이너로 묶는다. 안을 다 돈 뒤 다음 요소로 간다.
.ignore (기본값)
자식을 숨기고 새 요소 1개를 만든다. 라벨은 직접 붙인다.
accessibilityRepresentation
커스텀 컨트롤의 접근성을 표준 뷰(Slider 등)로 통째로 바꾼다.
댓글 셀 — 요소 5개를 1개로 (WWDC24 예시 구조)
import SwiftUI

struct CommentRow: View {
    let author: String   // 사용자 입력이라 번역 대상 아님
    let message: String
    let isUnread: Bool
    var body: some View {
        HStack {
            if isUnread {
                Circle().fill(.blue).frame(width: 8, height: 8)
                    .accessibilityLabel("읽지 않음")  // 도형은 기본 요소가 없다
            }
            VStack(alignment: .leading) {
                Text(message)
                Text(author).foregroundStyle(.secondary)
            }
            Spacer()
            Button("좋아요", systemImage: "star") { }
            Button("답장", systemImage: "arrowshape.turn.up.left") { }
        }
        .labelStyle(.iconOnly)
        // 라벨은 합쳐지고, 두 버튼은 커스텀 동작으로 바뀐다
        .accessibilityElement(children: .combine)
    }
}

WWDC24 발표 설명: "The label of the element will be the combined labels of all of the views. Important traits will be merged and any actions from controls like buttons will be turned into custom actions." 즉 셀 하나에 한 번 멈추고, 위아래 스와이프로 "좋아요"·"답장"을 고른다.

label · value · hint · traits · 이름 붙은 동작
import SwiftUI

struct FilterChip: View {
    let title: LocalizedStringKey
    @Binding var isOn: Bool
    let resultCount: Int
    var body: some View {
        Button { isOn.toggle() } label: {
            Text(title)
                .padding(8)
                .background(isOn ? Color.accentColor : Color.gray.opacity(0.2),
                            in: .capsule)
        }
        .accessibilityAddTraits(.isToggle)   // "스위치 버튼"으로 읽힌다
        .accessibilityHint("결과 \(resultCount)개로 좁힙니다")
        .accessibilityAction(named: "필터 초기화") { isOn = false }
    }
}

struct DownloadRing: View {
    let progress: Double
    var body: some View {
        Circle().trim(from: 0, to: progress)
            .stroke(.blue, lineWidth: 4)
            .frame(width: 28, height: 28)
            .accessibilityLabel("다운로드")                        // 무엇인가
            .accessibilityValue(Text(progress, format: .percent)) // 지금 상태
    }
}

isToggle trait은 WWDC23에 추가됐고 문서상 iOS 17+다. 발표 원문: "isToggle provides the proper accessibility hint and "switch button" description." (WWDC23 · Build accessible apps with SwiftUI and UIKit)

커스텀 컨트롤은 표준 뷰로 대체 · 셀 안 읽는 순서 조정
import SwiftUI

struct RatingBar: View {
    @Binding var value: Double   // 0...5
    var body: some View {
        Capsule().fill(.gray.opacity(0.3))
            .overlay(alignment: .leading) {
                GeometryReader { geo in
                    Capsule().fill(.orange)
                        .frame(width: geo.size.width * value / 5)
                }
            }
            .frame(height: 12)
            .accessibilityRepresentation {   // VoiceOver에는 Slider로 보인다
                Slider(value: $value, in: 0...5, step: 1) { Text("별점") }
            }
    }
}

struct FriendCell: View {
    let name: String
    var body: some View {
        HStack {
            Text(name)
            Button("도전 수락") { }
                .accessibilitySortPriority(1)   // 높을수록 먼저 (기본 0)
        }
        .accessibilityElement(children: .contain)  // 셀 안을 다 돌고 다음 셀로
    }
}

accessibilityRepresentation에 넣은 뷰는 화면에 그려지지 않고 접근성 요소를 만드는 데만 쓰인다 (Apple 문서). WWDC21은 커스텀 컨트롤에 이 방법을 권장한다: "It's recommended to use accessibilityRepresentation(representation:) when possible, to make custom controls accessible." (WWDC21 · SwiftUI Accessibility: Beyond the basics)

점검 절차 — 네 단계

① Accessibility Preview Xcode 캔버스 ☐ 요소 개수·순서 ☐ label·traits ☐ 요소 없음 = 접근 불가 ② VoiceOver 실기기 직접 들어 본다 ☐ 스와이프 순서 ☐ 읽는 문장 ☐ 커스텀 동작 ③ Accessibility Inspector Audit 자동 검사 ☐ 잘린 텍스트 ☐ 라벨 없는 요소 ☐ 글자 크기·대비 ④ 극단 설정 Preview §1·§2 Preview 재사용 ☐ 최대 Dynamic Type ☐ 다크 + 고대비 ☐ RTL 레이아웃 문제를 고쳤으면 ①부터 다시
① Preview로 빠르게 좁히고, ② 실제로 들어 보고, ③ 도구로 놓친 것을 잡고, ④ 극단 설정에서 레이아웃이 버티는지 본다.

① Accessibility Preview는 Xcode 13부터 캔버스에서 요소를 정렬 순서대로 보여 준다 (WWDC21 · 10119). ③ Accessibility Inspector는 Xcode > Open Developer Tool에서 연다. 문서에 따르면 Audit이 잘린 텍스트, 라벨 없는 요소, 글자 크기, 색 대비를 검사한다 (Apple 문서 · Accessibility Inspector).

UIKitSwiftUI
isAccessibilityElement + 컨테이너의 accessibilityElementsaccessibilityElement(children:)
accessibilityLabel/Value/Hint같은 이름의 모디파이어
accessibilityTraits.insert(.header).accessibilityAddTraits(.isHeader)
accessibilityCustomActions.accessibilityAction(named:_:) · .combine이 자동 변환
accessibilityIncrement/Decrement (adjustable).accessibilityRepresentation { Slider } 또는 .accessibilityAdjustableAction
♿ 접근성 한 줄

커스텀 모양이 필요하면 뷰를 새로 그리기 전에 ButtonStyle·ToggleStyle부터 본다. WWDC24: 스타일을 바꿔도 "its element keeps its label and traits."

4. 앱 구조 다리 — AppDelegate는 어디로 갔나

SwiftUI 앱은 App ⊃ Scene ⊃ View의 소유 구조다. "the app contains the scenes, and the scenes contain views." (WWDC20 · App essentials in SwiftUI) @main이 붙은 App이 UIApplicationMain과 윈도우 설정을 대신한다.

@main struct ShopApp: App @UIApplicationDelegateAdaptor → AppDelegate WindowGroup — Scene (창마다 한 벌) ContentView — View 트리 뷰 상태는 창마다 따로 UIKit 대응 AppDelegate SceneDelegate + UIWindow rootViewController
AppDelegate는 어댑터로 남고, SceneDelegate가 하던 일은 WindowGroup과 뷰 모디파이어(scenePhase·onOpenURL)로 흩어진다.

푸시 토큰, 서드파티 SDK 초기화처럼 UIApplicationDelegate 콜백이 필요한 일은 @UIApplicationDelegateAdaptor로 연결한다. 문서 규칙: App 선언에서 한 번만 선언한다. 두 번 선언하면 런타임 에러다 (Apple 문서 · UIApplicationDelegateAdaptor).

AppDelegate 콜백은 어댑터로, 딥링크는 onOpenURL로
import SwiftUI
import UIKit

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions:
                        [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        // 서드파티 SDK 초기화, 푸시 등록
        application.registerForRemoteNotifications()
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        // 토큰을 서버로 전송
    }
}

struct RootView: View {
    var body: some View { Text("홈") }
}

@main
struct ShopApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        WindowGroup {
            RootView()
                .onOpenURL { url in print("딥링크:", url) }  // 라우팅은 Day 10
        }
    }
}

UIApplication.shared.open(url) 대신 환경값 openURL을 쓴다. 상위 뷰에서 OpenURLAction을 바꿔 끼우면 하위의 Link와 마크다운 링크가 든 Text도 그 동작을 쓴다 (Apple 문서 · openURL).

openURL 읽기 + 상위에서 링크 처리 가로채기
import SwiftUI

struct HelpView: View {
    @Environment(\.openURL) private var openURL
    var body: some View {
        VStack(spacing: 12) {
            Button("고객센터 열기") {
                if let url = URL(string: "https://example.com/help") {
                    openURL(url)
                }
            }
            Text("[약관](https://example.com/terms)을 확인하세요")
        }
    }
}

#Preview {
    HelpView()
        .environment(\.openURL, OpenURLAction { url in
            print("앱 안에서 처리:", url)
            return .handled   // .systemAction이면 시스템 기본 동작
        })
}
UIKitSwiftUI
@main class AppDelegate / UIApplicationMain@main struct …: App
AppDelegate 콜백 (푸시·SDK)@UIApplicationDelegateAdaptor
SceneDelegate + window.rootViewControllerWindowGroup { RootView() }
sceneDidBecomeActive 등@Environment(\.scenePhase) (Day 12)
scene(_:openURLContexts:).onOpenURL (Day 10)
UIApplication.shared.open(url)@Environment(\.openURL)

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
서버에서 받은 카테고리 이름 let name: String을 Text(name)으로 보여 준다. 번역이 안 되는 게 버그인가?
아니다. 서버·사용자 데이터는 번역 대상이 아니라서 init(_:)이 번역하지 않는 게 맞다. 버그는 앱이 정한 고정 문구를 String 변수로 들고 다닐 때다. 그때는 LocalizedStringKey로 들고 가거나 String(localized:)로 만든다.
쉽게 말하면 친구 이름은 번역하지 않는다. 번역하는 건 "안녕하세요" 같은 앱이 미리 써 둔 인사말뿐이다.
셀에 .accessibilityElement(children: .combine)을 붙였다. 안에 있던 두 버튼은 어떻게 되나?
따로 멈추는 요소가 아니게 되고, 합쳐진 요소의 커스텀 동작이 된다. VoiceOver에서 셀에 한 번 멈춘 뒤 위아래 스와이프로 동작을 골라 실행한다 (WWDC24 10073). 셀 안에서 버튼마다 멈추게 하려면 .contain을 쓴다.
쉽게 말하면 메뉴판 한 장을 통째로 건네고 "음료는 뒷면에 있어요"라고 알려 주는 것과 같다.
🔮 코드 예측: HStack { Text("A"); Text("B").accessibilitySortPriority(1); Text("C") }.accessibilityElement(children: .contain) — VoiceOver로 오른쪽 스와이프하면 읽는 순서는?
B → A → C. 문서: "Higher numbers are sorted first. The default sort priority is zero." B만 1이라 맨 앞으로 가고, 둘 다 0인 A와 C는 원래 배치 순서를 지킨다. 우선순위는 같은 컨테이너 안 요소끼리만 비교한다 (.contain이 그 경계를 만든다).
쉽게 말하면 줄 서기에서 "먼저" 표를 받은 친구만 맨 앞으로 가고, 나머지는 서 있던 순서 그대로다.
🔁 Day 2 복습: 커스텀 ButtonStyle로 버튼 모양을 완전히 바꿨다. VoiceOver의 "버튼" trait와 라벨은 사라지나?
남는다. 스타일은 겉모양만 바꾸고 요소의 라벨·trait·동작은 유지된다. WWDC24: "Attributes and actions are applied to the view even when changing a view's visual style." 그래서 onTapGesture로 새로 만들기보다 Button + ButtonStyle이 접근성에 유리하다.
쉽게 말하면 옷을 갈아입어도 이름표는 그대로 달려 있다.

🛠 미니 과제 (선택 · 10분)

출시 전 점검에서 지적받은 상품 카드를 고친다. 번역 누락, 고정 크기, VoiceOver 멈춤 4번이 한꺼번에 들어 있다.

시작 코드 — 한국어·기본 글자·라이트에서만 멀쩡하다
import SwiftUI

struct ProductCard: View {
    let badgeText: String = "신상품"
    let name: String
    let price: Decimal
    @State private var isLiked = false

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(badgeText)
                    .font(.system(size: 11))
                    .foregroundStyle(Color(red: 0.2, green: 0.2, blue: 0.2))
                Text(name)
                Text("\(price)원")
            }
            Spacer()
            Image(systemName: isLiked ? "heart.fill" : "heart")
                .onTapGesture { isLiked.toggle() }
        }
        .padding()
    }
}

#Preview {
    ProductCard(name: "무선 이어폰", price: 129000)
}

확인 방법: Preview를 세 벌 만든다 — .dynamicTypeSize(.accessibility3), .preferredColorScheme(.dark), .environment(\.layoutDirection, .rightToLeft). 캔버스의 Accessibility Preview에서 카드가 요소 1개로 보이고, 세 Preview 모두 글자가 잘리지 않으면 성공이다.

정답 코드 펼치기
✅ 번역·Dynamic Type·접근성 요소 정리
import SwiftUI

struct ProductCard: View {
    let badgeKey: LocalizedStringKey = "신상품"
    let name: String
    let price: Decimal
    @State private var isLiked = false

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(badgeKey)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Text(name)
                Text(price, format: .currency(code: "KRW"))
            }
            Spacer()
            Button(isLiked ? "좋아요 취소" : "좋아요",
                   systemImage: isLiked ? "heart.fill" : "heart") {
                isLiked.toggle()
            }
            .labelStyle(.iconOnly)
        }
        .padding()
        .accessibilityElement(children: .combine)
    }
}

#Preview("최대 글자") {
    ProductCard(name: "무선 이어폰", price: 129000)
        .dynamicTypeSize(.accessibility3)
}
#Preview("다크") {
    ProductCard(name: "무선 이어폰", price: 129000)
        .preferredColorScheme(.dark)
}
#Preview("RTL") {
    ProductCard(name: "무선 이어폰", price: 129000)
        .environment(\.layoutDirection, .rightToLeft)
}

출처 · 참고자료