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()이다.
Day 1에서 본 대로 모디파이어는 뷰를 한 겹씩 감싼다. resizable()은 Image를 돌려주는 Image 메서드이고, scaledToFill()·frame은 아무 뷰에나 붙는 View 모디파이어라 some View를 돌려준다. 그래서 순서가 틀리면 결과가 틀린 게 아니라 아예 컴파일되지 않는다. scaledToFit()은 aspectRatio(contentMode: .fit)의 줄임이다.
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은 효과를 네 가지 행동으로 나눈다.
.bounce. value:가 바뀔 때마다 한 번 재생..variableColor, .pulse, .scale. isActive:가 true인 동안..appear, .disappear..replace. .contentTransition(.symbolEffect(.replace))로 쓴다.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"라고 설명한다. 뷰가 나타나면 곧바로 받기 시작한다는 뜻이다.
Apple 문서: "You can't apply image-specific modifiers, like resizable…, directly to an AsyncImage." AsyncImage는 Image가 아니라 View다. 1절 도해의 오른쪽 구간에 있는 셈이다. 클로저가 넘겨주는 image에 붙인다.
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 하나다.
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 안에서 쓸 때 두 가지
- 크기를 고정한다. 문서에 따르면 기본 placeholder는 "fills the available space"이고, 로드가 끝나면 이미지 크기로 바뀐다. 칸 크기가 phase마다 달라지면 행 높이가 튄다. 위 코드처럼
AsyncImage바깥에frame을 준다. - 행 identity를 안정적으로 둔다. 받아 온 이미지는 뷰 상태다. Day 4 규칙대로 identity가 바뀌면 상태가 사라지고 처음부터 다시 받는다. 배열 인덱스 대신 모델의
id를 쓴다.
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)만 적는다. 실제 위치는 시스템이 플랫폼에 맞게 정한다.
| UIKit | SwiftUI |
|---|---|
navigationItem.leftBarButtonItem | ToolbarItem(placement: .topBarLeading) |
navigationItem.rightBarButtonItem(s) | ToolbarItem(placement: .topBarTrailing) / ToolbarItemGroup |
navigationItem.titleView | ToolbarItem(placement: .principal) |
toolbarItems + UIToolbar | ToolbarItemGroup(placement: .bottomBar) |
inputAccessoryView | ToolbarItemGroup(placement: .keyboard) — Day 11 |
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)
}
}
}
}
}Apple 문서에서 topBarLeading의 선언은 @backDeployed(before: iOS 17.0)다. iOS 17 SDK에서 생겼지만 iOS 14까지 쓸 수 있다는 뜻이다. 예전 이름 .navigationBarLeading/.navigationBarTrailing은 레거시 코드에서 읽을 줄만 알면 된다. 새 코드는 topBar…를 쓴다.
4. 메뉴 — Menu, contextMenu, 파괴적 버튼
| UIKit | SwiftUI |
|---|---|
UIBarButtonItem(menu: UIMenu(...)), UIButton.menu | Menu { … } 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"를 자동으로 준다고 설명한다. 색을 직접 칠하지 않는다.
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("탭: 새 메모") // 길게 누르면 메뉴가 뜬다
}
}
}
}
}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은 이미 준수한다.
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()를 부르는 대신, 어떤 값이 바뀌면 울릴지를 선언한다. 이것도 "상태 → 결과" 규칙이다.
| UIKit | SwiftUI (SensoryFeedback) |
|---|---|
UINotificationFeedbackGenerator .success/.warning/.error | .success · .warning · .error |
UISelectionFeedbackGenerator | .selection |
UIImpactFeedbackGenerator(style: .light) | .impact(weight: .light) |
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()
}
}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에 무슨 일이 생기나?
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() }요구사항
- 회색 사각형을
AsyncImage로 바꾸고 phase 세 경우를 모두 그린다. 칸은 56×56, fill + 자르기. - ♥ 버튼을
heart/heart.fill심볼로 바꾸고, 누를 때.bounce효과와.selection햅틱을 준다. - 상단 오른쪽에
Menu를 두고 "즐겨찾기 모두 해제"(파괴적 버튼)와 앱 소개 URLShareLink를 넣는다.
확인 방법: 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() }