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(_:)으로 가서 번역 없이 그대로 나온다.
"Use this initializer to create a text view that displays — without localization — the text in a string variable." (Apple 문서 · Text init(_:))
풀이: String 변수를 받는 init은 번역하지 않는다. 번역하려면 먼저 LocalizedStringKey를 만들어 넘기라고 문서가 안내한다.
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).
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).
"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).
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)
}| UIKit | SwiftUI |
|---|---|
label.text = NSLocalizedString("key", comment:) | Text("key", comment:) — 리터럴이면 자동 |
모델 속 NSLocalizedString | String(localized:comment:) |
DateFormatter·NumberFormatter | Text(value, format:) · .formatted() |
leadingAnchor (left 대신) | alignment: .leading |
.strings + .stringsdict | .xcstrings 하나 (Xcode 15+) |
2. 테마·다크 모드·Dynamic Type
색과 글자 크기는 시스템이 바꿀 수 있게 맡기는 것이 기본이다. 직접 분기하는 코드는 마지막 수단이다.
colorScheme 문서도 색만 바꿀 거라면 Asset Catalog에서 하라고 적는다.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).
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)
}| UIKit | SwiftUI |
|---|---|
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). 기본 뷰는 요소를 알아서 만든다. 할 일은 몇 개로 묶을지와 빠진 정보 채우기다.
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." 즉 셀 하나에 한 번 멈추고, 위아래 스와이프로 "좋아요"·"답장"을 고른다.
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 13부터 캔버스에서 요소를 정렬 순서대로 보여 준다 (WWDC21 · 10119). ③ Accessibility Inspector는 Xcode > Open Developer Tool에서 연다. 문서에 따르면 Audit이 잘린 텍스트, 라벨 없는 요소, 글자 크기, 색 대비를 검사한다 (Apple 문서 · Accessibility Inspector).
| UIKit | SwiftUI |
|---|---|
isAccessibilityElement + 컨테이너의 accessibilityElements | accessibilityElement(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과 윈도우 설정을 대신한다.
scenePhase·onOpenURL)로 흩어진다.푸시 토큰, 서드파티 SDK 초기화처럼 UIApplicationDelegate 콜백이 필요한 일은 @UIApplicationDelegateAdaptor로 연결한다. 문서 규칙: App 선언에서 한 번만 선언한다. 두 번 선언하면 런타임 에러다 (Apple 문서 · UIApplicationDelegateAdaptor).
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).
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이면 시스템 기본 동작
})
}| UIKit | SwiftUI |
|---|---|
@main class AppDelegate / UIApplicationMain | @main struct …: App |
| AppDelegate 콜백 (푸시·SDK) | @UIApplicationDelegateAdaptor |
SceneDelegate + window.rootViewController | WindowGroup { 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)을 붙였다. 안에 있던 두 버튼은 어떻게 되나?
.contain을 쓴다.🔮 코드 예측: HStack { Text("A"); Text("B").accessibilitySortPriority(1); Text("C") }.accessibilityElement(children: .contain) — VoiceOver로 오른쪽 스와이프하면 읽는 순서는?
.contain이 그 경계를 만든다).🔁 Day 2 복습: 커스텀 ButtonStyle로 버튼 모양을 완전히 바꿨다. VoiceOver의 "버튼" trait와 라벨은 사라지나?
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)
}- 요구 1:
badgeText가 번역되게 하고, 가격은format: .currency(code: "KRW")로 표시한다. - 요구 2: 배지 글자는 텍스트 스타일(
.caption)로, 색은.secondary로 바꾼다. - 요구 3: 하트를
Button으로 바꾸고, 카드 전체를 요소 1개로 합쳐 하트가 커스텀 동작이 되게 한다.
확인 방법: Preview를 세 벌 만든다 — .dynamicTypeSize(.accessibility3), .preferredColorScheme(.dark), .environment(\.layoutDirection, .rightToLeft). 캔버스의 Accessibility Preview에서 카드가 요소 1개로 보이고, 세 Preview 모두 글자가 잘리지 않으면 성공이다.
정답 코드 펼치기
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)
}