← SwiftUI 14일 마스터 부록 A

부록 A · 매일 쓰는 부품

UIImageView·바 버튼·UIMenu·공유 시트·햅틱을 SwiftUI의 Image·AsyncImage·toolbar·Menu·ShareLink·sensoryFeedback으로 옮긴다.
진행률
0 / 0 완료
UIKit 화면 하나에는 늘 같은 부품이 들어간다. UIImageView, 서버 이미지 로더, navigationItem의 바 버튼, UIMenu, UIActivityViewController, UIFeedbackGenerator. SwiftUI에도 전부 대응하는 부품이 있고, 모두 모디파이어로 감싸기(Day 1)와 상태가 바뀌면 다시 그리기(Day 3) 규칙을 그대로 따른다. 이 부록은 그 부품들을 실무 순서대로 한 번씩 조립해 본다.

부록 목표 · 30분

  • 사진 썸네일이 찌그러지거나 프레임 밖으로 넘칠 때 resizable → scaledToFill → frame → clipped 순서로 고친다.
  • AsyncImage의 phase를 switch로 그리고, List 행에서 높이가 튀지 않게 크기를 고정한다.
  • navigationItem.leftBarButtonItem·titleView 코드를 ToolbarItem(placement:)으로 옮긴다.
  • UIMenu·컨텍스트 메뉴·공유 시트·햅틱을 Menu·contextMenu·ShareLink·sensoryFeedback로 바꾼다.

1. Image와 SF Symbols — 모디파이어 순서가 곧 결과다 🔥 핵심

Image는 기본적으로 원본 픽셀 크기로 그려진다. Apple 문서 Fitting images into available space의 예시에서 4032×3024 사진에 .frame(width: 300, height: 400)만 붙이면 프레임 밖으로 넘쳐 구름만 보인다. 프레임은 이미지를 줄이지 않는다. 이미지를 줄이는 건 resizable()이다.

Image 전용 메서드 가능 구간 View 모디파이어 구간 (감싸진 뒤) Image("p") .resizable() .scaledToFill() .frame(80,80) .clipped() Image Image some View some View some View 순서를 뒤집어 .frame(…).resizable()로 쓰면 컴파일 에러 — 이미 Image가 아니다
반환 타입이 순서를 강제한다. Image 메서드(resizable, renderingMode)를 먼저, 크기·자르기 모디파이어를 나중에 붙인다.

Day 1에서 본 대로 모디파이어는 뷰를 한 겹씩 감싼다. resizable()은 Image를 돌려주는 Image 메서드이고, scaledToFill()·frame은 아무 뷰에나 붙는 View 모디파이어라 some View를 돌려준다. 그래서 순서가 틀리면 결과가 틀린 게 아니라 아예 컴파일되지 않는다. scaledToFit()은 aspectRatio(contentMode: .fit)의 줄임이다.

.scaledToFit() 전부 보임 · 빈 공간 남음 넘침 넘침 .scaledToFill() 가득 채움 · 프레임 밖으로 그려짐 + .clipped() 넘친 부분을 잘라냄
점선 사각형이 frame이다. fill은 frame보다 크게 그려지므로 썸네일에는 거의 항상 clipped()가 따라붙는다.
✅ 썸네일 정석 — Image 메서드 → 비율 → 크기 → 자르기
import SwiftUI

struct Thumbnail: View {
    let name: String            // Asset Catalog 이미지 이름

    var body: some View {
        Image(name)
            .resizable()                    // ① 이미지가 제안 크기를 받아들이게
            .scaledToFill()                 // ② 비율 유지, 빈틈 없이 채우기
            .frame(width: 80, height: 80)   // ③ 칸 크기 고정
            .clipped()                      // ④ 넘친 부분 잘라내기
            .clipShape(.rect(cornerRadius: 8))
    }
}

UIKit으로 옮기면 contentMode = .scaleAspectFit ↔ scaledToFit(), .scaleAspectFill + clipsToBounds = true ↔ scaledToFill() + clipped()다.

SF Symbols — 글자처럼 다루는 아이콘

Image(systemName:)은 SF Symbols 아이콘을 만든다. 심볼은 폰트처럼 동작해서 크기는 .font로, 색은 .foregroundStyle로 정한다. symbolRenderingMode로 여러 겹 색칠 방식을 고르고, iOS 17의 symbolEffect로 움직임을 준다. WWDC23 · Animate symbols in your app은 효과를 네 가지 행동으로 나눈다.

discrete — 한 번 튄다
.bounce. value:가 바뀔 때마다 한 번 재생.
indefinite — 켜 둔 동안 유지
.variableColor, .pulse, .scale. isActive:가 true인 동안.
transition — 나타남/사라짐
.appear, .disappear.
content transition — 다른 심볼로
.replace. .contentTransition(.symbolEffect(.replace))로 쓴다.
✅ 렌더링 모드 + 세 종류의 symbolEffect (iOS 17)
import SwiftUI

struct SymbolDemo: View {
    @State private var likes = 0
    @State private var isSyncing = true
    @State private var isMuted = false

    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: "cloud.sun.rain.fill")
                .symbolRenderingMode(.multicolor)      // 심볼 고유 색
                .font(.system(size: 44))               // 크기는 폰트로

            Button { likes += 1 } label: {
                Image(systemName: "heart.fill")
                    .symbolEffect(.bounce, value: likes)   // 값이 바뀔 때마다 한 번
            }

            Image(systemName: "wifi")
                .symbolEffect(.variableColor.iterative, isActive: isSyncing)

            Button { isMuted.toggle() } label: {
                Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
                    .contentTransition(.symbolEffect(.replace))   // 심볼 교체 애니메이션
            }
        }
        .font(.title)
    }
}
♿ 접근성 한 줄

Apple Image 문서: 장식용 이미지는 Image(decorative:)로 만들면 VoiceOver가 건너뛴다. 아이콘만 있는 버튼은 Label("좋아요", systemImage: "heart")로 만들어야 VoiceOver가 이름을 읽는다.

2. AsyncImage — 서버 이미지와 phase

AsyncImage는 URL을 받아 공유 URLSession으로 이미지를 내려받아 보여준다(iOS 15+). WWDC21 · What's new in SwiftUI는 "AsyncImage loads its content immediately"라고 설명한다. 뷰가 나타나면 곧바로 받기 시작한다는 뜻이다.

⚠️ AsyncImage에는 resizable을 바로 못 붙인다

Apple 문서: "You can't apply image-specific modifiers, like resizable…, directly to an AsyncImage." AsyncImage는 Image가 아니라 View다. 1절 도해의 오른쪽 구간에 있는 셈이다. 클로저가 넘겨주는 image에 붙인다.

✅ 가장 흔한 형태 — content + placeholder
import SwiftUI

struct Avatar: View {
    let url: URL?

    var body: some View {
        AsyncImage(url: url) { image in
            image
                .resizable()          // 여기서는 Image라서 가능
                .scaledToFill()
        } placeholder: {
            ProgressView()
        }
        .frame(width: 44, height: 44)
        .clipShape(.circle)
    }
}

phase는 Day 12의 LoadState와 같은 모양이다

실패 화면까지 그리려면 init(url:scale:transaction:content:)를 쓴다. 클로저는 AsyncImagePhase를 받는다. Day 12에서 플래그 세 개 대신 만든 LoadState enum과 구조가 같다. 그래서 그리는 법도 같다. switch 하나다.

AsyncImagePhase (Apple) LoadState (Day 12, 내 코드) .empty .success(Image) .failure(Error) .idle / .loading .loaded(Value) .failed(String) 차이 하나: Apple enum은 case가 늘 수 있어 @unknown default가 필요하다
"아직 없음 · 성공 값 · 실패 이유" 세 칸짜리 enum은 비동기 UI의 공통 모양이다. 한 번 익히면 AsyncImage도 같은 switch로 그린다.
✅ phase switch + List 행 — 크기를 고정해 행 높이가 튀지 않게
import SwiftUI

struct Product: Identifiable {
    let id: Int
    var name: String
    var imageURL: URL?
}

struct ProductRow: View {
    let product: Product

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: product.imageURL,
                       transaction: Transaction(animation: .easeIn)) { phase in
                switch phase {
                case .empty:
                    ProgressView()
                case .success(let image):
                    image.resizable().scaledToFill()
                case .failure:
                    Image(systemName: "photo")
                        .foregroundStyle(.secondary)
                @unknown default:            // Swift 6 모드에서는 없으면 컴파일 에러
                    EmptyView()
                }
            }
            .frame(width: 60, height: 60)     // 세 phase 모두 같은 칸
            .clipShape(.rect(cornerRadius: 8))

            Text(product.name)
        }
    }
}

struct ProductList: View {
    let products: [Product]
    var body: some View {
        List(products) { ProductRow(product: $0) }   // 안정적인 id (Day 4)
    }
}

List 안에서 쓸 때 두 가지

🆕 캐시 iOS 27+

iOS 17~26용 AsyncImage API에는 캐시 정책이나 세션을 고르는 옵션이 없다. 받을 URL과 scale, transaction만 넘긴다. Apple 문서는 이렇게 쓴다: "In iOS 27 … and later, AsyncImage caches downloaded image data following the transport protocol." 캐시 정책을 바꾸는 init(request:scale:transaction:content:)와 세션을 지정하는 asyncImageURLSession(_:)도 iOS 27에 추가됐다. iOS 17 타깃에서 디스크 캐시나 다운샘플링이 꼭 필요하면 URLSession으로 직접 로더를 만들고, 상태는 Day 12의 LoadState로 둔다.

3. 툴바 — navigationItem 자리를 placement로

UIKit에서는 VC의 navigationItem에 바 버튼을 대입한다. SwiftUI에서는 NavigationStack 안의 뷰에 .toolbar { }를 붙이고, 각 아이템에 어디에 둘지(ToolbarItemPlacement)만 적는다. 실제 위치는 시스템이 플랫폼에 맞게 정한다.

UIKitSwiftUI
navigationItem.leftBarButtonItemToolbarItem(placement: .topBarLeading)
navigationItem.rightBarButtonItem(s)ToolbarItem(placement: .topBarTrailing) / ToolbarItemGroup
navigationItem.titleViewToolbarItem(placement: .principal)
toolbarItems + UIToolbarToolbarItemGroup(placement: .bottomBar)
inputAccessoryViewToolbarItemGroup(placement: .keyboard) — Day 11
Leading principal Trailing 콘텐츠 bottomBar 상단 바 그대로 TextField (포커스) keyboard 소프트웨어 키보드
placement는 "의미"를 적는 것이고 좌표는 시스템이 정한다. principal은 가운데 자리를 차지하며 navigationTitle보다 우선한다(ToolbarItemPlacement 문서).
✅ 상단 좌·중·우 + 하단 바
import SwiftUI

struct InboxView: View {
    @State private var filterUnread = false

    var body: some View {
        NavigationStack {
            List(1..<20, id: \.self) { Text("메일 \($0)") }
                .navigationTitle("받은편지함")
                .toolbar {
                    ToolbarItem(placement: .topBarLeading) {
                        Button("편집") { }
                    }
                    ToolbarItem(placement: .principal) {          // titleView 자리
                        Toggle("안 읽음만", isOn: $filterUnread)
                            .toggleStyle(.button)
                    }
                    ToolbarItem(placement: .topBarTrailing) {
                        Button("작성", systemImage: "square.and.pencil") { }
                    }
                    ToolbarItemGroup(placement: .bottomBar) {      // UIToolbar 자리
                        Button("보관", systemImage: "archivebox") { }
                        Spacer()
                        Text("20개").font(.caption)
                    }
                }
        }
    }
}
📝 navigationBarLeading vs topBarLeading

Apple 문서에서 topBarLeading의 선언은 @backDeployed(before: iOS 17.0)다. iOS 17 SDK에서 생겼지만 iOS 14까지 쓸 수 있다는 뜻이다. 예전 이름 .navigationBarLeading/.navigationBarTrailing은 레거시 코드에서 읽을 줄만 알면 된다. 새 코드는 topBar…를 쓴다.

4. 메뉴 — Menu, contextMenu, 파괴적 버튼

UIKitSwiftUI
UIBarButtonItem(menu: UIMenu(...)), UIButton.menuMenu { … } label: { … }
showsMenuAsPrimaryAction = false + 탭 액션Menu(content:label:primaryAction:) — 탭은 액션, 길게 누르면 메뉴
UIContextMenuInteraction + previewProvider.contextMenu { … } preview: { … } (iOS 16+)
UIAction(attributes: .destructive)Button(role: .destructive)

Menu 안에는 Button·Toggle·Picker·하위 Menu·Section을 그대로 넣는다. 문서에 따르면 primaryAction을 주면 "the menu presentation will happen on a secondary gesture, such as on long press". WWDC21 10018은 role: .destructive가 "the expected red tint"를 자동으로 준다고 설명한다. 색을 직접 칠하지 않는다.

✅ 툴바 Menu — Section·Picker·파괴적 버튼 + primaryAction
import SwiftUI

enum SortOrder: String, CaseIterable, Identifiable {
    case newest = "최신순", name = "이름순"
    var id: Self { self }
}

struct NotesView: View {
    @State private var sort: SortOrder = .newest

    var body: some View {
        NavigationStack {
            Text("정렬: \(sort.rawValue)")
                .toolbar {
                    Menu {
                        Picker("정렬", selection: $sort) {
                            ForEach(SortOrder.allCases) { Text($0.rawValue).tag($0) }
                        }
                        Section {
                            Button("모두 삭제", systemImage: "trash", role: .destructive) { }
                        }
                    } label: {
                        Label("새 메모", systemImage: "plus")
                    } primaryAction: {
                        print("탭: 새 메모")      // 길게 누르면 메뉴가 뜬다
                    }
                }
        }
    }
}
✅ 행 길게 누르기 — contextMenu + 커스텀 preview
import SwiftUI

struct PhotoCell: View {
    let name: String
    let onDelete: () -> Void

    var body: some View {
        Text(name)
            .padding()
            .contextMenu {
                Button("즐겨찾기", systemImage: "heart") { }
                Button("삭제", systemImage: "trash", role: .destructive, action: onDelete)
            } preview: {
                Image(systemName: "photo")         // 떠오를 미리보기
                    .font(.system(size: 120))
                    .padding()
            }
    }
}

5. 공유와 햅틱 — ShareLink, sensoryFeedback

ShareLink(iOS 16+)는 공유 시트를 띄우는 버튼 뷰다. UIKit처럼 UIActivityViewController를 만들어 present하지 않는다. WWDC22 · What's new in SwiftUI: 내용과 미리보기만 주면 "it automatically creates a standard share icon button". 공유할 값은 Transferable이면 된다. URL·String은 이미 준수한다.

✅ URL 공유 + 내 타입을 Transferable로
import SwiftUI

struct Photo: Transferable {
    var image: Image
    var caption: String

    static var transferRepresentation: some TransferRepresentation {
        ProxyRepresentation(exporting: \.image)   // 공유할 땐 Image로 변환
    }
}

struct ShareDemo: View {
    let photo = Photo(image: Image(systemName: "sun.max"), caption: "맑음")

    var body: some View {
        VStack(spacing: 20) {
            ShareLink(item: URL(string: "https://developer.apple.com/swiftui/")!)   // 기본 공유 아이콘 버튼

            ShareLink(item: photo,
                      preview: SharePreview(photo.caption, image: photo.image)) {
                Label("사진 공유", systemImage: "square.and.arrow.up")
            }
        }
    }
}

햅틱은 sensoryFeedback(_:trigger:)(iOS 17)이다. 문서 요약: "Plays the specified feedback when the provided trigger value changes." 제너레이터를 만들고 prepare()·impactOccurred()를 부르는 대신, 어떤 값이 바뀌면 울릴지를 선언한다. 이것도 "상태 → 결과" 규칙이다.

UIKitSwiftUI (SensoryFeedback)
UINotificationFeedbackGenerator .success/.warning/.error.success · .warning · .error
UISelectionFeedbackGenerator.selection
UIImpactFeedbackGenerator(style: .light).impact(weight: .light)
시간 0 1 1 2 📳 울림 조용 📳 울림 0 → 1 변화 1 → 1 같은 값 대입 1 → 2 변화 "버튼을 눌렀다"가 아니라 trigger 값이 바뀌었다가 조건이다
trigger는 Equatable 값이다. 같은 값을 다시 대입하면 변화가 없으므로 울리지 않는다. 누를 때마다 울려야 하면 카운터처럼 매번 바뀌는 값을 trigger로 쓴다.
✅ 저장 성공 · 조건부 경고 · 가벼운 충격
import SwiftUI

struct CartView: View {
    @State private var saveCount = 0
    @State private var quantity = 1

    var body: some View {
        VStack(spacing: 20) {
            Button("저장") { saveCount += 1 }                 // 누를 때마다 값이 바뀐다
                .sensoryFeedback(.success, trigger: saveCount)

            Stepper("수량 \(quantity)", value: $quantity, in: 1...10)
                .sensoryFeedback(.impact(weight: .light), trigger: quantity)
                .sensoryFeedback(.warning, trigger: quantity) { _, new in
                    new == 10                                  // 최대치에 닿을 때만
                }
        }
        .padding()
    }
}
👀 Preview 팁

symbolEffect·contextMenu·ShareLink는 Preview에서 확인하고, 햅틱은 실기기에서 직접 눌러 보며 세기를 맞춘다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
Image("p").frame(width: 80, height: 80).resizable()는 왜 컴파일되지 않고, 어떻게 고치나?
resizable()은 Image의 메서드다. frame을 붙이는 순간 Image가 some View로 감싸져서 더 이상 Image 메서드를 부를 수 없다. .resizable().scaledToFill().frame(…).clipped() 순서로 바꾼다.
쉽게 말하면 사진을 액자에 넣고 나면 사진을 가위로 자를 수 없다. 자르고 늘리는 건 액자에 넣기 전에 한다.
AsyncImagePhase를 switch할 때 Day 12의 LoadState와 달리 무엇을 더 써야 하나? 이유는?
@unknown default다. AsyncImagePhase는 Apple이 소유한 enum이라 나중에 case가 추가될 수 있다. Swift 5 모드에서는 경고, Swift 6 모드에서는 컴파일 에러가 난다. 내가 만든 LoadState는 case를 내가 다 알기 때문에 필요 없다.
쉽게 말하면 내 필통 속 물건은 다 알지만 남의 필통엔 모르는 물건이 새로 들어올 수 있다. 그래서 "그 밖의 것" 칸을 하나 만들어 둔다.
🔮 코드 예측: @State var done = false, 버튼 액션 done = true, 버튼에 .sensoryFeedback(.success, trigger: done). 버튼을 세 번 누르면 몇 번 울리나?
한 번이다. 첫 탭에서만 false → true로 값이 바뀐다. 두 번째·세 번째는 true를 다시 대입할 뿐 값이 같아서 trigger가 바뀌지 않는다. 누를 때마다 울리게 하려면 count += 1처럼 매번 바뀌는 값을 trigger로 쓴다.
쉽게 말하면 초인종은 "불이 켜질 때" 울리게 해 놨는데, 이미 켜진 불을 또 켜도 아무 변화가 없으니 안 울린다.
🔁 관련 Day (Day 4): 상품 List를 ForEach(Array(products.enumerated()), id: \.offset)로 그렸다. 맨 앞에 상품을 하나 끼워 넣으면 행의 AsyncImage에 무슨 일이 생기나?
인덱스가 identity라서 모든 행이 한 칸씩 밀린 다른 상품을 가리키게 된다. 행은 같은 identity로 남아 있는데 URL이 바뀌므로 이미지를 다시 받고, 그동안 엉뚱한 이미지나 placeholder가 보일 수 있다. id: \.id처럼 모델의 안정적인 ID를 쓰면 기존 행은 그대로 두고 새 행만 추가된다.
쉽게 말하면 줄 선 순서로 이름표를 붙이면 한 명이 끼어들 때 모두의 이름표가 바뀐다. 이름표는 사람 이름으로 붙여야 한다.

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

플레이스홀더만 있는 즐겨찾기 목록을 실무 부품으로 채운다. Preview 하나로 확인한다.

시작 코드
import SwiftUI

struct Place: Identifiable {
    let id: Int
    var name: String
    var imageURL: URL?
    var isFavorite = false
}

struct PlacesView: View {
    @State private var places = [
        Place(id: 1, name: "성산일출봉", imageURL: URL(string: "https://example.com/1.jpg")),
        Place(id: 2, name: "남산타워", imageURL: nil)
    ]

    var body: some View {
        NavigationStack {
            List($places) { $place in
                HStack {
                    Rectangle().fill(.gray)      // TODO: 서버 이미지
                    Text(place.name)
                    Spacer()
                    Button("♥") { place.isFavorite.toggle() }
                }
            }
            .navigationTitle("가고 싶은 곳")
        }
    }
}

#Preview { PlacesView() }

요구사항

확인 방법: Preview에서 ① URL이 없는 행은 실패 아이콘이 56×56 칸에 뜬다 ② ♥를 누르면 채워지며 튄다 ③ 메뉴의 해제 버튼이 빨간색이고 누르면 모든 하트가 빈다.

정답 코드 보기
✅ 정답 예시
import SwiftUI

struct Place: Identifiable {
    let id: Int
    var name: String
    var imageURL: URL?
    var isFavorite = false
}

struct PlacesView: View {
    @State private var places = [
        Place(id: 1, name: "성산일출봉", imageURL: URL(string: "https://example.com/1.jpg")),
        Place(id: 2, name: "남산타워", imageURL: nil)
    ]

    var body: some View {
        NavigationStack {
            List($places) { $place in
                HStack(spacing: 12) {
                    AsyncImage(url: place.imageURL) { phase in
                        switch phase {
                        case .empty: ProgressView()
                        case .success(let image): image.resizable().scaledToFill()
                        case .failure: Image(systemName: "photo").foregroundStyle(.secondary)
                        @unknown default: EmptyView()
                        }
                    }
                    .frame(width: 56, height: 56)
                    .clipShape(.rect(cornerRadius: 8))

                    Text(place.name)
                    Spacer()
                    Button {
                        place.isFavorite.toggle()
                    } label: {
                        Label("즐겨찾기", systemImage: place.isFavorite ? "heart.fill" : "heart")
                            .labelStyle(.iconOnly)
                            .symbolEffect(.bounce, value: place.isFavorite)
                    }
                    .buttonStyle(.borderless)
                    .sensoryFeedback(.selection, trigger: place.isFavorite)
                }
            }
            .navigationTitle("가고 싶은 곳")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Menu("더 보기", systemImage: "ellipsis.circle") {
                        ShareLink(item: URL(string: "https://developer.apple.com/swiftui/")!)
                        Button("즐겨찾기 모두 해제", role: .destructive) {
                            for i in places.indices { places[i].isFavorite = false }
                        }
                    }
                }
            }
        }
    }
}

#Preview { PlacesView() }

출처 · 참고자료