# HIG Lab — AI Reference 합본 (50개 프레임워크) > 이 문서는 50개 Apple 프레임워크 AI Reference를 하나로 합친 것입니다. > 개별 문서: https://m1zz.github.io/HIGLab/ai-reference/ --- # AccessorySetupKit AI Reference > 액세서리 연결 및 설정 가이드. 이 문서를 읽고 AccessorySetupKit 코드를 생성할 수 있습니다. ## 개요 AccessorySetupKit은 iOS 18+에서 제공하는 Bluetooth/Wi-Fi 액세서리 페어링 프레임워크입니다. 시스템 UI를 통해 사용자 친화적인 액세서리 발견, 페어링, 설정 경험을 제공합니다. 기존 CoreBluetooth보다 간편하고 보안성 높은 연결을 지원합니다. ## 필수 Import ```swift import AccessorySetupKit ``` ## 프로젝트 설정 ### 1. Info.plist ```xml NSBluetoothAlwaysUsageDescription 액세서리를 연결하기 위해 Bluetooth가 필요합니다. NSLocalNetworkUsageDescription Wi-Fi 액세서리를 찾기 위해 로컬 네트워크 접근이 필요합니다. NSBonjourServices _myaccessory._tcp ``` ### 2. Capability - Wireless Accessory Configuration (필요 시) ## 핵심 구성요소 ### 1. ASAccessorySession (세션) ```swift import AccessorySetupKit // 세션 생성 let session = ASAccessorySession() // 이벤트 핸들러 session.eventHandler = { event in switch event { case .accessoryAdded(let accessory): print("액세서리 추가됨: \(accessory.displayName)") case .accessoryRemoved(let accessory): print("액세서리 제거됨: \(accessory.displayName)") case .accessoryChanged(let accessory): print("액세서리 변경됨: \(accessory.displayName)") case .activated: print("세션 활성화됨") case .invalidated(let error): print("세션 무효화됨: \(error?.localizedDescription ?? "")") @unknown default: break } } // 세션 활성화 session.activate(on: DispatchQueue.main) ``` ### 2. ASPickerDisplayItem (피커 항목) ```swift // Bluetooth 액세서리 let bluetoothItem = ASPickerDisplayItem( name: "My Smart Device", productImage: UIImage(named: "device-icon")!, descriptor: ASDiscoveryDescriptor(bluetoothServiceUUID: CBUUID(string: "180A")) ) // Wi-Fi 액세서리 let wifiItem = ASPickerDisplayItem( name: "Smart Home Hub", productImage: UIImage(named: "hub-icon")!, descriptor: ASDiscoveryDescriptor( ssid: ASDiscoveryDescriptor.ssidPrefix("SmartHub-"), supportedOptions: .ssidPrefix ) ) ``` ### 3. ASAccessory (연결된 액세서리) ```swift // 연결된 액세서리 정보 let accessory: ASAccessory accessory.displayName // 표시 이름 accessory.state // 연결 상태 accessory.bluetoothIdentifier // Bluetooth UUID accessory.ssid // Wi-Fi SSID ``` ## 전체 작동 예제 ```swift import SwiftUI import AccessorySetupKit import CoreBluetooth // MARK: - Accessory Manager @Observable class AccessoryManager { var accessories: [ASAccessory] = [] var isSessionActive = false var isShowingPicker = false var errorMessage: String? private var session: ASAccessorySession? var isSupported: Bool { ASAccessorySession.isSupported } func activateSession() { session = ASAccessorySession() session?.eventHandler = { [weak self] event in DispatchQueue.main.async { self?.handleEvent(event) } } session?.activate(on: .main) } private func handleEvent(_ event: ASAccessoryEvent) { switch event { case .activated: isSessionActive = true // 이미 페어링된 액세서리 로드 loadPairedAccessories() case .invalidated(let error): isSessionActive = false if let error = error { errorMessage = error.localizedDescription } case .accessoryAdded(let accessory): if !accessories.contains(where: { $0.bluetoothIdentifier == accessory.bluetoothIdentifier }) { accessories.append(accessory) } case .accessoryRemoved(let accessory): accessories.removeAll { $0.bluetoothIdentifier == accessory.bluetoothIdentifier } case .accessoryChanged(let accessory): if let index = accessories.firstIndex(where: { $0.bluetoothIdentifier == accessory.bluetoothIdentifier }) { accessories[index] = accessory } @unknown default: break } } private func loadPairedAccessories() { // 이전에 페어링된 액세서리 복원 accessories = session?.accessories ?? [] } // MARK: - 액세서리 검색 및 추가 func showAccessoryPicker() { guard let session = session else { return } // 검색할 액세서리 정의 let items = [ // Bluetooth 장치 ASPickerDisplayItem( name: "스마트 센서", productImage: UIImage(systemName: "sensor.fill")!, descriptor: ASDiscoveryDescriptor( bluetoothServiceUUID: CBUUID(string: "180A") ) ), // 커스텀 Bluetooth 서비스 ASPickerDisplayItem( name: "피트니스 밴드", productImage: UIImage(systemName: "figure.run")!, descriptor: ASDiscoveryDescriptor( bluetoothServiceUUID: CBUUID(string: "180D"), // Heart Rate bluetoothCompanyIdentifier: ASDiscoveryDescriptor.bluetoothCompanyIdentifierApple ) ) ] // 피커 표시 session.showPicker(for: items) { [weak self] error in DispatchQueue.main.async { if let error = error { self?.errorMessage = "피커 오류: \(error.localizedDescription)" } } } } // MARK: - 액세서리 제거 func removeAccessory(_ accessory: ASAccessory) { session?.removeAccessory(accessory) { [weak self] error in DispatchQueue.main.async { if let error = error { self?.errorMessage = "제거 실패: \(error.localizedDescription)" } } } } // MARK: - 액세서리 이름 변경 func renameAccessory(_ accessory: ASAccessory, to newName: String) { session?.renameAccessory(accessory, to: newName) { [weak self] error in DispatchQueue.main.async { if let error = error { self?.errorMessage = "이름 변경 실패: \(error.localizedDescription)" } } } } deinit { session?.invalidate() } } // MARK: - Main View struct AccessorySetupView: View { @State private var manager = AccessoryManager() @State private var showingRenameSheet = false @State private var accessoryToRename: ASAccessory? @State private var newName = "" var body: some View { NavigationStack { Group { if !manager.isSupported { ContentUnavailableView( "지원되지 않음", systemImage: "antenna.radiowaves.left.and.right.slash", description: Text("이 기기에서는 AccessorySetupKit을 사용할 수 없습니다") ) } else if !manager.isSessionActive { VStack(spacing: 20) { ProgressView() Text("세션 활성화 중...") } } else if manager.accessories.isEmpty { ContentUnavailableView( "연결된 액세서리 없음", systemImage: "antenna.radiowaves.left.and.right", description: Text("새 액세서리를 추가하세요") ) } else { List { ForEach(manager.accessories, id: \.displayName) { accessory in AccessoryRow(accessory: accessory) .contextMenu { Button { accessoryToRename = accessory newName = accessory.displayName showingRenameSheet = true } label: { Label("이름 변경", systemImage: "pencil") } Button(role: .destructive) { manager.removeAccessory(accessory) } label: { Label("제거", systemImage: "trash") } } } } } } .navigationTitle("액세서리") .toolbar { ToolbarItem(placement: .primaryAction) { Button { manager.showAccessoryPicker() } label: { Image(systemName: "plus") } .disabled(!manager.isSessionActive) } } .alert("오류", isPresented: Binding( get: { manager.errorMessage != nil }, set: { if !$0 { manager.errorMessage = nil } } )) { Button("확인", role: .cancel) {} } message: { Text(manager.errorMessage ?? "") } .sheet(isPresented: $showingRenameSheet) { RenameSheet( name: $newName, onSave: { if let accessory = accessoryToRename { manager.renameAccessory(accessory, to: newName) } showingRenameSheet = false }, onCancel: { showingRenameSheet = false } ) } .task { manager.activateSession() } } } } // MARK: - Accessory Row struct AccessoryRow: View { let accessory: ASAccessory var body: some View { HStack(spacing: 16) { // 아이콘 Image(systemName: iconForAccessory) .font(.title2) .foregroundStyle(.blue) .frame(width: 44, height: 44) .background(.blue.opacity(0.1), in: Circle()) // 정보 VStack(alignment: .leading, spacing: 4) { Text(accessory.displayName) .font(.headline) HStack { Circle() .fill(stateColor) .frame(width: 8, height: 8) Text(stateText) .font(.caption) .foregroundStyle(.secondary) } } Spacer() // 연결 타입 표시 if accessory.bluetoothIdentifier != nil { Image(systemName: "antenna.radiowaves.left.and.right") .foregroundStyle(.secondary) } } .padding(.vertical, 4) } var iconForAccessory: String { // 액세서리 타입에 따른 아이콘 if accessory.displayName.lowercased().contains("sensor") { return "sensor.fill" } else if accessory.displayName.lowercased().contains("band") { return "figure.run" } else { return "antenna.radiowaves.left.and.right" } } var stateColor: Color { switch accessory.state { case .connected: return .green case .connecting: return .orange case .disconnected: return .red @unknown default: return .gray } } var stateText: String { switch accessory.state { case .connected: return "연결됨" case .connecting: return "연결 중..." case .disconnected: return "연결 안 됨" @unknown default: return "알 수 없음" } } } // MARK: - Rename Sheet struct RenameSheet: View { @Binding var name: String let onSave: () -> Void let onCancel: () -> Void var body: some View { NavigationStack { Form { TextField("액세서리 이름", text: $name) } .navigationTitle("이름 변경") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소", action: onCancel) } ToolbarItem(placement: .confirmationAction) { Button("저장", action: onSave) .disabled(name.isEmpty) } } } .presentationDetents([.medium]) } } #Preview { AccessorySetupView() } ``` ## 고급 패턴 ### 1. 액세서리 마이그레이션 (CoreBluetooth → AccessorySetupKit) ```swift import CoreBluetooth import AccessorySetupKit class AccessoryMigrationManager { let session = ASAccessorySession() func migrateExistingAccessory(peripheral: CBPeripheral) { // 기존 CoreBluetooth 페어링을 AccessorySetupKit으로 마이그레이션 let migrationItem = ASMigrationDisplayItem( name: peripheral.name ?? "Unknown Device", productImage: UIImage(systemName: "antenna.radiowaves.left.and.right")!, descriptor: ASDiscoveryDescriptor( bluetoothServiceUUID: CBUUID(string: "180A") ) ) session.showPicker(for: [migrationItem]) { error in if let error = error { print("마이그레이션 실패: \(error)") } } } } ``` ### 2. Wi-Fi 액세서리 설정 ```swift func setupWiFiAccessory() { let wifiItem = ASPickerDisplayItem( name: "Smart Home Hub", productImage: UIImage(named: "hub")!, descriptor: ASDiscoveryDescriptor( ssid: ASDiscoveryDescriptor.ssidPrefix("SmartHub-"), supportedOptions: .ssidPrefix ) ) // Wi-Fi 자격 증명 설정 (선택적) wifiItem.setupAssistant = { accessory, completion in // 사용자에게 Wi-Fi 비밀번호 입력 요청 // 또는 프로비저닝 프로토콜 실행 completion(.success) } session.showPicker(for: [wifiItem]) { error in // 처리 } } ``` ### 3. Matter 디바이스 통합 ```swift import HomeKit import AccessorySetupKit class MatterSetupManager { let session = ASAccessorySession() let homeManager = HMHomeManager() func setupMatterDevice() { // Matter 프로토콜 지원 액세서리 let matterItem = ASPickerDisplayItem( name: "Matter Smart Light", productImage: UIImage(systemName: "lightbulb.fill")!, descriptor: ASDiscoveryDescriptor( bluetoothServiceUUID: CBUUID(string: "FFF6") // Matter BLE Service ) ) session.showPicker(for: [matterItem]) { [weak self] error in if error == nil { // HomeKit에 추가 self?.addToHomeKit() } } } private func addToHomeKit() { // HomeKit 통합 로직 } } ``` ### 4. 액세서리 펌웨어 업데이트 ```swift func checkForFirmwareUpdate(accessory: ASAccessory) async { // CoreBluetooth를 통해 펌웨어 버전 확인 guard let identifier = accessory.bluetoothIdentifier else { return } // 펌웨어 업데이트 가능 여부 확인 let currentVersion = await fetchFirmwareVersion(identifier) let latestVersion = await fetchLatestVersion() if latestVersion > currentVersion { // 업데이트 UI 표시 await showFirmwareUpdateUI(accessory: accessory, version: latestVersion) } } ``` ## 주의사항 1. **iOS 버전** - AccessorySetupKit: iOS 18+ 필요 - 이전 버전은 CoreBluetooth 사용 2. **개인정보 문자열** - Bluetooth, 로컬 네트워크 권한 설명 필수 - 누락 시 앱 거부 3. **시스템 UI** - 피커는 시스템 제공 UI 사용 - 커스터마이징 제한적 4. **백그라운드 제한** - 피커는 포그라운드에서만 동작 - 연결된 액세서리와의 통신은 백그라운드 가능 5. **시뮬레이터** - Bluetooth 기능 미지원 - 실기기 테스트 필수 --- # ActivityKit AI Reference > Live Activity와 Dynamic Island 구현 가이드. 이 문서를 읽고 Live Activity를 생성할 수 있습니다. ## 개요 ActivityKit은 잠금 화면과 Dynamic Island에 실시간 진행 상황을 표시하는 Live Activity를 만드는 프레임워크입니다. 배달 추적, 스포츠 경기, 타이머 등 **진행 중인 작업**에 적합합니다. ## 필수 Import ```swift import ActivityKit import WidgetKit import SwiftUI ``` ## 핵심 구성요소 ### 1. ActivityAttributes (데이터 모델) ```swift struct DeliveryAttributes: ActivityAttributes { // 정적 데이터 (Activity 생성 시 설정, 변경 불가) let orderNumber: String let restaurantName: String // 동적 데이터 (업데이트 가능) struct ContentState: Codable, Hashable { let status: DeliveryStatus let estimatedArrival: Date let driverName: String? } } enum DeliveryStatus: String, Codable { case ordered = "주문 완료" case preparing = "준비 중" case pickedUp = "픽업 완료" case delivering = "배달 중" case delivered = "배달 완료" } ``` ### 2. Live Activity Widget ```swift struct DeliveryLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in // 잠금 화면 뷰 LockScreenView(context: context) } dynamicIsland: { context in // Dynamic Island 뷰 DynamicIsland { // Expanded (길게 누름) DynamicIslandExpandedRegion(.leading) { Image(systemName: "bicycle") } DynamicIslandExpandedRegion(.trailing) { Text(context.state.estimatedArrival, style: .timer) } DynamicIslandExpandedRegion(.center) { Text(context.state.status.rawValue) } DynamicIslandExpandedRegion(.bottom) { ProgressView(value: 0.7) } } compactLeading: { // Compact 좌측 Image(systemName: "bicycle") } compactTrailing: { // Compact 우측 Text(context.state.estimatedArrival, style: .timer) } minimal: { // Minimal (다른 Activity와 함께 표시) Image(systemName: "bicycle") } } } } ``` ### 3. 잠금 화면 뷰 ```swift struct LockScreenView: View { let context: ActivityViewContext var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: "bicycle") .foregroundStyle(.blue) Text(context.attributes.restaurantName) .font(.headline) Spacer() Text(context.state.estimatedArrival, style: .timer) .font(.title2.monospacedDigit()) } Text(context.state.status.rawValue) .font(.subheadline) .foregroundStyle(.secondary) ProgressView(value: progressValue) .tint(.blue) } .padding() .activityBackgroundTint(.black.opacity(0.8)) } var progressValue: Double { switch context.state.status { case .ordered: return 0.2 case .preparing: return 0.4 case .pickedUp: return 0.6 case .delivering: return 0.8 case .delivered: return 1.0 } } } ``` ## 전체 작동 예제 ```swift import ActivityKit import SwiftUI // MARK: - Attributes struct DeliveryAttributes: ActivityAttributes { let orderNumber: String let restaurantName: String struct ContentState: Codable, Hashable { let status: String let remainingMinutes: Int } } // MARK: - Live Activity 시작 func startDeliveryActivity() { // 지원 여부 확인 guard ActivityAuthorizationInfo().areActivitiesEnabled else { print("Live Activity 비활성화됨") return } let attributes = DeliveryAttributes( orderNumber: "12345", restaurantName: "맛있는 피자" ) let initialState = DeliveryAttributes.ContentState( status: "주문 완료", remainingMinutes: 30 ) let content = ActivityContent( state: initialState, staleDate: Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ) do { let activity = try Activity.request( attributes: attributes, content: content, pushType: nil // 푸시 업데이트 시 .token ) print("Activity 시작: \(activity.id)") } catch { print("Activity 시작 실패: \(error)") } } // MARK: - Live Activity 업데이트 func updateDeliveryActivity(activity: Activity, newStatus: String, minutes: Int) async { let newState = DeliveryAttributes.ContentState( status: newStatus, remainingMinutes: minutes ) let content = ActivityContent(state: newState, staleDate: nil) await activity.update(content) } // MARK: - Live Activity 종료 func endDeliveryActivity(activity: Activity) async { let finalState = DeliveryAttributes.ContentState( status: "배달 완료", remainingMinutes: 0 ) let content = ActivityContent(state: finalState, staleDate: nil) await activity.end( content, dismissalPolicy: .default // 즉시 사라짐. .after(Date()) 사용 가능 ) } // MARK: - 모든 Activity 조회 func getAllActivities() -> [Activity] { return Activity.activities } ``` ## Dynamic Island 레이아웃 ### Compact (기본 상태) ```swift compactLeading: { // 좌측: 아이콘 Image(systemName: "bicycle") .foregroundStyle(.blue) } compactTrailing: { // 우측: 핵심 정보 Text("12분") .font(.caption.monospacedDigit()) } ``` ### Minimal (다른 Activity와 공유) ```swift minimal: { // 작은 원형 영역 Image(systemName: "bicycle") .foregroundStyle(.blue) } ``` ### Expanded (길게 누름) ```swift DynamicIsland { DynamicIslandExpandedRegion(.leading) { VStack(alignment: .leading) { Image(systemName: "bicycle") .font(.title) Text("배달 중") .font(.caption) } } DynamicIslandExpandedRegion(.trailing) { VStack(alignment: .trailing) { Text("12분") .font(.title2) Text("도착 예정") .font(.caption) } } DynamicIslandExpandedRegion(.center) { Text("맛있는 피자") .font(.headline) } DynamicIslandExpandedRegion(.bottom) { // 진행률 바, 버튼 등 ProgressView(value: 0.7) // 인터랙티브 버튼 (iOS 17+) Button(intent: CallDriverIntent()) { Label("전화하기", systemImage: "phone.fill") } } } ``` ## Info.plist 설정 ```xml NSSupportsLiveActivities NSSupportsLiveActivitiesFrequentUpdates ``` ## 푸시 업데이트 ```swift // Activity 시작 시 푸시 토큰 요청 let activity = try Activity.request( attributes: attributes, content: content, pushType: .token ) // 토큰 받기 for await tokenData in activity.pushTokenUpdates { let token = tokenData.map { String(format: "%02x", $0) }.joined() // 서버에 토큰 전송 } ``` ## 주의사항 1. **시간 제한**: 최대 8시간 활성, 종료 후 4시간 유지 2. **Widget Extension 필요**: Live Activity는 Widget Extension에 구현 3. **Dynamic Island**: iPhone 14 Pro 이상만 지원 (잠금 화면은 모든 기기) 4. **업데이트 빈도**: 시스템이 throttle 할 수 있음 5. **백그라운드**: 앱이 백그라운드여도 푸시로 업데이트 가능 ## 파일 구조 ``` MyApp/ ├── MyApp/ │ ├── MyApp.swift │ └── ActivityManager.swift # Activity 관리 로직 └── MyWidgetExtension/ ├── MyLiveActivity.swift # Live Activity Widget └── DeliveryAttributes.swift # 공유 모델 (앱과 공유) ``` --- # AlarmKit AI Reference > 알람 시계 앱 구현 가이드. 이 문서를 읽고 AlarmKit 코드를 생성할 수 있습니다. ## 개요 AlarmKit은 iOS 18+에서 제공하는 알람 앱 개발 프레임워크입니다. 시스템 알람 앱과 동일한 신뢰성 있는 알람 기능을 제공하며, 배터리 최적화 상태에서도 정확한 시간에 알람이 울립니다. ## 필수 Import ```swift import AlarmKit ``` ## 프로젝트 설정 ### 1. Capability 추가 Xcode > Signing & Capabilities > Background Modes > Background processing ### 2. Info.plist ```xml NSAlarmUsageDescription 지정한 시간에 알람을 울리기 위해 필요합니다. ``` ## 핵심 구성요소 ### 1. AlarmManager ```swift import AlarmKit // 알람 매니저 인스턴스 let alarmManager = AlarmManager.shared // 권한 요청 func requestPermission() async -> Bool { await alarmManager.requestAuthorization() } // 권한 상태 확인 let status = alarmManager.authorizationStatus ``` ### 2. Alarm (알람 생성) ```swift // 단일 알람 let alarm = Alarm( id: UUID(), time: DateComponents(hour: 7, minute: 30), label: "기상 알람", sound: .default, isEnabled: true ) // 반복 알람 let weekdayAlarm = Alarm( id: UUID(), time: DateComponents(hour: 7, minute: 0), label: "출근 알람", sound: .custom(named: "morning"), repeatDays: [.monday, .tuesday, .wednesday, .thursday, .friday], isEnabled: true ) ``` ### 3. AlarmSound (알람 소리) ```swift // 기본 소리 AlarmSound.default // 시스템 소리 AlarmSound.system(.radar) AlarmSound.system(.beacon) // 커스텀 소리 (번들에 포함된 오디오 파일) AlarmSound.custom(named: "rooster") ``` ## 전체 작동 예제 ```swift import SwiftUI import AlarmKit // MARK: - Alarm Model (앱 내부용) struct AlarmItem: Identifiable, Codable { let id: UUID var hour: Int var minute: Int var label: String var isEnabled: Bool var repeatDays: Set var soundName: String var timeString: String { String(format: "%02d:%02d", hour, minute) } var repeatDescription: String { if repeatDays.isEmpty { return "반복 안 함" } else if repeatDays.count == 7 { return "매일" } else if repeatDays == [.monday, .tuesday, .wednesday, .thursday, .friday] { return "주중" } else if repeatDays == [.saturday, .sunday] { return "주말" } else { return repeatDays.sorted(by: { $0.rawValue < $1.rawValue }) .map { $0.shortName } .joined(separator: ", ") } } } enum Weekday: Int, Codable, CaseIterable { case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday var shortName: String { switch self { case .sunday: return "일" case .monday: return "월" case .tuesday: return "화" case .wednesday: return "수" case .thursday: return "목" case .friday: return "금" case .saturday: return "토" } } } // MARK: - Alarm Manager @Observable class AlarmViewModel { var alarms: [AlarmItem] = [] var isAuthorized = false var showingAddAlarm = false private let alarmManager = AlarmManager.shared private let userDefaults = UserDefaults.standard private let alarmsKey = "savedAlarms" init() { loadAlarms() checkAuthorization() } func checkAuthorization() { isAuthorized = alarmManager.authorizationStatus == .authorized } func requestAuthorization() async { isAuthorized = await alarmManager.requestAuthorization() } // MARK: - CRUD func addAlarm(_ alarm: AlarmItem) { alarms.append(alarm) if alarm.isEnabled { scheduleAlarm(alarm) } saveAlarms() } func updateAlarm(_ alarm: AlarmItem) { guard let index = alarms.firstIndex(where: { $0.id == alarm.id }) else { return } // 기존 알람 취소 cancelAlarm(alarms[index]) // 새 알람 설정 alarms[index] = alarm if alarm.isEnabled { scheduleAlarm(alarm) } saveAlarms() } func deleteAlarm(_ alarm: AlarmItem) { cancelAlarm(alarm) alarms.removeAll { $0.id == alarm.id } saveAlarms() } func toggleAlarm(_ alarm: AlarmItem) { guard let index = alarms.firstIndex(where: { $0.id == alarm.id }) else { return } alarms[index].isEnabled.toggle() if alarms[index].isEnabled { scheduleAlarm(alarms[index]) } else { cancelAlarm(alarms[index]) } saveAlarms() } // MARK: - AlarmKit 연동 private func scheduleAlarm(_ alarm: AlarmItem) { Task { do { let alarmKitAlarm = Alarm( id: alarm.id, time: DateComponents(hour: alarm.hour, minute: alarm.minute), label: alarm.label, sound: alarm.soundName == "default" ? .default : .custom(named: alarm.soundName), repeatDays: Set(alarm.repeatDays.map { AlarmRepeatDay(rawValue: $0.rawValue)! }), isEnabled: true ) try await alarmManager.schedule(alarmKitAlarm) } catch { print("알람 예약 실패: \(error)") } } } private func cancelAlarm(_ alarm: AlarmItem) { Task { try? await alarmManager.cancel(alarmWithId: alarm.id) } } // MARK: - Persistence private func saveAlarms() { if let data = try? JSONEncoder().encode(alarms) { userDefaults.set(data, forKey: alarmsKey) } } private func loadAlarms() { if let data = userDefaults.data(forKey: alarmsKey), let saved = try? JSONDecoder().decode([AlarmItem].self, from: data) { alarms = saved } } } // MARK: - Main View struct AlarmListView: View { @State private var viewModel = AlarmViewModel() var body: some View { NavigationStack { Group { if !viewModel.isAuthorized { ContentUnavailableView( "알람 권한 필요", systemImage: "alarm.fill", description: Text("알람을 설정하려면 권한이 필요합니다") ) .overlay(alignment: .bottom) { Button("권한 허용") { Task { await viewModel.requestAuthorization() } } .buttonStyle(.borderedProminent) .padding(.bottom, 50) } } else if viewModel.alarms.isEmpty { ContentUnavailableView( "알람 없음", systemImage: "alarm", description: Text("새 알람을 추가하세요") ) } else { List { ForEach(viewModel.alarms) { alarm in AlarmRow(alarm: alarm, viewModel: viewModel) } .onDelete { indexSet in for index in indexSet { viewModel.deleteAlarm(viewModel.alarms[index]) } } } } } .navigationTitle("알람") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { viewModel.showingAddAlarm = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $viewModel.showingAddAlarm) { AddAlarmView(viewModel: viewModel) } } } } // MARK: - Alarm Row struct AlarmRow: View { let alarm: AlarmItem let viewModel: AlarmViewModel var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { Text(alarm.timeString) .font(.system(size: 48, weight: .light, design: .rounded)) .foregroundStyle(alarm.isEnabled ? .primary : .secondary) HStack { Text(alarm.label) if !alarm.repeatDays.isEmpty { Text("• \(alarm.repeatDescription)") } } .font(.subheadline) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { alarm.isEnabled }, set: { _ in viewModel.toggleAlarm(alarm) } )) .labelsHidden() } .padding(.vertical, 4) } } // MARK: - Add Alarm View struct AddAlarmView: View { let viewModel: AlarmViewModel @Environment(\.dismiss) private var dismiss @State private var selectedTime = Date() @State private var label = "알람" @State private var repeatDays: Set = [] @State private var selectedSound = "default" let sounds = ["default", "radar", "beacon", "chimes", "signal"] var body: some View { NavigationStack { Form { DatePicker("시간", selection: $selectedTime, displayedComponents: .hourAndMinute) .datePickerStyle(.wheel) .labelsHidden() Section { TextField("라벨", text: $label) NavigationLink { RepeatDayPicker(selectedDays: $repeatDays) } label: { HStack { Text("반복") Spacer() Text(repeatDescription) .foregroundStyle(.secondary) } } Picker("소리", selection: $selectedSound) { ForEach(sounds, id: \.self) { sound in Text(sound.capitalized).tag(sound) } } } } .navigationTitle("알람 추가") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("저장") { let components = Calendar.current.dateComponents([.hour, .minute], from: selectedTime) let newAlarm = AlarmItem( id: UUID(), hour: components.hour ?? 7, minute: components.minute ?? 0, label: label, isEnabled: true, repeatDays: repeatDays, soundName: selectedSound ) viewModel.addAlarm(newAlarm) dismiss() } } } } } var repeatDescription: String { if repeatDays.isEmpty { return "안 함" } if repeatDays.count == 7 { return "매일" } return repeatDays.sorted { $0.rawValue < $1.rawValue } .map { $0.shortName } .joined(separator: " ") } } // MARK: - Repeat Day Picker struct RepeatDayPicker: View { @Binding var selectedDays: Set var body: some View { List { ForEach(Weekday.allCases, id: \.self) { day in HStack { Text(dayName(day)) Spacer() if selectedDays.contains(day) { Image(systemName: "checkmark") .foregroundStyle(.blue) } } .contentShape(Rectangle()) .onTapGesture { if selectedDays.contains(day) { selectedDays.remove(day) } else { selectedDays.insert(day) } } } } .navigationTitle("반복") } func dayName(_ day: Weekday) -> String { switch day { case .sunday: return "일요일마다" case .monday: return "월요일마다" case .tuesday: return "화요일마다" case .wednesday: return "수요일마다" case .thursday: return "목요일마다" case .friday: return "금요일마다" case .saturday: return "토요일마다" } } } #Preview { AlarmListView() } ``` ## 고급 패턴 ### 1. 스누즈 처리 ```swift // 알람 응답 처리 func handleAlarmResponse(_ response: AlarmResponse) { switch response.action { case .dismiss: // 알람 종료 break case .snooze: // 스누즈 - 9분 후 다시 알람 scheduleSnoozeAlarm(originalAlarm: response.alarm) } } func scheduleSnoozeAlarm(originalAlarm: Alarm) { let snoozeTime = Calendar.current.date(byAdding: .minute, value: 9, to: Date())! let components = Calendar.current.dateComponents([.hour, .minute], from: snoozeTime) let snoozeAlarm = Alarm( id: UUID(), time: components, label: "\(originalAlarm.label) (스누즈)", sound: originalAlarm.sound, isEnabled: true ) Task { try? await alarmManager.schedule(snoozeAlarm) } } ``` ### 2. 다음 알람 시간 계산 ```swift func nextAlarmTime(for alarm: AlarmItem) -> Date? { let calendar = Calendar.current var components = DateComponents() components.hour = alarm.hour components.minute = alarm.minute let now = Date() if alarm.repeatDays.isEmpty { // 단일 알람 var nextDate = calendar.nextDate(after: now, matching: components, matchingPolicy: .nextTime)! if nextDate <= now { nextDate = calendar.date(byAdding: .day, value: 1, to: nextDate)! } return nextDate } else { // 반복 알람 var nextDates: [Date] = [] for day in alarm.repeatDays { components.weekday = day.rawValue if let date = calendar.nextDate(after: now, matching: components, matchingPolicy: .nextTime) { nextDates.append(date) } } return nextDates.min() } } ``` ### 3. 위젯 연동 ```swift import WidgetKit struct AlarmWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "AlarmWidget", provider: AlarmTimelineProvider()) { entry in AlarmWidgetView(entry: entry) } .configurationDisplayName("다음 알람") .description("다음 알람 시간을 표시합니다") .supportedFamilies([.systemSmall]) } } struct AlarmTimelineProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = AlarmEntry(date: Date(), nextAlarm: getNextAlarm()) let timeline = Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(60))) completion(timeline) } } ``` ## 주의사항 1. **iOS 버전** - AlarmKit: iOS 18+ 필요 - 이전 버전은 UNNotificationRequest 사용 2. **권한** - 알람 권한 별도 요청 필요 - 알림 권한과 다름 3. **배터리 최적화** - AlarmKit 알람은 배터리 절약 모드에서도 동작 - 일반 알림보다 높은 우선순위 4. **시뮬레이터** - 알람 기능 제한적 - 실기기 테스트 권장 5. **포그라운드 제한** - 앱이 포그라운드일 때도 시스템 알람 UI 표시 --- # App Intents AI Reference > Siri, 단축어, 위젯과 앱을 통합하는 가이드. 이 문서를 읽고 App Intents 코드를 생성할 수 있습니다. ## 개요 App Intents는 Siri, 단축어 앱, Spotlight와 앱 기능을 연결하는 프레임워크입니다. 사용자가 음성 명령이나 단축어로 앱 기능을 실행할 수 있게 합니다. ## 필수 Import ```swift import AppIntents ``` ## 핵심 구성요소 ### 1. AppIntent 프로토콜 (기본 Intent) ```swift struct AddTaskIntent: AppIntent { static var title: LocalizedStringResource = "할 일 추가" static var description = IntentDescription("새로운 할 일을 추가합니다") @Parameter(title: "할 일 제목") var taskTitle: String @Parameter(title: "우선순위", default: .medium) var priority: TaskPriority func perform() async throws -> some IntentResult { let task = TaskManager.shared.addTask(title: taskTitle, priority: priority) return .result(value: task.title, dialog: "\(taskTitle) 추가됨") } } ``` ### 2. AppEnum (열거형 파라미터) ```swift enum TaskPriority: String, AppEnum { case low, medium, high static var typeDisplayRepresentation: TypeDisplayRepresentation = "우선순위" static var caseDisplayRepresentations: [TaskPriority: DisplayRepresentation] = [ .low: "낮음", .medium: "보통", .high: "높음" ] } ``` ### 3. AppEntity (커스텀 엔티티) ```swift struct TaskEntity: AppEntity { var id: UUID var title: String var isCompleted: Bool static var typeDisplayRepresentation: TypeDisplayRepresentation = "할 일" var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(title)") } static var defaultQuery = TaskQuery() } struct TaskQuery: EntityQuery { func entities(for identifiers: [UUID]) async throws -> [TaskEntity] { TaskManager.shared.tasks(for: identifiers).map { $0.toEntity() } } func suggestedEntities() async throws -> [TaskEntity] { TaskManager.shared.recentTasks.map { $0.toEntity() } } } ``` ### 4. AppShortcutsProvider (Siri 자동 등록) ```swift struct MyAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: AddTaskIntent(), phrases: [ "할 일 추가해줘 \(.applicationName)", "\(.applicationName)에 \(\.$taskTitle) 추가" ], shortTitle: "할 일 추가", systemImageName: "plus.circle" ) } } ``` ## 전체 작동 예제 ```swift import SwiftUI import AppIntents // MARK: - Intent 정의 struct CompleteTaskIntent: AppIntent { static var title: LocalizedStringResource = "할 일 완료" static var description = IntentDescription("할 일을 완료 처리합니다") @Parameter(title: "할 일") var task: TaskEntity static var parameterSummary: some ParameterSummary { Summary("'\(\.$task)' 완료하기") } func perform() async throws -> some IntentResult & ReturnsValue { await TaskManager.shared.complete(task.id) return .result(value: true, dialog: "\(task.title) 완료!") } } // MARK: - 위젯 Intent 연동 (iOS 17+) struct TaskToggleIntent: AppIntent { static var title: LocalizedStringResource = "할 일 토글" @Parameter(title: "Task ID") var taskId: String init() {} init(taskId: String) { self.taskId = taskId } func perform() async throws -> some IntentResult { await TaskManager.shared.toggle(UUID(uuidString: taskId)!) return .result() } } // 위젯에서 사용 struct TaskWidgetView: View { let task: TaskItem var body: some View { Button(intent: TaskToggleIntent(taskId: task.id.uuidString)) { HStack { Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") Text(task.title) } } } } ``` ## 고급 패턴 ### 1. 결과 반환 타입들 ```swift // 단순 완료 func perform() async throws -> some IntentResult { return .result() } // 값 반환 func perform() async throws -> some IntentResult & ReturnsValue { return .result(value: "결과값") } // 대화 응답 func perform() async throws -> some IntentResult & ProvidesDialog { return .result(dialog: "완료되었습니다") } // 앱 열기 func perform() async throws -> some IntentResult & OpensIntent { return .result(opensIntent: OpenTaskDetailIntent(taskId: id)) } ``` ### 2. 동적 옵션 제공 ```swift struct SelectTaskIntent: AppIntent { @Parameter(title: "할 일") var task: TaskEntity? static var parameterSummary: some ParameterSummary { Summary("'\(\.$task)' 선택") } } // EntityQuery에 검색 기능 추가 struct TaskQuery: EntityStringQuery { func entities(matching string: String) async throws -> [TaskEntity] { TaskManager.shared.search(string).map { $0.toEntity() } } } ``` ### 3. Focus Filter (집중 모드 연동) ```swift struct WorkFocusFilter: SetFocusFilterIntent { static var title: LocalizedStringResource = "업무 모드" @Parameter(title: "업무 프로젝트만 표시") var showWorkOnly: Bool var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: showWorkOnly ? "업무만" : "전체") } func perform() async throws -> some IntentResult { AppState.shared.workModeEnabled = showWorkOnly return .result() } } ``` ## 주의사항 1. **Siri 구문 규칙** - `\(.applicationName)` 필수 포함 - 자연스러운 한국어 구문 사용 - 파라미터는 `\(\.$paramName)` 형식 2. **위젯 Intent (iOS 17+)** - `Button(intent:)` 사용 - 앱 실행 없이 바로 실행됨 - Interactive Widget 활성화 필요 3. **백그라운드 실행** - Intent는 백그라운드에서 실행됨 - UI 업데이트는 메인 스레드에서 - 긴 작업은 피하기 (30초 제한) 4. **테스트** - 단축어 앱에서 직접 테스트 - Siri: "Hey Siri, [앱이름]으로 [구문]" --- # ARKit AI Reference > 증강현실 앱 구현 가이드. 이 문서를 읽고 ARKit 코드를 생성할 수 있습니다. ## 개요 ARKit은 iOS 기기의 카메라와 센서를 활용해 증강현실 경험을 만드는 프레임워크입니다. 평면 감지, 이미지 추적, 얼굴 추적, 물체 배치 등을 지원합니다. ## 필수 Import ```swift import ARKit import RealityKit // 3D 렌더링 (권장) // 또는 import SceneKit // 레거시 3D 렌더링 ``` ## 프로젝트 설정 ```xml NSCameraUsageDescription AR 경험을 위해 카메라 접근이 필요합니다. UIRequiredDeviceCapabilities arkit ``` ## 핵심 구성요소 ### 1. ARView (RealityKit) ```swift import SwiftUI import RealityKit import ARKit struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // 평면 감지 설정 let config = ARWorldTrackingConfiguration() config.planeDetection = [.horizontal, .vertical] config.environmentTexturing = .automatic arView.session.run(config) return arView } func updateUIView(_ uiView: ARView, context: Context) {} } ``` ### 2. AR 세션 설정 종류 ```swift // 월드 트래킹 (가장 일반적) let worldConfig = ARWorldTrackingConfiguration() worldConfig.planeDetection = [.horizontal, .vertical] worldConfig.sceneReconstruction = .mesh // LiDAR 기기만 // 얼굴 트래킹 (전면 카메라) let faceConfig = ARFaceTrackingConfiguration() // 이미지 트래킹 let imageConfig = ARImageTrackingConfiguration() imageConfig.trackingImages = referenceImages // AR Resource Group // 바디 트래킹 let bodyConfig = ARBodyTrackingConfiguration() ``` ### 3. 3D 객체 배치 ```swift func placeObject(at position: SIMD3, in arView: ARView) { // 앵커 생성 let anchor = AnchorEntity(world: position) // 3D 모델 로드 if let model = try? Entity.loadModel(named: "toy_robot") { model.scale = SIMD3(repeating: 0.01) anchor.addChild(model) } // 또는 기본 도형 let box = ModelEntity( mesh: .generateBox(size: 0.1), materials: [SimpleMaterial(color: .blue, isMetallic: true)] ) anchor.addChild(box) arView.scene.addAnchor(anchor) } ``` ## 전체 작동 예제 ```swift import SwiftUI import RealityKit import ARKit // MARK: - AR View Container struct ARFurnitureView: UIViewRepresentable { @Binding var selectedModel: String? func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // AR 설정 let config = ARWorldTrackingConfiguration() config.planeDetection = [.horizontal] config.environmentTexturing = .automatic arView.session.run(config) arView.session.delegate = context.coordinator // 탭 제스처 let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) arView.addGestureRecognizer(tapGesture) context.coordinator.arView = arView // 코칭 오버레이 추가 let coachingOverlay = ARCoachingOverlayView() coachingOverlay.session = arView.session coachingOverlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] coachingOverlay.goal = .horizontalPlane arView.addSubview(coachingOverlay) return arView } func updateUIView(_ uiView: ARView, context: Context) { context.coordinator.selectedModel = selectedModel } func makeCoordinator() -> Coordinator { Coordinator(selectedModel: $selectedModel) } class Coordinator: NSObject, ARSessionDelegate { var arView: ARView? var selectedModel: String? @Binding var selectedModelBinding: String? init(selectedModel: Binding) { _selectedModelBinding = selectedModel } @objc func handleTap(_ gesture: UITapGestureRecognizer) { guard let arView = arView, let modelName = selectedModel else { return } let location = gesture.location(in: arView) // 레이캐스트로 평면 찾기 if let result = arView.raycast(from: location, allowing: .estimatedPlane, alignment: .horizontal).first { placeFurniture(modelName: modelName, at: result.worldTransform, in: arView) } } func placeFurniture(modelName: String, at transform: simd_float4x4, in arView: ARView) { let position = SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z) let anchor = AnchorEntity(world: position) // 모델 로드 if let model = try? Entity.loadModel(named: modelName) { model.generateCollisionShapes(recursive: true) // 제스처 활성화 (이동, 회전) arView.installGestures([.translation, .rotation], for: model) anchor.addChild(model) arView.scene.addAnchor(anchor) // 배치 후 선택 해제 DispatchQueue.main.async { self.selectedModelBinding = nil } } } // 평면 감지 시각화 func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { for anchor in anchors { if let planeAnchor = anchor as? ARPlaneAnchor { visualizePlane(planeAnchor) } } } private func visualizePlane(_ anchor: ARPlaneAnchor) { guard let arView = arView else { return } let extent = anchor.planeExtent let plane = ModelEntity( mesh: .generatePlane(width: extent.width, depth: extent.height), materials: [SimpleMaterial(color: .blue.withAlphaComponent(0.3), isMetallic: false)] ) let anchorEntity = AnchorEntity(anchor: anchor) anchorEntity.addChild(plane) arView.scene.addAnchor(anchorEntity) } } } // MARK: - Main View struct ARFurnitureApp: View { @State private var selectedModel: String? let models = ["chair", "table", "lamp", "plant"] var body: some View { ZStack { ARFurnitureView(selectedModel: $selectedModel) .ignoresSafeArea() VStack { Spacer() // 가구 선택 UI ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(models, id: \.self) { model in Button { selectedModel = model } label: { VStack { Image(systemName: iconFor(model)) .font(.title) Text(model) .font(.caption) } .padding() .background(selectedModel == model ? Color.blue : Color.white) .foregroundStyle(selectedModel == model ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 12)) } } } .padding() } .background(.ultraThinMaterial) } } } func iconFor(_ model: String) -> String { switch model { case "chair": return "chair.fill" case "table": return "table.furniture.fill" case "lamp": return "lamp.desk.fill" case "plant": return "leaf.fill" default: return "cube.fill" } } } ``` ## 고급 패턴 ### 1. 이미지 추적 ```swift func setupImageTracking(for arView: ARView) { guard let referenceImages = ARReferenceImage.referenceImages(inGroupNamed: "AR Resources", bundle: nil) else { return } let config = ARImageTrackingConfiguration() config.trackingImages = referenceImages config.maximumNumberOfTrackedImages = 4 arView.session.run(config) } // 이미지 감지 시 처리 func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { for anchor in anchors { if let imageAnchor = anchor as? ARImageAnchor { let imageName = imageAnchor.referenceImage.name ?? "unknown" print("감지된 이미지: \(imageName)") // 이미지 위에 콘텐츠 배치 placeContent(on: imageAnchor) } } } ``` ### 2. 얼굴 추적 ```swift func setupFaceTracking(for arView: ARView) { guard ARFaceTrackingConfiguration.isSupported else { return } let config = ARFaceTrackingConfiguration() config.maximumNumberOfTrackedFaces = 1 arView.session.run(config) } // 얼굴 필터 적용 func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) { for anchor in anchors { if let faceAnchor = anchor as? ARFaceAnchor { // 표정 감지 let smile = faceAnchor.blendShapes[.mouthSmileLeft]?.floatValue ?? 0 let eyeBlink = faceAnchor.blendShapes[.eyeBlinkLeft]?.floatValue ?? 0 // 얼굴 메시 업데이트 updateFaceMesh(with: faceAnchor) } } } ``` ### 3. LiDAR 메시 스캔 (Pro 기기) ```swift func setupMeshScanning(for arView: ARView) { guard ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) else { return } let config = ARWorldTrackingConfiguration() config.sceneReconstruction = .meshWithClassification config.planeDetection = [.horizontal, .vertical] arView.session.run(config) arView.debugOptions = [.showSceneUnderstanding] } ``` ## 주의사항 1. **기기 지원 확인** ```swift // AR 지원 확인 ARWorldTrackingConfiguration.isSupported // 얼굴 추적 (TrueDepth 카메라) ARFaceTrackingConfiguration.isSupported // LiDAR 메시 ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) ``` 2. **세션 관리** - 앱이 백그라운드 갈 때 `session.pause()` - 복귀 시 `session.run(config, options: .resetTracking)` 3. **성능 최적화** - 복잡한 3D 모델은 LOD(Level of Detail) 사용 - 앵커가 너무 많으면 성능 저하 - `environmentTexturing = .automatic` 활용 4. **사용자 경험** - `ARCoachingOverlayView`로 가이드 제공 - 평면 감지 전 안내 메시지 - 조명 부족 시 경고 --- # Authentication Services AI Reference > Sign in with Apple 및 패스키 구현 가이드. 이 문서를 읽고 인증 코드를 생성할 수 있습니다. ## 개요 Authentication Services는 Sign in with Apple, 패스키(Passkeys), 자동 완성 비밀번호를 관리하는 프레임워크입니다. ## 필수 Import ```swift import AuthenticationServices ``` ## 프로젝트 설정 1. **Capabilities**: Sign in with Apple 추가 2. **App ID**: Apple Developer에서 Sign in with Apple 활성화 ## 핵심 구성요소 ### 1. Sign in with Apple 버튼 ```swift import SwiftUI import AuthenticationServices struct SignInView: View { var body: some View { SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.email, .fullName] } onCompletion: { result in switch result { case .success(let auth): handleAuthorization(auth) case .failure(let error): print("로그인 실패: \(error)") } } .signInWithAppleButtonStyle(.black) .frame(height: 50) } func handleAuthorization(_ authorization: ASAuthorization) { if let credential = authorization.credential as? ASAuthorizationAppleIDCredential { let userID = credential.user let email = credential.email let fullName = credential.fullName let identityToken = credential.identityToken // 서버로 전송하여 인증 print("User ID: \(userID)") } } } ``` ### 2. 버튼 스타일 ```swift // 검은색 배경 SignInWithAppleButton(.signIn) { ... } onCompletion: { ... } .signInWithAppleButtonStyle(.black) // 흰색 배경 .signInWithAppleButtonStyle(.white) // 테두리만 .signInWithAppleButtonStyle(.whiteOutline) // 버튼 타입 SignInWithAppleButton(.signIn) // "Sign in with Apple" SignInWithAppleButton(.signUp) // "Sign up with Apple" SignInWithAppleButton(.continue) // "Continue with Apple" ``` ## 전체 작동 예제 ```swift import SwiftUI import AuthenticationServices // MARK: - Auth Manager @Observable class AuthManager { var isAuthenticated = false var userID: String? var email: String? var fullName: PersonNameComponents? var error: Error? func handleSignIn(_ authorization: ASAuthorization) { guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { return } // 사용자 정보 저장 userID = credential.user email = credential.email // 첫 로그인 시에만 제공 fullName = credential.fullName // 첫 로그인 시에만 제공 // Keychain에 userID 저장 saveUserID(credential.user) // 서버 인증용 토큰 if let tokenData = credential.identityToken, let token = String(data: tokenData, encoding: .utf8) { // 서버로 토큰 전송하여 검증 authenticateWithServer(token: token, userID: credential.user) } isAuthenticated = true } func checkExistingCredential() { guard let userID = loadUserID() else { return } let provider = ASAuthorizationAppleIDProvider() provider.getCredentialState(forUserID: userID) { state, error in DispatchQueue.main.async { switch state { case .authorized: self.userID = userID self.isAuthenticated = true case .revoked, .notFound: self.signOut() default: break } } } } func signOut() { isAuthenticated = false userID = nil email = nil fullName = nil deleteUserID() } // MARK: - Keychain private func saveUserID(_ userID: String) { let data = userID.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "appleUserID", kSecValueData as String: data ] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) } private func loadUserID() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "appleUserID", kSecReturnData as String: true ] var result: AnyObject? SecItemCopyMatching(query as CFDictionary, &result) if let data = result as? Data { return String(data: data, encoding: .utf8) } return nil } private func deleteUserID() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "appleUserID" ] SecItemDelete(query as CFDictionary) } private func authenticateWithServer(token: String, userID: String) { // 서버 API 호출 // POST /auth/apple { identityToken: token, userID: userID } } } // MARK: - Views struct AuthView: View { @State private var authManager = AuthManager() var body: some View { NavigationStack { if authManager.isAuthenticated { ProfileView(authManager: authManager) } else { LoginView(authManager: authManager) } } .task { authManager.checkExistingCredential() } } } struct LoginView: View { let authManager: AuthManager var body: some View { VStack(spacing: 24) { Image(systemName: "person.crop.circle.badge.checkmark") .font(.system(size: 80)) .foregroundStyle(.blue) Text("환영합니다") .font(.largeTitle.bold()) Text("Apple 계정으로 간편하게 로그인하세요") .foregroundStyle(.secondary) .multilineTextAlignment(.center) Spacer() SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.email, .fullName] request.nonce = generateNonce() // 보안용 } onCompletion: { result in switch result { case .success(let authorization): authManager.handleSignIn(authorization) case .failure(let error): authManager.error = error } } .signInWithAppleButtonStyle(.black) .frame(height: 50) .padding(.horizontal, 40) } .padding() } func generateNonce() -> String { // 서버와 공유하는 임의 문자열 (CSRF 방지) let charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._" return String((0..<32).map { _ in charset.randomElement()! }) } } struct ProfileView: View { let authManager: AuthManager var body: some View { VStack(spacing: 20) { Image(systemName: "person.crop.circle.fill") .font(.system(size: 100)) .foregroundStyle(.blue) if let name = authManager.fullName { Text(PersonNameComponentsFormatter.localizedString(from: name, style: .default)) .font(.title2.bold()) } if let email = authManager.email { Text(email) .foregroundStyle(.secondary) } Text("ID: \(authManager.userID?.prefix(8) ?? "")...") .font(.caption) .foregroundStyle(.secondary) Spacer() Button("로그아웃", role: .destructive) { authManager.signOut() } .buttonStyle(.bordered) } .padding() .navigationTitle("프로필") } } ``` ## 고급 패턴 ### 1. 패스키 (Passkeys) ```swift class PasskeyManager: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { func signInWithPasskey(challenge: Data) { let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com") let request = provider.createCredentialAssertionRequest(challenge: challenge) let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self controller.presentationContextProvider = self controller.performRequests() } func registerPasskey(challenge: Data, userID: Data, userName: String) { let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com") let request = provider.createCredentialRegistrationRequest( challenge: challenge, name: userName, userID: userID ) let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self controller.presentationContextProvider = self controller.performRequests() } func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { // 패스키 로그인 성공 let signature = credential.signature let clientDataJSON = credential.rawClientDataJSON // 서버로 전송하여 검증 } if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { // 패스키 등록 성공 let attestationObject = credential.rawAttestationObject // 서버에 저장 } } func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } .first { $0.isKeyWindow }! } } ``` ### 2. 기존 로그인 + Apple 통합 ```swift func performExistingAccountSetup() { let appleProvider = ASAuthorizationAppleIDProvider() let appleRequest = appleProvider.createRequest() appleRequest.requestedScopes = [.email, .fullName] let passwordProvider = ASAuthorizationPasswordProvider() let passwordRequest = passwordProvider.createRequest() let controller = ASAuthorizationController(authorizationRequests: [appleRequest, passwordRequest]) controller.delegate = self controller.presentationContextProvider = self controller.performRequests() } ``` ### 3. 자격 증명 상태 모니터링 ```swift func observeCredentialState() { NotificationCenter.default.addObserver( forName: ASAuthorizationAppleIDProvider.credentialRevokedNotification, object: nil, queue: .main ) { _ in // 사용자가 Apple ID 설정에서 앱 연결 해제 // 로그아웃 처리 self.signOut() } } ``` ## 주의사항 1. **이메일/이름은 첫 로그인만** - `email`, `fullName`은 최초 로그인 시에만 제공 - 반드시 서버에 저장해야 함 - 재로그인 시 `nil` 2. **User ID 관리** - `credential.user`는 변하지 않는 고유 ID - Keychain에 안전하게 저장 - 앱 삭제 후 재설치해도 동일 3. **서버 검증 필수** - `identityToken`을 서버에서 검증 - Apple의 공개 키로 JWT 검증 - `nonce` 일치 확인 4. **Hide My Email** - 사용자가 이메일 숨김 선택 가능 - `xxx@privaterelay.appleid.com` 형태 - 릴레이로 실제 이메일로 전달됨 --- # AVFoundation AI Reference > 카메라, 오디오, 비디오 캡처 가이드. 이 문서를 읽고 AVFoundation 코드를 생성할 수 있습니다. ## 개요 AVFoundation은 미디어 캡처, 재생, 편집을 위한 프레임워크입니다. 카메라 앱, 비디오 녹화, 오디오 처리 등을 구현합니다. ## 필수 Import ```swift import AVFoundation import AVKit // 재생 UI ``` ## 프로젝트 설정 (Info.plist) ```xml NSCameraUsageDescription 사진/비디오 촬영을 위해 카메라 접근이 필요합니다. NSMicrophoneUsageDescription 비디오 녹화 시 오디오 녹음이 필요합니다. NSPhotoLibraryUsageDescription 촬영한 미디어를 저장하기 위해 필요합니다. ``` ## 핵심 구성요소 ### 1. 카메라 세션 설정 ```swift class CameraManager: NSObject { let captureSession = AVCaptureSession() private var videoOutput: AVCapturePhotoOutput? func setupCamera() { captureSession.beginConfiguration() captureSession.sessionPreset = .photo // 카메라 입력 guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input = try? AVCaptureDeviceInput(device: camera) else { return } if captureSession.canAddInput(input) { captureSession.addInput(input) } // 사진 출력 let output = AVCapturePhotoOutput() if captureSession.canAddOutput(output) { captureSession.addOutput(output) videoOutput = output } captureSession.commitConfiguration() } func startSession() { DispatchQueue.global(qos: .userInitiated).async { self.captureSession.startRunning() } } func stopSession() { captureSession.stopRunning() } } ``` ### 2. 권한 요청 ```swift func requestCameraPermission() async -> Bool { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: return true case .notDetermined: return await AVCaptureDevice.requestAccess(for: .video) case .denied, .restricted: return false @unknown default: return false } } ``` ## 전체 작동 예제 ### 카메라 앱 ```swift import SwiftUI import AVFoundation // MARK: - Camera Manager @Observable class CameraManager: NSObject { let captureSession = AVCaptureSession() var capturedImage: UIImage? var isSessionRunning = false var error: Error? private var photoOutput: AVCapturePhotoOutput? private var currentDevice: AVCaptureDevice? override init() { super.init() } func checkPermission() async -> Bool { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: return true case .notDetermined: return await AVCaptureDevice.requestAccess(for: .video) default: return false } } func setupSession() { captureSession.beginConfiguration() captureSession.sessionPreset = .photo // 카메라 선택 guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { error = CameraError.noCameraAvailable return } currentDevice = camera // 입력 추가 do { let input = try AVCaptureDeviceInput(device: camera) if captureSession.canAddInput(input) { captureSession.addInput(input) } } catch { self.error = error return } // 출력 추가 let output = AVCapturePhotoOutput() output.maxPhotoQualityPrioritization = .quality if captureSession.canAddOutput(output) { captureSession.addOutput(output) photoOutput = output } captureSession.commitConfiguration() } func startSession() { guard !captureSession.isRunning else { return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.captureSession.startRunning() DispatchQueue.main.async { self?.isSessionRunning = true } } } func stopSession() { guard captureSession.isRunning else { return } captureSession.stopRunning() isSessionRunning = false } func capturePhoto() { guard let output = photoOutput else { return } let settings = AVCapturePhotoSettings() settings.flashMode = .auto output.capturePhoto(with: settings, delegate: self) } func switchCamera() { guard let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput else { return } let newPosition: AVCaptureDevice.Position = currentInput.device.position == .back ? .front : .back guard let newDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition) else { return } captureSession.beginConfiguration() captureSession.removeInput(currentInput) if let newInput = try? AVCaptureDeviceInput(device: newDevice), captureSession.canAddInput(newInput) { captureSession.addInput(newInput) currentDevice = newDevice } captureSession.commitConfiguration() } func setZoom(_ factor: CGFloat) { guard let device = currentDevice else { return } do { try device.lockForConfiguration() device.videoZoomFactor = max(1.0, min(factor, device.activeFormat.videoMaxZoomFactor)) device.unlockForConfiguration() } catch { print("줌 설정 실패: \(error)") } } } extension CameraManager: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { if let error { self.error = error return } guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else { return } DispatchQueue.main.async { self.capturedImage = image } } } enum CameraError: Error { case noCameraAvailable case permissionDenied } // MARK: - Camera Preview struct CameraPreview: UIViewRepresentable { let session: AVCaptureSession func makeUIView(context: Context) -> UIView { let view = UIView(frame: .zero) let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) DispatchQueue.main.async { previewLayer.frame = view.bounds } return view } func updateUIView(_ uiView: UIView, context: Context) { if let layer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer { layer.frame = uiView.bounds } } } // MARK: - View struct CameraView: View { @State private var camera = CameraManager() @State private var zoomFactor: CGFloat = 1.0 var body: some View { ZStack { // 카메라 프리뷰 CameraPreview(session: camera.captureSession) .ignoresSafeArea() .gesture( MagnificationGesture() .onChanged { value in zoomFactor = value camera.setZoom(value) } ) // 컨트롤 VStack { Spacer() HStack(spacing: 60) { // 카메라 전환 Button { camera.switchCamera() } label: { Image(systemName: "camera.rotate") .font(.title) .foregroundStyle(.white) } // 촬영 버튼 Button { camera.capturePhoto() } label: { Circle() .stroke(.white, lineWidth: 4) .frame(width: 70, height: 70) } // 플래시 Button { // 플래시 토글 } label: { Image(systemName: "bolt.fill") .font(.title) .foregroundStyle(.white) } } .padding(.bottom, 40) } } .task { if await camera.checkPermission() { camera.setupSession() camera.startSession() } } .onDisappear { camera.stopSession() } .sheet(item: Binding( get: { camera.capturedImage.map { CapturedImage(image: $0) } }, set: { _ in camera.capturedImage = nil } )) { captured in Image(uiImage: captured.image) .resizable() .scaledToFit() } } } struct CapturedImage: Identifiable { let id = UUID() let image: UIImage } ``` ## 고급 패턴 ### 1. 비디오 녹화 ```swift class VideoRecorder: NSObject { private var movieOutput: AVCaptureMovieFileOutput? private var captureSession: AVCaptureSession var isRecording: Bool { movieOutput?.isRecording ?? false } init(session: AVCaptureSession) { self.captureSession = session super.init() setupMovieOutput() } private func setupMovieOutput() { let output = AVCaptureMovieFileOutput() if captureSession.canAddOutput(output) { captureSession.addOutput(output) movieOutput = output } } func startRecording() { guard let output = movieOutput, !output.isRecording else { return } let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).mov") output.startRecording(to: tempURL, recordingDelegate: self) } func stopRecording() { movieOutput?.stopRecording() } } extension VideoRecorder: AVCaptureFileOutputRecordingDelegate { func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { // 저장 완료 처리 print("녹화 완료: \(outputFileURL)") } } ``` ### 2. 오디오 녹음 ```swift import AVFAudio class AudioRecorder { private var audioRecorder: AVAudioRecorder? func startRecording() throws { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .default) try session.setActive(true) let url = FileManager.default.temporaryDirectory.appendingPathComponent("recording.m4a") let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatMPEG4AAC), AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] audioRecorder = try AVAudioRecorder(url: url, settings: settings) audioRecorder?.record() } func stopRecording() -> URL? { audioRecorder?.stop() return audioRecorder?.url } } ``` ### 3. 실시간 프레임 처리 ```swift class FrameProcessor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { var onFrame: ((CVPixelBuffer) -> Void)? func setupVideoOutput(for session: AVCaptureSession) { let output = AVCaptureVideoDataOutput() output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "video.queue")) output.alwaysDiscardsLateVideoFrames = true if session.canAddOutput(output) { session.addOutput(output) } } func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } onFrame?(pixelBuffer) } } ``` ## 주의사항 1. **스레드 안전** - `captureSession.startRunning()` 은 블로킹 → 백그라운드에서 - 설정 변경은 `beginConfiguration()` / `commitConfiguration()` 사이에서 2. **권한** - 카메라 권한은 비동기 확인 - 마이크 권한도 별도 필요 (비디오 녹화 시) 3. **메모리 관리** - 실시간 프레임 처리 시 `autoreleasepool` 사용 - `alwaysDiscardsLateVideoFrames = true` 설정 4. **시뮬레이터 제한** - 카메라는 실제 기기에서만 테스트 가능 --- # AVKit AI Reference > 비디오 재생 UI 구현 가이드. 이 문서를 읽고 AVKit 코드를 생성할 수 있습니다. ## 개요 AVKit은 Apple 플랫폼의 표준 비디오 플레이어 UI를 제공합니다. AVFoundation 위에 구축되어 재생 컨트롤, Picture in Picture, AirPlay 등을 자동 지원합니다. ## 필수 Import ```swift import AVKit import AVFoundation // 세부 제어 필요 시 ``` ## 프로젝트 설정 ```xml UIBackgroundModes audio UIBackgroundModes audio picture-in-picture ``` ## 핵심 구성요소 ### 1. VideoPlayer (SwiftUI) ```swift import SwiftUI import AVKit struct SimpleVideoPlayer: View { let player = AVPlayer(url: URL(string: "https://example.com/video.mp4")!) var body: some View { VideoPlayer(player: player) .frame(height: 300) .onAppear { player.play() } .onDisappear { player.pause() } } } ``` ### 2. AVPlayerViewController (UIKit) ```swift import AVKit class VideoViewController: UIViewController { func playVideo() { let url = URL(string: "https://example.com/video.mp4")! let player = AVPlayer(url: url) let playerVC = AVPlayerViewController() playerVC.player = player present(playerVC, animated: true) { player.play() } } } ``` ### 3. AVPlayer 상태 관리 ```swift @Observable class VideoPlayerManager { let player: AVPlayer var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 private var timeObserver: Any? init(url: URL) { player = AVPlayer(url: url) setupObservers() } private func setupObservers() { // 재생 상태 player.publisher(for: \.timeControlStatus) .sink { [weak self] status in self?.isPlaying = status == .playing } .store(in: &cancellables) // 시간 업데이트 let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in self?.currentTime = time.seconds } } } ``` ## 전체 작동 예제 ```swift import SwiftUI import AVKit // MARK: - Video Model struct Video: Identifiable { let id = UUID() let title: String let url: URL let thumbnail: String } // MARK: - Video Player Manager @Observable class VideoPlayerViewModel { var player: AVPlayer? var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 var isLoading = true var error: String? private var timeObserver: Any? private var statusObserver: NSKeyValueObservation? func loadVideo(url: URL) { // 기존 플레이어 정리 cleanup() isLoading = true error = nil let playerItem = AVPlayerItem(url: url) player = AVPlayer(playerItem: playerItem) // 상태 관찰 statusObserver = playerItem.observe(\.status) { [weak self] item, _ in DispatchQueue.main.async { switch item.status { case .readyToPlay: self?.isLoading = false self?.duration = item.duration.seconds case .failed: self?.isLoading = false self?.error = item.error?.localizedDescription default: break } } } // 시간 관찰 let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in self?.currentTime = time.seconds } // 재생 완료 알림 NotificationCenter.default.addObserver( self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem ) } func play() { player?.play() isPlaying = true } func pause() { player?.pause() isPlaying = false } func seek(to time: Double) { let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) player?.seek(to: cmTime) } func skipForward(_ seconds: Double = 10) { let newTime = min(currentTime + seconds, duration) seek(to: newTime) } func skipBackward(_ seconds: Double = 10) { let newTime = max(currentTime - seconds, 0) seek(to: newTime) } @objc private func playerDidFinish() { isPlaying = false seek(to: 0) } func cleanup() { if let observer = timeObserver { player?.removeTimeObserver(observer) } statusObserver?.invalidate() NotificationCenter.default.removeObserver(self) player = nil } deinit { cleanup() } } // MARK: - Custom Video Player View struct CustomVideoPlayer: View { @State private var viewModel = VideoPlayerViewModel() @State private var showControls = true let video: Video var body: some View { ZStack { // 비디오 if let player = viewModel.player { VideoPlayer(player: player) .onTapGesture { withAnimation { showControls.toggle() } } } // 로딩 if viewModel.isLoading { ProgressView() .scaleEffect(1.5) } // 에러 if let error = viewModel.error { ContentUnavailableView( "재생 오류", systemImage: "exclamationmark.triangle", description: Text(error) ) } // 컨트롤 if showControls && !viewModel.isLoading && viewModel.error == nil { VideoControlsOverlay(viewModel: viewModel) } } .background(.black) .onAppear { viewModel.loadVideo(url: video.url) } .onDisappear { viewModel.cleanup() } } } // MARK: - Controls Overlay struct VideoControlsOverlay: View { @Bindable var viewModel: VideoPlayerViewModel var body: some View { VStack { Spacer() // 재생 컨트롤 HStack(spacing: 48) { Button { viewModel.skipBackward() } label: { Image(systemName: "gobackward.10") .font(.title) } Button { viewModel.isPlaying ? viewModel.pause() : viewModel.play() } label: { Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") .font(.largeTitle) } Button { viewModel.skipForward() } label: { Image(systemName: "goforward.10") .font(.title) } } .foregroundStyle(.white) Spacer() // 프로그레스 바 VStack(spacing: 8) { Slider( value: $viewModel.currentTime, in: 0...max(viewModel.duration, 1) ) { editing in if !editing { viewModel.seek(to: viewModel.currentTime) } } .tint(.white) HStack { Text(formatTime(viewModel.currentTime)) Spacer() Text(formatTime(viewModel.duration)) } .font(.caption) .foregroundStyle(.white.opacity(0.8)) } .padding() } .background( LinearGradient( colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom ) ) } func formatTime(_ seconds: Double) -> String { guard seconds.isFinite else { return "--:--" } let mins = Int(seconds) / 60 let secs = Int(seconds) % 60 return String(format: "%d:%02d", mins, secs) } } // MARK: - Video List View struct VideoListView: View { let videos = [ Video(title: "Big Buck Bunny", url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")!, thumbnail: "hare"), Video(title: "Elephant Dream", url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4")!, thumbnail: "elephant"), Video(title: "Sintel", url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4")!, thumbnail: "figure.wave") ] var body: some View { NavigationStack { List(videos) { video in NavigationLink { CustomVideoPlayer(video: video) .navigationBarTitleDisplayMode(.inline) } label: { HStack { Image(systemName: video.thumbnail) .font(.largeTitle) .frame(width: 60, height: 60) .background(.quaternary) .clipShape(RoundedRectangle(cornerRadius: 8)) Text(video.title) .font(.headline) } } } .navigationTitle("비디오") } } } #Preview { VideoListView() } ``` ## 고급 패턴 ### 1. Picture in Picture ```swift import AVKit class PiPVideoViewController: AVPlayerViewController, AVPlayerViewControllerDelegate { override func viewDidLoad() { super.viewDidLoad() delegate = self allowsPictureInPicturePlayback = true } // PiP 시작 func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { print("PiP 시작") } // PiP 종료 func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { print("PiP 종료") } // PiP에서 복원 func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { // UI 복원 completionHandler(true) } } ``` ### 2. 오디오 세션 설정 ```swift import AVFoundation func configureAudioSession() { do { let session = AVAudioSession.sharedInstance() // 백그라운드 재생 허용 try session.setCategory(.playback, mode: .moviePlayback) try session.setActive(true) } catch { print("오디오 세션 설정 실패: \(error)") } } ``` ### 3. 커스텀 오버레이 ```swift import SwiftUI import AVKit struct VideoPlayerWithOverlay: View { let player: AVPlayer @State private var showOverlay = false var body: some View { VideoPlayer(player: player) { // 커스텀 오버레이 VStack { HStack { Spacer() Button("자막") { // 자막 토글 } .padding() .background(.ultraThinMaterial) .clipShape(Capsule()) } Spacer() } .padding() } } } ``` ### 4. AirPlay 지원 ```swift import AVKit import MediaPlayer struct AirPlayButton: UIViewRepresentable { func makeUIView(context: Context) -> AVRoutePickerView { let picker = AVRoutePickerView() picker.activeTintColor = .systemBlue picker.tintColor = .gray return picker } func updateUIView(_ uiView: AVRoutePickerView, context: Context) {} } // 사용 struct VideoPlayerWithAirPlay: View { var body: some View { VStack { VideoPlayer(player: player) HStack { AirPlayButton() .frame(width: 44, height: 44) } } } } ``` ### 5. HLS 스트리밍 ```swift // HLS 스트림 재생 let hlsURL = URL(string: "https://example.com/stream.m3u8")! let player = AVPlayer(url: hlsURL) // 자막 트랙 선택 func selectSubtitleTrack(player: AVPlayer, languageCode: String) { guard let group = player.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else { return } let option = group.options.first { option in option.locale?.languageCode == languageCode } player.currentItem?.select(option, in: group) } // 화질 선택 (비트레이트 제한) func limitBitrate(player: AVPlayer, maxBitrate: Double) { player.currentItem?.preferredPeakBitRate = maxBitrate } ``` ## 주의사항 1. **메모리 관리** ```swift // onDisappear에서 정리 .onDisappear { player.pause() player.replaceCurrentItem(with: nil) } ``` 2. **백그라운드 재생** - Info.plist에 `audio` background mode 필수 - 오디오 세션 `.playback` 카테고리 설정 3. **AirPlay** - 기본적으로 활성화됨 - 비활성화: `allowsExternalPlayback = false` 4. **로컬 vs 스트리밍** ```swift // 로컬 파일 let url = Bundle.main.url(forResource: "video", withExtension: "mp4")! // 스트리밍 let url = URL(string: "https://...")! ``` 5. **시뮬레이터 제한** - Picture in Picture 미지원 - AirPlay 미지원 - 실기기 테스트 권장 --- # CallKit AI Reference > VoIP 통화 앱 구현 가이드. 이 문서를 읽고 CallKit 코드를 생성할 수 있습니다. ## 개요 CallKit은 VoIP 앱이 시스템 통화 UI와 통합되도록 해주는 프레임워크입니다. 수신/발신 통화 화면, 연락처 차단, 발신자 식별 등 네이티브 전화 앱과 동일한 경험을 제공합니다. ## 필수 Import ```swift import CallKit import AVFoundation // 오디오 세션 import PushKit // VoIP 푸시 ``` ## 프로젝트 설정 ### 1. Capability 추가 - Background Modes > Voice over IP - Background Modes > Remote notifications - Push Notifications ### 2. Info.plist ```xml NSMicrophoneUsageDescription 통화를 위해 마이크 접근이 필요합니다. ``` ## 핵심 구성요소 ### 1. CXProvider (통화 이벤트) ```swift import CallKit class CallManager: NSObject { let provider: CXProvider let callController = CXCallController() override init() { let config = CXProviderConfiguration() config.localizedName = "My VoIP App" config.supportsVideo = true config.maximumCallsPerCallGroup = 1 config.supportedHandleTypes = [.phoneNumber, .generic] config.iconTemplateImageData = UIImage(named: "CallIcon")?.pngData() config.ringtoneSound = "ringtone.wav" provider = CXProvider(configuration: config) super.init() provider.setDelegate(self, queue: nil) } } ``` ### 2. CXCallController (통화 제어) ```swift // 발신 통화 시작 func startCall(handle: String, video: Bool = false) { let uuid = UUID() let handle = CXHandle(type: .phoneNumber, value: handle) let startCallAction = CXStartCallAction(call: uuid, handle: handle) startCallAction.isVideo = video let transaction = CXTransaction(action: startCallAction) callController.request(transaction) { error in if let error = error { print("발신 실패: \(error)") } } } // 통화 종료 func endCall(uuid: UUID) { let endCallAction = CXEndCallAction(call: uuid) let transaction = CXTransaction(action: endCallAction) callController.request(transaction) { error in if let error = error { print("종료 실패: \(error)") } } } ``` ### 3. 수신 통화 보고 ```swift // 수신 통화를 시스템에 보고 func reportIncomingCall(uuid: UUID, handle: String, hasVideo: Bool, completion: @escaping (Error?) -> Void) { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .phoneNumber, value: handle) update.hasVideo = hasVideo update.localizedCallerName = "발신자 이름" provider.reportNewIncomingCall(with: uuid, update: update) { error in completion(error) } } ``` ## 전체 작동 예제 ```swift import SwiftUI import CallKit import AVFoundation import PushKit // MARK: - Call Model struct Call: Identifiable { let id: UUID let handle: String let isOutgoing: Bool var isOnHold: Bool = false var isMuted: Bool = false var startTime: Date? } // MARK: - Call Manager @Observable class CallManager: NSObject { var activeCalls: [Call] = [] var callState: String = "대기 중" private let provider: CXProvider private let callController = CXCallController() private var audioSession: AVAudioSession { AVAudioSession.sharedInstance() } override init() { let config = CXProviderConfiguration() config.localizedName = "VoIP Demo" config.supportsVideo = true config.maximumCallsPerCallGroup = 1 config.maximumCallGroups = 1 config.supportedHandleTypes = [.phoneNumber, .generic] config.includesCallsInRecents = true provider = CXProvider(configuration: config) super.init() provider.setDelegate(self, queue: nil) } // MARK: - 발신 통화 func startOutgoingCall(to handle: String, hasVideo: Bool = false) { let uuid = UUID() let cxHandle = CXHandle(type: .phoneNumber, value: handle) let startAction = CXStartCallAction(call: uuid, handle: cxHandle) startAction.isVideo = hasVideo let transaction = CXTransaction(action: startAction) callController.request(transaction) { [weak self] error in if let error = error { print("발신 실패: \(error)") return } DispatchQueue.main.async { let call = Call(id: uuid, handle: handle, isOutgoing: true) self?.activeCalls.append(call) self?.callState = "발신 중..." } } } // MARK: - 수신 통화 (VoIP 푸시에서 호출) func reportIncomingCall(uuid: UUID, handle: String, hasVideo: Bool) { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .phoneNumber, value: handle) update.hasVideo = hasVideo update.localizedCallerName = getContactName(for: handle) provider.reportNewIncomingCall(with: uuid, update: update) { [weak self] error in if let error = error { print("수신 보고 실패: \(error)") return } DispatchQueue.main.async { let call = Call(id: uuid, handle: handle, isOutgoing: false) self?.activeCalls.append(call) self?.callState = "수신 중..." } } } // MARK: - 통화 종료 func endCall(uuid: UUID) { let endAction = CXEndCallAction(call: uuid) let transaction = CXTransaction(action: endAction) callController.request(transaction) { error in if let error = error { print("종료 실패: \(error)") } } } // MARK: - 보류 func setHold(uuid: UUID, onHold: Bool) { let holdAction = CXSetHeldCallAction(call: uuid, onHold: onHold) let transaction = CXTransaction(action: holdAction) callController.request(transaction) { error in if let error = error { print("보류 실패: \(error)") } } } // MARK: - 음소거 func setMute(uuid: UUID, muted: Bool) { let muteAction = CXSetMutedCallAction(call: uuid, muted: muted) let transaction = CXTransaction(action: muteAction) callController.request(transaction) { error in if let error = error { print("음소거 실패: \(error)") } } } // MARK: - DTMF func sendDTMF(uuid: UUID, digits: String) { let dtmfAction = CXPlayDTMFCallAction(call: uuid, digits: digits, type: .singleTone) let transaction = CXTransaction(action: dtmfAction) callController.request(transaction) { error in if let error = error { print("DTMF 실패: \(error)") } } } // MARK: - 헬퍼 private func getContactName(for handle: String) -> String { // 연락처에서 이름 조회 return handle } private func configureAudioSession() { do { try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .defaultToSpeaker]) try audioSession.setActive(true) } catch { print("오디오 세션 설정 실패: \(error)") } } } // MARK: - CXProviderDelegate extension CallManager: CXProviderDelegate { func providerDidReset(_ provider: CXProvider) { // 모든 통화 종료 activeCalls.removeAll() callState = "대기 중" } func provider(_ provider: CXProvider, perform action: CXStartCallAction) { // 발신 통화 시작 configureAudioSession() // 실제 VoIP 연결 시작 connectToVoIPServer(for: action.callUUID) action.fulfill() // 연결 완료 보고 provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date()) } func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { // 수신 통화 응답 configureAudioSession() // 실제 VoIP 연결 connectToVoIPServer(for: action.callUUID) DispatchQueue.main.async { if let index = self.activeCalls.firstIndex(where: { $0.id == action.callUUID }) { self.activeCalls[index].startTime = Date() } self.callState = "통화 중" } action.fulfill() } func provider(_ provider: CXProvider, perform action: CXEndCallAction) { // 통화 종료 disconnectFromVoIPServer(for: action.callUUID) DispatchQueue.main.async { self.activeCalls.removeAll { $0.id == action.callUUID } self.callState = self.activeCalls.isEmpty ? "대기 중" : "통화 중" } action.fulfill() } func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { // 보류 토글 DispatchQueue.main.async { if let index = self.activeCalls.firstIndex(where: { $0.id == action.callUUID }) { self.activeCalls[index].isOnHold = action.isOnHold } } action.fulfill() } func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { // 음소거 토글 DispatchQueue.main.async { if let index = self.activeCalls.firstIndex(where: { $0.id == action.callUUID }) { self.activeCalls[index].isMuted = action.isMuted } } action.fulfill() } func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { // 오디오 세션 활성화됨 - 오디오 스트림 시작 startAudioStream() } func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { // 오디오 세션 비활성화됨 - 오디오 스트림 중지 stopAudioStream() } // MARK: - VoIP 연결 (구현 필요) private func connectToVoIPServer(for uuid: UUID) { // WebRTC, SIP 등 실제 연결 구현 } private func disconnectFromVoIPServer(for uuid: UUID) { // 연결 해제 } private func startAudioStream() { // 오디오 스트림 시작 } private func stopAudioStream() { // 오디오 스트림 중지 } } // MARK: - VoIP Push (PushKit) class PushKitManager: NSObject, PKPushRegistryDelegate { let callManager: CallManager let registry = PKPushRegistry(queue: .main) init(callManager: CallManager) { self.callManager = callManager super.init() registry.delegate = self registry.desiredPushTypes = [.voIP] } func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() print("VoIP 푸시 토큰: \(token)") // 서버에 토큰 등록 } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { // VoIP 푸시 수신 let uuid = UUID() let handle = payload.dictionaryPayload["handle"] as? String ?? "알 수 없음" let hasVideo = payload.dictionaryPayload["hasVideo"] as? Bool ?? false // 반드시 reportNewIncomingCall 호출 (iOS 13+) callManager.reportIncomingCall(uuid: uuid, handle: handle, hasVideo: hasVideo) completion() } } // MARK: - Main View struct CallView: View { @State private var callManager = CallManager() @State private var phoneNumber = "" var body: some View { NavigationStack { List { // 상태 Section { LabeledContent("상태", value: callManager.callState) } // 발신 Section("발신") { TextField("전화번호", text: $phoneNumber) .keyboardType(.phonePad) Button { callManager.startOutgoingCall(to: phoneNumber) } label: { Label("음성 통화", systemImage: "phone.fill") } .disabled(phoneNumber.isEmpty) Button { callManager.startOutgoingCall(to: phoneNumber, hasVideo: true) } label: { Label("영상 통화", systemImage: "video.fill") } .disabled(phoneNumber.isEmpty) } // 활성 통화 if !callManager.activeCalls.isEmpty { Section("활성 통화") { ForEach(callManager.activeCalls) { call in VStack(alignment: .leading, spacing: 8) { HStack { Text(call.handle) .font(.headline) Spacer() if call.isOnHold { Text("보류 중") .font(.caption) .foregroundStyle(.orange) } } HStack(spacing: 16) { Button { callManager.setMute(uuid: call.id, muted: !call.isMuted) } label: { Image(systemName: call.isMuted ? "mic.slash.fill" : "mic.fill") } Button { callManager.setHold(uuid: call.id, onHold: !call.isOnHold) } label: { Image(systemName: call.isOnHold ? "play.fill" : "pause.fill") } Spacer() Button(role: .destructive) { callManager.endCall(uuid: call.id) } label: { Image(systemName: "phone.down.fill") } } .buttonStyle(.bordered) } } } } // 테스트 수신 (개발용) #if DEBUG Section("테스트") { Button("수신 통화 시뮬레이션") { callManager.reportIncomingCall( uuid: UUID(), handle: "010-1234-5678", hasVideo: false ) } } #endif } .navigationTitle("VoIP") } } } #Preview { CallView() } ``` ## 고급 패턴 ### 1. 발신자 식별 (Call Directory Extension) ```swift // CallDirectoryHandler.swift (Call Directory Extension) import CallKit class CallDirectoryHandler: CXCallDirectoryProvider { override func beginRequest(with context: CXCallDirectoryExtensionContext) { // 차단 번호 추가 addBlockedNumbers(to: context) // 발신자 식별 추가 addIdentificationEntries(to: context) context.completeRequest() } private func addBlockedNumbers(to context: CXCallDirectoryExtensionContext) { let blockedNumbers: [CXCallDirectoryPhoneNumber] = [ 821012345678, // 국가코드 포함, 숫자만 821087654321 ] for number in blockedNumbers.sorted() { context.addBlockingEntry(withNextSequentialPhoneNumber: number) } } private func addIdentificationEntries(to context: CXCallDirectoryExtensionContext) { let phoneNumbers: [CXCallDirectoryPhoneNumber] = [821011112222] let labels = ["스팸 의심"] for (number, label) in zip(phoneNumbers.sorted(), labels) { context.addIdentificationEntry( withNextSequentialPhoneNumber: number, label: label ) } } } ``` ### 2. 통화 기록 통합 ```swift // CXProviderConfiguration에서 설정 config.includesCallsInRecents = true // 통화 종료 시 기록 업데이트 func provider(_ provider: CXProvider, perform action: CXEndCallAction) { // 통화 기록에 추가 정보 포함 let update = CXCallUpdate() update.localizedCallerName = "통화 상대 이름" provider.reportCall(with: action.callUUID, updated: update) action.fulfill() } ``` ## 주의사항 1. **VoIP 푸시 필수** (iOS 13+) - VoIP 푸시 수신 시 반드시 `reportNewIncomingCall` 호출 - 미호출 시 앱 종료됨 2. **백그라운드 모드** - Voice over IP 필수 - Remote notifications 권장 3. **오디오 세션** - CallKit이 오디오 세션 관리 - `didActivate`/`didDeactivate`에서 스트림 제어 4. **시뮬레이터 제한** - 시스템 통화 UI 미표시 - 실기기 테스트 필수 5. **중국 제한** - 중국에서 CallKit 사용 제한 - 대체 UI 준비 필요 --- # CloudKit AI Reference > iCloud 데이터 동기화 가이드. 이 문서를 읽고 CloudKit 코드를 생성할 수 있습니다. ## 개요 CloudKit은 Apple의 클라우드 데이터베이스 서비스입니다. 사용자의 iCloud 계정을 통해 데이터를 저장하고 기기 간 동기화합니다. ## 필수 Import ```swift import CloudKit ``` ## 프로젝트 설정 1. **Capabilities 추가**: Signing & Capabilities → + CloudKit 2. **Container 선택**: `iCloud.com.yourcompany.appname` 3. **Record Types 정의**: CloudKit Dashboard에서 스키마 생성 ## 핵심 구성요소 ### 1. Container & Database ```swift // 기본 컨테이너 let container = CKContainer.default() // 커스텀 컨테이너 let container = CKContainer(identifier: "iCloud.com.example.app") // 데이터베이스 종류 let privateDB = container.privateCloudDatabase // 사용자 개인 데이터 let publicDB = container.publicCloudDatabase // 모든 사용자 공유 let sharedDB = container.sharedCloudDatabase // 공유된 데이터 ``` ### 2. CKRecord (데이터 모델) ```swift // 레코드 생성 let record = CKRecord(recordType: "Note") record["title"] = "메모 제목" record["content"] = "메모 내용" record["createdAt"] = Date() record["isPinned"] = false // 에셋 (파일/이미지) let imageURL = FileManager.default.temporaryDirectory.appendingPathComponent("image.jpg") record["image"] = CKAsset(fileURL: imageURL) // 참조 (관계) let folderRecordID = CKRecord.ID(recordName: "folder-123") record["folder"] = CKRecord.Reference(recordID: folderRecordID, action: .deleteSelf) ``` ### 3. CRUD 작업 ```swift class CloudKitManager { private let database = CKContainer.default().privateCloudDatabase // CREATE func save(_ record: CKRecord) async throws -> CKRecord { try await database.save(record) } // READ (단일) func fetch(recordID: CKRecord.ID) async throws -> CKRecord { try await database.record(for: recordID) } // READ (쿼리) func fetchNotes() async throws -> [CKRecord] { let predicate = NSPredicate(value: true) let query = CKQuery(recordType: "Note", predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] let (results, _) = try await database.records(matching: query) return results.compactMap { try? $0.1.get() } } // UPDATE func update(_ record: CKRecord) async throws -> CKRecord { try await database.save(record) // save가 update 역할도 함 } // DELETE func delete(recordID: CKRecord.ID) async throws { try await database.deleteRecord(withID: recordID) } } ``` ## 전체 작동 예제 ```swift import SwiftUI import CloudKit // MARK: - 모델 struct Note: Identifiable { let id: CKRecord.ID var title: String var content: String var createdAt: Date init(record: CKRecord) { self.id = record.recordID self.title = record["title"] as? String ?? "" self.content = record["content"] as? String ?? "" self.createdAt = record["createdAt"] as? Date ?? Date() } func toRecord() -> CKRecord { let record = CKRecord(recordType: "Note", recordID: id) record["title"] = title record["content"] = content record["createdAt"] = createdAt return record } } // MARK: - ViewModel @Observable class NotesViewModel { var notes: [Note] = [] var isLoading = false var error: Error? private let database = CKContainer.default().privateCloudDatabase func fetchNotes() async { isLoading = true defer { isLoading = false } do { let query = CKQuery(recordType: "Note", predicate: NSPredicate(value: true)) query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] let (results, _) = try await database.records(matching: query) notes = results.compactMap { result in guard let record = try? result.1.get() else { return nil } return Note(record: record) } } catch { self.error = error } } func addNote(title: String, content: String) async { let record = CKRecord(recordType: "Note") record["title"] = title record["content"] = content record["createdAt"] = Date() do { let saved = try await database.save(record) let note = Note(record: saved) notes.insert(note, at: 0) } catch { self.error = error } } func deleteNote(_ note: Note) async { do { try await database.deleteRecord(withID: note.id) notes.removeAll { $0.id == note.id } } catch { self.error = error } } } // MARK: - View struct NotesListView: View { @State private var viewModel = NotesViewModel() @State private var showingAddSheet = false var body: some View { NavigationStack { List { ForEach(viewModel.notes) { note in VStack(alignment: .leading) { Text(note.title).font(.headline) Text(note.content).font(.subheadline).foregroundStyle(.secondary) } } .onDelete { indexSet in for index in indexSet { Task { await viewModel.deleteNote(viewModel.notes[index]) } } } } .navigationTitle("메모") .toolbar { Button("추가", systemImage: "plus") { showingAddSheet = true } } .refreshable { await viewModel.fetchNotes() } .task { await viewModel.fetchNotes() } .overlay { if viewModel.isLoading { ProgressView() } } } } } ``` ## 고급 패턴 ### 1. 실시간 구독 (Push) ```swift func subscribeToChanges() async throws { let subscription = CKQuerySubscription( recordType: "Note", predicate: NSPredicate(value: true), subscriptionID: "note-changes", options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true // 백그라운드 알림 subscription.notificationInfo = notification try await database.save(subscription) } // AppDelegate에서 처리 func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) if let queryNotification = notification as? CKQueryNotification { // 변경된 recordID 가져오기 if let recordID = queryNotification.recordID { // 데이터 새로고침 } } return .newData } ``` ### 2. 배치 작업 ```swift func batchSave(records: [CKRecord]) async throws { let operation = CKModifyRecordsOperation(recordsToSave: records) operation.savePolicy = .changedKeys // 변경된 키만 저장 try await database.modifyRecords(saving: records, deleting: []) } func batchDelete(recordIDs: [CKRecord.ID]) async throws { try await database.modifyRecords(saving: [], deleting: recordIDs) } ``` ### 3. Zone 기반 동기화 ```swift // 커스텀 존 생성 let zoneID = CKRecordZone.ID(zoneName: "MyZone", ownerName: CKCurrentUserDefaultName) let zone = CKRecordZone(zoneID: zoneID) try await database.save(zone) // 존 내 레코드 저장 let record = CKRecord(recordType: "Note", recordID: CKRecord.ID(zoneID: zoneID)) // 변경 사항 가져오기 (효율적 동기화) func fetchChanges(since token: CKServerChangeToken?) async throws { let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration() config.previousServerChangeToken = token let operation = CKFetchRecordZoneChangesOperation( recordZoneIDs: [zoneID], configurationsByRecordZoneID: [zoneID: config] ) // ... 변경 사항 처리 } ``` ### 4. 공유 (Sharing) ```swift func share(_ record: CKRecord) async throws -> CKShare { let share = CKShare(rootRecord: record) share.publicPermission = .readOnly let (savedRecords, _) = try await database.modifyRecords( saving: [record, share], deleting: [] ) return savedRecords.first { $0 is CKShare } as! CKShare } ``` ## 주의사항 1. **iCloud 계정 필수** - 사용자 로그인 상태 확인 필요 - `CKContainer.default().accountStatus()` 체크 2. **쿼터 제한** - Private DB: 용량 무제한 (사용자 iCloud 용량) - Public DB: 앱당 1GB 무료 - 대용량 파일은 CKAsset 사용 3. **오프라인 처리** - CloudKit은 오프라인 캐시 없음 - Core Data + CloudKit 조합 권장 (NSPersistentCloudKitContainer) 4. **에러 처리** ```swift do { try await database.save(record) } catch let error as CKError { switch error.code { case .networkFailure: // 네트워크 오류 case .serverRecordChanged: // 충돌 case .quotaExceeded: // 용량 초과 default: break } } ``` --- # Contacts AI Reference > 연락처 접근 및 관리 가이드. 이 문서를 읽고 Contacts 코드를 생성할 수 있습니다. ## 개요 Contacts 프레임워크는 사용자의 연락처에 접근하고 관리하는 기능을 제공합니다. 연락처 조회, 생성, 수정, 삭제를 지원합니다. ## 필수 Import ```swift import Contacts import ContactsUI // UI 컴포넌트 사용 시 ``` ## 프로젝트 설정 (Info.plist) ```xml NSContactsUsageDescription 친구를 초대하기 위해 연락처 접근이 필요합니다. ``` ## 핵심 구성요소 ### 1. CNContactStore (진입점) ```swift let contactStore = CNContactStore() // 권한 요청 func requestAccess() async -> Bool { do { return try await contactStore.requestAccess(for: .contacts) } catch { return false } } // 권한 상태 확인 let status = CNContactStore.authorizationStatus(for: .contacts) switch status { case .authorized: // 허용됨 case .denied: // 거부됨 case .notDetermined: // 미결정 case .restricted: // 제한됨 case .limited: // 제한적 접근 (iOS 18+) @unknown default: break } ``` ### 2. 연락처 조회 ```swift // 가져올 키 정의 let keysToFetch: [CNKeyDescriptor] = [ CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor, CNContactImageDataKey as CNKeyDescriptor, CNContactThumbnailImageDataKey as CNKeyDescriptor ] // 모든 연락처 조회 func fetchAllContacts() throws -> [CNContact] { let request = CNContactFetchRequest(keysToFetch: keysToFetch) request.sortOrder = .userDefault var contacts: [CNContact] = [] try contactStore.enumerateContacts(with: request) { contact, _ in contacts.append(contact) } return contacts } // 이름으로 검색 func searchContacts(name: String) throws -> [CNContact] { let predicate = CNContact.predicateForContacts(matchingName: name) return try contactStore.unifiedContacts(matching: predicate, keysToFetch: keysToFetch) } ``` ## 전체 작동 예제 ```swift import SwiftUI import Contacts import ContactsUI // MARK: - Contact Manager @Observable class ContactManager { let store = CNContactStore() var contacts: [CNContact] = [] var authorizationStatus: CNAuthorizationStatus = .notDetermined var searchText = "" var filteredContacts: [CNContact] { if searchText.isEmpty { return contacts } return contacts.filter { contact in contact.givenName.localizedCaseInsensitiveContains(searchText) || contact.familyName.localizedCaseInsensitiveContains(searchText) } } init() { checkAuthorizationStatus() } func checkAuthorizationStatus() { authorizationStatus = CNContactStore.authorizationStatus(for: .contacts) } func requestAccess() async -> Bool { do { let granted = try await store.requestAccess(for: .contacts) await MainActor.run { checkAuthorizationStatus() if granted { fetchContacts() } } return granted } catch { return false } } func fetchContacts() { let keys: [CNKeyDescriptor] = [ CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor, CNContactThumbnailImageDataKey as CNKeyDescriptor, CNContactViewController.descriptorForRequiredKeys() ] let request = CNContactFetchRequest(keysToFetch: keys) request.sortOrder = .userDefault var fetchedContacts: [CNContact] = [] do { try store.enumerateContacts(with: request) { contact, _ in fetchedContacts.append(contact) } contacts = fetchedContacts } catch { print("연락처 조회 실패: \(error)") } } func createContact(givenName: String, familyName: String, phoneNumber: String) throws { let newContact = CNMutableContact() newContact.givenName = givenName newContact.familyName = familyName let phone = CNLabeledValue( label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: phoneNumber) ) newContact.phoneNumbers = [phone] let saveRequest = CNSaveRequest() saveRequest.add(newContact, toContainerWithIdentifier: nil) try store.execute(saveRequest) fetchContacts() } func deleteContact(_ contact: CNContact) throws { guard let mutableContact = contact.mutableCopy() as? CNMutableContact else { return } let saveRequest = CNSaveRequest() saveRequest.delete(mutableContact) try store.execute(saveRequest) fetchContacts() } } // MARK: - Views struct ContactsListView: View { @State private var manager = ContactManager() @State private var showingAddContact = false @State private var selectedContact: CNContact? var body: some View { NavigationStack { Group { switch manager.authorizationStatus { case .authorized: contactListView case .notDetermined: requestAccessView default: deniedView } } .navigationTitle("연락처") .searchable(text: $manager.searchText, prompt: "이름 검색") .toolbar { if manager.authorizationStatus == .authorized { Button("추가", systemImage: "plus") { showingAddContact = true } } } .sheet(isPresented: $showingAddContact) { AddContactView(manager: manager) } .sheet(item: $selectedContact) { contact in ContactDetailView(contact: contact) } } } var contactListView: some View { List { ForEach(manager.filteredContacts, id: \.identifier) { contact in ContactRow(contact: contact) .onTapGesture { selectedContact = contact } } .onDelete { indexSet in for index in indexSet { let contact = manager.filteredContacts[index] try? manager.deleteContact(contact) } } } .overlay { if manager.contacts.isEmpty { ContentUnavailableView("연락처 없음", systemImage: "person.crop.circle.badge.questionmark") } } } var requestAccessView: some View { ContentUnavailableView { Label("연락처 접근 필요", systemImage: "person.crop.circle.badge.exclamationmark") } description: { Text("연락처를 보려면 접근 권한이 필요합니다") } actions: { Button("권한 요청") { Task { await manager.requestAccess() } } .buttonStyle(.borderedProminent) } } var deniedView: some View { ContentUnavailableView { Label("접근 거부됨", systemImage: "person.crop.circle.badge.minus") } description: { Text("설정에서 연락처 접근을 허용해주세요") } actions: { Button("설정 열기") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } } } struct ContactRow: View { let contact: CNContact var body: some View { HStack(spacing: 12) { // 프로필 이미지 if let imageData = contact.thumbnailImageData, let uiImage = UIImage(data: imageData) { Image(uiImage: uiImage) .resizable() .scaledToFill() .frame(width: 44, height: 44) .clipShape(Circle()) } else { Image(systemName: "person.circle.fill") .font(.system(size: 44)) .foregroundStyle(.gray) } VStack(alignment: .leading) { Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "이름 없음") .font(.headline) if let phone = contact.phoneNumbers.first?.value.stringValue { Text(phone) .font(.subheadline) .foregroundStyle(.secondary) } } } } } struct ContactDetailView: View { let contact: CNContact @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { Section { HStack { Spacer() VStack { if let imageData = contact.thumbnailImageData, let uiImage = UIImage(data: imageData) { Image(uiImage: uiImage) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipShape(Circle()) } else { Image(systemName: "person.circle.fill") .font(.system(size: 100)) .foregroundStyle(.gray) } Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "") .font(.title2.bold()) } Spacer() } } .listRowBackground(Color.clear) if !contact.phoneNumbers.isEmpty { Section("전화번호") { ForEach(contact.phoneNumbers, id: \.identifier) { phone in LabeledContent( CNLabeledValue.localizedString(forLabel: phone.label ?? ""), value: phone.value.stringValue ) } } } if !contact.emailAddresses.isEmpty { Section("이메일") { ForEach(contact.emailAddresses, id: \.identifier) { email in Text(email.value as String) } } } } .navigationBarTitleDisplayMode(.inline) .toolbar { Button("닫기") { dismiss() } } } } } struct AddContactView: View { let manager: ContactManager @Environment(\.dismiss) private var dismiss @State private var givenName = "" @State private var familyName = "" @State private var phoneNumber = "" var body: some View { NavigationStack { Form { Section("이름") { TextField("이름", text: $givenName) TextField("성", text: $familyName) } Section("전화번호") { TextField("전화번호", text: $phoneNumber) .keyboardType(.phonePad) } } .navigationTitle("새 연락처") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("저장") { try? manager.createContact( givenName: givenName, familyName: familyName, phoneNumber: phoneNumber ) dismiss() } .disabled(givenName.isEmpty && familyName.isEmpty) } } } } } // CNContact를 Identifiable로 extension CNContact: @retroactive Identifiable { public var id: String { identifier } } ``` ## 고급 패턴 ### 1. ContactsUI 피커 ```swift struct ContactPickerView: UIViewControllerRepresentable { @Binding var selectedContact: CNContact? func makeUIViewController(context: Context) -> CNContactPickerViewController { let picker = CNContactPickerViewController() picker.delegate = context.coordinator picker.predicateForEnablingContact = NSPredicate(format: "phoneNumbers.@count > 0") return picker } func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, CNContactPickerDelegate { let parent: ContactPickerView init(_ parent: ContactPickerView) { self.parent = parent } func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) { parent.selectedContact = contact } } } ``` ### 2. 연락처 수정 ```swift func updateContact(_ contact: CNContact, newPhoneNumber: String) throws { guard let mutableContact = contact.mutableCopy() as? CNMutableContact else { return } let phone = CNLabeledValue( label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: newPhoneNumber) ) mutableContact.phoneNumbers.append(phone) let saveRequest = CNSaveRequest() saveRequest.update(mutableContact) try store.execute(saveRequest) } ``` ### 3. 변경 감지 ```swift NotificationCenter.default.addObserver( forName: .CNContactStoreDidChange, object: nil, queue: .main ) { _ in // 연락처 새로고침 fetchContacts() } ``` ## 주의사항 1. **키 지정 필수** - 조회 시 필요한 키만 명시 - 미지정 키 접근 시 크래시 2. **CNContactViewController 사용 시** ```swift CNContactViewController.descriptorForRequiredKeys() ``` 3. **이름 포맷팅** ```swift CNContactFormatter.string(from: contact, style: .fullName) ``` 4. **iOS 18 Limited Access** - 사용자가 일부 연락처만 허용 가능 - `.limited` 상태 확인 필요 --- # Core Bluetooth AI Reference > BLE 기기 연결 및 통신 가이드. 이 문서를 읽고 Bluetooth LE 기능을 구현할 수 있습니다. ## 개요 Core Bluetooth는 Bluetooth Low Energy(BLE) 기기와 통신하는 프레임워크입니다. Central(스캔/연결)과 Peripheral(광고/서비스 제공) 역할을 지원합니다. ## 필수 Import ```swift import CoreBluetooth ``` ## Info.plist 설정 ```xml NSBluetoothAlwaysUsageDescription 주변 BLE 기기를 검색하고 연결하기 위해 블루투스 권한이 필요합니다. ``` ## 핵심 구성요소 (Central 역할) ### 1. CBCentralManager (스캔/연결 관리) ```swift class BluetoothManager: NSObject, ObservableObject { private var centralManager: CBCentralManager! override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil) } func startScanning() { // 특정 서비스 UUID로 필터링 (nil이면 모든 기기) centralManager.scanForPeripherals( withServices: [CBUUID(string: "180D")], // 심박 서비스 options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } func stopScanning() { centralManager.stopScan() } func connect(_ peripheral: CBPeripheral) { centralManager.connect(peripheral, options: nil) } func disconnect(_ peripheral: CBPeripheral) { centralManager.cancelPeripheralConnection(peripheral) } } extension BluetoothManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: print("블루투스 켜짐") startScanning() case .poweredOff: print("블루투스 꺼짐") case .unauthorized: print("권한 없음") default: break } } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { print("발견: \(peripheral.name ?? "Unknown") RSSI: \(RSSI)") // 기기 목록에 추가 } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { print("연결됨: \(peripheral.name ?? "")") peripheral.delegate = self peripheral.discoverServices(nil) // 모든 서비스 검색 } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { print("연결 실패: \(error?.localizedDescription ?? "")") } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { print("연결 해제: \(peripheral.name ?? "")") } } ``` ### 2. CBPeripheral (기기 통신) ```swift extension BluetoothManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard let services = peripheral.services else { return } for service in services { print("서비스: \(service.uuid)") peripheral.discoverCharacteristics(nil, for: service) } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard let characteristics = service.characteristics else { return } for char in characteristics { print("특성: \(char.uuid)") // 읽기 if char.properties.contains(.read) { peripheral.readValue(for: char) } // 알림 구독 if char.properties.contains(.notify) { peripheral.setNotifyValue(true, for: char) } } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard let data = characteristic.value else { return } print("값 수신: \(data)") // 데이터 파싱 } // 쓰기 func writeValue(_ data: Data, to characteristic: CBCharacteristic, peripheral: CBPeripheral) { if characteristic.properties.contains(.writeWithoutResponse) { peripheral.writeValue(data, for: characteristic, type: .withoutResponse) } else { peripheral.writeValue(data, for: characteristic, type: .withResponse) } } } ``` ## 전체 작동 예제: BLE 스캐너 ```swift import SwiftUI import CoreBluetooth // MARK: - 발견된 기기 모델 struct DiscoveredDevice: Identifiable { let id: UUID let peripheral: CBPeripheral let name: String let rssi: Int var isConnected = false } // MARK: - Bluetooth Manager @Observable class BLEManager: NSObject { var devices: [DiscoveredDevice] = [] var isScanning = false var isPoweredOn = false var connectedDevice: CBPeripheral? var receivedData: String = "" private var centralManager: CBCentralManager! override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil) } func startScan() { guard isPoweredOn else { return } devices.removeAll() centralManager.scanForPeripherals(withServices: nil, options: nil) isScanning = true } func stopScan() { centralManager.stopScan() isScanning = false } func connect(_ device: DiscoveredDevice) { stopScan() centralManager.connect(device.peripheral, options: nil) } func disconnect() { if let peripheral = connectedDevice { centralManager.cancelPeripheralConnection(peripheral) } } } extension BLEManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { isPoweredOn = central.state == .poweredOn } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { // 이름 있는 기기만 추가 guard let name = peripheral.name, !name.isEmpty else { return } // 중복 체크 if !devices.contains(where: { $0.peripheral.identifier == peripheral.identifier }) { let device = DiscoveredDevice( id: peripheral.identifier, peripheral: peripheral, name: name, rssi: RSSI.intValue ) devices.append(device) } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { connectedDevice = peripheral peripheral.delegate = self peripheral.discoverServices(nil) // 연결 상태 업데이트 if let index = devices.firstIndex(where: { $0.id == peripheral.identifier }) { devices[index].isConnected = true } } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { connectedDevice = nil if let index = devices.firstIndex(where: { $0.id == peripheral.identifier }) { devices[index].isConnected = false } } } extension BLEManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { peripheral.services?.forEach { service in peripheral.discoverCharacteristics(nil, for: service) } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { service.characteristics?.forEach { char in if char.properties.contains(.notify) { peripheral.setNotifyValue(true, for: char) } if char.properties.contains(.read) { peripheral.readValue(for: char) } } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let data = characteristic.value, let string = String(data: data, encoding: .utf8) { receivedData = string } } } // MARK: - View struct BLEScannerView: View { @State private var bleManager = BLEManager() var body: some View { NavigationStack { List { Section { if bleManager.isScanning { HStack { ProgressView() Text("스캔 중...") } } } Section("발견된 기기 (\(bleManager.devices.count))") { ForEach(bleManager.devices) { device in HStack { VStack(alignment: .leading) { Text(device.name) .font(.headline) Text("RSSI: \(device.rssi) dBm") .font(.caption) .foregroundStyle(.secondary) } Spacer() if device.isConnected { Text("연결됨") .foregroundStyle(.green) } } .contentShape(Rectangle()) .onTapGesture { if device.isConnected { bleManager.disconnect() } else { bleManager.connect(device) } } } } if !bleManager.receivedData.isEmpty { Section("수신 데이터") { Text(bleManager.receivedData) .font(.system(.body, design: .monospaced)) } } } .navigationTitle("BLE 스캐너") .toolbar { Button(bleManager.isScanning ? "중지" : "스캔") { if bleManager.isScanning { bleManager.stopScan() } else { bleManager.startScan() } } .disabled(!bleManager.isPoweredOn) } } } } #Preview { BLEScannerView() } ``` ## 일반적인 BLE 서비스 UUID ```swift struct BLEServiceUUID { static let heartRate = CBUUID(string: "180D") static let battery = CBUUID(string: "180F") static let deviceInfo = CBUUID(string: "180A") static let bloodPressure = CBUUID(string: "1810") static let glucose = CBUUID(string: "1808") // Nordic UART 서비스 static let nordicUART = CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E") } ``` ## 백그라운드 지원 ```swift // Info.plist UIBackgroundModes bluetooth-central // 복원 식별자와 함께 생성 centralManager = CBCentralManager( delegate: self, queue: nil, options: [CBCentralManagerOptionRestoreIdentifierKey: "myBLEManager"] ) // 복원 델리게이트 func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { // 복원된 연결 처리 } } ``` ## 주의사항 1. **권한**: iOS 13+ NSBluetoothAlwaysUsageDescription 필수 2. **메인 스레드**: UI 업데이트는 메인 스레드에서 3. **강한 참조**: Peripheral은 연결 중 강하게 참조해야 함 4. **UUID 형식**: "180D" (16비트) 또는 전체 UUID (128비트) 5. **시뮬레이터**: 블루투스 테스트 불가, 실기기 필요 --- # Core NFC AI Reference > NFC 태그 읽기/쓰기 가이드. 이 문서를 읽고 Core NFC 코드를 생성할 수 있습니다. ## 개요 Core NFC는 iPhone의 NFC 리더를 사용해 NDEF 태그를 읽고 쓸 수 있는 프레임워크입니다. URL, 텍스트, 연락처 등 다양한 데이터 형식을 지원하며, ISO 7816, ISO 15693, FeliCa 태그도 지원합니다. ## 필수 Import ```swift import CoreNFC ``` ## 프로젝트 설정 ### 1. Capability 추가 Xcode > Signing & Capabilities > + Near Field Communication Tag Reading ### 2. Info.plist 설정 ```xml NFCReaderUsageDescription NFC 태그를 읽기 위해 필요합니다. com.apple.developer.nfc.readersession.iso7816.select-identifiers A0000002471001 com.apple.developer.nfc.readersession.felica.systemcodes 12FC ``` ## 핵심 구성요소 ### 1. NFCNDEFReaderSession (NDEF 읽기) ```swift import CoreNFC class NFCReader: NSObject, NFCNDEFReaderSessionDelegate { var session: NFCNDEFReaderSession? func startScanning() { guard NFCNDEFReaderSession.readingAvailable else { print("NFC를 사용할 수 없습니다") return } session = NFCNDEFReaderSession( delegate: self, queue: nil, invalidateAfterFirstRead: true ) session?.alertMessage = "NFC 태그에 iPhone을 가까이 대세요" session?.begin() } func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { for message in messages { for record in message.records { // 레코드 처리 } } } func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { print("세션 종료: \(error.localizedDescription)") } } ``` ### 2. NFCNDEFMessage (NDEF 메시지) ```swift // NDEF 레코드 타입 let record = message.records.first! record.typeNameFormat // TNF (well-known, media 등) record.type // 레코드 타입 (T, U, Sp 등) record.identifier // 식별자 record.payload // 실제 데이터 // URL 파싱 if let url = record.wellKnownTypeURIPayload() { print("URL: \(url)") } // 텍스트 파싱 if let (text, locale) = record.wellKnownTypeTextPayload() { print("텍스트: \(text), 언어: \(locale)") } ``` ### 3. NFCTagReaderSession (고급 태그) ```swift class AdvancedNFCReader: NSObject, NFCTagReaderSessionDelegate { var session: NFCTagReaderSession? func startScanning() { session = NFCTagReaderSession( pollingOption: [.iso14443, .iso15693, .iso18092], delegate: self ) session?.alertMessage = "태그를 스캔하세요" session?.begin() } func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { guard let tag = tags.first else { return } session.connect(to: tag) { error in if let error = error { session.invalidate(errorMessage: "연결 실패") return } switch tag { case .miFare(let miFareTag): self.handleMiFare(miFareTag) case .iso7816(let iso7816Tag): self.handleISO7816(iso7816Tag) case .iso15693(let iso15693Tag): self.handleISO15693(iso15693Tag) case .feliCa(let feliCaTag): self.handleFeliCa(feliCaTag) @unknown default: break } } } func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) { print("에러: \(error)") } func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) { print("NFC 세션 활성화") } } ``` ## 전체 작동 예제 ```swift import SwiftUI import CoreNFC // MARK: - NFC Manager @Observable class NFCManager: NSObject { var scannedMessage: String = "" var scannedURL: URL? var isScanning = false var errorMessage: String? var isNFCAvailable: Bool { NFCNDEFReaderSession.readingAvailable } private var session: NFCNDEFReaderSession? private var writeSession: NFCNDEFReaderSession? private var messageToWrite: NFCNDEFMessage? // MARK: - 읽기 func startScanning() { guard isNFCAvailable else { errorMessage = "이 기기는 NFC를 지원하지 않습니다" return } scannedMessage = "" scannedURL = nil errorMessage = nil session = NFCNDEFReaderSession( delegate: self, queue: nil, invalidateAfterFirstRead: true ) session?.alertMessage = "NFC 태그에 iPhone을 가까이 대세요" session?.begin() isScanning = true } // MARK: - 쓰기 func writeURL(_ url: URL) { guard isNFCAvailable else { return } // URL 레코드 생성 guard let payload = NFCNDEFPayload.wellKnownTypeURIPayload(url: url) else { return } messageToWrite = NFCNDEFMessage(records: [payload]) writeSession = NFCNDEFReaderSession( delegate: self, queue: nil, invalidateAfterFirstRead: false ) writeSession?.alertMessage = "쓸 태그에 iPhone을 가까이 대세요" writeSession?.begin() isScanning = true } func writeText(_ text: String) { guard isNFCAvailable else { return } // 텍스트 레코드 생성 guard let payload = NFCNDEFPayload.wellKnownTypeTextPayload( string: text, locale: Locale.current ) else { return } messageToWrite = NFCNDEFMessage(records: [payload]) writeSession = NFCNDEFReaderSession( delegate: self, queue: nil, invalidateAfterFirstRead: false ) writeSession?.alertMessage = "쓸 태그에 iPhone을 가까이 대세요" writeSession?.begin() isScanning = true } } // MARK: - NFCNDEFReaderSessionDelegate extension NFCManager: NFCNDEFReaderSessionDelegate { func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) { print("NFC 세션 활성화") } func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { // 읽기 전용 모드 for message in messages { processMessage(message) } DispatchQueue.main.async { self.isScanning = false } } func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCTag]) { guard let tag = tags.first else { session.invalidate(errorMessage: "태그를 찾을 수 없습니다") return } session.connect(to: tag) { error in if let error = error { session.invalidate(errorMessage: "연결 실패: \(error.localizedDescription)") return } // 태그 타입에 따라 NDEF 핸들 가져오기 var ndefTag: NFCNDEFTag? switch tag { case .miFare(let miFareTag): ndefTag = miFareTag case .iso7816(let iso7816Tag): ndefTag = iso7816Tag case .iso15693(let iso15693Tag): ndefTag = iso15693Tag case .feliCa(let feliCaTag): ndefTag = feliCaTag @unknown default: session.invalidate(errorMessage: "지원하지 않는 태그") return } guard let ndef = ndefTag else { return } // 쓰기 모드 if let message = self.messageToWrite { self.writeToTag(ndef, message: message, session: session) } else { // 읽기 모드 self.readFromTag(ndef, session: session) } } } private func readFromTag(_ tag: NFCNDEFTag, session: NFCNDEFReaderSession) { tag.readNDEF { message, error in if let error = error { session.invalidate(errorMessage: "읽기 실패: \(error.localizedDescription)") return } if let message = message { self.processMessage(message) session.alertMessage = "태그를 읽었습니다!" session.invalidate() } } } private func writeToTag(_ tag: NFCNDEFTag, message: NFCNDEFMessage, session: NFCNDEFReaderSession) { tag.queryNDEFStatus { status, capacity, error in if let error = error { session.invalidate(errorMessage: "상태 확인 실패: \(error.localizedDescription)") return } switch status { case .notSupported: session.invalidate(errorMessage: "NDEF를 지원하지 않는 태그입니다") case .readOnly: session.invalidate(errorMessage: "읽기 전용 태그입니다") case .readWrite: tag.writeNDEF(message) { error in if let error = error { session.invalidate(errorMessage: "쓰기 실패: \(error.localizedDescription)") } else { session.alertMessage = "쓰기 완료!" session.invalidate() DispatchQueue.main.async { self.messageToWrite = nil } } } @unknown default: session.invalidate(errorMessage: "알 수 없는 상태") } DispatchQueue.main.async { self.isScanning = false } } } private func processMessage(_ message: NFCNDEFMessage) { var texts: [String] = [] for record in message.records { // URL if let url = record.wellKnownTypeURIPayload() { DispatchQueue.main.async { self.scannedURL = url } texts.append("URL: \(url.absoluteString)") } // 텍스트 if let (text, locale) = record.wellKnownTypeTextPayload() { texts.append("[\(locale.identifier)] \(text)") } } DispatchQueue.main.async { self.scannedMessage = texts.joined(separator: "\n") } } func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { DispatchQueue.main.async { self.isScanning = false if let nfcError = error as? NFCReaderError, nfcError.code != .readerSessionInvalidationErrorFirstNDEFTagRead && nfcError.code != .readerSessionInvalidationErrorUserCanceled { self.errorMessage = error.localizedDescription } } } } // MARK: - Main View struct NFCView: View { @State private var manager = NFCManager() @State private var textToWrite = "" @State private var urlToWrite = "" @State private var showWriteSheet = false var body: some View { NavigationStack { List { // 상태 섹션 Section { HStack { Image(systemName: manager.isNFCAvailable ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundStyle(manager.isNFCAvailable ? .green : .red) Text(manager.isNFCAvailable ? "NFC 사용 가능" : "NFC 사용 불가") } } // 읽기 결과 if !manager.scannedMessage.isEmpty { Section("읽은 내용") { Text(manager.scannedMessage) if let url = manager.scannedURL { Link("링크 열기", destination: url) } } } // 에러 if let error = manager.errorMessage { Section { Label(error, systemImage: "exclamationmark.triangle") .foregroundStyle(.red) } } // 액션 Section { Button { manager.startScanning() } label: { Label("태그 읽기", systemImage: "wave.3.right") } .disabled(manager.isScanning) Button { showWriteSheet = true } label: { Label("태그에 쓰기", systemImage: "square.and.pencil") } .disabled(manager.isScanning) } } .navigationTitle("NFC") .overlay { if manager.isScanning { VStack { ProgressView() Text("스캔 중...") } .padding() .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } } .sheet(isPresented: $showWriteSheet) { NavigationStack { Form { Section("텍스트 쓰기") { TextField("텍스트", text: $textToWrite) Button("쓰기") { manager.writeText(textToWrite) showWriteSheet = false } .disabled(textToWrite.isEmpty) } Section("URL 쓰기") { TextField("URL", text: $urlToWrite) .keyboardType(.URL) .autocapitalization(.none) Button("쓰기") { if let url = URL(string: urlToWrite) { manager.writeURL(url) showWriteSheet = false } } .disabled(URL(string: urlToWrite) == nil) } } .navigationTitle("태그에 쓰기") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { showWriteSheet = false } } } } .presentationDetents([.medium]) } } } } #Preview { NFCView() } ``` ## 고급 패턴 ### 1. 백그라운드 태그 읽기 ```swift // AppDelegate에서 설정 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // 백그라운드 NDEF 감지는 자동 return true } // SceneDelegate에서 처리 func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } // NFC 태그의 URL 처리 handleNFCURL(url) } ``` ### 2. ISO 7816 스마트카드 ```swift func handleISO7816(_ tag: NFCISO7816Tag) { // AID 선택 let selectAID = NFCISO7816APDU( instructionClass: 0x00, instructionCode: 0xA4, p1Parameter: 0x04, p2Parameter: 0x00, data: Data([0xA0, 0x00, 0x00, 0x02, 0x47, 0x10, 0x01]), expectedResponseLength: -1 ) tag.sendCommand(apdu: selectAID) { data, sw1, sw2, error in if sw1 == 0x90 && sw2 == 0x00 { print("선택 성공, 데이터: \(data)") } } } ``` ### 3. FeliCa (Suica 등) ```swift func handleFeliCa(_ tag: NFCFeliCaTag) { let serviceCode = Data([0x00, 0x0B]) // 서비스 코드 tag.readWithoutEncryption( serviceCodeList: [serviceCode], blockList: [Data([0x80, 0x00])] ) { status1, status2, blocks, error in if let error = error { print("읽기 실패: \(error)") return } for block in blocks { print("블록 데이터: \(block.hexString)") } } } ``` ## 주의사항 1. **기기 호환성** ```swift // iPhone 7 이상, iOS 11+ guard NFCNDEFReaderSession.readingAvailable else { // NFC 미지원 return } ``` 2. **세션 제한** - 한 번에 하나의 NFC 세션만 가능 - 60초 타임아웃 - 포그라운드에서만 동작 3. **태그 타입** - NDEF: 대부분의 NFC 태그 - ISO 7816: 스마트카드, 신용카드 - FeliCa: 일본 교통카드 (Suica) - MIFARE: 접근카드 4. **앱 백그라운드 태그 읽기** - iOS 12+에서 지원 - Universal Links 또는 URL Scheme 사용 - entitlements 필요 5. **시뮬레이터** - NFC 미지원 - 실기기 테스트 필수 --- # Core Haptics AI Reference > 햅틱 피드백 구현 가이드. 이 문서를 읽고 Core Haptics 코드를 생성할 수 있습니다. ## 개요 Core Haptics는 커스텀 햅틱(진동) 패턴을 생성하고 재생하는 프레임워크입니다. 게임, 알림, UI 피드백에 풍부한 촉각 경험을 제공합니다. ## 필수 Import ```swift import CoreHaptics ``` ## 핵심 구성요소 ### 1. 햅틱 엔진 설정 ```swift class HapticManager { private var engine: CHHapticEngine? init() { guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { print("햅틱 미지원 기기") return } do { engine = try CHHapticEngine() try engine?.start() // 엔진 리셋 핸들러 engine?.resetHandler = { [weak self] in try? self?.engine?.start() } // 엔진 중지 핸들러 engine?.stoppedHandler = { reason in print("엔진 중지: \(reason)") } } catch { print("햅틱 엔진 초기화 실패: \(error)") } } } ``` ### 2. 간단한 UIKit 햅틱 ```swift // 가장 간단한 방법 (UIKit) let impact = UIImpactFeedbackGenerator(style: .medium) impact.impactOccurred() // 스타일: .light, .medium, .heavy, .soft, .rigid // 알림 햅틱 let notification = UINotificationFeedbackGenerator() notification.notificationOccurred(.success) // .success, .warning, .error // 선택 햅틱 let selection = UISelectionFeedbackGenerator() selection.selectionChanged() ``` ## 전체 작동 예제 ```swift import SwiftUI import CoreHaptics // MARK: - Haptic Manager @Observable class HapticManager { private var engine: CHHapticEngine? var supportsHaptics: Bool init() { supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics setupEngine() } private func setupEngine() { guard supportsHaptics else { return } do { engine = try CHHapticEngine() engine?.playsHapticsOnly = true engine?.resetHandler = { [weak self] in try? self?.engine?.start() } try engine?.start() } catch { print("햅틱 엔진 설정 실패: \(error)") } } // MARK: - 기본 햅틱 func playImpact(style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) { let generator = UIImpactFeedbackGenerator(style: style) generator.impactOccurred() } func playNotification(type: UINotificationFeedbackGenerator.FeedbackType) { let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(type) } func playSelection() { let generator = UISelectionFeedbackGenerator() generator.selectionChanged() } // MARK: - 커스텀 햅틱 패턴 func playCustomPattern() { guard let engine else { return } do { // 이벤트 정의 let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0) // 탭 이벤트 let tap = CHHapticEvent( eventType: .hapticTransient, parameters: [sharpness, intensity], relativeTime: 0 ) // 연속 진동 let continuous = CHHapticEvent( eventType: .hapticContinuous, parameters: [sharpness, intensity], relativeTime: 0.1, duration: 0.3 ) let pattern = try CHHapticPattern(events: [tap, continuous], parameters: []) let player = try engine.makePlayer(with: pattern) try player.start(atTime: 0) } catch { print("커스텀 햅틱 재생 실패: \(error)") } } // 심박동 패턴 func playHeartbeat() { guard let engine else { return } do { var events: [CHHapticEvent] = [] for beat in 0..<4 { let time = Double(beat) * 0.6 // 강한 박동 events.append(CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) ], relativeTime: time )) // 약한 박동 (0.15초 후) events.append(CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2) ], relativeTime: time + 0.15 )) } let pattern = try CHHapticPattern(events: events, parameters: []) let player = try engine.makePlayer(with: pattern) try player.start(atTime: 0) } catch { print("심박동 햅틱 실패: \(error)") } } // 성공 패턴 func playSuccess() { guard let engine else { return } do { let events = [ CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) ], relativeTime: 0 ), CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7) ], relativeTime: 0.1 ) ] let pattern = try CHHapticPattern(events: events, parameters: []) let player = try engine.makePlayer(with: pattern) try player.start(atTime: 0) } catch { print("성공 햅틱 실패: \(error)") } } // 에러 패턴 func playError() { guard let engine else { return } do { var events: [CHHapticEvent] = [] for i in 0..<3 { events.append(CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0) ], relativeTime: Double(i) * 0.1 )) } let pattern = try CHHapticPattern(events: events, parameters: []) let player = try engine.makePlayer(with: pattern) try player.start(atTime: 0) } catch { print("에러 햅틱 실패: \(error)") } } } // MARK: - Views struct HapticDemoView: View { @State private var haptic = HapticManager() var body: some View { NavigationStack { List { Section("기본 햅틱") { Button("Light Impact") { haptic.playImpact(style: .light) } Button("Medium Impact") { haptic.playImpact(style: .medium) } Button("Heavy Impact") { haptic.playImpact(style: .heavy) } Button("Selection") { haptic.playSelection() } } Section("알림 햅틱") { Button("Success") { haptic.playNotification(type: .success) } .tint(.green) Button("Warning") { haptic.playNotification(type: .warning) } .tint(.orange) Button("Error") { haptic.playNotification(type: .error) } .tint(.red) } Section("커스텀 패턴") { Button("Custom Pattern") { haptic.playCustomPattern() } Button("Heartbeat 💓") { haptic.playHeartbeat() } Button("Success ✓") { haptic.playSuccess() } Button("Error ✗") { haptic.playError() } } if !haptic.supportsHaptics { Section { Text("이 기기는 햅틱을 지원하지 않습니다") .foregroundStyle(.secondary) } } } .navigationTitle("햅틱 데모") } } } ``` ## 고급 패턴 ### 1. AHAP 파일 사용 ```swift // AHAP (Apple Haptic Audio Pattern) 파일 로드 func playFromFile(named filename: String) { guard let engine, let url = Bundle.main.url(forResource: filename, withExtension: "ahap") else { return } do { try engine.playPattern(from: url) } catch { print("AHAP 재생 실패: \(error)") } } ``` **AHAP 파일 예시 (success.ahap)**: ```json { "Version": 1.0, "Pattern": [ { "Event": { "Time": 0.0, "EventType": "HapticTransient", "EventParameters": [ {"ParameterID": "HapticIntensity", "ParameterValue": 0.8}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.4} ] } }, { "Event": { "Time": 0.1, "EventType": "HapticTransient", "EventParameters": [ {"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.6} ] } } ] } ``` ### 2. 실시간 파라미터 조절 ```swift func playWithDynamicControl() throws { guard let engine else { return } // 연속 진동 이벤트 let event = CHHapticEvent( eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) ], relativeTime: 0, duration: 2.0 ) // 동적 파라미터 (시간에 따라 변화) let curve = CHHapticParameterCurve( parameterID: .hapticIntensityControl, controlPoints: [ CHHapticParameterCurve.ControlPoint(relativeTime: 0, value: 0.2), CHHapticParameterCurve.ControlPoint(relativeTime: 0.5, value: 1.0), CHHapticParameterCurve.ControlPoint(relativeTime: 1.0, value: 0.2) ], relativeTime: 0 ) let pattern = try CHHapticPattern(events: [event], parameterCurves: [curve]) let player = try engine.makeAdvancedPlayer(with: pattern) try player.start(atTime: 0) } ``` ### 3. 오디오와 햅틱 동기화 ```swift func playAudioHaptic() { guard let engine else { return } engine.playsHapticsOnly = false // 오디오도 재생 do { let audioEvent = CHHapticEvent( eventType: .audioContinuous, parameters: [ CHHapticEventParameter(parameterID: .audioVolume, value: 0.5) ], relativeTime: 0, duration: 1.0 ) let hapticEvent = CHHapticEvent( eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8) ], relativeTime: 0, duration: 1.0 ) let pattern = try CHHapticPattern(events: [audioEvent, hapticEvent], parameters: []) let player = try engine.makePlayer(with: pattern) try player.start(atTime: 0) } catch { print("오디오-햅틱 실패: \(error)") } } ``` ## 주의사항 1. **기기 지원 확인** ```swift CHHapticEngine.capabilitiesForHardware().supportsHaptics CHHapticEngine.capabilitiesForHardware().supportsAudio ``` 2. **엔진 라이프사이클** - 앱 백그라운드 시 엔진 자동 중지 - `resetHandler`에서 재시작 처리 3. **배터리 고려** - 과도한 햅틱은 배터리 소모 - 짧고 의미 있는 피드백 권장 4. **시뮬레이터 제한** - 시뮬레이터에서는 햅틱 체험 불가 - 실제 기기에서 테스트 필요 --- # Core Image AI Reference > 이미지 필터링 및 처리 가이드. 이 문서를 읽고 Core Image 코드를 생성할 수 있습니다. ## 개요 Core Image는 GPU 가속 이미지 필터링 프레임워크로, 200개 이상의 내장 필터를 제공합니다. 실시간 이미지/비디오 처리, 얼굴 감지, QR 코드 인식 등을 지원합니다. ## 필수 Import ```swift import CoreImage import CoreImage.CIFilterBuiltins // 타입 안전한 필터 API ``` ## 핵심 구성요소 ### 1. CIImage (입력/출력) ```swift // UIImage에서 생성 let ciImage = CIImage(image: uiImage) // CGImage에서 생성 let ciImage = CIImage(cgImage: cgImage) // Data에서 생성 let ciImage = CIImage(data: imageData) // URL에서 생성 let ciImage = CIImage(contentsOf: url) ``` ### 2. CIFilter (필터) ```swift // 타입 안전한 API (권장) let filter = CIFilter.sepiaTone() filter.inputImage = ciImage filter.intensity = 0.8 let output = filter.outputImage // 문자열 기반 API (레거시) let filter = CIFilter(name: "CISepiaTone")! filter.setValue(ciImage, forKey: kCIInputImageKey) filter.setValue(0.8, forKey: kCIInputIntensityKey) let output = filter.outputImage ``` ### 3. CIContext (렌더링) ```swift // 기본 컨텍스트 let context = CIContext() // Metal 가속 (성능 최적화) let context = CIContext(mtlDevice: MTLCreateSystemDefaultDevice()!) // CGImage로 렌더링 let cgImage = context.createCGImage(ciImage, from: ciImage.extent) // UIImage로 변환 let uiImage = UIImage(cgImage: cgImage!) ``` ## 전체 작동 예제 ```swift import SwiftUI import CoreImage import CoreImage.CIFilterBuiltins import PhotosUI // MARK: - Filter Type enum ImageFilter: String, CaseIterable { case original = "원본" case sepia = "세피아" case noir = "흑백 누아르" case chrome = "크롬" case fade = "페이드" case instant = "인스턴트" case mono = "모노" case vignette = "비네트" case bloom = "블룸" case sharpen = "샤픈" } // MARK: - Image Processor @Observable class ImageProcessor { var originalImage: UIImage? var filteredImage: UIImage? var currentFilter: ImageFilter = .original var intensity: Float = 0.5 var isProcessing = false private let context = CIContext(options: [.useSoftwareRenderer: false]) func applyFilter() { guard let original = originalImage, let ciImage = CIImage(image: original) else { return } isProcessing = true Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } let output = await self.processImage(ciImage, filter: self.currentFilter) await MainActor.run { self.filteredImage = output self.isProcessing = false } } } private func processImage(_ input: CIImage, filter: ImageFilter) async -> UIImage? { let output: CIImage? switch filter { case .original: output = input case .sepia: let filter = CIFilter.sepiaTone() filter.inputImage = input filter.intensity = intensity output = filter.outputImage case .noir: let filter = CIFilter.photoEffectNoir() filter.inputImage = input output = filter.outputImage case .chrome: let filter = CIFilter.photoEffectChrome() filter.inputImage = input output = filter.outputImage case .fade: let filter = CIFilter.photoEffectFade() filter.inputImage = input output = filter.outputImage case .instant: let filter = CIFilter.photoEffectInstant() filter.inputImage = input output = filter.outputImage case .mono: let filter = CIFilter.photoEffectMono() filter.inputImage = input output = filter.outputImage case .vignette: let filter = CIFilter.vignette() filter.inputImage = input filter.intensity = intensity * 2 filter.radius = 1.5 output = filter.outputImage case .bloom: let filter = CIFilter.bloom() filter.inputImage = input filter.intensity = intensity filter.radius = 10 output = filter.outputImage case .sharpen: let filter = CIFilter.sharpenLuminance() filter.inputImage = input filter.sharpness = intensity output = filter.outputImage } guard let outputImage = output, let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return nil } return UIImage(cgImage: cgImage) } } // MARK: - Main View struct ImageFilterView: View { @State private var processor = ImageProcessor() @State private var selectedItem: PhotosPickerItem? var body: some View { NavigationStack { VStack(spacing: 0) { // 이미지 표시 ZStack { if let image = processor.filteredImage ?? processor.originalImage { Image(uiImage: image) .resizable() .scaledToFit() } else { ContentUnavailableView( "이미지 선택", systemImage: "photo.badge.plus", description: Text("사진을 선택하여 필터를 적용하세요") ) } if processor.isProcessing { ProgressView() .scaleEffect(1.5) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.ultraThinMaterial) } } .frame(maxHeight: .infinity) // 필터 컨트롤 if processor.originalImage != nil { VStack(spacing: 16) { // 강도 조절 if processor.currentFilter != .original && [.sepia, .vignette, .bloom, .sharpen].contains(processor.currentFilter) { HStack { Text("강도") Slider(value: $processor.intensity, in: 0...1) .onChange(of: processor.intensity) { _, _ in processor.applyFilter() } } .padding(.horizontal) } // 필터 선택 ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(ImageFilter.allCases, id: \.self) { filter in FilterButton( filter: filter, isSelected: processor.currentFilter == filter ) { processor.currentFilter = filter processor.applyFilter() } } } .padding(.horizontal) } } .padding(.vertical) .background(.ultraThinMaterial) } } .navigationTitle("이미지 필터") .toolbar { ToolbarItem(placement: .topBarTrailing) { PhotosPicker(selection: $selectedItem, matching: .images) { Image(systemName: "photo.badge.plus") } } if processor.filteredImage != nil { ToolbarItem(placement: .topBarTrailing) { ShareLink(item: Image(uiImage: processor.filteredImage!), preview: SharePreview("필터 적용 이미지", image: Image(uiImage: processor.filteredImage!))) } } } .onChange(of: selectedItem) { _, newItem in Task { if let data = try? await newItem?.loadTransferable(type: Data.self), let image = UIImage(data: data) { processor.originalImage = image processor.filteredImage = image processor.currentFilter = .original } } } } } } // MARK: - Filter Button struct FilterButton: View { let filter: ImageFilter let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { Text(filter.rawValue) .font(.subheadline) .padding(.horizontal, 16) .padding(.vertical, 8) .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.2)) .foregroundStyle(isSelected ? .white : .primary) .clipShape(Capsule()) } } } #Preview { ImageFilterView() } ``` ## 고급 패턴 ### 1. 필터 체이닝 ```swift func applyMultipleFilters(to image: CIImage) -> CIImage? { // 밝기 조절 let brightness = CIFilter.colorControls() brightness.inputImage = image brightness.brightness = 0.1 guard let brightened = brightness.outputImage else { return nil } // 대비 조절 let contrast = CIFilter.colorControls() contrast.inputImage = brightened contrast.contrast = 1.2 guard let contrasted = contrast.outputImage else { return nil } // 비네트 추가 let vignette = CIFilter.vignette() vignette.inputImage = contrasted vignette.intensity = 1.0 vignette.radius = 2.0 return vignette.outputImage } ``` ### 2. 얼굴 감지 ```swift func detectFaces(in image: CIImage) -> [CIFaceFeature] { let detector = CIDetector( ofType: CIDetectorTypeFace, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh] )! let features = detector.features( in: image, options: [CIDetectorSmile: true, CIDetectorEyeBlink: true] ) as? [CIFaceFeature] ?? [] for face in features { print("얼굴 위치: \(face.bounds)") print("웃음 감지: \(face.hasSmile)") print("왼쪽 눈 감김: \(face.leftEyeClosed)") print("오른쪽 눈 감김: \(face.rightEyeClosed)") } return features } ``` ### 3. QR/바코드 감지 ```swift func detectQRCode(in image: CIImage) -> [String] { let detector = CIDetector( ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh] )! let features = detector.features(in: image) as? [CIQRCodeFeature] ?? [] return features.compactMap { $0.messageString } } ``` ### 4. 실시간 비디오 필터 ```swift import AVFoundation class VideoFilterProcessor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { private let context = CIContext() private let filter = CIFilter.sepiaTone() var onFrame: ((UIImage) -> Void)? func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } let ciImage = CIImage(cvPixelBuffer: pixelBuffer) filter.inputImage = ciImage filter.intensity = 0.8 guard let outputImage = filter.outputImage, let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return } let uiImage = UIImage(cgImage: cgImage) DispatchQueue.main.async { self.onFrame?(uiImage) } } } ``` ### 5. 커스텀 필터 (CIKernel) ```swift // Metal Shading Language로 커스텀 필터 작성 let kernelSource = """ #include extern "C" float4 customEffect(coreimage::sampler src, float intensity) { float4 color = src.sample(src.coord()); float gray = dot(color.rgb, float3(0.299, 0.587, 0.114)); float3 result = mix(color.rgb, float3(gray), intensity); return float4(result, color.a); } """ // 커스텀 필터 사용 func applyCustomFilter(to image: CIImage) -> CIImage? { guard let kernel = try? CIColorKernel(functionName: "customEffect", fromMetalLibraryData: metalLibData) else { return nil } return kernel.apply( extent: image.extent, arguments: [image, 0.5] ) } ``` ## 주요 필터 목록 ### 색상 조절 | 필터 | 설명 | |------|------| | `CIFilter.colorControls()` | 밝기, 대비, 채도 | | `CIFilter.exposureAdjust()` | 노출 조절 | | `CIFilter.gammaAdjust()` | 감마 조절 | | `CIFilter.hueAdjust()` | 색조 조절 | | `CIFilter.temperatureAndTint()` | 색온도 | ### 사진 효과 | 필터 | 설명 | |------|------| | `CIFilter.photoEffectChrome()` | 크롬 효과 | | `CIFilter.photoEffectFade()` | 페이드 효과 | | `CIFilter.photoEffectInstant()` | 인스턴트 카메라 | | `CIFilter.photoEffectMono()` | 흑백 | | `CIFilter.photoEffectNoir()` | 누아르 | ### 블러/샤픈 | 필터 | 설명 | |------|------| | `CIFilter.gaussianBlur()` | 가우시안 블러 | | `CIFilter.boxBlur()` | 박스 블러 | | `CIFilter.motionBlur()` | 모션 블러 | | `CIFilter.sharpenLuminance()` | 샤프닝 | | `CIFilter.unsharpMask()` | 언샤프 마스크 | ## 주의사항 1. **CIContext 재사용** ```swift // ❌ 매번 생성 (느림) func process() { let context = CIContext() // ... } // ✅ 인스턴스 변수로 재사용 private let context = CIContext() ``` 2. **백그라운드 처리** - 이미지 처리는 메인 스레드 차단 - `Task.detached`로 백그라운드 실행 3. **메모리 관리** - CIImage는 lazy evaluation - 체인이 길면 중간에 렌더링 고려 4. **좌표계** - Core Image는 좌하단 원점 - UIKit은 좌상단 원점 - 변환 필요할 수 있음 5. **시뮬레이터 성능** - 실제 기기보다 훨씬 느림 - 성능 테스트는 실기기에서 --- # Core Location AI Reference > 위치 서비스 및 지오펜싱 가이드. 이 문서를 읽고 Core Location 코드를 생성할 수 있습니다. ## 개요 Core Location은 기기의 위치, 고도, 방향 정보를 제공하는 프레임워크입니다. GPS, Wi-Fi, 셀룰러, 비콘을 활용해 위치를 파악합니다. ## 필수 Import ```swift import CoreLocation ``` ## 프로젝트 설정 (Info.plist) ```xml NSLocationWhenInUseUsageDescription 현재 위치를 지도에 표시하기 위해 필요합니다. NSLocationAlwaysAndWhenInUseUsageDescription 백그라운드에서 위치 기반 알림을 보내기 위해 필요합니다. ``` ## 핵심 구성요소 ### 1. CLLocationManager ```swift @Observable class LocationManager: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() var location: CLLocation? var authorizationStatus: CLAuthorizationStatus = .notDetermined override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyBest } func requestPermission() { manager.requestWhenInUseAuthorization() } func startUpdating() { manager.startUpdatingLocation() } func stopUpdating() { manager.stopUpdatingLocation() } // MARK: - Delegate func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { location = locations.last } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationStatus = manager.authorizationStatus } } ``` ### 2. 권한 상태 ```swift switch manager.authorizationStatus { case .notDetermined: // 아직 요청 안 함 manager.requestWhenInUseAuthorization() case .restricted, .denied: // 설정으로 유도 if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } case .authorizedWhenInUse: // 앱 사용 중만 허용 manager.startUpdatingLocation() case .authorizedAlways: // 항상 허용 (백그라운드 가능) manager.startUpdatingLocation() @unknown default: break } ``` ### 3. 정확도 설정 ```swift // 최고 정확도 (배터리 소모 높음) manager.desiredAccuracy = kCLLocationAccuracyBest // 네비게이션용 manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation // 10미터 정확도 manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters // 100미터 정확도 (배터리 절약) manager.desiredAccuracy = kCLLocationAccuracyHundredMeters // 킬로미터 정확도 manager.desiredAccuracy = kCLLocationAccuracyKilometer // 최소 이동 거리 (미터) manager.distanceFilter = 10 // 10m 이동 시마다 업데이트 ``` ## 전체 작동 예제 ```swift import SwiftUI import CoreLocation // MARK: - Location Manager @Observable class LocationManager: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() var location: CLLocation? var placemark: CLPlacemark? var authorizationStatus: CLAuthorizationStatus = .notDetermined var isLoading = false var error: Error? override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyBest manager.distanceFilter = 10 authorizationStatus = manager.authorizationStatus } func requestPermission() { manager.requestWhenInUseAuthorization() } func requestLocation() { isLoading = true manager.requestLocation() // 단일 위치 요청 } func startContinuousUpdates() { manager.startUpdatingLocation() } func stopUpdates() { manager.stopUpdatingLocation() } private func reverseGeocode(_ location: CLLocation) { let geocoder = CLGeocoder() geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in self?.placemark = placemarks?.first } } // MARK: - CLLocationManagerDelegate func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { isLoading = false guard let newLocation = locations.last else { return } // 정확도 필터링 guard newLocation.horizontalAccuracy > 0 && newLocation.horizontalAccuracy < 100 else { return } location = newLocation reverseGeocode(newLocation) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { isLoading = false self.error = error } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationStatus = manager.authorizationStatus if authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways { requestLocation() } } } // MARK: - View struct LocationView: View { @State private var locationManager = LocationManager() var body: some View { NavigationStack { VStack(spacing: 24) { // 권한 상태 StatusBadge(status: locationManager.authorizationStatus) // 현재 위치 if let location = locationManager.location { VStack(spacing: 8) { Text("현재 위치") .font(.headline) Text("\(location.coordinate.latitude, specifier: "%.4f"), \(location.coordinate.longitude, specifier: "%.4f")") .font(.system(.body, design: .monospaced)) if let placemark = locationManager.placemark { Text(formatAddress(placemark)) .foregroundStyle(.secondary) } Text("정확도: \(Int(location.horizontalAccuracy))m") .font(.caption) .foregroundStyle(.secondary) } .padding() .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } else if locationManager.isLoading { ProgressView("위치 확인 중...") } // 버튼 VStack(spacing: 12) { if locationManager.authorizationStatus == .notDetermined { Button("위치 권한 요청") { locationManager.requestPermission() } .buttonStyle(.borderedProminent) } else if locationManager.authorizationStatus == .authorizedWhenInUse || locationManager.authorizationStatus == .authorizedAlways { Button("현재 위치 새로고침") { locationManager.requestLocation() } .buttonStyle(.bordered) } else { Button("설정에서 권한 허용") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .buttonStyle(.bordered) } } Spacer() } .padding() .navigationTitle("위치") } } func formatAddress(_ placemark: CLPlacemark) -> String { [placemark.locality, placemark.thoroughfare, placemark.subThoroughfare] .compactMap { $0 } .joined(separator: " ") } } struct StatusBadge: View { let status: CLAuthorizationStatus var body: some View { HStack { Image(systemName: icon) Text(text) } .font(.caption) .padding(.horizontal, 12) .padding(.vertical, 6) .background(color.opacity(0.2)) .foregroundStyle(color) .clipShape(Capsule()) } var icon: String { switch status { case .authorizedAlways, .authorizedWhenInUse: return "checkmark.circle.fill" case .denied, .restricted: return "xmark.circle.fill" default: return "questionmark.circle.fill" } } var text: String { switch status { case .authorizedAlways: return "항상 허용" case .authorizedWhenInUse: return "앱 사용 중 허용" case .denied: return "거부됨" case .restricted: return "제한됨" case .notDetermined: return "권한 필요" @unknown default: return "알 수 없음" } } var color: Color { switch status { case .authorizedAlways, .authorizedWhenInUse: return .green case .denied, .restricted: return .red default: return .orange } } } ``` ## 고급 패턴 ### 1. 지오펜싱 ```swift func setupGeofence(center: CLLocationCoordinate2D, radius: Double, identifier: String) { let region = CLCircularRegion( center: center, radius: radius, identifier: identifier ) region.notifyOnEntry = true region.notifyOnExit = true manager.startMonitoring(for: region) } // Delegate func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { print("진입: \(region.identifier)") // 로컬 알림 등 } func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { print("이탈: \(region.identifier)") } ``` ### 2. 백그라운드 위치 ```swift // 1. Capabilities: Background Modes → Location updates 체크 // 2. Info.plist: NSLocationAlwaysAndWhenInUseUsageDescription func enableBackgroundLocation() { manager.allowsBackgroundLocationUpdates = true manager.pausesLocationUpdatesAutomatically = false manager.showsBackgroundLocationIndicator = true // 파란 바 표시 } ``` ### 3. 방향 (Heading) ```swift func startHeadingUpdates() { if CLLocationManager.headingAvailable() { manager.startUpdatingHeading() } } func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { let trueHeading = newHeading.trueHeading // 진북 기준 (0-360) let magneticHeading = newHeading.magneticHeading // 자북 기준 print("방향: \(trueHeading)°") } ``` ### 4. 거리 계산 ```swift let seoul = CLLocation(latitude: 37.5665, longitude: 126.9780) let busan = CLLocation(latitude: 35.1796, longitude: 129.0756) let distance = seoul.distance(from: busan) // 미터 단위 print("서울-부산: \(distance / 1000) km") // ~325 km ``` ## 주의사항 1. **권한 요청 타이밍** - 앱 시작 시 바로 요청 ❌ - 기능 사용 직전에 요청 ✅ - 왜 필요한지 설명 UI 추가 2. **배터리 최적화** - 필요할 때만 `startUpdatingLocation()` - 단일 요청은 `requestLocation()` 사용 - `distanceFilter` 적절히 설정 3. **정확도 vs 배터리** - `kCLLocationAccuracyBest`: GPS 사용, 배터리 많이 소모 - `kCLLocationAccuracyHundredMeters`: Wi-Fi/Cell, 절약 4. **시뮬레이터 테스트** - Features → Location → Custom Location - 또는 GPX 파일로 경로 시뮬레이션 --- # Core ML AI Reference > 온디바이스 머신러닝 가이드. 이 문서를 읽고 Core ML 코드를 생성할 수 있습니다. ## 개요 Core ML은 학습된 ML 모델을 앱에서 실행하는 프레임워크입니다. 이미지 분류, 객체 감지, 자연어 처리 등 다양한 ML 작업을 온디바이스에서 수행합니다. ## 필수 Import ```swift import CoreML import Vision // 이미지 분석 시 ``` ## 핵심 구성요소 ### 1. 모델 로드 ```swift // 1. 번들된 모델 (컴파일된 .mlmodelc) let model = try? MyImageClassifier(configuration: MLModelConfiguration()) // 2. 동적 로드 (URL에서) let modelURL = Bundle.main.url(forResource: "MyModel", withExtension: "mlmodelc")! let model = try MLModel(contentsOf: modelURL) // 3. 백그라운드에서 컴파일 let sourceURL = Bundle.main.url(forResource: "MyModel", withExtension: "mlmodel")! let compiledURL = try await MLModel.compileModel(at: sourceURL) let model = try MLModel(contentsOf: compiledURL) ``` ### 2. 모델 설정 ```swift let config = MLModelConfiguration() // 연산 장치 선택 config.computeUnits = .all // CPU + GPU + Neural Engine config.computeUnits = .cpuOnly // CPU만 config.computeUnits = .cpuAndGPU // Neural Engine 제외 // GPU 허용 config.allowLowPrecisionAccumulationOnGPU = true let model = try MyModel(configuration: config) ``` ### 3. Vision + Core ML ```swift func classifyImage(_ image: UIImage) async throws -> [(String, Float)] { guard let cgImage = image.cgImage else { throw ClassificationError.invalidImage } // Core ML 모델을 Vision 모델로 래핑 let model = try VNCoreMLModel(for: MobileNetV2().model) let request = VNCoreMLRequest(model: model) request.imageCropAndScaleOption = .centerCrop let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) try handler.perform([request]) guard let results = request.results as? [VNClassificationObservation] else { throw ClassificationError.noResults } return results.prefix(5).map { ($0.identifier, $0.confidence) } } ``` ## 전체 작동 예제 ### 이미지 분류기 ```swift import SwiftUI import CoreML import Vision import PhotosUI // MARK: - Classifier @Observable class ImageClassifier { var predictions: [(label: String, confidence: Float)] = [] var isProcessing = false var error: Error? private var model: VNCoreMLModel? init() { setupModel() } private func setupModel() { do { // MobileNetV2 모델 사용 (Apple 제공) let config = MLModelConfiguration() config.computeUnits = .all let coreMLModel = try MobileNetV2(configuration: config).model model = try VNCoreMLModel(for: coreMLModel) } catch { self.error = error } } func classify(_ image: UIImage) async { guard let cgImage = image.cgImage, let model = model else { return } isProcessing = true defer { isProcessing = false } let request = VNCoreMLRequest(model: model) request.imageCropAndScaleOption = .centerCrop let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) if let results = request.results as? [VNClassificationObservation] { await MainActor.run { predictions = results.prefix(5).map { (label: $0.identifier.components(separatedBy: ",").first ?? $0.identifier, confidence: $0.confidence) } } } } catch { await MainActor.run { self.error = error } } } } // MARK: - View struct ImageClassifierView: View { @State private var classifier = ImageClassifier() @State private var selectedItem: PhotosPickerItem? @State private var selectedImage: UIImage? var body: some View { NavigationStack { VStack(spacing: 20) { // 이미지 선택 PhotosPicker(selection: $selectedItem, matching: .images) { Group { if let image = selectedImage { Image(uiImage: image) .resizable() .scaledToFit() } else { ContentUnavailableView("이미지 선택", systemImage: "photo", description: Text("분류할 이미지를 선택하세요")) } } .frame(maxHeight: 300) } // 결과 if classifier.isProcessing { ProgressView("분석 중...") } else if !classifier.predictions.isEmpty { VStack(alignment: .leading, spacing: 12) { Text("분류 결과") .font(.headline) ForEach(classifier.predictions, id: \.label) { prediction in HStack { Text(prediction.label) Spacer() Text("\(Int(prediction.confidence * 100))%") .foregroundStyle(.secondary) } ProgressView(value: prediction.confidence) .tint(prediction.confidence > 0.5 ? .green : .orange) } } .padding() .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } Spacer() } .padding() .navigationTitle("이미지 분류") .onChange(of: selectedItem) { _, newItem in Task { if let data = try? await newItem?.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedImage = image await classifier.classify(image) } } } } } } ``` ### 텍스트 분류 ```swift import NaturalLanguage @Observable class SentimentAnalyzer { var sentiment: String = "" var confidence: Double = 0 func analyze(_ text: String) { let tagger = NLTagger(tagSchemes: [.sentimentScore]) tagger.string = text let (sentiment, _) = tagger.tag(at: text.startIndex, unit: .paragraph, scheme: .sentimentScore) if let sentimentScore = sentiment?.rawValue, let score = Double(sentimentScore) { self.confidence = abs(score) if score > 0.1 { self.sentiment = "긍정적 😊" } else if score < -0.1 { self.sentiment = "부정적 😞" } else { self.sentiment = "중립적 😐" } } } } ``` ## 고급 패턴 ### 1. 커스텀 모델 사용 ```swift // 1. Create ML로 학습한 모델 // 2. Xcode 프로젝트에 .mlmodel 파일 추가 // 3. 자동 생성된 클래스 사용 class CustomClassifier { let model: MyCustomModel init() throws { let config = MLModelConfiguration() model = try MyCustomModel(configuration: config) } func predict(input: MLMultiArray) throws -> MyCustomModelOutput { let input = MyCustomModelInput(features: input) return try model.prediction(input: input) } } ``` ### 2. 실시간 카메라 분류 ```swift import AVFoundation class CameraClassifier: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { var onPrediction: ([(String, Float)]) -> Void = { _ in } private let model: VNCoreMLModel private let captureSession = AVCaptureSession() init() throws { let coreModel = try MobileNetV2(configuration: MLModelConfiguration()).model model = try VNCoreMLModel(for: coreModel) super.init() setupCamera() } private func setupCamera() { guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device) else { return } captureSession.addInput(input) let output = AVCaptureVideoDataOutput() output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "ml.queue")) captureSession.addOutput(output) } func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } let request = VNCoreMLRequest(model: model) { [weak self] request, _ in guard let results = request.results as? [VNClassificationObservation] else { return } let predictions = results.prefix(3).map { ($0.identifier, $0.confidence) } DispatchQueue.main.async { self?.onPrediction(predictions) } } let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:]) try? handler.perform([request]) } } ``` ### 3. 모델 업데이트 (On-Device Training) ```swift // Updatable 모델 필요 (.mlmodel에서 설정) func updateModel(with trainingData: MLBatchProvider) async throws { let modelURL = Bundle.main.url(forResource: "UpdatableModel", withExtension: "mlmodelc")! let updateTask = try MLUpdateTask( forModelAt: modelURL, trainingData: trainingData, configuration: nil, completionHandler: { context in // 업데이트된 모델 저장 let updatedModelURL = context.model.modelDescription.metadata[MLModelMetadataKey.creatorDefinedKey(key: "updatedModelURL")] } ) updateTask.resume() } ``` ## 주의사항 1. **모델 크기** - 앱 번들 크기에 영향 - 큰 모델은 On-Demand Resources 고려 - 양자화로 크기 축소 가능 2. **성능 최적화** ```swift // Neural Engine 우선 사용 config.computeUnits = .all // 저전력 모드에서 CPU만 if ProcessInfo.processInfo.isLowPowerModeEnabled { config.computeUnits = .cpuOnly } ``` 3. **입력 전처리** - 모델이 요구하는 이미지 크기로 리사이즈 - 정규화 필요한 경우 직접 처리 - Vision 사용 시 자동 처리됨 4. **에러 처리** ```swift do { let prediction = try model.prediction(input: input) } catch MLModelError.generic { // 일반 오류 } catch MLModelError.io { // 입출력 오류 } catch { // 기타 오류 } ``` --- # CryptoKit AI Reference > 암호화 및 해싱 가이드. 이 문서를 읽고 CryptoKit 코드를 생성할 수 있습니다. ## 개요 CryptoKit은 암호화, 해싱, 키 관리를 위한 Swift 네이티브 프레임워크입니다. AES, SHA, HMAC, 공개키 암호화 등을 지원합니다. ## 필수 Import ```swift import CryptoKit ``` ## 핵심 구성요소 ### 1. 해싱 (Hash) ```swift // SHA-256 let data = "Hello, World!".data(using: .utf8)! let hash = SHA256.hash(data: data) let hashString = hash.compactMap { String(format: "%02x", $0) }.joined() // SHA-384 let hash384 = SHA384.hash(data: data) // SHA-512 let hash512 = SHA512.hash(data: data) ``` ### 2. 대칭 암호화 (AES-GCM) ```swift // 키 생성 let key = SymmetricKey(size: .bits256) // 암호화 func encrypt(data: Data, key: SymmetricKey) throws -> Data { let sealedBox = try AES.GCM.seal(data, using: key) return sealedBox.combined! } // 복호화 func decrypt(data: Data, key: SymmetricKey) throws -> Data { let sealedBox = try AES.GCM.SealedBox(combined: data) return try AES.GCM.open(sealedBox, using: key) } ``` ### 3. HMAC (메시지 인증) ```swift let key = SymmetricKey(size: .bits256) let data = "message".data(using: .utf8)! // HMAC 생성 let authCode = HMAC.authenticationCode(for: data, using: key) let authString = Data(authCode).base64EncodedString() // HMAC 검증 let isValid = HMAC.isValidAuthenticationCode(authCode, authenticating: data, using: key) ``` ## 전체 작동 예제 ```swift import SwiftUI import CryptoKit // MARK: - Crypto Manager class CryptoManager { private var key: SymmetricKey init() { // 키 로드 또는 생성 if let savedKey = Self.loadKey() { key = savedKey } else { key = SymmetricKey(size: .bits256) Self.saveKey(key) } } // MARK: - Encryption func encrypt(_ string: String) throws -> String { guard let data = string.data(using: .utf8) else { throw CryptoError.encodingFailed } let sealedBox = try AES.GCM.seal(data, using: key) guard let combined = sealedBox.combined else { throw CryptoError.encryptionFailed } return combined.base64EncodedString() } func decrypt(_ base64String: String) throws -> String { guard let data = Data(base64Encoded: base64String) else { throw CryptoError.decodingFailed } let sealedBox = try AES.GCM.SealedBox(combined: data) let decryptedData = try AES.GCM.open(sealedBox, using: key) guard let string = String(data: decryptedData, encoding: .utf8) else { throw CryptoError.decodingFailed } return string } // MARK: - Hashing func hash(_ string: String) -> String { let data = Data(string.utf8) let hash = SHA256.hash(data: data) return hash.compactMap { String(format: "%02x", $0) }.joined() } func verifyHash(_ string: String, against hashString: String) -> Bool { return hash(string) == hashString } // MARK: - Password Hashing (with salt) func hashPassword(_ password: String, salt: Data? = nil) -> (hash: String, salt: String) { let saltData = salt ?? Self.generateSalt() var passwordData = Data(password.utf8) passwordData.append(saltData) let hash = SHA256.hash(data: passwordData) let hashString = hash.compactMap { String(format: "%02x", $0) }.joined() let saltString = saltData.base64EncodedString() return (hashString, saltString) } func verifyPassword(_ password: String, hash: String, salt: String) -> Bool { guard let saltData = Data(base64Encoded: salt) else { return false } let result = hashPassword(password, salt: saltData) return result.hash == hash } // MARK: - Key Management private static func generateSalt() -> Data { var salt = Data(count: 16) _ = salt.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } return salt } private static func saveKey(_ key: SymmetricKey) { let keyData = key.withUnsafeBytes { Data($0) } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "encryptionKey", kSecValueData as String: keyData ] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) } private static func loadKey() -> SymmetricKey? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "encryptionKey", kSecReturnData as String: true ] var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let keyData = result as? Data else { return nil } return SymmetricKey(data: keyData) } } enum CryptoError: Error, LocalizedError { case encodingFailed case decodingFailed case encryptionFailed case decryptionFailed var errorDescription: String? { switch self { case .encodingFailed: return "인코딩 실패" case .decodingFailed: return "디코딩 실패" case .encryptionFailed: return "암호화 실패" case .decryptionFailed: return "복호화 실패" } } } // MARK: - View struct CryptoView: View { @State private var crypto = CryptoManager() @State private var inputText = "" @State private var encryptedText = "" @State private var decryptedText = "" @State private var hashText = "" var body: some View { NavigationStack { Form { Section("입력") { TextField("텍스트 입력", text: $inputText) } Section("암호화") { Button("암호화") { encryptedText = (try? crypto.encrypt(inputText)) ?? "실패" } .disabled(inputText.isEmpty) if !encryptedText.isEmpty { Text(encryptedText) .font(.caption) .textSelection(.enabled) } Button("복호화") { decryptedText = (try? crypto.decrypt(encryptedText)) ?? "실패" } .disabled(encryptedText.isEmpty) if !decryptedText.isEmpty { Text("결과: \(decryptedText)") .foregroundStyle(.green) } } Section("해싱 (SHA-256)") { Button("해시 생성") { hashText = crypto.hash(inputText) } .disabled(inputText.isEmpty) if !hashText.isEmpty { Text(hashText) .font(.caption.monospaced()) .textSelection(.enabled) } } } .navigationTitle("암호화") } } } ``` ## 고급 패턴 ### 1. 공개키 암호화 (P256) ```swift // 키 쌍 생성 let privateKey = P256.KeyAgreement.PrivateKey() let publicKey = privateKey.publicKey // 키 직렬화 let publicKeyData = publicKey.rawRepresentation let privateKeyData = privateKey.rawRepresentation // 키 복원 let restoredPublic = try P256.KeyAgreement.PublicKey(rawRepresentation: publicKeyData) let restoredPrivate = try P256.KeyAgreement.PrivateKey(rawRepresentation: privateKeyData) ``` ### 2. 키 교환 (Diffie-Hellman) ```swift // Alice의 키 let alicePrivate = P256.KeyAgreement.PrivateKey() let alicePublic = alicePrivate.publicKey // Bob의 키 let bobPrivate = P256.KeyAgreement.PrivateKey() let bobPublic = bobPrivate.publicKey // 공유 비밀 생성 let aliceShared = try alicePrivate.sharedSecretFromKeyAgreement(with: bobPublic) let bobShared = try bobPrivate.sharedSecretFromKeyAgreement(with: alicePublic) // 대칭키 도출 let symmetricKey = aliceShared.hkdfDerivedSymmetricKey( using: SHA256.self, salt: Data(), sharedInfo: Data("encryption".utf8), outputByteCount: 32 ) ``` ### 3. 디지털 서명 ```swift // 서명 let signingKey = P256.Signing.PrivateKey() let data = "Sign this message".data(using: .utf8)! let signature = try signingKey.signature(for: data) // 검증 let verifyingKey = signingKey.publicKey let isValid = verifyingKey.isValidSignature(signature, for: data) ``` ### 4. ChaCha20-Poly1305 (대안 암호화) ```swift let key = SymmetricKey(size: .bits256) let data = "Secret message".data(using: .utf8)! // 암호화 let sealedBox = try ChaChaPoly.seal(data, using: key) // 복호화 let decrypted = try ChaChaPoly.open(sealedBox, using: key) ``` ## 주의사항 1. **키 저장** - 평문으로 저장 금지 - Keychain 사용 권장 - Secure Enclave 활용 (가능 시) 2. **랜덤 생성** ```swift // 안전한 랜덤 var randomBytes = [UInt8](repeating: 0, count: 32) _ = SecRandomCopyBytes(kSecRandomDefault, 32, &randomBytes) // 또는 let key = SymmetricKey(size: .bits256) // 내부적으로 안전한 랜덤 사용 ``` 3. **해시 용도** - SHA-256: 일반 해싱 - 비밀번호: salt + 반복 해싱 또는 Argon2 권장 4. **성능** - CryptoKit은 하드웨어 가속 활용 - 대용량 데이터는 스트리밍 처리 ```swift var hasher = SHA256() hasher.update(data: chunk1) hasher.update(data: chunk2) let hash = hasher.finalize() ``` --- # EnergyKit AI Reference > 에너지 데이터 앱 구현 가이드. 이 문서를 읽고 EnergyKit 코드를 생성할 수 있습니다. ## 개요 EnergyKit은 iOS 18+에서 제공하는 에너지 사용량 및 그리드 데이터 접근 프레임워크입니다. 사용자의 전력 사용 패턴, 태양광 발전량, 탄소 발자국 등의 정보를 활용해 에너지 효율 앱을 개발할 수 있습니다. ## 필수 Import ```swift import EnergyKit ``` ## 프로젝트 설정 ### 1. Capability 추가 Xcode > Signing & Capabilities > + EnergyKit ### 2. Info.plist ```xml NSEnergyUsageDescription 에너지 사용 패턴을 분석하기 위해 필요합니다. ``` ## 핵심 구성요소 ### 1. EnergyManager ```swift import EnergyKit // 에너지 매니저 인스턴스 let energyManager = EnergyManager.shared // 권한 요청 func requestAccess() async throws -> Bool { try await energyManager.requestAuthorization() } // 권한 상태 let status = energyManager.authorizationStatus ``` ### 2. EnergyUsage (사용량 데이터) ```swift // 오늘의 에너지 사용량 let usage = try await energyManager.fetchUsage(for: .today) usage.totalConsumption // 총 소비량 (kWh) usage.peakDemand // 최대 수요 usage.offPeakConsumption // 비피크 소비량 usage.carbonFootprint // 탄소 발자국 (kg CO2) ``` ### 3. GridStatus (전력망 상태) ```swift // 현재 전력망 상태 let gridStatus = try await energyManager.fetchGridStatus() gridStatus.carbonIntensity // 탄소 집약도 (g CO2/kWh) gridStatus.renewablePercent // 재생에너지 비율 gridStatus.isLowCarbonTime // 저탄소 시간대 여부 gridStatus.nextLowCarbonTime // 다음 저탄소 시간대 ``` ## 전체 작동 예제 ```swift import SwiftUI import EnergyKit // MARK: - Energy View Model @Observable class EnergyViewModel { var isAuthorized = false var todayUsage: EnergyUsage? var weeklyUsage: [DailyUsage] = [] var gridStatus: GridStatus? var isLoading = false var errorMessage: String? private let energyManager = EnergyManager.shared var isSupported: Bool { EnergyManager.isSupported } func checkAuthorization() { isAuthorized = energyManager.authorizationStatus == .authorized } func requestAuthorization() async { do { isAuthorized = try await energyManager.requestAuthorization() } catch { errorMessage = "권한 요청 실패: \(error.localizedDescription)" } } func fetchData() async { guard isAuthorized else { return } isLoading = true errorMessage = nil do { // 오늘의 사용량 todayUsage = try await energyManager.fetchUsage(for: .today) // 주간 사용량 let calendar = Calendar.current var daily: [DailyUsage] = [] for dayOffset in 0..<7 { let date = calendar.date(byAdding: .day, value: -dayOffset, to: Date())! let usage = try await energyManager.fetchUsage(for: date) daily.append(DailyUsage(date: date, usage: usage)) } weeklyUsage = daily.reversed() // 전력망 상태 gridStatus = try await energyManager.fetchGridStatus() } catch { errorMessage = "데이터 로드 실패: \(error.localizedDescription)" } isLoading = false } } // MARK: - Models struct DailyUsage: Identifiable { let id = UUID() let date: Date let usage: EnergyUsage var dayName: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") formatter.dateFormat = "E" return formatter.string(from: date) } } // MARK: - Main View struct EnergyDashboardView: View { @State private var viewModel = EnergyViewModel() var body: some View { NavigationStack { ScrollView { if !viewModel.isSupported { ContentUnavailableView( "지원되지 않는 기기", systemImage: "bolt.slash", description: Text("이 기기에서는 EnergyKit을 사용할 수 없습니다") ) } else if !viewModel.isAuthorized { VStack(spacing: 20) { Image(systemName: "bolt.shield") .font(.system(size: 60)) .foregroundStyle(.yellow) Text("에너지 데이터 접근") .font(.title2.bold()) Text("에너지 사용량을 분석하고 절약 팁을 제공하기 위해 데이터 접근 권한이 필요합니다.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) Button("권한 허용") { Task { await viewModel.requestAuthorization() } } .buttonStyle(.borderedProminent) } .padding() } else if viewModel.isLoading { ProgressView("데이터 로딩 중...") .padding(.top, 100) } else { VStack(spacing: 20) { // 오늘의 사용량 if let usage = viewModel.todayUsage { TodayUsageCard(usage: usage) } // 전력망 상태 if let grid = viewModel.gridStatus { GridStatusCard(status: grid) } // 주간 차트 if !viewModel.weeklyUsage.isEmpty { WeeklyChartCard(data: viewModel.weeklyUsage) } // 절약 팁 SavingTipsCard(gridStatus: viewModel.gridStatus) } .padding() } // 에러 표시 if let error = viewModel.errorMessage { Text(error) .foregroundStyle(.red) .padding() } } .navigationTitle("에너지") .refreshable { await viewModel.fetchData() } .task { viewModel.checkAuthorization() if viewModel.isAuthorized { await viewModel.fetchData() } } } } } // MARK: - Today Usage Card struct TodayUsageCard: View { let usage: EnergyUsage var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Image(systemName: "bolt.fill") .foregroundStyle(.yellow) Text("오늘의 사용량") .font(.headline) } HStack(alignment: .firstTextBaseline) { Text(String(format: "%.1f", usage.totalConsumption)) .font(.system(size: 48, weight: .bold, design: .rounded)) Text("kWh") .font(.title3) .foregroundStyle(.secondary) } Divider() HStack { VStack(alignment: .leading) { Text("피크") .font(.caption) .foregroundStyle(.secondary) Text(String(format: "%.1f kWh", usage.peakConsumption)) .font(.subheadline.bold()) } Spacer() VStack(alignment: .leading) { Text("비피크") .font(.caption) .foregroundStyle(.secondary) Text(String(format: "%.1f kWh", usage.offPeakConsumption)) .font(.subheadline.bold()) } Spacer() VStack(alignment: .leading) { Text("탄소") .font(.caption) .foregroundStyle(.secondary) Text(String(format: "%.1f kg", usage.carbonFootprint)) .font(.subheadline.bold()) } } } .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } } // MARK: - Grid Status Card struct GridStatusCard: View { let status: GridStatus var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Image(systemName: "globe") .foregroundStyle(.green) Text("전력망 상태") .font(.headline) Spacer() if status.isLowCarbonTime { Label("저탄소 시간", systemImage: "leaf.fill") .font(.caption) .foregroundStyle(.green) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.green.opacity(0.2), in: Capsule()) } } HStack(spacing: 24) { VStack(alignment: .leading) { Text("재생에너지") .font(.caption) .foregroundStyle(.secondary) HStack(alignment: .firstTextBaseline, spacing: 2) { Text("\(Int(status.renewablePercent))") .font(.title.bold()) Text("%") .foregroundStyle(.secondary) } } VStack(alignment: .leading) { Text("탄소 집약도") .font(.caption) .foregroundStyle(.secondary) HStack(alignment: .firstTextBaseline, spacing: 2) { Text("\(Int(status.carbonIntensity))") .font(.title.bold()) Text("g/kWh") .font(.caption) .foregroundStyle(.secondary) } } } // 재생에너지 비율 바 GeometryReader { geometry in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 4) .fill(.gray.opacity(0.2)) RoundedRectangle(cornerRadius: 4) .fill(.green) .frame(width: geometry.size.width * status.renewablePercent / 100) } } .frame(height: 8) if let nextLowCarbon = status.nextLowCarbonTime { Text("다음 저탄소 시간: \(nextLowCarbon.formatted(.dateTime.hour().minute()))") .font(.caption) .foregroundStyle(.secondary) } } .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } } // MARK: - Weekly Chart Card struct WeeklyChartCard: View { let data: [DailyUsage] var maxUsage: Double { data.map(\.usage.totalConsumption).max() ?? 1 } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Image(systemName: "chart.bar.fill") .foregroundStyle(.blue) Text("주간 사용량") .font(.headline) } HStack(alignment: .bottom, spacing: 8) { ForEach(data) { daily in VStack(spacing: 4) { Text(String(format: "%.0f", daily.usage.totalConsumption)) .font(.caption2) .foregroundStyle(.secondary) RoundedRectangle(cornerRadius: 4) .fill(.blue.gradient) .frame(height: CGFloat(daily.usage.totalConsumption / maxUsage) * 100) Text(daily.dayName) .font(.caption) } .frame(maxWidth: .infinity) } } .frame(height: 140) } .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } } // MARK: - Saving Tips Card struct SavingTipsCard: View { let gridStatus: GridStatus? var tips: [String] { var result = [ "세탁기와 식기세척기는 비피크 시간대에 사용하세요", "에어컨 온도를 1도 높이면 에너지 3% 절약", "대기전력 차단을 위해 멀티탭 스위치를 끄세요" ] if let status = gridStatus { if status.isLowCarbonTime { result.insert("지금은 저탄소 시간! 전기 사용에 좋은 때입니다", at: 0) } if status.renewablePercent > 50 { result.insert("재생에너지 비율이 높습니다. 친환경 전력 사용 중!", at: 0) } } return result } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: "lightbulb.fill") .foregroundStyle(.orange) Text("절약 팁") .font(.headline) } ForEach(tips.prefix(3), id: \.self) { tip in HStack(alignment: .top, spacing: 8) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) .font(.caption) Text(tip) .font(.subheadline) } } } .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } } #Preview { EnergyDashboardView() } ``` ## 고급 패턴 ### 1. 에너지 절약 알림 ```swift import UserNotifications func scheduleEnergyAlerts() async { let gridStatus = try await energyManager.fetchGridStatus() // 저탄소 시간대 알림 if let nextLowCarbon = gridStatus.nextLowCarbonTime { let content = UNMutableNotificationContent() content.title = "저탄소 시간대 시작" content.body = "지금 전기를 사용하면 탄소 배출이 적습니다!" content.sound = .default let trigger = UNTimeIntervalNotificationTrigger( timeInterval: nextLowCarbon.timeIntervalSinceNow, repeats: false ) let request = UNNotificationRequest( identifier: "lowCarbon", content: content, trigger: trigger ) try? await UNUserNotificationCenter.current().add(request) } } ``` ### 2. HomeKit 연동 ```swift import HomeKit class SmartEnergyManager { let homeManager = HMHomeManager() let energyManager = EnergyManager.shared func optimizeDevices() async throws { let gridStatus = try await energyManager.fetchGridStatus() // 고탄소 시간대에는 불필요한 기기 끄기 if !gridStatus.isLowCarbonTime { for home in homeManager.homes { for accessory in home.accessories { // 비필수 기기 식별 및 제어 if isNonEssential(accessory) { try await turnOff(accessory) } } } } } } ``` ### 3. 위젯 ```swift import WidgetKit struct EnergyWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "EnergyWidget", provider: EnergyTimelineProvider()) { entry in EnergyWidgetView(entry: entry) } .configurationDisplayName("에너지 현황") .description("오늘의 에너지 사용량과 전력망 상태를 표시합니다") .supportedFamilies([.systemSmall, .systemMedium]) } } struct EnergyWidgetView: View { let entry: EnergyEntry var body: some View { VStack(alignment: .leading) { HStack { Image(systemName: "bolt.fill") .foregroundStyle(.yellow) Text("\(String(format: "%.1f", entry.usage)) kWh") .font(.headline) } if entry.isLowCarbonTime { Label("저탄소 시간", systemImage: "leaf.fill") .font(.caption) .foregroundStyle(.green) } } .containerBackground(.fill, for: .widget) } } ``` ## 주의사항 1. **iOS 버전** - EnergyKit: iOS 18+ 필요 - 이전 버전에서는 사용 불가 2. **지역 제한** - 에너지 데이터 제공 지역에서만 동작 - 모든 국가/지역에서 지원되지 않음 3. **스마트 미터 연동** - 스마트 미터가 설치된 가정에서만 상세 데이터 제공 - 미설치 시 추정 데이터 제공 4. **개인정보** - 에너지 사용 데이터는 민감 정보 - 명확한 사용 목적 고지 필요 5. **시뮬레이터** - 시뮬레이터에서 모의 데이터 제공 - 실제 데이터는 실기기 필요 --- # EventKit AI Reference > 캘린더 및 리마인더 접근 가이드. 이 문서를 읽고 EventKit 코드를 생성할 수 있습니다. ## 개요 EventKit은 사용자의 캘린더 이벤트와 리마인더에 접근하는 프레임워크입니다. 일정 생성, 조회, 수정, 삭제 및 리마인더 관리를 지원합니다. ## 필수 Import ```swift import EventKit import EventKitUI // UI 컴포넌트 사용 시 ``` ## 프로젝트 설정 (Info.plist) ```xml NSCalendarsUsageDescription 일정을 관리하기 위해 캘린더 접근이 필요합니다. NSRemindersUsageDescription 할 일을 관리하기 위해 미리 알림 접근이 필요합니다. ``` ## 핵심 구성요소 ### 1. EKEventStore (진입점) ```swift let eventStore = EKEventStore() // 권한 요청 (iOS 17+) func requestCalendarAccess() async -> Bool { do { return try await eventStore.requestFullAccessToEvents() } catch { return false } } func requestReminderAccess() async -> Bool { do { return try await eventStore.requestFullAccessToReminders() } catch { return false } } // iOS 16 이하 func requestAccessLegacy() async -> Bool { await withCheckedContinuation { continuation in eventStore.requestAccess(to: .event) { granted, _ in continuation.resume(returning: granted) } } } ``` ### 2. 이벤트 생성 ```swift func createEvent(title: String, startDate: Date, endDate: Date) throws { let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = endDate event.calendar = eventStore.defaultCalendarForNewEvents // 알림 추가 let alarm = EKAlarm(relativeOffset: -3600) // 1시간 전 event.addAlarm(alarm) try eventStore.save(event, span: .thisEvent) } ``` ### 3. 이벤트 조회 ```swift func fetchEvents(from startDate: Date, to endDate: Date) -> [EKEvent] { let predicate = eventStore.predicateForEvents( withStart: startDate, end: endDate, calendars: nil // nil이면 모든 캘린더 ) return eventStore.events(matching: predicate) } ``` ## 전체 작동 예제 ```swift import SwiftUI import EventKit import EventKitUI // MARK: - Calendar Manager @Observable class CalendarManager { let eventStore = EKEventStore() var events: [EKEvent] = [] var calendars: [EKCalendar] = [] var authorizationStatus: EKAuthorizationStatus = .notDetermined init() { checkAuthorizationStatus() } func checkAuthorizationStatus() { authorizationStatus = EKEventStore.authorizationStatus(for: .event) } func requestAccess() async -> Bool { if #available(iOS 17.0, *) { do { let granted = try await eventStore.requestFullAccessToEvents() await MainActor.run { checkAuthorizationStatus() if granted { loadCalendars() } } return granted } catch { return false } } else { return await withCheckedContinuation { continuation in eventStore.requestAccess(to: .event) { granted, _ in DispatchQueue.main.async { self.checkAuthorizationStatus() if granted { self.loadCalendars() } } continuation.resume(returning: granted) } } } } func loadCalendars() { calendars = eventStore.calendars(for: .event) } func fetchEvents(for date: Date) { let calendar = Calendar.current let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! let predicate = eventStore.predicateForEvents( withStart: startOfDay, end: endOfDay, calendars: nil ) events = eventStore.events(matching: predicate) .sorted { $0.startDate < $1.startDate } } func createEvent(title: String, startDate: Date, endDate: Date, calendar: EKCalendar? = nil) throws { let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = endDate event.calendar = calendar ?? eventStore.defaultCalendarForNewEvents try eventStore.save(event, span: .thisEvent) fetchEvents(for: startDate) } func deleteEvent(_ event: EKEvent) throws { try eventStore.remove(event, span: .thisEvent) if let index = events.firstIndex(of: event) { events.remove(at: index) } } } // MARK: - Views struct CalendarView: View { @State private var manager = CalendarManager() @State private var selectedDate = Date() @State private var showingAddEvent = false var body: some View { NavigationStack { Group { switch manager.authorizationStatus { case .fullAccess, .authorized: eventListView case .notDetermined: requestAccessView default: deniedView } } .navigationTitle("캘린더") .toolbar { if manager.authorizationStatus == .fullAccess || manager.authorizationStatus == .authorized { Button("추가", systemImage: "plus") { showingAddEvent = true } } } .sheet(isPresented: $showingAddEvent) { AddEventView(manager: manager, date: selectedDate) } } } var eventListView: some View { VStack { DatePicker("날짜", selection: $selectedDate, displayedComponents: .date) .datePickerStyle(.graphical) .padding() List { if manager.events.isEmpty { ContentUnavailableView("일정 없음", systemImage: "calendar", description: Text("이 날에 일정이 없습니다")) } else { ForEach(manager.events, id: \.eventIdentifier) { event in EventRow(event: event) } .onDelete { indexSet in for index in indexSet { try? manager.deleteEvent(manager.events[index]) } } } } } .onChange(of: selectedDate) { _, newDate in manager.fetchEvents(for: newDate) } .onAppear { manager.fetchEvents(for: selectedDate) } } var requestAccessView: some View { ContentUnavailableView { Label("캘린더 접근 필요", systemImage: "calendar.badge.exclamationmark") } description: { Text("일정을 관리하려면 캘린더 접근 권한이 필요합니다") } actions: { Button("권한 요청") { Task { await manager.requestAccess() } } .buttonStyle(.borderedProminent) } } var deniedView: some View { ContentUnavailableView { Label("접근 거부됨", systemImage: "calendar.badge.minus") } description: { Text("설정에서 캘린더 접근을 허용해주세요") } actions: { Button("설정 열기") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } } } struct EventRow: View { let event: EKEvent var body: some View { HStack { RoundedRectangle(cornerRadius: 2) .fill(Color(cgColor: event.calendar.cgColor)) .frame(width: 4) VStack(alignment: .leading) { Text(event.title) .font(.headline) if event.isAllDay { Text("하루 종일") .font(.caption) .foregroundStyle(.secondary) } else { Text("\(event.startDate.formatted(date: .omitted, time: .shortened)) - \(event.endDate.formatted(date: .omitted, time: .shortened))") .font(.caption) .foregroundStyle(.secondary) } } } } } struct AddEventView: View { let manager: CalendarManager let date: Date @Environment(\.dismiss) private var dismiss @State private var title = "" @State private var startDate = Date() @State private var endDate = Date() @State private var isAllDay = false var body: some View { NavigationStack { Form { TextField("제목", text: $title) Toggle("하루 종일", isOn: $isAllDay) DatePicker("시작", selection: $startDate, displayedComponents: isAllDay ? .date : [.date, .hourAndMinute]) DatePicker("종료", selection: $endDate, displayedComponents: isAllDay ? .date : [.date, .hourAndMinute]) } .navigationTitle("새 이벤트") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("추가") { try? manager.createEvent(title: title, startDate: startDate, endDate: endDate) dismiss() } .disabled(title.isEmpty) } } .onAppear { startDate = date endDate = Calendar.current.date(byAdding: .hour, value: 1, to: date) ?? date } } } } ``` ## 고급 패턴 ### 1. 리마인더 (미리 알림) ```swift func fetchReminders() async -> [EKReminder] { let predicate = eventStore.predicateForReminders(in: nil) return await withCheckedContinuation { continuation in eventStore.fetchReminders(matching: predicate) { reminders in continuation.resume(returning: reminders ?? []) } } } func createReminder(title: String, dueDate: Date?) throws { let reminder = EKReminder(eventStore: eventStore) reminder.title = title reminder.calendar = eventStore.defaultCalendarForNewReminders() if let dueDate { reminder.dueDateComponents = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute], from: dueDate ) } try eventStore.save(reminder, commit: true) } func completeReminder(_ reminder: EKReminder) throws { reminder.isCompleted = true try eventStore.save(reminder, commit: true) } ``` ### 2. 반복 이벤트 ```swift func createRecurringEvent(title: String, startDate: Date, recurrence: EKRecurrenceRule) throws { let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) event.calendar = eventStore.defaultCalendarForNewEvents event.addRecurrenceRule(recurrence) try eventStore.save(event, span: .futureEvents) } // 매주 월요일 반복 let weeklyRule = EKRecurrenceRule( recurrenceWith: .weekly, interval: 1, daysOfTheWeek: [EKRecurrenceDayOfWeek(.monday)], daysOfTheMonth: nil, monthsOfTheYear: nil, weeksOfTheYear: nil, daysOfTheYear: nil, setPositions: nil, end: nil // 무한 반복 ) // 매월 15일 반복, 10회 let monthlyRule = EKRecurrenceRule( recurrenceWith: .monthly, interval: 1, daysOfTheWeek: nil, daysOfTheMonth: [15], monthsOfTheYear: nil, weeksOfTheYear: nil, daysOfTheYear: nil, setPositions: nil, end: EKRecurrenceEnd(occurrenceCount: 10) ) ``` ### 3. EventKitUI 사용 ```swift struct EventEditViewWrapper: UIViewControllerRepresentable { let eventStore: EKEventStore let event: EKEvent? @Environment(\.dismiss) private var dismiss func makeUIViewController(context: Context) -> EKEventEditViewController { let controller = EKEventEditViewController() controller.eventStore = eventStore controller.event = event ?? EKEvent(eventStore: eventStore) controller.editViewDelegate = context.coordinator return controller } func updateUIViewController(_ uiViewController: EKEventEditViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(dismiss: dismiss) } class Coordinator: NSObject, EKEventEditViewDelegate { let dismiss: DismissAction init(dismiss: DismissAction) { self.dismiss = dismiss } func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) { dismiss() } } } ``` ## 주의사항 1. **iOS 17 권한 변경** - `.fullAccess`: 전체 접근 - `.writeOnly`: 쓰기만 (읽기 불가) - 기존 `.authorized`는 deprecated 2. **변경 감지** ```swift NotificationCenter.default.addObserver( forName: .EKEventStoreChanged, object: eventStore, queue: .main ) { _ in // 캘린더 데이터 새로고침 } ``` 3. **캘린더 색상** ```swift let color = Color(cgColor: event.calendar.cgColor) ``` 4. **시간대 처리** - `EKEvent`는 시간대 정보 포함 - `startDate`, `endDate`는 UTC 기준 --- # ExtensibleImage AI Reference > 확장 가능한 이미지 처리 가이드. 이 문서를 읽고 ExtensibleImage 코드를 생성할 수 있습니다. ## 개요 ExtensibleImage는 iOS 18+에서 제공하는 이미지 확장 프레임워크입니다. 앱에서 시스템 사진 앱 및 다른 앱에 커스텀 이미지 편집 기능을 제공할 수 있습니다. Photo Editing Extension의 현대적인 대체제로, 더 나은 성능과 유연성을 제공합니다. ## 필수 Import ```swift import ExtensibleImage ``` ## 프로젝트 설정 ### 1. Extension Target 추가 File > New > Target > Extensible Image Extension ### 2. Info.plist (Extension) ```xml NSExtension NSExtensionAttributes PHSupportedMediaTypes Image EIImageEditingCapabilities filter adjustment effect NSExtensionPointIdentifier com.apple.extensible-image.editing NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ImageEditingProvider ``` ## 핵심 구성요소 ### 1. EIImageEditingProvider (확장 제공자) ```swift import ExtensibleImage class ImageEditingProvider: EIImageEditingProvider { override func viewController( for configuration: EIImageEditingConfiguration ) -> EIImageEditingViewController { return ImageEditorViewController(configuration: configuration) } } ``` ### 2. EIImageEditingViewController (편집 뷰컨트롤러) ```swift class ImageEditorViewController: EIImageEditingViewController { override func viewDidLoad() { super.viewDidLoad() setupUI() } // 원본 이미지 접근 var originalImage: UIImage? { configuration.inputImage } // 편집 완료 func finishEditing(with image: UIImage) { completeEditing(with: image) } // 편집 취소 func cancelEditing() { cancelRequest() } } ``` ### 3. EIImageEditingConfiguration (설정) ```swift // 편집 설정 정보 let config: EIImageEditingConfiguration config.inputImage // 입력 이미지 config.contentMode // 콘텐츠 모드 config.adjustmentData // 이전 조정 데이터 (재편집 시) ``` ## 전체 작동 예제 ### Extension 구현 ```swift // ImageEditingProvider.swift import ExtensibleImage class ImageEditingProvider: EIImageEditingProvider { override func viewController( for configuration: EIImageEditingConfiguration ) -> EIImageEditingViewController { return FilterEditorViewController(configuration: configuration) } } // FilterEditorViewController.swift import SwiftUI import ExtensibleImage import CoreImage import CoreImage.CIFilterBuiltins class FilterEditorViewController: EIImageEditingViewController { private var hostingController: UIHostingController? override func viewDidLoad() { super.viewDidLoad() let editorView = FilterEditorView( originalImage: configuration.inputImage, onComplete: { [weak self] image in self?.completeEditing(with: image) }, onCancel: { [weak self] in self?.cancelRequest() } ) hostingController = UIHostingController(rootView: editorView) if let hostingView = hostingController?.view { addChild(hostingController!) view.addSubview(hostingView) hostingView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hostingView.topAnchor.constraint(equalTo: view.topAnchor), hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) hostingController?.didMove(toParent: self) } } } // FilterEditorView.swift struct FilterEditorView: View { let originalImage: UIImage? let onComplete: (UIImage) -> Void let onCancel: () -> Void @State private var processedImage: UIImage? @State private var selectedFilter: FilterType = .none @State private var intensity: Double = 0.5 @State private var isProcessing = false enum FilterType: String, CaseIterable { case none = "원본" case sepia = "세피아" case noir = "누아르" case chrome = "크롬" case fade = "페이드" case vignette = "비네트" case bloom = "블룸" } var displayImage: UIImage? { processedImage ?? originalImage } var body: some View { NavigationStack { VStack(spacing: 0) { // 이미지 미리보기 GeometryReader { geometry in if let image = displayImage { Image(uiImage: image) .resizable() .scaledToFit() .frame(width: geometry.size.width, height: geometry.size.height) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } } .overlay { if isProcessing { Color.black.opacity(0.3) ProgressView() .tint(.white) } } // 강도 슬라이더 if selectedFilter != .none { VStack(spacing: 8) { HStack { Text("강도") Slider(value: $intensity, in: 0...1) Text("\(Int(intensity * 100))%") .frame(width: 50) } .padding(.horizontal) } .padding(.vertical, 8) .background(.bar) } // 필터 선택 ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(FilterType.allCases, id: \.self) { filter in FilterButton( filter: filter, isSelected: selectedFilter == filter ) { selectedFilter = filter } } } .padding() } .background(.bar) } .navigationTitle("필터") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { onCancel() } } ToolbarItem(placement: .confirmationAction) { Button("완료") { if let image = processedImage ?? originalImage { onComplete(image) } } } } .onChange(of: selectedFilter) { _, _ in applyFilter() } .onChange(of: intensity) { _, _ in applyFilter() } } } func applyFilter() { guard let original = originalImage, let ciImage = CIImage(image: original) else { return } if selectedFilter == .none { processedImage = originalImage return } isProcessing = true Task.detached(priority: .userInitiated) { let output = await processFilter(ciImage, filter: selectedFilter, intensity: intensity) await MainActor.run { processedImage = output isProcessing = false } } } func processFilter(_ input: CIImage, filter: FilterType, intensity: Double) async -> UIImage? { let context = CIContext() var output: CIImage? switch filter { case .none: output = input case .sepia: let filter = CIFilter.sepiaTone() filter.inputImage = input filter.intensity = Float(intensity) output = filter.outputImage case .noir: let filter = CIFilter.photoEffectNoir() filter.inputImage = input output = filter.outputImage case .chrome: let filter = CIFilter.photoEffectChrome() filter.inputImage = input output = filter.outputImage case .fade: let filter = CIFilter.photoEffectFade() filter.inputImage = input output = filter.outputImage case .vignette: let filter = CIFilter.vignette() filter.inputImage = input filter.intensity = Float(intensity * 2) filter.radius = 1.5 output = filter.outputImage case .bloom: let filter = CIFilter.bloom() filter.inputImage = input filter.intensity = Float(intensity) filter.radius = 10 output = filter.outputImage } guard let ciOutput = output, let cgImage = context.createCGImage(ciOutput, from: ciOutput.extent) else { return nil } return UIImage(cgImage: cgImage) } } // FilterButton.swift struct FilterButton: View { let filter: FilterEditorView.FilterType let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { VStack(spacing: 4) { RoundedRectangle(cornerRadius: 8) .fill(isSelected ? Color.blue : Color.gray.opacity(0.3)) .frame(width: 60, height: 60) .overlay { Image(systemName: iconFor(filter)) .foregroundStyle(isSelected ? .white : .primary) } Text(filter.rawValue) .font(.caption) .foregroundStyle(isSelected ? .blue : .primary) } } } func iconFor(_ filter: FilterEditorView.FilterType) -> String { switch filter { case .none: return "photo" case .sepia: return "camera.filters" case .noir: return "circle.lefthalf.filled" case .chrome: return "sparkles" case .fade: return "sun.haze" case .vignette: return "circle.dashed" case .bloom: return "light.max" } } } ``` ### 호스트 앱에서 Extension 호출 ```swift import SwiftUI import PhotosUI import ExtensibleImage struct ImageEditingHostView: View { @State private var selectedItem: PhotosPickerItem? @State private var selectedImage: UIImage? @State private var showingEditor = false var body: some View { NavigationStack { VStack { if let image = selectedImage { Image(uiImage: image) .resizable() .scaledToFit() .padding() Button("편집") { showingEditor = true } .buttonStyle(.borderedProminent) } else { ContentUnavailableView( "이미지 선택", systemImage: "photo.badge.plus" ) } } .navigationTitle("이미지 편집") .toolbar { PhotosPicker(selection: $selectedItem, matching: .images) { Image(systemName: "photo.badge.plus") } } .onChange(of: selectedItem) { _, item in Task { if let data = try? await item?.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedImage = image } } } .sheet(isPresented: $showingEditor) { if let image = selectedImage { ExtensibleImageEditor( image: image, onComplete: { editedImage in selectedImage = editedImage showingEditor = false }, onCancel: { showingEditor = false } ) } } } } } // ExtensibleImageEditor wrapper struct ExtensibleImageEditor: UIViewControllerRepresentable { let image: UIImage let onComplete: (UIImage) -> Void let onCancel: () -> Void func makeUIViewController(context: Context) -> UINavigationController { let config = EIImageEditingConfiguration(inputImage: image) let editor = FilterEditorViewController(configuration: config) // 커스텀 완료/취소 핸들러 설정 context.coordinator.onComplete = onComplete context.coordinator.onCancel = onCancel return UINavigationController(rootViewController: editor) } func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator { var onComplete: ((UIImage) -> Void)? var onCancel: (() -> Void)? } } ``` ## 고급 패턴 ### 1. 조정 데이터 저장/복원 ```swift struct FilterAdjustment: Codable { var filterType: String var intensity: Double var timestamp: Date } extension FilterEditorViewController { func saveAdjustmentData() -> Data? { let adjustment = FilterAdjustment( filterType: selectedFilter.rawValue, intensity: intensity, timestamp: Date() ) return try? JSONEncoder().encode(adjustment) } func loadAdjustmentData() { guard let data = configuration.adjustmentData, let adjustment = try? JSONDecoder().decode(FilterAdjustment.self, from: data) else { return } selectedFilter = FilterType(rawValue: adjustment.filterType) ?? .none intensity = adjustment.intensity } override func completeEditing(with image: UIImage) { // 조정 데이터와 함께 저장 let adjustmentData = saveAdjustmentData() completeEditing(with: image, adjustmentData: adjustmentData) } } ``` ### 2. Live Photo 지원 ```swift extension ImageEditingProvider { override func supportedMediaTypes() -> EIMediaTypes { return [.image, .livePhoto] } } class LivePhotoEditorViewController: EIImageEditingViewController { override func viewDidLoad() { super.viewDidLoad() if let livePhoto = configuration.inputLivePhoto { // Live Photo 처리 processLivePhoto(livePhoto) } else if let image = configuration.inputImage { // 일반 이미지 처리 processImage(image) } } } ``` ### 3. 배치 편집 ```swift struct BatchEditingView: View { @State private var images: [UIImage] = [] @State private var processedImages: [UIImage] = [] @State private var selectedFilter: FilterType = .sepia @State private var isProcessing = false var body: some View { VStack { // 이미지 그리드 LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) { ForEach(processedImages.indices, id: \.self) { index in Image(uiImage: processedImages[index]) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipped() } } // 일괄 적용 버튼 Button("모든 이미지에 필터 적용") { applyFilterToAll() } .disabled(isProcessing) } } func applyFilterToAll() { isProcessing = true Task { var results: [UIImage] = [] for image in images { if let processed = await applyFilter(to: image, filter: selectedFilter) { results.append(processed) } } await MainActor.run { processedImages = results isProcessing = false } } } } ``` ## 주의사항 1. **iOS 버전** - ExtensibleImage: iOS 18+ 필요 - 이전 버전은 Photo Editing Extension 사용 2. **Extension 제한** - 메모리 제한 있음 - 대용량 이미지 처리 시 주의 3. **성능 최적화** - 이미지 처리는 백그라운드 스레드에서 - 미리보기는 축소된 이미지 사용 4. **조정 데이터** - 비파괴 편집을 위해 조정 데이터 저장 - 재편집 시 원본 유지 5. **시뮬레이터** - Extension 테스트 가능 - 사진 앱 연동은 실기기 필요 --- # Foundation Models AI Reference > 온디바이스 AI/LLM 구현 가이드. 이 문서를 읽고 Foundation Models를 활용할 수 있습니다. ## 개요 Foundation Models는 iOS 26+에서 온디바이스 AI 기능을 제공하는 프레임워크입니다. Apple Intelligence를 활용해 프라이버시를 보호하면서 텍스트 생성, 요약, 도구 사용 등을 구현합니다. ## 필수 Import ```swift import FoundationModels ``` ## 핵심 구성요소 ### 1. LanguageModelSession (세션 생성) ```swift // 기본 세션 let session = LanguageModelSession() // 시스템 프롬프트 포함 let session = LanguageModelSession( instructions: "당신은 친절한 요리 도우미입니다. 한국어로 답변하세요." ) ``` ### 2. 텍스트 생성 ```swift // 단순 생성 let response = try await session.respond(to: "파스타 레시피 알려줘") print(response.content) // 스트리밍 for try await partial in session.streamResponse(to: "파스타 레시피 알려줘") { print(partial.content, terminator: "") } ``` ### 3. Tool (도구) 정의 ```swift @Generable struct WeatherTool: Tool { static let name = "weather" static let description = "도시의 현재 날씨를 가져옵니다" struct Arguments: Codable, Sendable { @Guide(description: "도시 이름 (예: 서울, 부산)") let city: String } func call(arguments: Arguments) async throws -> String { // 실제 날씨 API 호출 또는 시뮬레이션 return "\(arguments.city)의 현재 날씨: 맑음, 23°C" } } ``` ## 전체 작동 예제: AI 챗봇 ```swift import SwiftUI import FoundationModels // MARK: - Message Model struct ChatMessage: Identifiable { let id = UUID() let role: Role let content: String let timestamp: Date enum Role { case user, assistant } } // MARK: - ViewModel @Observable class ChatViewModel { var messages: [ChatMessage] = [] var inputText = "" var isLoading = false private var session: LanguageModelSession? init() { setupSession() } private func setupSession() { session = LanguageModelSession( instructions: """ 당신은 친절하고 도움이 되는 AI 어시스턴트입니다. 간결하고 정확하게 답변하세요. 한국어로 대화합니다. """ ) } func sendMessage() async { let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } // 사용자 메시지 추가 let userMessage = ChatMessage(role: .user, content: text, timestamp: Date()) messages.append(userMessage) inputText = "" isLoading = true do { // AI 응답 생성 let response = try await session?.respond(to: text) let assistantMessage = ChatMessage( role: .assistant, content: response?.content ?? "응답을 생성할 수 없습니다.", timestamp: Date() ) messages.append(assistantMessage) } catch { let errorMessage = ChatMessage( role: .assistant, content: "오류: \(error.localizedDescription)", timestamp: Date() ) messages.append(errorMessage) } isLoading = false } func clearHistory() { messages.removeAll() setupSession() // 세션 초기화 } } // MARK: - View struct ChatView: View { @State private var viewModel = ChatViewModel() var body: some View { NavigationStack { VStack(spacing: 0) { // 메시지 목록 ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(viewModel.messages) { message in MessageBubble(message: message) } if viewModel.isLoading { ProgressView() .padding() } } .padding() } .onChange(of: viewModel.messages.count) { if let lastMessage = viewModel.messages.last { proxy.scrollTo(lastMessage.id, anchor: .bottom) } } } Divider() // 입력 필드 HStack(spacing: 12) { TextField("메시지 입력...", text: $viewModel.inputText) .textFieldStyle(.roundedBorder) Button { Task { await viewModel.sendMessage() } } label: { Image(systemName: "arrow.up.circle.fill") .font(.title) } .disabled(viewModel.inputText.isEmpty || viewModel.isLoading) } .padding() } .navigationTitle("AI 챗봇") .toolbar { Button("초기화") { viewModel.clearHistory() } } } } } struct MessageBubble: View { let message: ChatMessage var body: some View { HStack { if message.role == .user { Spacer() } Text(message.content) .padding(12) .background(message.role == .user ? Color.blue : Color.gray.opacity(0.2)) .foregroundStyle(message.role == .user ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 16)) if message.role == .assistant { Spacer() } } } } #Preview { ChatView() } ``` ## Tool 사용 예제 ```swift // 여러 도구 정의 @Generable struct CalculatorTool: Tool { static let name = "calculator" static let description = "수학 계산을 수행합니다" struct Arguments: Codable, Sendable { @Guide(description: "계산식 (예: 2 + 2, 10 * 5)") let expression: String } func call(arguments: Arguments) async throws -> String { // 간단한 계산 로직 let expr = NSExpression(format: arguments.expression) if let result = expr.expressionValue(with: nil, context: nil) as? NSNumber { return "결과: \(result)" } return "계산할 수 없습니다" } } @Generable struct SearchTool: Tool { static let name = "search" static let description = "정보를 검색합니다" struct Arguments: Codable, Sendable { @Guide(description: "검색 키워드") let query: String } func call(arguments: Arguments) async throws -> String { // 실제로는 API 호출 return "'\(arguments.query)'에 대한 검색 결과입니다..." } } // 도구와 함께 세션 생성 let session = LanguageModelSession( instructions: "도구를 적극 활용해 사용자를 도와주세요.", tools: [WeatherTool(), CalculatorTool(), SearchTool()] ) // 도구 호출이 필요한 질문 let response = try await session.respond(to: "서울 날씨 어때?") // AI가 자동으로 WeatherTool 호출 ``` ## 스트리밍 응답 ```swift @Observable class StreamingViewModel { var streamedText = "" var isStreaming = false func streamResponse(prompt: String) async { let session = LanguageModelSession() isStreaming = true streamedText = "" do { for try await partial in session.streamResponse(to: prompt) { streamedText += partial.content } } catch { streamedText = "오류: \(error.localizedDescription)" } isStreaming = false } } struct StreamingView: View { @State var viewModel = StreamingViewModel() var body: some View { VStack { ScrollView { Text(viewModel.streamedText) .padding() } Button("생성 시작") { Task { await viewModel.streamResponse(prompt: "인공지능의 역사를 설명해주세요") } } .disabled(viewModel.isStreaming) } } } ``` ## 주의사항 1. **iOS 26+ 전용**: 이전 버전에서는 사용 불가 2. **Apple Silicon 필요**: 온디바이스 AI는 Neural Engine 필요 3. **프라이버시**: 데이터가 기기를 떠나지 않음 4. **Sendable 준수**: Tool Arguments는 Sendable 필수 5. **@Generable 매크로**: Tool 정의 시 필수 ## 가용성 확인 ```swift if LanguageModelSession.isAvailable { // Foundation Models 사용 가능 } else { // 대체 로직 (예: 서버 API) } ``` --- # HealthKit AI Reference > 건강 데이터 읽기/쓰기 가이드. 이 문서를 읽고 HealthKit 코드를 생성할 수 있습니다. ## 개요 HealthKit은 건강 및 피트니스 데이터를 읽고 쓰는 프레임워크입니다. 걸음 수, 심박수, 수면, 운동 기록 등 다양한 건강 데이터를 관리합니다. ## 필수 Import ```swift import HealthKit ``` ## 프로젝트 설정 1. **Capabilities**: HealthKit 추가 2. **Info.plist**: - `NSHealthShareUsageDescription`: 읽기 권한 설명 - `NSHealthUpdateUsageDescription`: 쓰기 권한 설명 ## 핵심 구성요소 ### 1. HKHealthStore (진입점) ```swift class HealthKitManager { let healthStore = HKHealthStore() var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } func requestAuthorization() async throws { let readTypes: Set = [ HKQuantityType(.stepCount), HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned), HKCategoryType(.sleepAnalysis) ] let writeTypes: Set = [ HKQuantityType(.stepCount), HKQuantityType(.activeEnergyBurned) ] try await healthStore.requestAuthorization(toShare: writeTypes, read: readTypes) } } ``` ### 2. 데이터 타입 ```swift // 수량형 (Quantity) let stepCount = HKQuantityType(.stepCount) let heartRate = HKQuantityType(.heartRate) let calories = HKQuantityType(.activeEnergyBurned) let distance = HKQuantityType(.distanceWalkingRunning) // 카테고리형 (Category) let sleep = HKCategoryType(.sleepAnalysis) let mindfulness = HKCategoryType(.mindfulSession) // 운동 (Workout) let workout = HKWorkoutType.workoutType() // 특성 (Characteristic) - 읽기 전용 let bloodType = HKCharacteristicType(.bloodType) let biologicalSex = HKCharacteristicType(.biologicalSex) ``` ### 3. 데이터 읽기 ```swift // 오늘 걸음 수 func fetchTodaySteps() async throws -> Double { let stepType = HKQuantityType(.stepCount) let predicate = HKQuery.predicateForSamples( withStart: Calendar.current.startOfDay(for: Date()), end: Date() ) let descriptor = HKStatisticsQueryDescriptor( predicate: HKSamplePredicate.quantitySample(type: stepType, predicate: predicate), options: .cumulativeSum ) let result = try await descriptor.result(for: healthStore) return result?.sumQuantity()?.doubleValue(for: .count()) ?? 0 } // 최근 심박수 func fetchRecentHeartRate() async throws -> [HKQuantitySample] { let heartRateType = HKQuantityType(.heartRate) let sortDescriptor = SortDescriptor(\HKQuantitySample.startDate, order: .reverse) let descriptor = HKSampleQueryDescriptor( predicates: [.quantitySample(type: heartRateType)], sortDescriptors: [sortDescriptor], limit: 10 ) return try await descriptor.result(for: healthStore) } ``` ## 전체 작동 예제 ```swift import SwiftUI import HealthKit // MARK: - ViewModel @Observable class HealthViewModel { let healthStore = HKHealthStore() var steps: Double = 0 var heartRate: Double = 0 var calories: Double = 0 var sleepHours: Double = 0 var isAuthorized = false var error: Error? func requestAuthorization() async { guard HKHealthStore.isHealthDataAvailable() else { return } let readTypes: Set = [ HKQuantityType(.stepCount), HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned), HKCategoryType(.sleepAnalysis) ] do { try await healthStore.requestAuthorization(toShare: [], read: readTypes) isAuthorized = true await fetchAllData() } catch { self.error = error } } func fetchAllData() async { await withTaskGroup(of: Void.self) { group in group.addTask { await self.fetchSteps() } group.addTask { await self.fetchHeartRate() } group.addTask { await self.fetchCalories() } group.addTask { await self.fetchSleep() } } } private func fetchSteps() async { let stepType = HKQuantityType(.stepCount) let predicate = HKQuery.predicateForSamples( withStart: Calendar.current.startOfDay(for: Date()), end: Date() ) let descriptor = HKStatisticsQueryDescriptor( predicate: HKSamplePredicate.quantitySample(type: stepType, predicate: predicate), options: .cumulativeSum ) do { let result = try await descriptor.result(for: healthStore) steps = result?.sumQuantity()?.doubleValue(for: .count()) ?? 0 } catch { print("걸음 수 조회 실패: \(error)") } } private func fetchHeartRate() async { let heartRateType = HKQuantityType(.heartRate) let sortDescriptor = SortDescriptor(\HKQuantitySample.startDate, order: .reverse) let descriptor = HKSampleQueryDescriptor( predicates: [.quantitySample(type: heartRateType)], sortDescriptors: [sortDescriptor], limit: 1 ) do { let samples = try await descriptor.result(for: healthStore) if let sample = samples.first { heartRate = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) } } catch { print("심박수 조회 실패: \(error)") } } private func fetchCalories() async { let calorieType = HKQuantityType(.activeEnergyBurned) let predicate = HKQuery.predicateForSamples( withStart: Calendar.current.startOfDay(for: Date()), end: Date() ) let descriptor = HKStatisticsQueryDescriptor( predicate: HKSamplePredicate.quantitySample(type: calorieType, predicate: predicate), options: .cumulativeSum ) do { let result = try await descriptor.result(for: healthStore) calories = result?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0 } catch { print("칼로리 조회 실패: \(error)") } } private func fetchSleep() async { let sleepType = HKCategoryType(.sleepAnalysis) let calendar = Calendar.current let now = Date() let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))! let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now) let sortDescriptor = SortDescriptor(\HKCategorySample.startDate, order: .forward) let descriptor = HKSampleQueryDescriptor( predicates: [.categorySample(type: sleepType, predicate: predicate)], sortDescriptors: [sortDescriptor] ) do { let samples = try await descriptor.result(for: healthStore) let asleepSamples = samples.filter { $0.value != HKCategoryValueSleepAnalysis.inBed.rawValue } sleepHours = asleepSamples.reduce(0) { total, sample in total + sample.endDate.timeIntervalSince(sample.startDate) } / 3600 } catch { print("수면 조회 실패: \(error)") } } } // MARK: - View struct HealthDashboardView: View { @State private var viewModel = HealthViewModel() var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { HealthCard(title: "걸음", value: "\(Int(viewModel.steps))", unit: "걸음", icon: "figure.walk", color: .green) HealthCard(title: "심박수", value: "\(Int(viewModel.heartRate))", unit: "BPM", icon: "heart.fill", color: .red) HealthCard(title: "칼로리", value: "\(Int(viewModel.calories))", unit: "kcal", icon: "flame.fill", color: .orange) HealthCard(title: "수면", value: String(format: "%.1f", viewModel.sleepHours), unit: "시간", icon: "moon.fill", color: .indigo) } .padding() } .navigationTitle("건강") .task { await viewModel.requestAuthorization() } .refreshable { await viewModel.fetchAllData() } } } } struct HealthCard: View { let title: String let value: String let unit: String let icon: String let color: Color var body: some View { VStack(alignment: .leading, spacing: 8) { Label(title, systemImage: icon) .font(.subheadline) .foregroundStyle(color) HStack(alignment: .lastTextBaseline, spacing: 4) { Text(value) .font(.system(size: 32, weight: .bold, design: .rounded)) Text(unit) .font(.caption) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(color.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 16)) } } ``` ## 고급 패턴 ### 1. 데이터 쓰기 ```swift func saveWorkout(type: HKWorkoutActivityType, duration: TimeInterval, calories: Double) async throws { let workout = HKWorkout( activityType: type, start: Date().addingTimeInterval(-duration), end: Date(), duration: duration, totalEnergyBurned: HKQuantity(unit: .kilocalorie(), doubleValue: calories), totalDistance: nil, metadata: nil ) try await healthStore.save(workout) } func saveSteps(count: Double, start: Date, end: Date) async throws { let stepType = HKQuantityType(.stepCount) let quantity = HKQuantity(unit: .count(), doubleValue: count) let sample = HKQuantitySample(type: stepType, quantity: quantity, start: start, end: end) try await healthStore.save(sample) } ``` ### 2. 백그라운드 업데이트 ```swift func enableBackgroundDelivery() async throws { let stepType = HKQuantityType(.stepCount) try await healthStore.enableBackgroundDelivery(for: stepType, frequency: .hourly) // Observer Query로 변경 감지 let query = HKObserverQuery(sampleType: stepType, predicate: nil) { query, completionHandler, error in // 데이터 변경됨 → 새로고침 Task { await self.fetchSteps() } completionHandler() } healthStore.execute(query) } ``` ### 3. 통계 컬렉션 (차트용) ```swift func fetchWeeklySteps() async throws -> [(date: Date, steps: Double)] { let stepType = HKQuantityType(.stepCount) let calendar = Calendar.current let now = Date() let startOfWeek = calendar.date(byAdding: .day, value: -7, to: calendar.startOfDay(for: now))! let predicate = HKQuery.predicateForSamples(withStart: startOfWeek, end: now) let descriptor = HKStatisticsCollectionQueryDescriptor( predicate: HKSamplePredicate.quantitySample(type: stepType, predicate: predicate), options: .cumulativeSum, anchorDate: startOfWeek, intervalComponents: DateComponents(day: 1) ) let collection = try await descriptor.result(for: healthStore) var results: [(Date, Double)] = [] collection.enumerateStatistics(from: startOfWeek, to: now) { statistics, _ in let steps = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0 results.append((statistics.startDate, steps)) } return results } ``` ## 주의사항 1. **권한 확인** - 권한 상태는 정확히 알 수 없음 (프라이버시) - `.notDetermined`, `.sharingDenied` 등 제한적 상태만 확인 가능 2. **단위 변환** ```swift // 거리: 미터 또는 마일 let meters = quantity.doubleValue(for: .meter()) let miles = quantity.doubleValue(for: .mile()) // 에너지: 칼로리 또는 줄 let kcal = quantity.doubleValue(for: .kilocalorie()) // 심박수: count/min let bpm = quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) ``` 3. **백그라운드 제한** - `enableBackgroundDelivery` 필요 - 빈도: `.immediate`, `.hourly`, `.daily` - 배터리 영향 고려 4. **시뮬레이터 제한** - 시뮬레이터에서는 HealthKit 사용 불가 - 실제 기기에서만 테스트 가능 --- # Image Playground AI Reference > Apple Intelligence 이미지 생성 가이드. 이 문서를 읽고 Image Playground 코드를 생성할 수 있습니다. ## 개요 Image Playground는 Apple Intelligence의 이미지 생성 프레임워크입니다. 텍스트 프롬프트, 개념, 사람 등을 기반으로 세 가지 스타일(애니메이션, 일러스트, 스케치)의 이미지를 생성합니다. iOS 18.1+, Apple Silicon 기기 필요. ## 필수 Import ```swift import ImagePlayground ``` ## 프로젝트 설정 - **iOS 18.1+** 필요 - **Apple Silicon** 기기만 지원 (A17 Pro 이상) - 추가 권한 불필요 ## 핵심 구성요소 ### 1. ImagePlaygroundSheet (SwiftUI) ```swift import SwiftUI import ImagePlayground struct ContentView: View { @State private var showPlayground = false @State private var generatedImage: URL? var body: some View { VStack { if let imageURL = generatedImage { AsyncImage(url: imageURL) { image in image.resizable().scaledToFit() } placeholder: { ProgressView() } } Button("이미지 생성") { showPlayground = true } } .imagePlaygroundSheet( isPresented: $showPlayground, concept: "우주에서 피자를 먹는 고양이" ) { url in generatedImage = url } } } ``` ### 2. Concept (입력 개념) ```swift // 텍스트 개념 ImagePlaygroundConcept.text("해변의 일몰") // 추출된 개념 (텍스트에서 핵심 개념 추출) ImagePlaygroundConcept.extracted(from: "강아지가 공원에서 뛰어놀고 있다", title: "강아지") // 사람 (PersonsNameComponents) ImagePlaygroundConcept.person(url: photoURL, nameComponents: personName) ``` ### 3. Style (이미지 스타일) ```swift // 애니메이션 (3D 느낌) ImagePlaygroundStyle.animation // 일러스트 (플랫 디자인) ImagePlaygroundStyle.illustration // 스케치 (손그림 느낌) ImagePlaygroundStyle.sketch ``` ## 전체 작동 예제 ```swift import SwiftUI import ImagePlayground // MARK: - Main View struct ImagePlaygroundView: View { @State private var showPlayground = false @State private var generatedImages: [URL] = [] @State private var prompt = "" @State private var selectedStyle: ImagePlaygroundStyle = .animation @State private var isSupported = false var body: some View { NavigationStack { VStack(spacing: 20) { // 지원 여부 확인 if !isSupported { ContentUnavailableView( "지원되지 않는 기기", systemImage: "exclamationmark.triangle", description: Text("Image Playground는 A17 Pro 이상의 Apple Silicon 기기에서만 사용 가능합니다.") ) } else { // 생성된 이미지 그리드 if generatedImages.isEmpty { ContentUnavailableView( "생성된 이미지 없음", systemImage: "photo.badge.plus", description: Text("아래 버튼을 눌러 이미지를 생성하세요") ) } else { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 12) { ForEach(generatedImages, id: \.self) { url in AsyncImage(url: url) { image in image .resizable() .aspectRatio(contentMode: .fill) .frame(height: 150) .clipShape(RoundedRectangle(cornerRadius: 12)) } placeholder: { RoundedRectangle(cornerRadius: 12) .fill(.quaternary) .frame(height: 150) .overlay { ProgressView() } } .contextMenu { Button { copyImage(from: url) } label: { Label("복사", systemImage: "doc.on.doc") } ShareLink(item: url) } } } .padding() } } Spacer() // 프롬프트 입력 VStack(spacing: 12) { TextField("무엇을 그릴까요?", text: $prompt) .textFieldStyle(.roundedBorder) // 스타일 선택 Picker("스타일", selection: $selectedStyle) { Text("애니메이션").tag(ImagePlaygroundStyle.animation) Text("일러스트").tag(ImagePlaygroundStyle.illustration) Text("스케치").tag(ImagePlaygroundStyle.sketch) } .pickerStyle(.segmented) // 생성 버튼 Button { showPlayground = true } label: { Label("이미지 생성", systemImage: "wand.and.stars") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(prompt.isEmpty) } .padding() } } .navigationTitle("Image Playground") .toolbar { if !generatedImages.isEmpty { ToolbarItem(placement: .topBarTrailing) { Button("모두 삭제") { generatedImages.removeAll() } } } } .imagePlaygroundSheet( isPresented: $showPlayground, concepts: [.text(prompt)], style: selectedStyle, title: "이미지 생성" ) { url in generatedImages.append(url) prompt = "" } .task { // 지원 여부 확인 isSupported = ImagePlaygroundViewController.isAvailable } } } func copyImage(from url: URL) { if let data = try? Data(contentsOf: url), let image = UIImage(data: data) { UIPasteboard.general.image = image } } } #Preview { ImagePlaygroundView() } ``` ## 고급 패턴 ### 1. 여러 Concept 조합 ```swift struct MultiConceptView: View { @State private var showPlayground = false @State private var result: URL? var body: some View { Button("생성") { showPlayground = true } .imagePlaygroundSheet( isPresented: $showPlayground, concepts: [ .text("판타지 성"), .text("눈 덮인 산"), .extracted(from: "마법사가 주문을 외우고 있다", title: "마법사") ], style: .illustration ) { url in result = url } } } ``` ### 2. UIKit 통합 (ImagePlaygroundViewController) ```swift import UIKit import ImagePlayground class ImagePlaygroundHostVC: UIViewController { func presentPlayground() { guard ImagePlaygroundViewController.isAvailable else { showUnsupportedAlert() return } let playgroundVC = ImagePlaygroundViewController() playgroundVC.delegate = self // 초기 개념 설정 playgroundVC.concepts = [ .text("귀여운 로봇") ] // 스타일 설정 playgroundVC.style = .animation present(playgroundVC, animated: true) } func showUnsupportedAlert() { let alert = UIAlertController( title: "지원되지 않음", message: "이 기기에서는 Image Playground를 사용할 수 없습니다.", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "확인", style: .default)) present(alert, animated: true) } } extension ImagePlaygroundHostVC: ImagePlaygroundViewControllerDelegate { func imagePlaygroundViewController( _ controller: ImagePlaygroundViewController, didCreateImageAt imageURL: URL ) { // 생성된 이미지 처리 if let data = try? Data(contentsOf: imageURL), let image = UIImage(data: data) { // 이미지 사용 handleGeneratedImage(image) } controller.dismiss(animated: true) } func imagePlaygroundViewControllerDidCancel( _ controller: ImagePlaygroundViewController ) { controller.dismiss(animated: true) } func handleGeneratedImage(_ image: UIImage) { // 이미지 처리 로직 } } ``` ### 3. 사람 포함 이미지 생성 ```swift import SwiftUI import ImagePlayground struct PersonImageView: View { @State private var showPlayground = false @State private var result: URL? // 사람 사진 URL let personPhotoURL: URL var body: some View { Button("내 캐릭터 생성") { showPlayground = true } .imagePlaygroundSheet( isPresented: $showPlayground, concepts: [ .person( url: personPhotoURL, nameComponents: PersonNameComponents(givenName: "철수") ), .text("우주 비행사") ], style: .animation ) { url in result = url } } } ``` ### 4. 지원 여부 체크 후 대체 UI ```swift struct AdaptiveImageView: View { var body: some View { if ImagePlaygroundViewController.isAvailable { ImagePlaygroundView() } else { // 대체 UI (예: 스티커 선택기) StickerPickerView() } } } ``` ### 5. 이미지 저장 및 공유 ```swift func saveToPhotos(url: URL) async throws { let data = try Data(contentsOf: url) guard let image = UIImage(data: data) else { return } UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) } // ShareLink 사용 struct ShareableImageView: View { let imageURL: URL var body: some View { VStack { AsyncImage(url: imageURL) { image in image.resizable().scaledToFit() } placeholder: { ProgressView() } ShareLink( item: imageURL, preview: SharePreview("생성된 이미지", image: imageURL) ) { Label("공유", systemImage: "square.and.arrow.up") } } } } ``` ## 주의사항 1. **기기 요구사항** ```swift // 런타임 확인 필수 if ImagePlaygroundViewController.isAvailable { // 사용 가능 } else { // 대체 UI 표시 } ``` 2. **지원 기기** - iPhone 15 Pro / Pro Max 이상 - M1 이상 iPad / Mac - 시뮬레이터 미지원 3. **이미지 특성** - 생성 이미지는 임시 URL로 제공됨 - 영구 저장 필요 시 직접 복사 4. **개인정보** - 사람 이미지 사용 시 명시적 동의 필요 - 생성된 이미지는 로컬 처리 5. **스타일 제한** - 세 가지 스타일만 지원 (animation, illustration, sketch) - 사실적인 이미지 생성 불가 - 텍스트 렌더링 제한적 --- # LocalAuthentication AI Reference > Face ID / Touch ID 생체 인증 가이드. 이 문서를 읽고 생체 인증 코드를 생성할 수 있습니다. ## 개요 LocalAuthentication은 Face ID, Touch ID, 기기 암호를 통한 사용자 인증을 제공하는 프레임워크입니다. ## 필수 Import ```swift import LocalAuthentication ``` ## 프로젝트 설정 (Info.plist) ```xml NSFaceIDUsageDescription 앱 잠금 해제를 위해 Face ID를 사용합니다. ``` ## 핵심 구성요소 ### 1. LAContext ```swift let context = LAContext() // 생체 인증 가능 여부 확인 var error: NSError? if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { // 생체 인증 가능 } else { // 불가능 (error로 이유 확인) } // 생체 인증 타입 확인 switch context.biometryType { case .faceID: print("Face ID") case .touchID: print("Touch ID") case .opticID: print("Optic ID (Vision Pro)") case .none: print("생체 인증 없음") @unknown default: break } ``` ### 2. 인증 정책 ```swift // 생체 인증만 .deviceOwnerAuthenticationWithBiometrics // 생체 인증 + 기기 암호 (fallback) .deviceOwnerAuthentication ``` ### 3. 인증 실행 ```swift func authenticate() async -> Bool { let context = LAContext() context.localizedCancelTitle = "취소" context.localizedFallbackTitle = "암호 입력" // 빈 문자열이면 숨김 do { return try await context.evaluatePolicy( .deviceOwnerAuthenticationWithBiometrics, localizedReason: "앱 잠금을 해제합니다" ) } catch { return false } } ``` ## 전체 작동 예제 ```swift import SwiftUI import LocalAuthentication // MARK: - Biometric Manager @Observable class BiometricManager { var isAuthenticated = false var biometryType: LABiometryType = .none var canUseBiometrics = false var error: BiometricError? init() { checkBiometricAvailability() } func checkBiometricAvailability() { let context = LAContext() var error: NSError? canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) biometryType = context.biometryType if let error { self.error = mapError(error) } } func authenticate() async { let context = LAContext() context.localizedCancelTitle = "취소" context.localizedFallbackTitle = "암호 사용" // 이전 인증 무효화 (선택) context.invalidate() do { let success = try await context.evaluatePolicy( .deviceOwnerAuthenticationWithBiometrics, localizedReason: biometryReason ) await MainActor.run { isAuthenticated = success error = nil } } catch let authError as LAError { await MainActor.run { isAuthenticated = false error = mapLAError(authError) } } catch { await MainActor.run { isAuthenticated = false self.error = .unknown } } } func authenticateWithPasscode() async { let context = LAContext() do { let success = try await context.evaluatePolicy( .deviceOwnerAuthentication, // 암호 fallback 포함 localizedReason: "앱 잠금을 해제합니다" ) await MainActor.run { isAuthenticated = success } } catch { await MainActor.run { isAuthenticated = false } } } func lock() { isAuthenticated = false } // MARK: - Helpers private var biometryReason: String { switch biometryType { case .faceID: return "Face ID로 앱 잠금을 해제합니다" case .touchID: return "Touch ID로 앱 잠금을 해제합니다" case .opticID: return "Optic ID로 앱 잠금을 해제합니다" default: return "앱 잠금을 해제합니다" } } private func mapError(_ error: NSError) -> BiometricError { switch error.code { case LAError.biometryNotAvailable.rawValue: return .notAvailable case LAError.biometryNotEnrolled.rawValue: return .notEnrolled case LAError.biometryLockout.rawValue: return .lockout default: return .unknown } } private func mapLAError(_ error: LAError) -> BiometricError { switch error.code { case .userCancel: return .userCancelled case .userFallback: return .userFallback case .authenticationFailed: return .authenticationFailed case .biometryLockout: return .lockout default: return .unknown } } } enum BiometricError: Error, LocalizedError { case notAvailable case notEnrolled case lockout case userCancelled case userFallback case authenticationFailed case unknown var errorDescription: String? { switch self { case .notAvailable: return "생체 인증을 사용할 수 없습니다" case .notEnrolled: return "생체 인증이 설정되지 않았습니다" case .lockout: return "너무 많은 시도로 잠겼습니다. 기기 암호를 사용하세요" case .userCancelled: return "사용자가 취소했습니다" case .userFallback: return "암호 입력을 선택했습니다" case .authenticationFailed: return "인증에 실패했습니다" case .unknown: return "알 수 없는 오류가 발생했습니다" } } } // MARK: - Views struct LockScreenView: View { @State private var biometricManager = BiometricManager() var body: some View { Group { if biometricManager.isAuthenticated { MainContentView(biometricManager: biometricManager) } else { AuthenticationView(biometricManager: biometricManager) } } } } struct AuthenticationView: View { let biometricManager: BiometricManager var body: some View { VStack(spacing: 32) { Image(systemName: biometricIcon) .font(.system(size: 80)) .foregroundStyle(.blue) Text("앱 잠금") .font(.title.bold()) Text("보안을 위해 인증이 필요합니다") .foregroundStyle(.secondary) if let error = biometricManager.error { Text(error.localizedDescription) .foregroundStyle(.red) .font(.caption) } Spacer() VStack(spacing: 16) { if biometricManager.canUseBiometrics { Button { Task { await biometricManager.authenticate() } } label: { Label(biometricButtonTitle, systemImage: biometricIcon) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) } Button("암호로 잠금 해제") { Task { await biometricManager.authenticateWithPasscode() } } .buttonStyle(.bordered) } .padding(.horizontal, 40) } .padding() .task { // 앱 시작 시 자동으로 인증 요청 if biometricManager.canUseBiometrics { await biometricManager.authenticate() } } } var biometricIcon: String { switch biometricManager.biometryType { case .faceID: return "faceid" case .touchID: return "touchid" case .opticID: return "opticid" default: return "lock.fill" } } var biometricButtonTitle: String { switch biometricManager.biometryType { case .faceID: return "Face ID로 잠금 해제" case .touchID: return "Touch ID로 잠금 해제" case .opticID: return "Optic ID로 잠금 해제" default: return "잠금 해제" } } } struct MainContentView: View { let biometricManager: BiometricManager @Environment(\.scenePhase) private var scenePhase var body: some View { NavigationStack { List { Section("민감한 데이터") { Text("비밀번호: ••••••••") Text("카드번호: •••• •••• •••• 1234") } } .navigationTitle("보안 금고") .toolbar { Button("잠금") { biometricManager.lock() } } } .onChange(of: scenePhase) { _, newPhase in // 앱이 백그라운드로 가면 잠금 if newPhase == .background { biometricManager.lock() } } } } ``` ## 고급 패턴 ### 1. Keychain과 연동 ```swift import Security func saveToKeychain(data: Data, withBiometricProtection: Bool) throws { let access: SecAccessControlCreateFlags = withBiometricProtection ? .biometryCurrentSet : [] guard let accessControl = SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, access, nil ) else { throw KeychainError.accessControlCreationFailed } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "secureData", kSecValueData as String: data, kSecAttrAccessControl as String: accessControl ] SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } } func readFromKeychain() async throws -> Data { let context = LAContext() context.localizedReason = "저장된 데이터에 접근합니다" let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "secureData", kSecReturnData as String: true, kSecUseAuthenticationContext as String: context ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { throw KeychainError.readFailed(status) } return data } ``` ### 2. 재인증 방지 (일정 시간) ```swift class BiometricManager { private var lastAuthTime: Date? private let authValidDuration: TimeInterval = 300 // 5분 var needsReauthentication: Bool { guard let lastAuth = lastAuthTime else { return true } return Date().timeIntervalSince(lastAuth) > authValidDuration } func authenticate() async { guard needsReauthentication else { isAuthenticated = true return } // 실제 인증 수행 // ... lastAuthTime = Date() } } ``` ## 주의사항 1. **Info.plist 필수** - Face ID 사용 시 `NSFaceIDUsageDescription` 필수 - 누락 시 크래시 2. **에러 처리** - `.userCancel`: 사용자 취소 (조용히 처리) - `.userFallback`: 암호 입력 선택 - `.biometryLockout`: 너무 많은 실패 (기기 암호 필요) 3. **생체 정보 변경 감지** ```swift // 저장된 값과 비교 let oldDomainState = loadedDomainState let newDomainState = context.evaluatedPolicyDomainState if oldDomainState != newDomainState { // 생체 정보가 변경됨 (지문 추가/삭제 등) // 재인증 요구 가능 } ``` 4. **시뮬레이터 테스트** - Features → Face ID / Touch ID → Enrolled - Matching / Non-matching Face/Finger로 테스트 --- # MapKit AI Reference > 지도 및 위치 기반 서비스 가이드. 이 문서를 읽고 MapKit 코드를 생성할 수 있습니다. ## 개요 MapKit은 앱에 대화형 지도를 추가하는 프레임워크입니다. 위치 검색, 경로 안내, 커스텀 마커 등을 지원합니다. ## 필수 Import ```swift import MapKit import SwiftUI ``` ## 핵심 구성요소 ### 1. 기본 지도 (iOS 17+) ```swift struct SimpleMapView: View { var body: some View { Map() // 현재 위치 기반 기본 지도 } } // 특정 위치로 시작 struct SeoulMapView: View { @State private var position = MapCameraPosition.region( MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) ) ) var body: some View { Map(position: $position) } } ``` ### 2. 마커 & 어노테이션 ```swift struct Place: Identifiable { let id = UUID() let name: String let coordinate: CLLocationCoordinate2D } struct MarkerMapView: View { let places = [ Place(name: "서울역", coordinate: CLLocationCoordinate2D(latitude: 37.5547, longitude: 126.9707)), Place(name: "강남역", coordinate: CLLocationCoordinate2D(latitude: 37.4979, longitude: 127.0276)) ] var body: some View { Map { // 기본 마커 ForEach(places) { place in Marker(place.name, coordinate: place.coordinate) .tint(.red) } // 커스텀 어노테이션 Annotation("카페", coordinate: CLLocationCoordinate2D(latitude: 37.52, longitude: 127.0)) { Image(systemName: "cup.and.saucer.fill") .padding(8) .background(.white) .clipShape(Circle()) .shadow(radius: 2) } } } } ``` ### 3. 지도 스타일 & 컨트롤 ```swift struct StyledMapView: View { @State private var position = MapCameraPosition.automatic var body: some View { Map(position: $position) { // 콘텐츠 } .mapStyle(.imagery(elevation: .realistic)) // 위성 + 3D // .mapStyle(.standard) // .mapStyle(.hybrid) .mapControls { MapCompass() MapScaleView() MapUserLocationButton() MapPitchToggle() } } } ``` ## 전체 작동 예제 ```swift import SwiftUI import MapKit // MARK: - 모델 struct Landmark: Identifiable { let id = UUID() let name: String let category: Category let coordinate: CLLocationCoordinate2D enum Category: String, CaseIterable { case restaurant = "식당" case cafe = "카페" case attraction = "명소" var icon: String { switch self { case .restaurant: return "fork.knife" case .cafe: return "cup.and.saucer.fill" case .attraction: return "star.fill" } } var color: Color { switch self { case .restaurant: return .orange case .cafe: return .brown case .attraction: return .yellow } } } } // MARK: - ViewModel @Observable class MapViewModel { var landmarks: [Landmark] = [] var selectedLandmark: Landmark? var searchText = "" var cameraPosition = MapCameraPosition.automatic func search() async { let request = MKLocalSearch.Request() request.naturalLanguageQuery = searchText request.resultTypes = .pointOfInterest let search = MKLocalSearch(request: request) do { let response = try await search.start() landmarks = response.mapItems.map { item in Landmark( name: item.name ?? "Unknown", category: .attraction, coordinate: item.placemark.coordinate ) } // 결과로 카메라 이동 if let first = landmarks.first { cameraPosition = .region(MKCoordinateRegion( center: first.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) )) } } catch { print("검색 실패: \(error)") } } func selectLandmark(_ landmark: Landmark) { selectedLandmark = landmark cameraPosition = .camera(MapCamera( centerCoordinate: landmark.coordinate, distance: 1000, heading: 0, pitch: 60 )) } } // MARK: - View struct PlaceExplorerView: View { @State private var viewModel = MapViewModel() @State private var showingDetail = false var body: some View { Map(position: $viewModel.cameraPosition, selection: $viewModel.selectedLandmark) { ForEach(viewModel.landmarks) { landmark in Marker(landmark.name, systemImage: landmark.category.icon, coordinate: landmark.coordinate) .tint(landmark.category.color) .tag(landmark) } // 현재 위치 UserAnnotation() } .mapStyle(.standard(elevation: .realistic, pointsOfInterest: .including([.cafe, .restaurant]))) .mapControls { MapUserLocationButton() MapCompass() } .safeAreaInset(edge: .top) { HStack { TextField("장소 검색", text: $viewModel.searchText) .textFieldStyle(.roundedBorder) Button("검색") { Task { await viewModel.search() } } .buttonStyle(.borderedProminent) } .padding() .background(.ultraThinMaterial) } .sheet(item: $viewModel.selectedLandmark) { landmark in LandmarkDetailView(landmark: landmark) .presentationDetents([.medium]) } } } struct LandmarkDetailView: View { let landmark: Landmark var body: some View { VStack(alignment: .leading, spacing: 16) { Text(landmark.name) .font(.title2.bold()) Label(landmark.category.rawValue, systemImage: landmark.category.icon) .foregroundStyle(landmark.category.color) // 길찾기 버튼 Button("Apple 지도에서 열기") { let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: landmark.coordinate)) mapItem.name = landmark.name mapItem.openInMaps(launchOptions: [ MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving ]) } .buttonStyle(.borderedProminent) } .padding() } } ``` ## 고급 패턴 ### 1. 경로 표시 ```swift struct RouteMapView: View { @State private var route: MKRoute? let start = CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780) let end = CLLocationCoordinate2D(latitude: 37.4979, longitude: 127.0276) var body: some View { Map { Marker("출발", coordinate: start).tint(.green) Marker("도착", coordinate: end).tint(.red) if let route { MapPolyline(route.polyline) .stroke(.blue, lineWidth: 5) } } .task { await calculateRoute() } } func calculateRoute() async { let request = MKDirections.Request() request.source = MKMapItem(placemark: MKPlacemark(coordinate: start)) request.destination = MKMapItem(placemark: MKPlacemark(coordinate: end)) request.transportType = .automobile let directions = MKDirections(request: request) do { let response = try await directions.calculate() route = response.routes.first } catch { print("경로 계산 실패: \(error)") } } } ``` ### 2. Look Around (스트리트 뷰) ```swift struct LookAroundView: View { let coordinate: CLLocationCoordinate2D @State private var scene: MKLookAroundScene? var body: some View { Group { if let scene { LookAroundPreview(scene: scene) } else { ContentUnavailableView("Look Around 불가", systemImage: "eye.slash") } } .task { let request = MKLookAroundSceneRequest(coordinate: coordinate) scene = try? await request.scene } } } ``` ### 3. 클러스터링 ```swift struct ClusteredMapView: View { let places: [Place] var body: some View { Map { ForEach(places) { place in Marker(place.name, coordinate: place.coordinate) .annotationTitles(.hidden) // 클러스터링 시 제목 숨김 } } .mapStyle(.standard(pointsOfInterest: .excludingAll)) } } ``` ### 4. 지오코딩 ```swift class GeocodingService { private let geocoder = CLGeocoder() // 주소 → 좌표 func geocode(address: String) async throws -> CLLocationCoordinate2D { let placemarks = try await geocoder.geocodeAddressString(address) guard let location = placemarks.first?.location else { throw GeocodingError.notFound } return location.coordinate } // 좌표 → 주소 func reverseGeocode(coordinate: CLLocationCoordinate2D) async throws -> String { let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) let placemarks = try await geocoder.reverseGeocodeLocation(location) guard let placemark = placemarks.first else { throw GeocodingError.notFound } return [placemark.locality, placemark.thoroughfare, placemark.subThoroughfare] .compactMap { $0 } .joined(separator: " ") } } ``` ## 주의사항 1. **권한 설정** - Info.plist: `NSLocationWhenInUseUsageDescription` - 지도 표시만은 권한 불필요 - 현재 위치 버튼 사용 시 필요 2. **iOS 17+ API** - `Map { }` 문법은 iOS 17+ - iOS 16: `Map(coordinateRegion:)` 3. **성능** - 마커가 많으면 클러스터링 고려 - 경로 계산은 비동기로 4. **제한사항** - Look Around은 일부 지역만 지원 - 중국에서는 GCJ-02 좌표계 사용 --- # MultipeerConnectivity AI Reference > P2P 통신 앱 구현 가이드. 이 문서를 읽고 MultipeerConnectivity 코드를 생성할 수 있습니다. ## 개요 MultipeerConnectivity는 Wi-Fi, Bluetooth, P2P Wi-Fi를 통해 근처 기기 간 직접 통신을 제공합니다. 인터넷 연결 없이 메시지, 파일, 스트림 데이터를 주고받을 수 있습니다. ## 필수 Import ```swift import MultipeerConnectivity ``` ## 프로젝트 설정 ```xml NSBluetoothAlwaysUsageDescription 근처 기기와 연결하기 위해 Bluetooth가 필요합니다. NSLocalNetworkUsageDescription 근처 기기를 찾기 위해 로컬 네트워크 접근이 필요합니다. NSBonjourServices _myapp._tcp _myapp._udp ``` ## 핵심 구성요소 ### 1. MCPeerID (기기 식별) ```swift // 현재 기기 ID let peerID = MCPeerID(displayName: UIDevice.current.name) // 커스텀 이름 let peerID = MCPeerID(displayName: "Player1") ``` ### 2. MCSession (세션 관리) ```swift let session = MCSession( peer: myPeerID, securityIdentity: nil, encryptionPreference: .required ) session.delegate = self ``` ### 3. MCNearbyServiceAdvertiser (광고) ```swift // 내 기기를 광고 let advertiser = MCNearbyServiceAdvertiser( peer: myPeerID, discoveryInfo: ["role": "host"], // 추가 정보 serviceType: "my-app" // 1-15자, 소문자/숫자/하이픈 ) advertiser.delegate = self advertiser.startAdvertisingPeer() ``` ### 4. MCNearbyServiceBrowser (탐색) ```swift // 근처 기기 탐색 let browser = MCNearbyServiceBrowser( peer: myPeerID, serviceType: "my-app" ) browser.delegate = self browser.startBrowsingForPeers() ``` ## 전체 작동 예제 ```swift import SwiftUI import MultipeerConnectivity // MARK: - Multipeer Manager @Observable class MultipeerManager: NSObject { var connectedPeers: [MCPeerID] = [] var availablePeers: [MCPeerID] = [] var receivedMessages: [ChatMessage] = [] var isAdvertising = false var isBrowsing = false private let serviceType = "chat-app" private let myPeerID: MCPeerID private var session: MCSession! private var advertiser: MCNearbyServiceAdvertiser! private var browser: MCNearbyServiceBrowser! override init() { myPeerID = MCPeerID(displayName: UIDevice.current.name) super.init() session = MCSession( peer: myPeerID, securityIdentity: nil, encryptionPreference: .required ) session.delegate = self advertiser = MCNearbyServiceAdvertiser( peer: myPeerID, discoveryInfo: nil, serviceType: serviceType ) advertiser.delegate = self browser = MCNearbyServiceBrowser( peer: myPeerID, serviceType: serviceType ) browser.delegate = self } // MARK: - 광고 시작/중지 func startAdvertising() { advertiser.startAdvertisingPeer() isAdvertising = true } func stopAdvertising() { advertiser.stopAdvertisingPeer() isAdvertising = false } // MARK: - 탐색 시작/중지 func startBrowsing() { browser.startBrowsingForPeers() isBrowsing = true } func stopBrowsing() { browser.stopBrowsingForPeers() isBrowsing = false } // MARK: - 연결 요청 func invitePeer(_ peer: MCPeerID) { browser.invitePeer( peer, to: session, withContext: nil, timeout: 30 ) } // MARK: - 메시지 전송 func send(_ message: String) { guard !session.connectedPeers.isEmpty else { return } let chatMessage = ChatMessage( sender: myPeerID.displayName, content: message, timestamp: Date() ) if let data = try? JSONEncoder().encode(chatMessage) { try? session.send(data, toPeers: session.connectedPeers, with: .reliable) receivedMessages.append(chatMessage) } } // MARK: - 파일 전송 func sendFile(url: URL, to peer: MCPeerID) { session.sendResource( at: url, withName: url.lastPathComponent, toPeer: peer ) { error in if let error = error { print("파일 전송 실패: \(error)") } } } // MARK: - 연결 해제 func disconnect() { session.disconnect() } } // MARK: - MCSessionDelegate extension MultipeerManager: MCSessionDelegate { func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { DispatchQueue.main.async { switch state { case .connected: if !self.connectedPeers.contains(peerID) { self.connectedPeers.append(peerID) } self.availablePeers.removeAll { $0 == peerID } case .notConnected: self.connectedPeers.removeAll { $0 == peerID } case .connecting: print("\(peerID.displayName) 연결 중...") @unknown default: break } } } func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { if let message = try? JSONDecoder().decode(ChatMessage.self, from: data) { DispatchQueue.main.async { self.receivedMessages.append(message) } } } func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { // 스트림 수신 처리 } func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { print("파일 수신 시작: \(resourceName)") } func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) { if let url = localURL { print("파일 수신 완료: \(url)") } } } // MARK: - MCNearbyServiceAdvertiserDelegate extension MultipeerManager: MCNearbyServiceAdvertiserDelegate { func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { // 자동 수락 (또는 UI로 확인) invitationHandler(true, session) } } // MARK: - MCNearbyServiceBrowserDelegate extension MultipeerManager: MCNearbyServiceBrowserDelegate { func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { DispatchQueue.main.async { if !self.availablePeers.contains(peerID) && !self.connectedPeers.contains(peerID) { self.availablePeers.append(peerID) } } } func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { DispatchQueue.main.async { self.availablePeers.removeAll { $0 == peerID } } } } // MARK: - Chat Message Model struct ChatMessage: Codable, Identifiable { let id = UUID() let sender: String let content: String let timestamp: Date enum CodingKeys: String, CodingKey { case sender, content, timestamp } } // MARK: - Main View struct MultipeerChatView: View { @State private var manager = MultipeerManager() @State private var messageText = "" var body: some View { NavigationStack { VStack(spacing: 0) { // 연결된 피어 if !manager.connectedPeers.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(manager.connectedPeers, id: \.displayName) { peer in Label(peer.displayName, systemImage: "person.fill") .padding(.horizontal, 12) .padding(.vertical, 6) .background(.green.opacity(0.2)) .clipShape(Capsule()) } } .padding() } .background(.bar) } // 메시지 목록 List(manager.receivedMessages) { message in VStack(alignment: .leading, spacing: 4) { HStack { Text(message.sender) .font(.caption.bold()) Spacer() Text(message.timestamp, style: .time) .font(.caption2) .foregroundStyle(.secondary) } Text(message.content) } } .listStyle(.plain) // 메시지 입력 HStack { TextField("메시지", text: $messageText) .textFieldStyle(.roundedBorder) Button { manager.send(messageText) messageText = "" } label: { Image(systemName: "paperplane.fill") } .disabled(messageText.isEmpty || manager.connectedPeers.isEmpty) } .padding() .background(.bar) } .navigationTitle("P2P 채팅") .toolbar { ToolbarItem(placement: .topBarLeading) { Menu { Toggle("광고", isOn: Binding( get: { manager.isAdvertising }, set: { $0 ? manager.startAdvertising() : manager.stopAdvertising() } )) Toggle("탐색", isOn: Binding( get: { manager.isBrowsing }, set: { $0 ? manager.startBrowsing() : manager.stopBrowsing() } )) } label: { Image(systemName: "antenna.radiowaves.left.and.right") } } ToolbarItem(placement: .topBarTrailing) { Menu { Section("발견된 기기") { ForEach(manager.availablePeers, id: \.displayName) { peer in Button(peer.displayName) { manager.invitePeer(peer) } } if manager.availablePeers.isEmpty { Text("없음") } } } label: { Image(systemName: "person.2") } } } .onAppear { manager.startAdvertising() manager.startBrowsing() } .onDisappear { manager.stopAdvertising() manager.stopBrowsing() manager.disconnect() } } } } #Preview { MultipeerChatView() } ``` ## 고급 패턴 ### 1. 스트림 데이터 전송 ```swift // 스트림 시작 func startStream(to peer: MCPeerID) throws -> OutputStream { try session.startStream(withName: "video", toPeer: peer) } // 스트림 수신 func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { stream.delegate = self stream.schedule(in: .main, forMode: .default) stream.open() } // StreamDelegate extension MultipeerManager: StreamDelegate { func stream(_ aStream: Stream, handle eventCode: Stream.Event) { switch eventCode { case .hasBytesAvailable: if let inputStream = aStream as? InputStream { var buffer = [UInt8](repeating: 0, count: 1024) let bytesRead = inputStream.read(&buffer, maxLength: buffer.count) if bytesRead > 0 { let data = Data(bytes: buffer, count: bytesRead) // 데이터 처리 } } case .endEncountered: aStream.close() default: break } } } ``` ### 2. 초대 UI (MCBrowserViewController) ```swift import UIKit import MultipeerConnectivity class PeerBrowserVC: UIViewController { var session: MCSession! var peerID: MCPeerID! func showBrowser() { let browserVC = MCBrowserViewController( serviceType: "my-app", session: session ) browserVC.delegate = self browserVC.minimumNumberOfPeers = 1 browserVC.maximumNumberOfPeers = 4 present(browserVC, animated: true) } } extension PeerBrowserVC: MCBrowserViewControllerDelegate { func browserViewControllerDidFinish(_ browserViewController: MCBrowserViewController) { dismiss(animated: true) } func browserViewControllerWasCancelled(_ browserViewController: MCBrowserViewController) { dismiss(animated: true) } } ``` ### 3. 보안 연결 ```swift // 인증서 기반 보안 func setupSecureSession() -> MCSession { // 인증서 로드 guard let certificateURL = Bundle.main.url(forResource: "cert", withExtension: "p12"), let certificateData = try? Data(contentsOf: certificateURL) else { fatalError("인증서 없음") } var items: CFArray? let options = [kSecImportExportPassphrase: "password"] SecPKCS12Import(certificateData as CFData, options as CFDictionary, &items) let identityDict = (items as! [[String: Any]])[0] let identity = identityDict[kSecImportItemIdentity as String] as! SecIdentity return MCSession( peer: myPeerID, securityIdentity: [identity], encryptionPreference: .required ) } // 인증서 검증 func session(_ session: MCSession, didReceiveCertificate certificate: [Any]?, fromPeer peerID: MCPeerID, certificateHandler: @escaping (Bool) -> Void) { // 인증서 검증 로직 certificateHandler(true) // 또는 false로 거부 } ``` ## 주의사항 1. **서비스 타입 규칙** ```swift // 1-15자, 소문자/숫자/하이픈만 // 첫 글자는 문자 let serviceType = "my-game" // ✅ let serviceType = "MyGame" // ❌ 대문자 let serviceType = "1game" // ❌ 숫자 시작 ``` 2. **백그라운드 제한** - 앱이 백그라운드로 가면 연결 끊김 - Background Modes로 일부 연장 가능 3. **배터리 소모** - 광고/탐색은 배터리 소모 큼 - 필요 시에만 활성화 4. **피어 수 제한** - 최대 8개 피어 연결 권장 - 그 이상은 성능 저하 5. **Info.plist 필수** - NSBonjourServices에 서비스 타입 등록 필수 - `_서비스타입._tcp` 형식 --- # MusicKit AI Reference > Apple Music 통합 가이드. 이 문서를 읽고 MusicKit 코드를 생성할 수 있습니다. ## 개요 MusicKit은 Apple Music 카탈로그 검색, 라이브러리 접근, 음악 재생을 지원하는 프레임워크입니다. Apple Music 구독자에게 전체 기능을 제공합니다. ## 필수 Import ```swift import MusicKit ``` ## 프로젝트 설정 1. **Capabilities**: Media & Apple Music 추가 2. **Info.plist**: ```xml NSAppleMusicUsageDescription 음악 라이브러리에 접근하기 위해 필요합니다. ``` ## 핵심 구성요소 ### 1. 권한 요청 ```swift func requestMusicAuthorization() async -> MusicAuthorization.Status { let status = await MusicAuthorization.request() return status } // 상태 확인 switch MusicAuthorization.currentStatus { case .authorized: // 허용됨 case .denied: // 거부됨 case .notDetermined: // 미결정 case .restricted: // 제한됨 @unknown default: break } ``` ### 2. 음악 검색 ```swift func searchSongs(term: String) async throws -> MusicItemCollection { var request = MusicCatalogSearchRequest(term: term, types: [Song.self]) request.limit = 25 let response = try await request.response() return response.songs } // 아티스트 검색 func searchArtists(term: String) async throws -> MusicItemCollection { var request = MusicCatalogSearchRequest(term: term, types: [Artist.self]) request.limit = 10 let response = try await request.response() return response.artists } ``` ### 3. 음악 재생 ```swift let player = ApplicationMusicPlayer.shared // 노래 재생 func playSong(_ song: Song) async throws { player.queue = [song] try await player.play() } // 앨범 재생 func playAlbum(_ album: Album) async throws { player.queue = ApplicationMusicPlayer.Queue(album: album) try await player.play() } // 재생 제어 player.pause() try await player.skipToNextEntry() try await player.skipToPreviousEntry() ``` ## 전체 작동 예제 ```swift import SwiftUI import MusicKit // MARK: - Music Manager @Observable class MusicManager { var authorizationStatus: MusicAuthorization.Status = .notDetermined var searchResults: MusicItemCollection = [] var isPlaying = false var currentSong: Song? var searchText = "" private let player = ApplicationMusicPlayer.shared init() { authorizationStatus = MusicAuthorization.currentStatus observePlayer() } func requestAuthorization() async { authorizationStatus = await MusicAuthorization.request() } func search() async { guard !searchText.isEmpty else { searchResults = [] return } do { var request = MusicCatalogSearchRequest(term: searchText, types: [Song.self]) request.limit = 25 let response = try await request.response() searchResults = response.songs } catch { print("검색 실패: \(error)") } } func play(_ song: Song) async { do { player.queue = [song] try await player.play() currentSong = song } catch { print("재생 실패: \(error)") } } func togglePlayPause() { if isPlaying { player.pause() } else { Task { try? await player.play() } } } func skipNext() async { try? await player.skipToNextEntry() } func skipPrevious() async { try? await player.skipToPreviousEntry() } private func observePlayer() { // 재생 상태 관찰 Task { for await state in player.state.objectWillChange.values { await MainActor.run { isPlaying = player.state.playbackStatus == .playing } } } } } // MARK: - Views struct MusicPlayerView: View { @State private var manager = MusicManager() var body: some View { NavigationStack { Group { switch manager.authorizationStatus { case .authorized: musicContentView case .notDetermined: requestAuthView default: deniedView } } .navigationTitle("음악") } } var musicContentView: some View { VStack(spacing: 0) { // 검색 List { ForEach(manager.searchResults, id: \.id) { song in SongRow(song: song) { Task { await manager.play(song) } } } } .searchable(text: $manager.searchText, prompt: "노래 검색") .onChange(of: manager.searchText) { _, _ in Task { await manager.search() } } // 미니 플레이어 if let song = manager.currentSong { MiniPlayerView(song: song, manager: manager) } } } var requestAuthView: some View { ContentUnavailableView { Label("Apple Music 접근 필요", systemImage: "music.note") } description: { Text("음악을 재생하려면 권한이 필요합니다") } actions: { Button("권한 요청") { Task { await manager.requestAuthorization() } } .buttonStyle(.borderedProminent) } } var deniedView: some View { ContentUnavailableView { Label("접근 거부됨", systemImage: "music.note.slash") } description: { Text("설정에서 Apple Music 접근을 허용해주세요") } actions: { Button("설정 열기") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } } } struct SongRow: View { let song: Song let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: 12) { // 앨범 아트 if let artwork = song.artwork { ArtworkImage(artwork, width: 50) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { RoundedRectangle(cornerRadius: 6) .fill(.gray.opacity(0.3)) .frame(width: 50, height: 50) } VStack(alignment: .leading) { Text(song.title) .font(.headline) .lineLimit(1) Text(song.artistName) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() if let duration = song.duration { Text(formatDuration(duration)) .font(.caption) .foregroundStyle(.secondary) } } } .buttonStyle(.plain) } func formatDuration(_ duration: TimeInterval) -> String { let minutes = Int(duration) / 60 let seconds = Int(duration) % 60 return String(format: "%d:%02d", minutes, seconds) } } struct MiniPlayerView: View { let song: Song let manager: MusicManager var body: some View { HStack(spacing: 16) { if let artwork = song.artwork { ArtworkImage(artwork, width: 50) .clipShape(RoundedRectangle(cornerRadius: 6)) } VStack(alignment: .leading) { Text(song.title) .font(.headline) .lineLimit(1) Text(song.artistName) .font(.caption) .foregroundStyle(.secondary) } Spacer() HStack(spacing: 20) { Button { Task { await manager.skipPrevious() } } label: { Image(systemName: "backward.fill") } Button { manager.togglePlayPause() } label: { Image(systemName: manager.isPlaying ? "pause.fill" : "play.fill") .font(.title2) } Button { Task { await manager.skipNext() } } label: { Image(systemName: "forward.fill") } } .foregroundStyle(.primary) } .padding() .background(.ultraThinMaterial) } } ``` ## 고급 패턴 ### 1. 사용자 라이브러리 접근 ```swift func fetchLibrarySongs() async throws -> MusicItemCollection { var request = MusicLibraryRequest() request.sort(by: \.dateAdded, ascending: false) request.limit = 50 let response = try await request.response() return response.items } func fetchLibraryPlaylists() async throws -> MusicItemCollection { let request = MusicLibraryRequest() let response = try await request.response() return response.items } ``` ### 2. 추천 음악 ```swift func fetchRecommendations() async throws -> MusicItemCollection { let request = MusicPersonalRecommendationsRequest() let response = try await request.response() return response.recommendations } func fetchCharts() async throws { let request = MusicCatalogChartsRequest(kinds: [.mostPlayed], types: [Song.self]) let response = try await request.response() // response.songCharts } ``` ### 3. 플레이리스트에 추가 ```swift func addToLibrary(_ song: Song) async throws { try await MusicLibrary.shared.add(song) } func createPlaylist(name: String, songs: [Song]) async throws { try await MusicLibrary.shared.createPlaylist(name: name, items: songs) } ``` ### 4. 가사 표시 ```swift func fetchLyrics(for song: Song) async throws -> String? { // song에 가사가 포함된 경우 let detailedSong = try await song.with([.lyrics]) return detailedSong.lyrics } ``` ## 주의사항 1. **Apple Music 구독 필요** - 전체 노래 재생은 구독자만 가능 - 미구독자는 미리듣기(30초)만 재생 2. **백그라운드 재생** - Capabilities: Background Modes → Audio 추가 - Info.plist: `UIBackgroundModes` → `audio` 3. **시뮬레이터 제한** - 시뮬레이터에서는 재생 불가 - 검색/라이브러리 조회는 가능 4. **아트워크 크기** ```swift // 원하는 크기로 아트워크 로드 if let artwork = song.artwork { ArtworkImage(artwork, width: 300, height: 300) } ``` --- # Network Framework AI Reference > 저수준 네트워크 통신 가이드. 이 문서를 읽고 Network 프레임워크 코드를 생성할 수 있습니다. ## 개요 Network 프레임워크는 TCP, UDP, QUIC, TLS 등 저수준 네트워크 연결을 위한 현대적인 API입니다. URLSession보다 세밀한 제어가 필요하거나, 커스텀 프로토콜, 실시간 통신이 필요할 때 사용합니다. ## 필수 Import ```swift import Network ``` ## 핵심 구성요소 ### 1. NWConnection (연결) ```swift // TCP 연결 let connection = NWConnection( host: "example.com", port: 8080, using: .tcp ) // TLS 연결 let tlsParams = NWProtocolTLS.Options() let tcpParams = NWProtocolTCP.Options() let params = NWParameters(tls: tlsParams, tcp: tcpParams) let secureConnection = NWConnection(host: "example.com", port: 443, using: params) // UDP 연결 let udpConnection = NWConnection( host: "example.com", port: 9000, using: .udp ) ``` ### 2. NWListener (서버) ```swift // TCP 서버 let listener = try NWListener(using: .tcp, on: 8080) listener.newConnectionHandler = { connection in // 새 연결 처리 } listener.start(queue: .main) ``` ### 3. NWPathMonitor (네트워크 상태) ```swift let monitor = NWPathMonitor() monitor.pathUpdateHandler = { path in if path.status == .satisfied { print("네트워크 연결됨") } if path.usesInterfaceType(.wifi) { print("Wi-Fi 사용 중") } } monitor.start(queue: .main) ``` ## 전체 작동 예제 ```swift import SwiftUI import Network // MARK: - TCP Client Manager @Observable class TCPClientManager { var isConnected = false var receivedMessages: [String] = [] var connectionStatus = "연결 안 됨" var errorMessage: String? private var connection: NWConnection? private let queue = DispatchQueue(label: "tcp.client") func connect(host: String, port: UInt16) { // 기존 연결 해제 disconnect() connectionStatus = "연결 중..." // TCP 연결 생성 let endpoint = NWEndpoint.hostPort( host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port) ) connection = NWConnection(to: endpoint, using: .tcp) // 상태 변경 핸들러 connection?.stateUpdateHandler = { [weak self] state in DispatchQueue.main.async { switch state { case .ready: self?.isConnected = true self?.connectionStatus = "연결됨" self?.startReceiving() case .failed(let error): self?.isConnected = false self?.connectionStatus = "연결 실패" self?.errorMessage = error.localizedDescription case .cancelled: self?.isConnected = false self?.connectionStatus = "연결 해제됨" case .waiting(let error): self?.connectionStatus = "대기 중: \(error.localizedDescription)" default: break } } } connection?.start(queue: queue) } func disconnect() { connection?.cancel() connection = nil isConnected = false connectionStatus = "연결 안 됨" } func send(_ message: String) { guard let data = (message + "\n").data(using: .utf8) else { return } connection?.send(content: data, completion: .contentProcessed { [weak self] error in if let error = error { DispatchQueue.main.async { self?.errorMessage = "전송 실패: \(error.localizedDescription)" } } }) } private func startReceiving() { connection?.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in if let data = content, let message = String(data: data, encoding: .utf8) { DispatchQueue.main.async { self?.receivedMessages.append(message.trimmingCharacters(in: .whitespacesAndNewlines)) } } if let error = error { DispatchQueue.main.async { self?.errorMessage = "수신 오류: \(error.localizedDescription)" } return } if !isComplete { self?.startReceiving() } } } } // MARK: - Network Monitor @Observable class NetworkMonitor { var isConnected = false var connectionType: String = "알 수 없음" var isExpensive = false var isConstrained = false private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "network.monitor") init() { startMonitoring() } func startMonitoring() { monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isConnected = path.status == .satisfied self?.isExpensive = path.isExpensive self?.isConstrained = path.isConstrained if path.usesInterfaceType(.wifi) { self?.connectionType = "Wi-Fi" } else if path.usesInterfaceType(.cellular) { self?.connectionType = "셀룰러" } else if path.usesInterfaceType(.wiredEthernet) { self?.connectionType = "이더넷" } else { self?.connectionType = "기타" } } } monitor.start(queue: queue) } func stopMonitoring() { monitor.cancel() } } // MARK: - Main View struct NetworkView: View { @State private var client = TCPClientManager() @State private var networkMonitor = NetworkMonitor() @State private var host = "localhost" @State private var port = "8080" @State private var messageToSend = "" var body: some View { NavigationStack { List { // 네트워크 상태 Section("네트워크 상태") { LabeledContent("연결") { HStack { Circle() .fill(networkMonitor.isConnected ? .green : .red) .frame(width: 8, height: 8) Text(networkMonitor.isConnected ? "연결됨" : "끊김") } } LabeledContent("타입", value: networkMonitor.connectionType) if networkMonitor.isExpensive { Label("데이터 비용 발생", systemImage: "dollarsign.circle") .foregroundStyle(.orange) } } // TCP 연결 Section("TCP 연결") { TextField("호스트", text: $host) .autocapitalization(.none) TextField("포트", text: $port) .keyboardType(.numberPad) HStack { Text("상태: \(client.connectionStatus)") .foregroundStyle(.secondary) Spacer() Circle() .fill(client.isConnected ? .green : .gray) .frame(width: 10, height: 10) } Button(client.isConnected ? "연결 해제" : "연결") { if client.isConnected { client.disconnect() } else if let portNum = UInt16(port) { client.connect(host: host, port: portNum) } } } // 메시지 전송 if client.isConnected { Section("메시지") { HStack { TextField("메시지", text: $messageToSend) Button("전송") { client.send(messageToSend) messageToSend = "" } .disabled(messageToSend.isEmpty) } } } // 수신 메시지 if !client.receivedMessages.isEmpty { Section("수신 메시지") { ForEach(client.receivedMessages.indices, id: \.self) { index in Text(client.receivedMessages[index]) .font(.system(.body, design: .monospaced)) } } } // 에러 if let error = client.errorMessage { Section { Label(error, systemImage: "exclamationmark.triangle") .foregroundStyle(.red) } } } .navigationTitle("Network") } } } #Preview { NetworkView() } ``` ## 고급 패턴 ### 1. TCP 서버 ```swift @Observable class TCPServer { var isRunning = false var connectedClients: [NWConnection] = [] private var listener: NWListener? private let queue = DispatchQueue(label: "tcp.server") func start(port: UInt16) throws { let params = NWParameters.tcp params.allowLocalEndpointReuse = true listener = try NWListener(using: params, on: NWEndpoint.Port(integerLiteral: port)) listener?.stateUpdateHandler = { [weak self] state in DispatchQueue.main.async { self?.isRunning = state == .ready } } listener?.newConnectionHandler = { [weak self] connection in self?.handleNewConnection(connection) } listener?.start(queue: queue) } func stop() { listener?.cancel() connectedClients.forEach { $0.cancel() } connectedClients.removeAll() isRunning = false } private func handleNewConnection(_ connection: NWConnection) { connection.stateUpdateHandler = { [weak self] state in switch state { case .ready: DispatchQueue.main.async { self?.connectedClients.append(connection) } self?.receive(on: connection) case .cancelled, .failed: DispatchQueue.main.async { self?.connectedClients.removeAll { $0 === connection } } default: break } } connection.start(queue: queue) } private func receive(on connection: NWConnection) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, _ in if let data = content { // 에코 서버 - 받은 메시지 되돌려 보내기 connection.send(content: data, completion: .idempotent) } if !isComplete { self?.receive(on: connection) } } } func broadcast(_ message: String) { guard let data = message.data(using: .utf8) else { return } for client in connectedClients { client.send(content: data, completion: .idempotent) } } } ``` ### 2. UDP 통신 ```swift class UDPManager { private var connection: NWConnection? private let queue = DispatchQueue(label: "udp") func sendUDP(message: String, to host: String, port: UInt16) { let endpoint = NWEndpoint.hostPort( host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port) ) connection = NWConnection(to: endpoint, using: .udp) connection?.stateUpdateHandler = { state in if state == .ready { self.send(message) } } connection?.start(queue: queue) } private func send(_ message: String) { guard let data = message.data(using: .utf8) else { return } connection?.send(content: data, completion: .contentProcessed { error in if let error = error { print("UDP 전송 실패: \(error)") } }) } } ``` ### 3. WebSocket ```swift class WebSocketManager { private var connection: NWConnection? private let queue = DispatchQueue(label: "websocket") func connect(to url: URL) { let host = url.host ?? "localhost" let port = UInt16(url.port ?? 443) // WebSocket 파라미터 let wsOptions = NWProtocolWebSocket.Options() wsOptions.autoReplyPing = true let tlsOptions = NWProtocolTLS.Options() let tcpOptions = NWProtocolTCP.Options() let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) params.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params ) connection?.stateUpdateHandler = { state in switch state { case .ready: print("WebSocket 연결됨") self.receiveMessage() case .failed(let error): print("연결 실패: \(error)") default: break } } connection?.start(queue: queue) } func sendText(_ text: String) { let metadata = NWProtocolWebSocket.Metadata(opcode: .text) let context = NWConnection.ContentContext( identifier: "text", metadata: [metadata] ) connection?.send( content: text.data(using: .utf8), contentContext: context, isComplete: true, completion: .idempotent ) } private func receiveMessage() { connection?.receiveMessage { content, context, _, error in if let data = content, let metadata = context?.protocolMetadata(definition: NWProtocolWebSocket.definition) as? NWProtocolWebSocket.Metadata { switch metadata.opcode { case .text: if let text = String(data: data, encoding: .utf8) { print("수신: \(text)") } case .binary: print("바이너리 수신: \(data.count) bytes") default: break } } if error == nil { self.receiveMessage() } } } } ``` ### 4. 특정 인터페이스로 연결 ```swift func connectViaWiFi(host: String, port: UInt16) { let params = NWParameters.tcp params.requiredInterfaceType = .wifi // Wi-Fi만 사용 let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params ) connection.start(queue: .main) } func connectViaCellular(host: String, port: UInt16) { let params = NWParameters.tcp params.requiredInterfaceType = .cellular // 셀룰러만 사용 let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params ) connection.start(queue: .main) } ``` ## 주의사항 1. **앱 전송 보안 (ATS)** ```xml NSAppTransportSecurity NSAllowsArbitraryLoads ``` 2. **연결 생명주기** ```swift // 반드시 cancel() 호출 deinit { connection?.cancel() } ``` 3. **스레드 안전** - 콜백은 지정한 큐에서 호출됨 - UI 업데이트는 `DispatchQueue.main.async` 4. **재연결 로직** - 자동 재연결 없음 - 직접 구현 필요 5. **로컬 네트워크 권한** - iOS 14+에서 로컬 네트워크 접근 시 권한 필요 - Info.plist에 NSLocalNetworkUsageDescription 추가 --- # User Notifications AI Reference > 푸시/로컬 알림 가이드. 이 문서를 읽고 UserNotifications 코드를 생성할 수 있습니다. ## 개요 UserNotifications는 로컬 및 원격 알림을 관리하는 프레임워크입니다. 알림 예약, 커스텀 UI, 액션 버튼 등을 지원합니다. ## 필수 Import ```swift import UserNotifications ``` ## 핵심 구성요소 ### 1. 권한 요청 ```swift func requestPermission() async throws -> Bool { let center = UNUserNotificationCenter.current() let granted = try await center.requestAuthorization(options: [ .alert, .badge, .sound, .criticalAlert, // 긴급 알림 (별도 승인 필요) .provisional // 조용한 알림 (권한 없이 가능) ]) return granted } // 현재 권한 상태 확인 func checkPermission() async -> UNAuthorizationStatus { let settings = await UNUserNotificationCenter.current().notificationSettings() return settings.authorizationStatus } ``` ### 2. 로컬 알림 생성 ```swift func scheduleNotification() async throws { let content = UNMutableNotificationContent() content.title = "알림 제목" content.subtitle = "부제목" content.body = "알림 내용입니다." content.sound = .default content.badge = 1 // 트리거: 5초 후 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: trigger ) try await UNUserNotificationCenter.current().add(request) } ``` ### 3. 트리거 종류 ```swift // 시간 간격 (초) let timeTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60, repeats: true) // 특정 날짜/시간 var dateComponents = DateComponents() dateComponents.hour = 9 dateComponents.minute = 0 let calendarTrigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) // 위치 기반 let center = CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780) let region = CLCircularRegion(center: center, radius: 100, identifier: "office") region.notifyOnEntry = true let locationTrigger = UNLocationNotificationTrigger(region: region, repeats: false) ``` ## 전체 작동 예제 ```swift import SwiftUI import UserNotifications // MARK: - Notification Manager @Observable class NotificationManager { var isAuthorized = false var pendingNotifications: [UNNotificationRequest] = [] private let center = UNUserNotificationCenter.current() func requestPermission() async { do { isAuthorized = try await center.requestAuthorization(options: [.alert, .badge, .sound]) await setupCategories() } catch { print("권한 요청 실패: \(error)") } } func checkStatus() async { let settings = await center.notificationSettings() isAuthorized = settings.authorizationStatus == .authorized } // 카테고리 및 액션 설정 private func setupCategories() async { let completeAction = UNNotificationAction( identifier: "COMPLETE", title: "완료", options: [.foreground] ) let snoozeAction = UNNotificationAction( identifier: "SNOOZE", title: "10분 뒤 알림", options: [] ) let taskCategory = UNNotificationCategory( identifier: "TASK_REMINDER", actions: [completeAction, snoozeAction], intentIdentifiers: [], options: [.customDismissAction] ) center.setNotificationCategories([taskCategory]) } // 알림 예약 func scheduleReminder(title: String, body: String, date: Date) async throws { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default content.categoryIdentifier = "TASK_REMINDER" content.userInfo = ["taskId": UUID().uuidString] let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: trigger ) try await center.add(request) await fetchPending() } // 매일 반복 알림 func scheduleDailyReminder(title: String, body: String, hour: Int, minute: Int) async throws { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default var dateComponents = DateComponents() dateComponents.hour = hour dateComponents.minute = minute let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) let request = UNNotificationRequest( identifier: "daily-\(hour)-\(minute)", content: content, trigger: trigger ) try await center.add(request) } // 대기 중인 알림 조회 func fetchPending() async { pendingNotifications = await center.pendingNotificationRequests() } // 알림 취소 func cancel(identifier: String) { center.removePendingNotificationRequests(withIdentifiers: [identifier]) } func cancelAll() { center.removeAllPendingNotificationRequests() } // 배지 초기화 func clearBadge() async { try? await center.setBadgeCount(0) } } // MARK: - View struct NotificationDemoView: View { @State private var manager = NotificationManager() @State private var reminderTitle = "" @State private var reminderDate = Date().addingTimeInterval(60) var body: some View { NavigationStack { Form { // 권한 섹션 Section("권한") { HStack { Text("알림 권한") Spacer() Text(manager.isAuthorized ? "허용됨" : "거부됨") .foregroundStyle(manager.isAuthorized ? .green : .red) } if !manager.isAuthorized { Button("권한 요청") { Task { await manager.requestPermission() } } } } // 알림 예약 Section("새 알림") { TextField("제목", text: $reminderTitle) DatePicker("시간", selection: $reminderDate, displayedComponents: [.date, .hourAndMinute]) Button("알림 예약") { Task { try? await manager.scheduleReminder( title: reminderTitle, body: "예약된 알림입니다", date: reminderDate ) reminderTitle = "" } } .disabled(reminderTitle.isEmpty) } // 대기 중인 알림 Section("예약된 알림 (\(manager.pendingNotifications.count))") { ForEach(manager.pendingNotifications, id: \.identifier) { request in VStack(alignment: .leading) { Text(request.content.title) .font(.headline) if let trigger = request.trigger as? UNCalendarNotificationTrigger, let nextDate = trigger.nextTriggerDate() { Text(nextDate, style: .relative) .font(.caption) .foregroundStyle(.secondary) } } .swipeActions { Button("삭제", role: .destructive) { manager.cancel(identifier: request.identifier) Task { await manager.fetchPending() } } } } if !manager.pendingNotifications.isEmpty { Button("모두 취소", role: .destructive) { manager.cancelAll() Task { await manager.fetchPending() } } } } } .navigationTitle("알림") .task { await manager.checkStatus() await manager.fetchPending() } } } } // MARK: - AppDelegate에서 알림 처리 class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { UNUserNotificationCenter.current().delegate = self return true } // 앱이 foreground일 때 알림 표시 func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { return [.banner, .badge, .sound] } // 알림 탭 또는 액션 버튼 처리 func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let userInfo = response.notification.request.content.userInfo let actionId = response.actionIdentifier switch actionId { case "COMPLETE": // 완료 처리 if let taskId = userInfo["taskId"] as? String { print("Task completed: \(taskId)") } case "SNOOZE": // 10분 뒤 다시 알림 let content = response.notification.request.content.mutableCopy() as! UNMutableNotificationContent let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 600, repeats: false) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) try? await center.add(request) default: break } } } ``` ## 고급 패턴 ### 1. 이미지 첨부 ```swift func scheduleWithImage(imageURL: URL) async throws { let content = UNMutableNotificationContent() content.title = "사진 알림" content.body = "새 사진이 도착했습니다" let attachment = try UNNotificationAttachment(identifier: "image", url: imageURL, options: nil) content.attachments = [attachment] let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) try await UNUserNotificationCenter.current().add(request) } ``` ### 2. 커스텀 알림 UI (Notification Content Extension) ```swift // NotificationViewController.swift (Extension Target) import UIKit import UserNotifications import UserNotificationsUI class NotificationViewController: UIViewController, UNNotificationContentExtension { @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var imageView: UIImageView! func didReceive(_ notification: UNNotification) { let content = notification.request.content titleLabel.text = content.title if let attachment = content.attachments.first, attachment.url.startAccessingSecurityScopedResource() { imageView.image = UIImage(contentsOfFile: attachment.url.path) attachment.url.stopAccessingSecurityScopedResource() } } } ``` ### 3. 원격 푸시 알림 (APNs) ```swift // AppDelegate func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() print("Device Token: \(token)") // 서버로 토큰 전송 } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { print("APNs 등록 실패: \(error)") } // 등록 요청 UIApplication.shared.registerForRemoteNotifications() ``` ## 주의사항 1. **권한 요청 타이밍** - 앱 첫 실행 시 바로 요청 ❌ - 알림이 필요한 기능 사용 직전 요청 ✅ 2. **알림 식별자** - 같은 식별자로 등록하면 기존 알림 덮어씀 - 업데이트 가능한 알림에 활용 3. **배지 관리** ```swift // 배지 설정 try await center.setBadgeCount(5) // 배지 초기화 (앱 열 때) try await center.setBadgeCount(0) ``` 4. **시뮬레이터 제한** - 원격 푸시 알림은 실제 기기에서만 테스트 가능 - 로컬 알림은 시뮬레이터에서 가능 --- # PassKit AI Reference > Apple Pay 및 Wallet 통합 가이드. 이 문서를 읽고 PassKit 코드를 생성할 수 있습니다. ## 개요 PassKit은 Apple Pay 결제와 Wallet 패스(탑승권, 티켓 등)를 관리하는 프레임워크입니다. 간편한 결제 UI와 패스 추가 기능을 제공합니다. ## 필수 Import ```swift import PassKit ``` ## 프로젝트 설정 1. **Capabilities**: Apple Pay 추가 2. **Merchant ID**: Apple Developer에서 생성 3. **Payment Processing Certificate**: 결제 처리용 인증서 ## 핵심 구성요소 ### 1. Apple Pay 지원 확인 ```swift // Apple Pay 사용 가능 여부 let canMakePayments = PKPaymentAuthorizationController.canMakePayments() // 특정 카드 네트워크 지원 확인 let networks: [PKPaymentNetwork] = [.visa, .masterCard, .amex] let canMakePaymentsWithNetworks = PKPaymentAuthorizationController.canMakePayments(usingNetworks: networks) ``` ### 2. 결제 요청 생성 ```swift func createPaymentRequest() -> PKPaymentRequest { let request = PKPaymentRequest() // 가맹점 정보 request.merchantIdentifier = "merchant.com.yourcompany.app" request.merchantCapabilities = [.capability3DS, .capabilityDebit, .capabilityCredit] // 지원 카드 request.supportedNetworks = [.visa, .masterCard, .amex] // 국가 및 통화 request.countryCode = "KR" request.currencyCode = "KRW" // 결제 항목 request.paymentSummaryItems = [ PKPaymentSummaryItem(label: "상품 A", amount: NSDecimalNumber(value: 10000)), PKPaymentSummaryItem(label: "배송비", amount: NSDecimalNumber(value: 3000)), PKPaymentSummaryItem(label: "내 가게", amount: NSDecimalNumber(value: 13000), type: .final) ] return request } ``` ### 3. Apple Pay 버튼 ```swift import SwiftUI import PassKit struct ApplePayButton: UIViewRepresentable { let action: () -> Void func makeUIView(context: Context) -> PKPaymentButton { let button = PKPaymentButton(paymentButtonType: .buy, paymentButtonStyle: .black) button.addTarget(context.coordinator, action: #selector(Coordinator.buttonTapped), for: .touchUpInside) return button } func updateUIView(_ uiView: PKPaymentButton, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(action: action) } class Coordinator { let action: () -> Void init(action: @escaping () -> Void) { self.action = action } @objc func buttonTapped() { action() } } } ``` ## 전체 작동 예제 ```swift import SwiftUI import PassKit // MARK: - Cart Item struct CartItem: Identifiable { let id = UUID() let name: String let price: Decimal var quantity: Int var total: Decimal { price * Decimal(quantity) } } // MARK: - Payment Manager @Observable class PaymentManager: NSObject { var cartItems: [CartItem] = [] var paymentStatus: PaymentStatus = .idle enum PaymentStatus { case idle case processing case success case failed(Error) } var canUseApplePay: Bool { PKPaymentAuthorizationController.canMakePayments(usingNetworks: supportedNetworks) } private let supportedNetworks: [PKPaymentNetwork] = [.visa, .masterCard, .amex] private let merchantIdentifier = "merchant.com.example.app" var subtotal: Decimal { cartItems.reduce(0) { $0 + $1.total } } var shippingCost: Decimal { subtotal >= 50000 ? 0 : 3000 } var total: Decimal { subtotal + shippingCost } func startPayment() { let request = PKPaymentRequest() request.merchantIdentifier = merchantIdentifier request.merchantCapabilities = [.capability3DS, .capabilityDebit, .capabilityCredit] request.supportedNetworks = supportedNetworks request.countryCode = "KR" request.currencyCode = "KRW" // 결제 항목 구성 var summaryItems: [PKPaymentSummaryItem] = cartItems.map { item in PKPaymentSummaryItem( label: "\(item.name) x\(item.quantity)", amount: NSDecimalNumber(decimal: item.total) ) } if shippingCost > 0 { summaryItems.append(PKPaymentSummaryItem( label: "배송비", amount: NSDecimalNumber(decimal: shippingCost) )) } summaryItems.append(PKPaymentSummaryItem( label: "내 가게", amount: NSDecimalNumber(decimal: total), type: .final )) request.paymentSummaryItems = summaryItems // 결제 시트 표시 let controller = PKPaymentAuthorizationController(paymentRequest: request) controller?.delegate = self controller?.present() paymentStatus = .processing } } extension PaymentManager: PKPaymentAuthorizationControllerDelegate { func paymentAuthorizationController(_ controller: PKPaymentAuthorizationController, didAuthorizePayment payment: PKPayment, handler completion: @escaping (PKPaymentAuthorizationResult) -> Void) { // 서버로 결제 토큰 전송 let token = payment.token.paymentData // 실제 앱에서는 서버 API 호출 Task { do { // let result = try await PaymentAPI.process(token: token) // 성공 시뮬레이션 try await Task.sleep(for: .seconds(1)) await MainActor.run { paymentStatus = .success } completion(PKPaymentAuthorizationResult(status: .success, errors: nil)) } catch { await MainActor.run { paymentStatus = .failed(error) } completion(PKPaymentAuthorizationResult(status: .failure, errors: [error])) } } } func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) { controller.dismiss() } } // MARK: - Views struct CheckoutView: View { @State private var paymentManager = PaymentManager() var body: some View { NavigationStack { VStack { // 장바구니 목록 List { ForEach(paymentManager.cartItems) { item in HStack { Text(item.name) Spacer() Text("\(item.quantity)개") .foregroundStyle(.secondary) Text("₩\(NSDecimalNumber(decimal: item.total).intValue)") } } } // 요약 VStack(spacing: 8) { HStack { Text("소계") Spacer() Text("₩\(NSDecimalNumber(decimal: paymentManager.subtotal).intValue)") } HStack { Text("배송비") Spacer() Text(paymentManager.shippingCost > 0 ? "₩\(NSDecimalNumber(decimal: paymentManager.shippingCost).intValue)" : "무료") } .foregroundStyle(.secondary) Divider() HStack { Text("총액") .font(.headline) Spacer() Text("₩\(NSDecimalNumber(decimal: paymentManager.total).intValue)") .font(.headline) } } .padding() .background(.regularMaterial) // Apple Pay 버튼 if paymentManager.canUseApplePay { ApplePayButton { paymentManager.startPayment() } .frame(height: 50) .padding(.horizontal) } else { Button("다른 결제 방법") { // 대체 결제 } .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) .padding(.horizontal) } } .navigationTitle("결제") .onAppear { // 샘플 데이터 paymentManager.cartItems = [ CartItem(name: "상품 A", price: 15000, quantity: 2), CartItem(name: "상품 B", price: 8000, quantity: 1) ] } .alert("결제 완료", isPresented: .constant(paymentManager.paymentStatus == .success)) { Button("확인") { paymentManager.paymentStatus = .idle } } } } } // 결제 상태 비교를 위한 Equatable extension PaymentManager.PaymentStatus: Equatable { static func == (lhs: PaymentManager.PaymentStatus, rhs: PaymentManager.PaymentStatus) -> Bool { switch (lhs, rhs) { case (.idle, .idle), (.processing, .processing), (.success, .success): return true case (.failed, .failed): return true default: return false } } } ``` ## 고급 패턴 ### 1. Wallet 패스 추가 ```swift func addPassToWallet(passData: Data) { guard let pass = try? PKPass(data: passData) else { return } let library = PKPassLibrary() if library.containsPass(pass) { // 이미 추가됨 return } let controller = PKAddPassesViewController(pass: pass) // present controller } // SwiftUI struct AddToWalletButton: View { let passURL: URL var body: some View { PKAddPassButton(.add) { // 패스 추가 로직 } } } ``` ### 2. 배송 옵션 ```swift func createRequestWithShipping() -> PKPaymentRequest { let request = createPaymentRequest() request.requiredShippingContactFields = [.postalAddress, .name, .phoneNumber] request.requiredBillingContactFields = [.postalAddress] request.shippingMethods = [ PKShippingMethod(label: "일반 배송", amount: NSDecimalNumber(value: 3000)), PKShippingMethod(label: "빠른 배송", amount: NSDecimalNumber(value: 5000)) ] request.shippingMethods?[0].identifier = "standard" request.shippingMethods?[1].identifier = "express" return request } // Delegate에서 배송 방법 변경 처리 func paymentAuthorizationController(_ controller: PKPaymentAuthorizationController, didSelect shippingMethod: PKShippingMethod, handler completion: @escaping (PKPaymentRequestShippingMethodUpdate) -> Void) { // 배송비에 따라 총액 재계산 let newItems = calculateItems(with: shippingMethod) completion(PKPaymentRequestShippingMethodUpdate(paymentSummaryItems: newItems)) } ``` ### 3. 구독 결제 ```swift let recurringItem = PKRecurringPaymentSummaryItem( label: "월간 구독", amount: NSDecimalNumber(value: 9900) ) recurringItem.intervalUnit = .month recurringItem.intervalCount = 1 recurringItem.startDate = Date() recurringItem.endDate = nil // 무기한 request.paymentSummaryItems = [recurringItem] request.recurringPaymentRequest = PKRecurringPaymentRequest( paymentDescription: "월간 프리미엄 구독", regularBilling: recurringItem, managementURL: URL(string: "https://example.com/manage")! ) ``` ## 주의사항 1. **시뮬레이터 테스트** - Apple Pay는 실제 기기에서만 완전 테스트 가능 - 시뮬레이터에서는 UI만 확인 가능 2. **Merchant ID 설정** - Apple Developer에서 생성 필요 - Xcode Capabilities에 추가 3. **결제 토큰 처리** - `PKPayment.token.paymentData`를 서버로 전송 - 서버에서 결제 프로세서(Stripe, Toss 등)로 전달 4. **에러 처리** ```swift switch payment.token.paymentMethod.type { case .debit: // 체크카드 case .credit: // 신용카드 default: break } ``` --- # PDFKit AI Reference > PDF 뷰어 및 편집 가이드. 이 문서를 읽고 PDFKit 코드를 생성할 수 있습니다. ## 개요 PDFKit은 PDF 문서를 표시하고 조작하는 프레임워크입니다. 페이지 탐색, 검색, 주석, 텍스트 선택 등을 지원합니다. ## 필수 Import ```swift import PDFKit import SwiftUI ``` ## 핵심 구성요소 ### 1. PDFDocument ```swift // URL에서 로드 let url = Bundle.main.url(forResource: "sample", withExtension: "pdf")! let document = PDFDocument(url: url) // 데이터에서 로드 let document = PDFDocument(data: pdfData) // 페이지 접근 let pageCount = document?.pageCount ?? 0 let page = document?.page(at: 0) ``` ### 2. PDFView ```swift let pdfView = PDFView() pdfView.document = document pdfView.autoScales = true pdfView.displayMode = .singlePageContinuous pdfView.displayDirection = .vertical ``` ### 3. PDFPage ```swift // 페이지 정보 let bounds = page.bounds(for: .mediaBox) let rotation = page.rotation // 썸네일 생성 let thumbnail = page.thumbnail(of: CGSize(width: 100, height: 150), for: .mediaBox) // 텍스트 추출 let text = page.string ``` ## 전체 작동 예제 ```swift import SwiftUI import PDFKit // MARK: - PDF View Wrapper struct PDFKitView: UIViewRepresentable { let document: PDFDocument @Binding var currentPage: Int func makeUIView(context: Context) -> PDFView { let pdfView = PDFView() pdfView.document = document pdfView.autoScales = true pdfView.displayMode = .singlePageContinuous pdfView.displayDirection = .vertical pdfView.delegate = context.coordinator // 페이지 변경 알림 NotificationCenter.default.addObserver( context.coordinator, selector: #selector(Coordinator.pageChanged), name: .PDFViewPageChanged, object: pdfView ) return pdfView } func updateUIView(_ uiView: PDFView, context: Context) { // 페이지 이동 if let page = document.page(at: currentPage) { uiView.go(to: page) } } func makeCoordinator() -> Coordinator { Coordinator(currentPage: $currentPage) } class Coordinator: NSObject, PDFViewDelegate { @Binding var currentPage: Int init(currentPage: Binding) { _currentPage = currentPage } @objc func pageChanged(_ notification: Notification) { guard let pdfView = notification.object as? PDFView, let page = pdfView.currentPage, let pageIndex = pdfView.document?.index(for: page) else { return } DispatchQueue.main.async { self.currentPage = pageIndex } } } } // MARK: - PDF Manager @Observable class PDFManager { var document: PDFDocument? var currentPage = 0 var searchResults: [PDFSelection] = [] var searchText = "" var pageCount: Int { document?.pageCount ?? 0 } func loadDocument(from url: URL) { document = PDFDocument(url: url) } func loadDocument(from data: Data) { document = PDFDocument(data: data) } func search(_ text: String) { guard let document, !text.isEmpty else { searchResults = [] return } searchResults = document.findString(text, withOptions: .caseInsensitive) } func goToNextPage() { if currentPage < pageCount - 1 { currentPage += 1 } } func goToPreviousPage() { if currentPage > 0 { currentPage -= 1 } } func goToPage(_ index: Int) { if index >= 0 && index < pageCount { currentPage = index } } } // MARK: - Views struct PDFReaderView: View { @State private var manager = PDFManager() @State private var showingThumbnails = false @State private var showingSearch = false let pdfURL: URL var body: some View { NavigationStack { Group { if let document = manager.document { PDFKitView(document: document, currentPage: $manager.currentPage) } else { ContentUnavailableView("PDF 로드 실패", systemImage: "doc.fill") } } .navigationTitle("PDF 뷰어") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .bottomBar) { HStack { Button(action: manager.goToPreviousPage) { Image(systemName: "chevron.left") } .disabled(manager.currentPage == 0) Spacer() Text("\(manager.currentPage + 1) / \(manager.pageCount)") .font(.caption) Spacer() Button(action: manager.goToNextPage) { Image(systemName: "chevron.right") } .disabled(manager.currentPage >= manager.pageCount - 1) } } ToolbarItem(placement: .topBarTrailing) { Menu { Button { showingThumbnails = true } label: { Label("썸네일", systemImage: "square.grid.2x2") } Button { showingSearch = true } label: { Label("검색", systemImage: "magnifyingglass") } if let document = manager.document { ShareLink(item: pdfURL) { Label("공유", systemImage: "square.and.arrow.up") } } } label: { Image(systemName: "ellipsis.circle") } } } .sheet(isPresented: $showingThumbnails) { ThumbnailsView(manager: manager) } .sheet(isPresented: $showingSearch) { SearchView(manager: manager) } } .onAppear { manager.loadDocument(from: pdfURL) } } } struct ThumbnailsView: View { let manager: PDFManager @Environment(\.dismiss) private var dismiss let columns = [GridItem(.adaptive(minimum: 100))] var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: columns, spacing: 16) { ForEach(0.. Data? { let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // A4 let renderer = UIGraphicsPDFRenderer(bounds: pageRect) return renderer.pdfData { context in context.beginPage() let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 14) ] let textRect = CGRect(x: 50, y: 50, width: pageRect.width - 100, height: pageRect.height - 100) text.draw(in: textRect, withAttributes: attributes) } } ``` ### 2. 주석 추가 ```swift func addHighlight(to page: PDFPage, selection: PDFSelection) { let highlight = PDFAnnotation( bounds: selection.bounds(for: page), forType: .highlight, withProperties: nil ) highlight.color = .yellow.withAlphaComponent(0.5) page.addAnnotation(highlight) } func addTextAnnotation(to page: PDFPage, at point: CGPoint, text: String) { let annotation = PDFAnnotation( bounds: CGRect(x: point.x, y: point.y, width: 200, height: 100), forType: .freeText, withProperties: nil ) annotation.contents = text annotation.font = UIFont.systemFont(ofSize: 12) annotation.color = .yellow page.addAnnotation(annotation) } ``` ### 3. PDF 저장 ```swift func savePDF(document: PDFDocument, to url: URL) -> Bool { return document.write(to: url) } func saveToPhotos(page: PDFPage) { if let image = page.thumbnail(of: CGSize(width: 1000, height: 1400), for: .mediaBox) { UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) } } ``` ### 4. 텍스트 추출 ```swift func extractAllText(from document: PDFDocument) -> String { var text = "" for i in 0.. Apple Pencil 드로잉 구현 가이드. 이 문서를 읽고 PencilKit 코드를 생성할 수 있습니다. ## 개요 PencilKit은 Apple Pencil과 손가락으로 자연스러운 드로잉 경험을 제공하는 프레임워크입니다. 그리기, 지우기, 도구 선택, 드로잉 저장/로드를 지원합니다. ## 필수 Import ```swift import PencilKit import SwiftUI ``` ## 핵심 구성요소 ### 1. PKCanvasView ```swift let canvasView = PKCanvasView() canvasView.drawingPolicy = .anyInput // 손가락 + 펜슬 canvasView.tool = PKInkingTool(.pen, color: .black, width: 5) canvasView.backgroundColor = .white ``` ### 2. PKToolPicker ```swift let toolPicker = PKToolPicker() toolPicker.setVisible(true, forFirstResponder: canvasView) toolPicker.addObserver(canvasView) canvasView.becomeFirstResponder() ``` ### 3. PKDrawing ```swift // 드로잉 가져오기 let drawing = canvasView.drawing // 드로잉 설정 canvasView.drawing = PKDrawing() // 이미지로 변환 let image = drawing.image(from: drawing.bounds, scale: 2.0) // 데이터로 저장 let data = drawing.dataRepresentation() // 데이터에서 로드 let loadedDrawing = try PKDrawing(data: data) ``` ## 전체 작동 예제 ```swift import SwiftUI import PencilKit // MARK: - Canvas View Wrapper struct CanvasView: UIViewRepresentable { @Binding var drawing: PKDrawing @Binding var tool: PKTool let showToolPicker: Bool func makeUIView(context: Context) -> PKCanvasView { let canvasView = PKCanvasView() canvasView.drawing = drawing canvasView.tool = tool canvasView.drawingPolicy = .anyInput canvasView.backgroundColor = .white canvasView.delegate = context.coordinator // 도구 피커 if showToolPicker { let toolPicker = PKToolPicker() toolPicker.setVisible(true, forFirstResponder: canvasView) toolPicker.addObserver(canvasView) canvasView.becomeFirstResponder() context.coordinator.toolPicker = toolPicker } return canvasView } func updateUIView(_ uiView: PKCanvasView, context: Context) { uiView.tool = tool if uiView.drawing != drawing { uiView.drawing = drawing } } func makeCoordinator() -> Coordinator { Coordinator(drawing: $drawing) } class Coordinator: NSObject, PKCanvasViewDelegate { @Binding var drawing: PKDrawing var toolPicker: PKToolPicker? init(drawing: Binding) { _drawing = drawing } func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { drawing = canvasView.drawing } } } // MARK: - Drawing Manager @Observable class DrawingManager { var drawing = PKDrawing() var tool: PKTool = PKInkingTool(.pen, color: .black, width: 5) var selectedColor: Color = .black var selectedWidth: CGFloat = 5 var toolType: ToolType = .pen enum ToolType: String, CaseIterable { case pen = "펜" case pencil = "연필" case marker = "마커" case eraser = "지우개" } var canUndo: Bool { !drawing.strokes.isEmpty } func updateTool() { let uiColor = UIColor(selectedColor) switch toolType { case .pen: tool = PKInkingTool(.pen, color: uiColor, width: selectedWidth) case .pencil: tool = PKInkingTool(.pencil, color: uiColor, width: selectedWidth) case .marker: tool = PKInkingTool(.marker, color: uiColor, width: selectedWidth * 2) case .eraser: tool = PKEraserTool(.bitmap) } } func clear() { drawing = PKDrawing() } func save() -> Data { drawing.dataRepresentation() } func load(from data: Data) { if let loadedDrawing = try? PKDrawing(data: data) { drawing = loadedDrawing } } func exportImage(scale: CGFloat = 2.0) -> UIImage { drawing.image(from: drawing.bounds, scale: scale) } } // MARK: - Views struct SketchPadView: View { @State private var manager = DrawingManager() @State private var showToolPicker = false @State private var showingExport = false var body: some View { NavigationStack { VStack(spacing: 0) { // 캔버스 CanvasView( drawing: $manager.drawing, tool: $manager.tool, showToolPicker: showToolPicker ) // 커스텀 도구바 if !showToolPicker { customToolbar } } .navigationTitle("스케치패드") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("지우기", role: .destructive) { manager.clear() } .disabled(!manager.canUndo) } ToolbarItem(placement: .topBarTrailing) { Menu { Toggle("시스템 도구 피커", isOn: $showToolPicker) Button { showingExport = true } label: { Label("이미지로 저장", systemImage: "square.and.arrow.down") } ShareLink(item: Image(uiImage: manager.exportImage()), preview: SharePreview("스케치", image: Image(uiImage: manager.exportImage()))) { Label("공유", systemImage: "square.and.arrow.up") } } label: { Image(systemName: "ellipsis.circle") } } } .alert("저장 완료", isPresented: $showingExport) { Button("확인") {} } message: { Text("이미지가 사진 앱에 저장되었습니다") } } } var customToolbar: some View { VStack(spacing: 12) { // 도구 선택 HStack(spacing: 16) { ForEach(DrawingManager.ToolType.allCases, id: \.self) { type in Button { manager.toolType = type manager.updateTool() } label: { VStack(spacing: 4) { Image(systemName: iconFor(type)) .font(.title2) Text(type.rawValue) .font(.caption2) } .foregroundStyle(manager.toolType == type ? .blue : .primary) } } } if manager.toolType != .eraser { // 색상 선택 HStack(spacing: 12) { ForEach([Color.black, .red, .orange, .yellow, .green, .blue, .purple], id: \.self) { color in Circle() .fill(color) .frame(width: 30, height: 30) .overlay { if manager.selectedColor == color { Circle() .stroke(.white, lineWidth: 2) .padding(2) } } .onTapGesture { manager.selectedColor = color manager.updateTool() } } ColorPicker("", selection: $manager.selectedColor) .labelsHidden() .onChange(of: manager.selectedColor) { _, _ in manager.updateTool() } } // 굵기 선택 HStack { Text("굵기") .font(.caption) Slider(value: $manager.selectedWidth, in: 1...20) .onChange(of: manager.selectedWidth) { _, _ in manager.updateTool() } Text("\(Int(manager.selectedWidth))") .font(.caption) .frame(width: 30) } } } .padding() .background(.regularMaterial) } func iconFor(_ type: DrawingManager.ToolType) -> String { switch type { case .pen: return "pencil.tip" case .pencil: return "pencil" case .marker: return "highlighter" case .eraser: return "eraser" } } } ``` ## 고급 패턴 ### 1. Lasso 선택 도구 ```swift let lassoTool = PKLassoTool() canvasView.tool = lassoTool // 선택된 스트로크 처리 // PKCanvasViewDelegate의 canvasViewDidFinishRendering에서 처리 ``` ### 2. 스트로크 분석 ```swift func analyzeStrokes(_ drawing: PKDrawing) { for stroke in drawing.strokes { let path = stroke.path let ink = stroke.ink print("색상: \(ink.color)") print("도구: \(ink.inkType)") print("포인트 수: \(path.count)") // 각 포인트 정보 for i in 0.. UIImage { let bounds = drawing.bounds UIGraphicsBeginImageContextWithOptions(bounds.size, false, 2.0) // 투명 배경 UIColor.clear.setFill() UIRectFill(CGRect(origin: .zero, size: bounds.size)) // 드로잉 렌더링 let image = drawing.image(from: bounds, scale: 2.0) image.draw(at: .zero) let result = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return result ?? UIImage() } ``` ### 4. 드로잉 병합 ```swift func mergeDrawings(_ drawings: [PKDrawing]) -> PKDrawing { var allStrokes: [PKStroke] = [] for drawing in drawings { allStrokes.append(contentsOf: drawing.strokes) } return PKDrawing(strokes: allStrokes) } ``` ## 주의사항 1. **Apple Pencil 최적화** - `drawingPolicy = .pencilOnly`: 펜슬만 드로잉 - `drawingPolicy = .anyInput`: 손가락도 드로잉 - `drawingPolicy = .default`: 시스템 설정 따름 2. **메모리 관리** - 복잡한 드로잉은 메모리 사용 증가 - 이미지 내보내기 시 scale 조절 3. **데이터 저장** ```swift // 저장 let data = drawing.dataRepresentation() try data.write(to: fileURL) // 로드 let data = try Data(contentsOf: fileURL) let drawing = try PKDrawing(data: data) ``` 4. **시뮬레이터 테스트** - 마우스/트랙패드로 테스트 가능 - 압력 감지는 실제 기기에서만 --- # PermissionKit AI Reference > 시스템 권한 관리 가이드. 이 문서를 읽고 PermissionKit 코드를 생성할 수 있습니다. ## 개요 PermissionKit은 iOS 18+에서 제공하는 통합 권한 관리 프레임워크입니다. 카메라, 마이크, 위치, 사진 등 다양한 시스템 권한을 일관된 API로 요청하고 관리할 수 있습니다. ## 필수 Import ```swift import PermissionKit ``` ## 프로젝트 설정 ### Info.plist (필요한 권한별) ```xml NSCameraUsageDescription 사진 촬영을 위해 카메라 접근이 필요합니다. NSMicrophoneUsageDescription 음성 녹음을 위해 마이크 접근이 필요합니다. NSLocationWhenInUseUsageDescription 현재 위치를 확인하기 위해 필요합니다. NSPhotoLibraryUsageDescription 사진을 저장하고 불러오기 위해 필요합니다. NSContactsUsageDescription 연락처를 불러오기 위해 필요합니다. NSUserNotificationsUsageDescription 알림을 보내기 위해 필요합니다. NSHealthShareUsageDescription 건강 데이터를 읽기 위해 필요합니다. ``` ## 핵심 구성요소 ### 1. PermissionManager ```swift import PermissionKit // 권한 매니저 let permissionManager = PermissionManager.shared // 단일 권한 요청 let status = await permissionManager.request(.camera) // 여러 권한 동시 요청 let results = await permissionManager.request([.camera, .microphone, .photoLibrary]) ``` ### 2. PermissionType (권한 유형) ```swift // 지원하는 권한 유형 PermissionType.camera // 카메라 PermissionType.microphone // 마이크 PermissionType.photoLibrary // 사진 라이브러리 PermissionType.location // 위치 (사용 중) PermissionType.locationAlways // 위치 (항상) PermissionType.contacts // 연락처 PermissionType.calendar // 캘린더 PermissionType.reminders // 미리알림 PermissionType.notifications // 알림 PermissionType.bluetooth // 블루투스 PermissionType.motion // 모션 PermissionType.health // 건강 ``` ### 3. PermissionStatus (권한 상태) ```swift // 권한 상태 확인 let status = await permissionManager.status(for: .camera) switch status { case .notDetermined: // 아직 요청 안 함 case .authorized: // 허용됨 case .denied: // 거부됨 case .restricted: // 제한됨 (보호자 설정 등) case .limited: // 제한적 접근 (사진 일부만 등) } ``` ## 전체 작동 예제 ```swift import SwiftUI import PermissionKit // MARK: - Permission View Model @Observable class PermissionViewModel { var permissions: [PermissionItem] = [] var showingSettingsAlert = false private let permissionManager = PermissionManager.shared init() { setupPermissions() } func setupPermissions() { permissions = [ PermissionItem(type: .camera, title: "카메라", icon: "camera.fill", description: "사진 및 동영상 촬영"), PermissionItem(type: .microphone, title: "마이크", icon: "mic.fill", description: "음성 녹음"), PermissionItem(type: .photoLibrary, title: "사진", icon: "photo.fill", description: "사진 저장 및 불러오기"), PermissionItem(type: .location, title: "위치", icon: "location.fill", description: "현재 위치 확인"), PermissionItem(type: .contacts, title: "연락처", icon: "person.crop.circle.fill", description: "연락처 접근"), PermissionItem(type: .notifications, title: "알림", icon: "bell.fill", description: "푸시 알림 수신"), PermissionItem(type: .calendar, title: "캘린더", icon: "calendar", description: "일정 접근"), PermissionItem(type: .motion, title: "모션", icon: "figure.walk", description: "걸음 수 및 활동") ] Task { await refreshStatuses() } } func refreshStatuses() async { for index in permissions.indices { let status = await permissionManager.status(for: permissions[index].type) await MainActor.run { permissions[index].status = status } } } func requestPermission(_ permission: PermissionItem) async { let result = await permissionManager.request(permission.type) await MainActor.run { if let index = permissions.firstIndex(where: { $0.type == permission.type }) { permissions[index].status = result } if result == .denied { showingSettingsAlert = true } } } func requestAllPermissions() async { let types = permissions.map(\.type) let results = await permissionManager.request(types) await MainActor.run { for (type, status) in results { if let index = permissions.firstIndex(where: { $0.type == type }) { permissions[index].status = status } } } } func openSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } // MARK: - Permission Item struct PermissionItem: Identifiable { let id = UUID() let type: PermissionType let title: String let icon: String let description: String var status: PermissionStatus = .notDetermined var statusText: String { switch status { case .notDetermined: return "요청 필요" case .authorized: return "허용됨" case .denied: return "거부됨" case .restricted: return "제한됨" case .limited: return "제한적" } } var statusColor: Color { switch status { case .authorized, .limited: return .green case .denied, .restricted: return .red case .notDetermined: return .orange } } } // MARK: - Main View struct PermissionManagerView: View { @State private var viewModel = PermissionViewModel() var body: some View { NavigationStack { List { // 전체 요청 버튼 Section { Button { Task { await viewModel.requestAllPermissions() } } label: { Label("모든 권한 요청", systemImage: "checkmark.shield.fill") } } // 권한 목록 Section("권한 목록") { ForEach(viewModel.permissions) { permission in PermissionRow( permission: permission, onRequest: { Task { await viewModel.requestPermission(permission) } } ) } } // 안내 Section { VStack(alignment: .leading, spacing: 8) { Text("권한이 거부된 경우") .font(.subheadline.bold()) Text("설정 앱에서 직접 권한을 변경할 수 있습니다.") .font(.caption) .foregroundStyle(.secondary) Button("설정 열기") { viewModel.openSettings() } .font(.subheadline) } } } .navigationTitle("권한 관리") .refreshable { await viewModel.refreshStatuses() } .alert("권한 거부됨", isPresented: $viewModel.showingSettingsAlert) { Button("설정으로 이동") { viewModel.openSettings() } Button("취소", role: .cancel) {} } message: { Text("권한이 거부되었습니다. 설정에서 직접 변경해주세요.") } } } } // MARK: - Permission Row struct PermissionRow: View { let permission: PermissionItem let onRequest: () -> Void var body: some View { HStack(spacing: 16) { Image(systemName: permission.icon) .font(.title2) .foregroundStyle(.blue) .frame(width: 40) VStack(alignment: .leading, spacing: 2) { Text(permission.title) .font(.headline) Text(permission.description) .font(.caption) .foregroundStyle(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 4) { Text(permission.statusText) .font(.caption) .foregroundStyle(permission.statusColor) if permission.status == .notDetermined { Button("요청") { onRequest() } .buttonStyle(.bordered) .controlSize(.small) } else if permission.status == .denied { Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) } else if permission.status == .authorized { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) } } } .padding(.vertical, 4) } } #Preview { PermissionManagerView() } ``` ## 고급 패턴 ### 1. 온보딩 권한 요청 플로우 ```swift struct OnboardingPermissionView: View { @State private var currentStep = 0 @State private var isCompleted = false @Environment(\.dismiss) private var dismiss let requiredPermissions: [PermissionType] = [ .camera, .microphone, .notifications ] var body: some View { VStack(spacing: 32) { // 프로그레스 ProgressView(value: Double(currentStep), total: Double(requiredPermissions.count)) .padding(.horizontal) Spacer() // 현재 권한 설명 if currentStep < requiredPermissions.count { PermissionExplainView(type: requiredPermissions[currentStep]) } else { // 완료 VStack(spacing: 16) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 80)) .foregroundStyle(.green) Text("설정 완료!") .font(.title.bold()) } } Spacer() // 버튼 if currentStep < requiredPermissions.count { Button { Task { await requestCurrentPermission() } } label: { Text("계속") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) Button("건너뛰기") { nextStep() } .foregroundStyle(.secondary) } else { Button { dismiss() } label: { Text("시작하기") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) } } .padding() } func requestCurrentPermission() async { let type = requiredPermissions[currentStep] _ = await PermissionManager.shared.request(type) await MainActor.run { nextStep() } } func nextStep() { withAnimation { currentStep += 1 } } } struct PermissionExplainView: View { let type: PermissionType var body: some View { VStack(spacing: 16) { Image(systemName: iconFor(type)) .font(.system(size: 60)) .foregroundStyle(.blue) Text(titleFor(type)) .font(.title2.bold()) Text(descriptionFor(type)) .multilineTextAlignment(.center) .foregroundStyle(.secondary) } } func iconFor(_ type: PermissionType) -> String { switch type { case .camera: return "camera.fill" case .microphone: return "mic.fill" case .notifications: return "bell.fill" default: return "questionmark.circle" } } func titleFor(_ type: PermissionType) -> String { switch type { case .camera: return "카메라 접근" case .microphone: return "마이크 접근" case .notifications: return "알림 허용" default: return "권한 요청" } } func descriptionFor(_ type: PermissionType) -> String { switch type { case .camera: return "사진과 동영상을 촬영하려면 카메라 접근 권한이 필요합니다." case .microphone: return "음성을 녹음하려면 마이크 접근 권한이 필요합니다." case .notifications: return "중요한 알림을 받으려면 알림 권한이 필요합니다." default: return "이 기능을 사용하려면 권한이 필요합니다." } } } ``` ### 2. 권한 상태 모니터링 ```swift class PermissionMonitor: ObservableObject { @Published var cameraStatus: PermissionStatus = .notDetermined @Published var locationStatus: PermissionStatus = .notDetermined private var cancellables = Set() init() { // 권한 상태 변경 구독 PermissionManager.shared.statusPublisher(for: .camera) .receive(on: DispatchQueue.main) .assign(to: &$cameraStatus) PermissionManager.shared.statusPublisher(for: .location) .receive(on: DispatchQueue.main) .assign(to: &$locationStatus) // 앱 포그라운드 전환 시 새로고침 NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) .sink { [weak self] _ in Task { await self?.refreshStatuses() } } .store(in: &cancellables) } func refreshStatuses() async { let camera = await PermissionManager.shared.status(for: .camera) let location = await PermissionManager.shared.status(for: .location) await MainActor.run { self.cameraStatus = camera self.locationStatus = location } } } ``` ### 3. 조건부 기능 제공 ```swift struct FeatureView: View { @State private var cameraPermission: PermissionStatus = .notDetermined var body: some View { Group { switch cameraPermission { case .authorized: CameraView() case .denied, .restricted: PermissionDeniedView( permission: .camera, onOpenSettings: openSettings ) case .notDetermined: PermissionRequestView( permission: .camera, onRequest: requestPermission ) case .limited: LimitedAccessView() } } .task { cameraPermission = await PermissionManager.shared.status(for: .camera) } } func requestPermission() { Task { cameraPermission = await PermissionManager.shared.request(.camera) } } func openSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } ``` ## 주의사항 1. **iOS 버전** - PermissionKit: iOS 18+ 전용 - 이전 버전은 개별 프레임워크 API 사용 2. **Info.plist 필수** - 각 권한별 Usage Description 필수 - 누락 시 앱 크래시 3. **권한 거부 처리** - 거부된 권한은 앱에서 재요청 불가 - 설정 앱으로 안내 필요 4. **제한적 접근** - 사진 등 일부 권한은 Limited 상태 가능 - 부분 접근에 맞는 UI 제공 필요 5. **테스트** - 시뮬레이터에서 대부분 테스트 가능 - 일부 권한(Bluetooth 등)은 실기기 필요 --- # PhotosUI AI Reference > 사진 라이브러리 접근 가이드. 이 문서를 읽고 PhotosUI 코드를 생성할 수 있습니다. ## 개요 PhotosUI는 사용자의 사진 라이브러리에서 이미지/비디오를 선택하는 UI를 제공합니다. iOS 16+ PHPickerViewController, SwiftUI의 PhotosPicker를 지원합니다. ## 필수 Import ```swift import PhotosUI import SwiftUI ``` ## 프로젝트 설정 (선택적) ```xml NSPhotoLibraryUsageDescription 앨범에서 사진을 선택하기 위해 필요합니다. NSPhotoLibraryAddUsageDescription 사진을 앨범에 저장하기 위해 필요합니다. ``` > **참고**: PhotosPicker는 권한 없이 사용 가능 (Limited Access) ## 핵심 구성요소 ### 1. PhotosPicker (SwiftUI, iOS 16+) ```swift struct SimplePickerView: View { @State private var selectedItem: PhotosPickerItem? @State private var selectedImage: UIImage? var body: some View { VStack { PhotosPicker(selection: $selectedItem, matching: .images) { Text("사진 선택") } if let image = selectedImage { Image(uiImage: image) .resizable() .scaledToFit() } } .onChange(of: selectedItem) { _, newItem in Task { if let data = try? await newItem?.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedImage = image } } } } } ``` ### 2. 다중 선택 ```swift struct MultiplePickerView: View { @State private var selectedItems: [PhotosPickerItem] = [] @State private var selectedImages: [UIImage] = [] var body: some View { VStack { PhotosPicker( selection: $selectedItems, maxSelectionCount: 5, matching: .images, photoLibrary: .shared() ) { Label("사진 선택 (최대 5장)", systemImage: "photo.on.rectangle.angled") } ScrollView(.horizontal) { HStack { ForEach(selectedImages, id: \.self) { image in Image(uiImage: image) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipShape(RoundedRectangle(cornerRadius: 8)) } } } } .onChange(of: selectedItems) { _, newItems in Task { selectedImages = [] for item in newItems { if let data = try? await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedImages.append(image) } } } } } } ``` ### 3. 필터 옵션 ```swift // 이미지만 PhotosPicker(selection: $item, matching: .images) // 비디오만 PhotosPicker(selection: $item, matching: .videos) // Live Photo PhotosPicker(selection: $item, matching: .livePhotos) // 스크린샷만 PhotosPicker(selection: $item, matching: .screenshots) // 조합 PhotosPicker(selection: $item, matching: .any(of: [.images, .videos])) // 제외 PhotosPicker(selection: $item, matching: .not(.videos)) ``` ## 전체 작동 예제 ```swift import SwiftUI import PhotosUI // MARK: - View Model @Observable class PhotoGalleryViewModel { var selectedItems: [PhotosPickerItem] = [] var images: [IdentifiableImage] = [] var isLoading = false @MainActor func loadImages() async { isLoading = true defer { isLoading = false } images = [] for item in selectedItems { if let image = await loadImage(from: item) { images.append(IdentifiableImage(image: image)) } } } private func loadImage(from item: PhotosPickerItem) async -> UIImage? { // 방법 1: Data로 로드 if let data = try? await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { return image } // 방법 2: Image로 직접 로드 (iOS 16+) // if let image = try? await item.loadTransferable(type: Image.self) { ... } return nil } } struct IdentifiableImage: Identifiable { let id = UUID() let image: UIImage } // MARK: - Views struct PhotoGalleryView: View { @State private var viewModel = PhotoGalleryViewModel() @State private var selectedImage: IdentifiableImage? let columns = [ GridItem(.adaptive(minimum: 100), spacing: 2) ] var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: columns, spacing: 2) { // 사진 추가 버튼 PhotosPicker( selection: $viewModel.selectedItems, maxSelectionCount: 20, matching: .images ) { ZStack { Color.gray.opacity(0.2) VStack { Image(systemName: "plus") .font(.largeTitle) Text("사진 추가") .font(.caption) } } .aspectRatio(1, contentMode: .fill) } // 선택된 이미지들 ForEach(viewModel.images) { item in Image(uiImage: item.image) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity) .aspectRatio(1, contentMode: .fill) .clipped() .onTapGesture { selectedImage = item } } } } .navigationTitle("갤러리") .overlay { if viewModel.isLoading { ProgressView("로딩 중...") .padding() .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } } .onChange(of: viewModel.selectedItems) { _, _ in Task { await viewModel.loadImages() } } .fullScreenCover(item: $selectedImage) { item in ImageDetailView(image: item.image) } } } } struct ImageDetailView: View { let image: UIImage @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Image(uiImage: image) .resizable() .scaledToFit() .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("닫기") { dismiss() } } ToolbarItem(placement: .bottomBar) { ShareLink(item: Image(uiImage: image), preview: SharePreview("사진", image: Image(uiImage: image))) } } } } } ``` ## 고급 패턴 ### 1. Transferable 커스텀 타입 ```swift struct ProfileImage: Transferable { let image: UIImage let metadata: ImageMetadata static var transferRepresentation: some TransferRepresentation { DataRepresentation(importedContentType: .image) { data in guard let image = UIImage(data: data) else { throw TransferError.importFailed } return ProfileImage(image: image, metadata: ImageMetadata()) } } } // 사용 if let profile = try? await item.loadTransferable(type: ProfileImage.self) { // profile.image, profile.metadata 사용 } ``` ### 2. Live Photo 로드 ```swift import Photos func loadLivePhoto(from item: PhotosPickerItem) async -> PHLivePhoto? { try? await item.loadTransferable(type: PHLivePhoto.self) } // LivePhotoView로 표시 struct LivePhotoViewContainer: UIViewRepresentable { let livePhoto: PHLivePhoto func makeUIView(context: Context) -> PHLivePhotoView { let view = PHLivePhotoView() view.livePhoto = livePhoto view.contentMode = .scaleAspectFit return view } func updateUIView(_ uiView: PHLivePhotoView, context: Context) { uiView.livePhoto = livePhoto } } ``` ### 3. 비디오 로드 ```swift func loadVideo(from item: PhotosPickerItem) async -> URL? { // Movie 타입으로 로드 if let movie = try? await item.loadTransferable(type: Movie.self) { return movie.url } return nil } struct Movie: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(contentType: .movie) { movie in SentTransferredFile(movie.url) } importing: { received in let destination = FileManager.default.temporaryDirectory.appendingPathComponent(received.file.lastPathComponent) try FileManager.default.copyItem(at: received.file, to: destination) return Movie(url: destination) } } } ``` ### 4. 전체 Photos 접근 (레거시) ```swift import Photos func requestFullAccess() async -> PHAuthorizationStatus { await PHPhotoLibrary.requestAuthorization(for: .readWrite) } func fetchAllPhotos() -> PHFetchResult { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] options.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue) return PHAsset.fetchAssets(with: options) } ``` ## 주의사항 1. **권한 없이 사용 가능** - `PhotosPicker`는 Limited Access로 동작 - 사용자가 선택한 사진만 접근 가능 - 전체 라이브러리 접근 시에만 권한 필요 2. **비동기 로딩** - `loadTransferable`은 async - 대용량 이미지는 시간 소요 - Progress 표시 권장 3. **메모리 관리** - 고해상도 이미지 주의 - 필요시 리사이즈하여 사용 ```swift func resizedImage(_ image: UIImage, maxSize: CGFloat) -> UIImage { let ratio = min(maxSize / image.size.width, maxSize / image.size.height) let newSize = CGSize(width: image.size.width * ratio, height: image.size.height * ratio) UIGraphicsBeginImageContextWithOptions(newSize, false, 0) image.draw(in: CGRect(origin: .zero, size: newSize)) let resized = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return resized ?? image } ``` 4. **iOS 버전** - `PhotosPicker`: iOS 16+ - iOS 15: `PHPickerViewController` 사용 --- # RealityKit AI Reference > 3D/AR 콘텐츠 렌더링 가이드. 이 문서를 읽고 RealityKit 코드를 생성할 수 있습니다. ## 개요 RealityKit은 Apple의 3D 렌더링 및 AR 엔진으로, 고품질 3D 콘텐츠를 쉽게 만들 수 있습니다. ARKit과 통합되어 증강현실 앱 개발에 최적화되어 있으며, visionOS의 핵심 프레임워크입니다. ## 필수 Import ```swift import RealityKit import ARKit // AR 기능 사용 시 import RealityKitContent // visionOS 프로젝트 ``` ## 프로젝트 설정 ```xml NSCameraUsageDescription AR 경험을 위해 카메라 접근이 필요합니다. ``` ## 핵심 구성요소 ### 1. Entity (기본 단위) ```swift // 모든 3D 객체의 기본 클래스 let entity = Entity() // ModelEntity: 3D 모델 let box = ModelEntity( mesh: .generateBox(size: 0.1), materials: [SimpleMaterial(color: .blue, isMetallic: true)] ) // AnchorEntity: 씬에 고정하는 앵커 let anchor = AnchorEntity(plane: .horizontal) anchor.addChild(box) ``` ### 2. 기본 도형 생성 ```swift // 박스 MeshResource.generateBox(size: 0.1) MeshResource.generateBox(width: 0.2, height: 0.1, depth: 0.3) // 구 MeshResource.generateSphere(radius: 0.05) // 평면 MeshResource.generatePlane(width: 0.2, depth: 0.2) // 텍스트 MeshResource.generateText("Hello", extrusionDepth: 0.01) ``` ### 3. Material (재질) ```swift // 단순 재질 let simple = SimpleMaterial(color: .red, isMetallic: false) // PBR 재질 var pbr = PhysicallyBasedMaterial() pbr.baseColor = .init(tint: .white, texture: .init(try! .load(named: "texture"))) pbr.roughness = .init(floatLiteral: 0.5) pbr.metallic = .init(floatLiteral: 0.8) // 반투명 재질 var transparent = SimpleMaterial() transparent.color = .init(tint: .blue.withAlphaComponent(0.5)) transparent.blending = .transparent(opacity: 0.5) ``` ## 전체 작동 예제 ```swift import SwiftUI import RealityKit import ARKit // MARK: - AR View Container struct RealityKitView: UIViewRepresentable { @Binding var placedObjects: [String] func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // AR 세션 설정 let config = ARWorldTrackingConfiguration() config.planeDetection = [.horizontal] config.environmentTexturing = .automatic arView.session.run(config) // 탭 제스처 추가 let tap = UITapGestureRecognizer( target: context.coordinator, action: #selector(Coordinator.handleTap(_:)) ) arView.addGestureRecognizer(tap) context.coordinator.arView = arView // 코칭 오버레이 let coaching = ARCoachingOverlayView() coaching.session = arView.session coaching.autoresizingMask = [.flexibleWidth, .flexibleHeight] coaching.goal = .horizontalPlane arView.addSubview(coaching) return arView } func updateUIView(_ uiView: ARView, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(placedObjects: $placedObjects) } class Coordinator: NSObject { var arView: ARView? @Binding var placedObjects: [String] init(placedObjects: Binding<[String]>) { _placedObjects = placedObjects } @objc func handleTap(_ gesture: UITapGestureRecognizer) { guard let arView = arView else { return } let location = gesture.location(in: arView) // 레이캐스트로 평면 찾기 if let result = arView.raycast( from: location, allowing: .estimatedPlane, alignment: .horizontal ).first { placeObject(at: result, in: arView) } } func placeObject(at raycastResult: ARRaycastResult, in arView: ARView) { let transform = raycastResult.worldTransform let position = SIMD3( transform.columns.3.x, transform.columns.3.y, transform.columns.3.z ) // 앵커 생성 let anchor = AnchorEntity(world: position) // 랜덤 도형 생성 let shapes: [(MeshResource, UIColor)] = [ (.generateBox(size: 0.05), .systemRed), (.generateSphere(radius: 0.03), .systemBlue), (.generateBox(width: 0.08, height: 0.02, depth: 0.04), .systemGreen) ] let (mesh, color) = shapes.randomElement()! let model = ModelEntity( mesh: mesh, materials: [SimpleMaterial(color: color, isMetallic: true)] ) // 충돌 감지 활성화 model.generateCollisionShapes(recursive: true) // 제스처 활성화 (이동, 회전, 크기 조절) arView.installGestures([.translation, .rotation, .scale], for: model) anchor.addChild(model) arView.scene.addAnchor(anchor) // 배치 기록 placedObjects.append(UUID().uuidString) } } } // MARK: - Main View struct ARObjectPlacerView: View { @State private var placedObjects: [String] = [] var body: some View { ZStack { RealityKitView(placedObjects: $placedObjects) .ignoresSafeArea() VStack { Spacer() HStack { Text("배치된 객체: \(placedObjects.count)") .padding() .background(.ultraThinMaterial) .clipShape(Capsule()) Spacer() Button("모두 삭제") { placedObjects.removeAll() } .padding() .background(.ultraThinMaterial) .clipShape(Capsule()) } .padding() } } } } #Preview { ARObjectPlacerView() } ``` ## 고급 패턴 ### 1. 3D 모델 로드 ```swift // USDZ 파일 로드 func loadModel(named name: String) async -> ModelEntity? { do { let entity = try await ModelEntity(named: name) return entity } catch { print("모델 로드 실패: \(error)") return nil } } // 번들에서 로드 let model = try? Entity.loadModel(named: "robot") // URL에서 로드 let url = URL(string: "https://example.com/model.usdz")! let model = try? await Entity(contentsOf: url) ``` ### 2. 애니메이션 ```swift // 이동 애니메이션 func animateEntity(_ entity: Entity) { var transform = entity.transform transform.translation = SIMD3(0, 0.5, 0) entity.move( to: transform, relativeTo: entity.parent, duration: 2.0, timingFunction: .easeInOut ) } // 회전 애니메이션 func rotateEntity(_ entity: Entity) { let rotation = simd_quatf(angle: .pi * 2, axis: SIMD3(0, 1, 0)) var transform = entity.transform transform.rotation = rotation entity.move(to: transform, relativeTo: entity.parent, duration: 3.0) } // 반복 애니메이션 func spinForever(_ entity: Entity) { guard let animation = entity.availableAnimations.first else { return } entity.playAnimation(animation.repeat()) } ``` ### 3. 조명 ```swift // 포인트 라이트 let pointLight = PointLight() pointLight.light.color = .white pointLight.light.intensity = 10000 pointLight.light.attenuationRadius = 2.0 // 스팟 라이트 let spotlight = SpotLight() spotlight.light.color = .yellow spotlight.light.intensity = 50000 spotlight.light.innerAngleInDegrees = 30 spotlight.light.outerAngleInDegrees = 60 // 디렉셔널 라이트 let directional = DirectionalLight() directional.light.color = .white directional.light.intensity = 1000 directional.shadow = DirectionalLightComponent.Shadow() ``` ### 4. 물리 시뮬레이션 ```swift func setupPhysics(for entity: ModelEntity) { // 충돌 형태 생성 entity.generateCollisionShapes(recursive: true) // 물리 바디 추가 (동적) entity.physicsBody = PhysicsBodyComponent( massProperties: .init(mass: 1.0), material: .generate(friction: 0.5, restitution: 0.3), mode: .dynamic ) } // 정적 바디 (움직이지 않음) func makeStatic(_ entity: ModelEntity) { entity.generateCollisionShapes(recursive: true) entity.physicsBody = PhysicsBodyComponent( massProperties: .default, mode: .static ) } // 힘 적용 func applyForce(to entity: ModelEntity) { entity.applyLinearImpulse(SIMD3(0, 5, 0), relativeTo: nil) } ``` ### 5. 오디오 ```swift // 공간 오디오 재생 func playSound(on entity: Entity) { guard let resource = try? AudioFileResource.load(named: "sound.mp3") else { return } let audioController = entity.prepareAudio(resource) audioController.play() } // 공간 오디오 컴포넌트 let spatialAudio = SpatialAudioComponent(directivity: .beam(focus: 0.5)) entity.components.set(spatialAudio) ``` ### 6. visionOS ImmersiveSpace ```swift // visionOS용 Immersive Space import SwiftUI import RealityKit struct ImmersiveView: View { var body: some View { RealityView { content in // 3D 콘텐츠 추가 let sphere = ModelEntity( mesh: .generateSphere(radius: 0.1), materials: [SimpleMaterial(color: .blue, isMetallic: true)] ) sphere.position = SIMD3(0, 1.5, -1) content.add(sphere) // 환경 조명 guard let environment = try? await EnvironmentResource(named: "studio") else { return } content.add(environment) } } } // App에서 ImmersiveSpace 선언 @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } ImmersiveSpace(id: "ImmersiveSpace") { ImmersiveView() } } } ``` ## 주의사항 1. **성능 최적화** ```swift // 복잡한 메시는 LOD 사용 // 불필요한 Entity 제거 anchor.removeFromParent() // 텍스처 크기 최적화 (2048x2048 이하) ``` 2. **메모리 관리** - Entity는 강한 참조 주의 - 씬에서 제거 시 `removeFromParent()` 호출 - 대용량 모델은 비동기 로드 3. **AR 세션 생명주기** ```swift // 백그라운드 진입 시 arView.session.pause() // 포그라운드 복귀 시 arView.session.run(config, options: .resetTracking) ``` 4. **충돌 감지** - `generateCollisionShapes(recursive: true)` 필수 - 제스처 사용 전 반드시 호출 5. **좌표계** - RealityKit은 미터 단위 사용 - Y축이 위쪽 (오른손 좌표계) --- # RelevanceKit AI Reference > 맥락 기반 관련성 판단 가이드. 이 문서를 읽고 RelevanceKit 코드를 생성할 수 있습니다. ## 개요 RelevanceKit은 iOS 18+에서 제공하는 Apple Intelligence 기반 프레임워크입니다. 사용자의 현재 맥락(시간, 위치, 활동 등)에 따라 콘텐츠의 관련성을 판단하고, 가장 적절한 정보를 적시에 표시할 수 있도록 도와줍니다. ## 필수 Import ```swift import RelevanceKit ``` ## 프로젝트 설정 ### Info.plist ```xml NSLocationWhenInUseUsageDescription 맥락 기반 추천을 위해 위치 정보가 필요합니다. NSMotionUsageDescription 활동 상태를 파악하기 위해 모션 데이터가 필요합니다. ``` ## 핵심 구성요소 ### 1. RelevanceEngine ```swift import RelevanceKit // 관련성 엔진 let engine = RelevanceEngine.shared // 현재 맥락 가져오기 let context = await engine.currentContext() ``` ### 2. RelevanceContext (맥락 정보) ```swift // 현재 맥락 let context = await engine.currentContext() context.timeOfDay // .morning, .afternoon, .evening, .night context.dayOfWeek // .weekday, .weekend context.activity // .stationary, .walking, .driving, .workout context.location // 위치 유형 (.home, .work, .commuting, .unknown) context.deviceUsage // .active, .passive context.focus // 현재 집중 모드 ``` ### 3. RelevanceScore (관련성 점수) ```swift // 항목의 관련성 점수 계산 let items: [ContentItem] = [...] let rankedItems = await engine.rank(items) { item in // 각 항목에 대한 관련성 힌트 제공 RelevanceHints( category: item.category, timeRelevance: item.scheduledTime, locationRelevance: item.location ) } // 점수별 정렬된 결과 for (item, score) in rankedItems { print("\(item.title): \(score.value)") // 0.0 ~ 1.0 } ``` ## 전체 작동 예제 ```swift import SwiftUI import RelevanceKit // MARK: - Content Item struct ContentItem: Identifiable { let id = UUID() let title: String let category: ContentCategory let scheduledTime: Date? let location: ContentLocation? let priority: Int } enum ContentCategory: String, CaseIterable { case work = "업무" case personal = "개인" case health = "건강" case entertainment = "엔터테인먼트" case shopping = "쇼핑" } struct ContentLocation { let type: LocationType let name: String enum LocationType { case home, work, gym, store, restaurant } } // MARK: - Relevance Manager @Observable class RelevanceManager { var currentContext: RelevanceContext? var rankedItems: [(ContentItem, RelevanceScore)] = [] var isLoading = false private let engine = RelevanceEngine.shared var isSupported: Bool { RelevanceEngine.isSupported } var contextSummary: String { guard let context = currentContext else { return "로딩 중..." } var parts: [String] = [] switch context.timeOfDay { case .morning: parts.append("🌅 아침") case .afternoon: parts.append("☀️ 오후") case .evening: parts.append("🌆 저녁") case .night: parts.append("🌙 밤") } switch context.activity { case .stationary: parts.append("정지") case .walking: parts.append("🚶 걷는 중") case .driving: parts.append("🚗 운전 중") case .workout: parts.append("🏃 운동 중") default: break } switch context.location { case .home: parts.append("🏠 집") case .work: parts.append("🏢 직장") case .commuting: parts.append("🚌 이동 중") default: break } return parts.joined(separator: " • ") } func fetchContext() async { currentContext = await engine.currentContext() } func rankItems(_ items: [ContentItem]) async { isLoading = true rankedItems = await engine.rank(items) { item in buildHints(for: item) } isLoading = false } private func buildHints(for item: ContentItem) -> RelevanceHints { var hints = RelevanceHints() // 카테고리 기반 힌트 switch item.category { case .work: hints.preferredContext = [.weekday, .work] hints.preferredTimeOfDay = [.morning, .afternoon] case .personal: hints.preferredContext = [.weekend, .home] case .health: hints.preferredActivity = [.stationary, .walking] hints.preferredTimeOfDay = [.morning, .evening] case .entertainment: hints.preferredContext = [.home] hints.preferredTimeOfDay = [.evening, .night] case .shopping: hints.preferredActivity = [.walking] } // 시간 기반 힌트 if let scheduledTime = item.scheduledTime { hints.timeRelevance = scheduledTime } // 위치 기반 힌트 if let location = item.location { switch location.type { case .home: hints.preferredContext.insert(.home) case .work: hints.preferredContext.insert(.work) case .gym: hints.preferredActivity.insert(.workout) default: break } } return hints } } // MARK: - Main View struct RelevanceView: View { @State private var manager = RelevanceManager() let sampleItems: [ContentItem] = [ ContentItem(title: "팀 미팅 준비", category: .work, scheduledTime: nil, location: ContentLocation(type: .work, name: "회사"), priority: 1), ContentItem(title: "운동하기", category: .health, scheduledTime: nil, location: ContentLocation(type: .gym, name: "헬스장"), priority: 2), ContentItem(title: "넷플릭스 보기", category: .entertainment, scheduledTime: nil, location: ContentLocation(type: .home, name: "집"), priority: 3), ContentItem(title: "장보기", category: .shopping, scheduledTime: nil, location: ContentLocation(type: .store, name: "마트"), priority: 4), ContentItem(title: "독서", category: .personal, scheduledTime: nil, location: nil, priority: 5), ContentItem(title: "이메일 확인", category: .work, scheduledTime: nil, location: nil, priority: 6), ContentItem(title: "명상", category: .health, scheduledTime: nil, location: ContentLocation(type: .home, name: "집"), priority: 7), ] var body: some View { NavigationStack { List { // 현재 맥락 Section("현재 맥락") { if !manager.isSupported { Label("이 기기에서 지원되지 않습니다", systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) } else { HStack { Image(systemName: "sparkles") .foregroundStyle(.purple) Text(manager.contextSummary) } } } // 관련성 순위 Section("추천 순서") { if manager.isLoading { ProgressView() } else if manager.rankedItems.isEmpty { Text("항목을 분석하려면 새로고침하세요") .foregroundStyle(.secondary) } else { ForEach(Array(manager.rankedItems.enumerated()), id: \.1.0.id) { index, pair in let (item, score) = pair RankedItemRow( rank: index + 1, item: item, score: score ) } } } // 설명 Section { VStack(alignment: .leading, spacing: 8) { Label("AI 기반 추천", systemImage: "brain") .font(.subheadline.bold()) Text("현재 시간, 위치, 활동 상태를 분석하여 가장 관련성 높은 항목을 상위에 표시합니다.") .font(.caption) .foregroundStyle(.secondary) } } } .navigationTitle("RelevanceKit") .refreshable { await manager.fetchContext() await manager.rankItems(sampleItems) } .task { await manager.fetchContext() await manager.rankItems(sampleItems) } } } } // MARK: - Ranked Item Row struct RankedItemRow: View { let rank: Int let item: ContentItem let score: RelevanceScore var body: some View { HStack(spacing: 12) { // 순위 Text("\(rank)") .font(.headline) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(rankColor, in: Circle()) // 아이템 정보 VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.headline) HStack { Text(item.category.rawValue) .font(.caption) .foregroundStyle(.secondary) if let location = item.location { Text("• \(location.name)") .font(.caption) .foregroundStyle(.secondary) } } } Spacer() // 관련성 점수 VStack(alignment: .trailing) { Text("\(Int(score.value * 100))%") .font(.headline) .foregroundStyle(scoreColor) Text("관련성") .font(.caption2) .foregroundStyle(.secondary) } } .padding(.vertical, 4) } var rankColor: Color { switch rank { case 1: return .yellow case 2: return .gray case 3: return .orange default: return .blue.opacity(0.7) } } var scoreColor: Color { if score.value >= 0.8 { return .green } if score.value >= 0.5 { return .orange } return .red } } #Preview { RelevanceView() } ``` ## 고급 패턴 ### 1. 위젯 관련성 최적화 ```swift import WidgetKit import RelevanceKit struct RelevantContentWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration( kind: "RelevantContent", provider: RelevantTimelineProvider() ) { entry in RelevantWidgetView(entry: entry) } .configurationDisplayName("스마트 추천") .description("현재 상황에 맞는 콘텐츠를 표시합니다") } } struct RelevantTimelineProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { let engine = RelevanceEngine.shared let currentContext = await engine.currentContext() // 맥락에 따른 콘텐츠 선택 let relevantItem = await selectMostRelevantItem(for: currentContext) let entry = RelevantEntry(date: Date(), item: relevantItem) // 맥락 변화 예상 시점에 새로고침 let refreshDate = calculateNextContextChange(from: currentContext) let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) completion(timeline) } } } ``` ### 2. 알림 타이밍 최적화 ```swift import UserNotifications import RelevanceKit class SmartNotificationManager { let engine = RelevanceEngine.shared func scheduleSmartNotification( title: String, body: String, preferredTime: Date, category: ContentCategory ) async { let context = await engine.currentContext() // 최적의 알림 시간 계산 let optimalTime = await engine.suggestOptimalTime( for: preferredTime, hints: RelevanceHints( category: category, preferredContext: contextFor(category) ) ) let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default let trigger = UNTimeIntervalNotificationTrigger( timeInterval: optimalTime.timeIntervalSinceNow, repeats: false ) let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: trigger ) try? await UNUserNotificationCenter.current().add(request) } private func contextFor(_ category: ContentCategory) -> Set { switch category { case .work: return [.weekday, .work] case .health: return [.morning, .evening] case .entertainment: return [.evening, .home] default: return [] } } } ``` ### 3. 검색 결과 재정렬 ```swift struct SmartSearchView: View { @State private var searchText = "" @State private var results: [SearchResult] = [] @State private var rankedResults: [(SearchResult, RelevanceScore)] = [] let engine = RelevanceEngine.shared var body: some View { List(rankedResults, id: \.0.id) { result, score in HStack { Text(result.title) Spacer() Text("\(Int(score.value * 100))%") .foregroundStyle(.secondary) } } .searchable(text: $searchText) .onChange(of: searchText) { _, query in Task { results = await search(query) rankedResults = await rerankResults(results) } } } func rerankResults(_ results: [SearchResult]) async -> [(SearchResult, RelevanceScore)] { await engine.rank(results) { result in RelevanceHints( category: result.category, recency: result.lastAccessed, frequency: result.accessCount ) } } } ``` ## 주의사항 1. **iOS 버전** - RelevanceKit: iOS 18+ 및 Apple Silicon 필요 - Apple Intelligence 기능 2. **개인정보** - 모든 분석은 온디바이스 - 사용자 데이터 서버 전송 없음 3. **배터리 고려** - 맥락 분석은 리소스 소모 - 불필요한 빈번한 호출 자제 4. **폴백 제공** - 미지원 기기에서는 기본 정렬 사용 - `isSupported` 확인 필수 5. **정확도** - 초기에는 학습 데이터 부족 - 사용 시간에 따라 정확도 향상 --- # SharePlay AI Reference > FaceTime 함께 보기 경험 구현 가이드. 이 문서를 읽고 SharePlay 코드를 생성할 수 있습니다. ## 개요 SharePlay는 FaceTime 통화 중 콘텐츠를 함께 보고 상호작용하는 기능을 제공합니다. GroupActivities 프레임워크를 통해 앱 상태를 실시간 동기화합니다. ## 필수 Import ```swift import GroupActivities ``` ## 프로젝트 설정 1. **Capabilities**: Group Activities 추가 2. **Info.plist**: ```xml NSSupportsLiveActivities ``` ## 핵심 구성요소 ### 1. GroupActivity 정의 ```swift struct WatchTogetherActivity: GroupActivity { // 콘텐츠 정보 let movie: Movie // 메타데이터 var metadata: GroupActivityMetadata { var metadata = GroupActivityMetadata() metadata.title = movie.title metadata.subtitle = "함께 보기" metadata.previewImage = movie.thumbnailImage metadata.type = .watchTogether return metadata } } struct Movie: Codable, Hashable { let id: String let title: String let url: URL var thumbnailImage: CGImage? { nil } } ``` ### 2. 활동 시작 ```swift func startSharePlay(movie: Movie) async { let activity = WatchTogetherActivity(movie: movie) switch await activity.prepareForActivation() { case .activationPreferred: do { _ = try await activity.activate() } catch { print("활성화 실패: \(error)") } case .activationDisabled: // SharePlay 비활성화됨 print("SharePlay가 비활성화되어 있습니다") case .cancelled: // 사용자 취소 break @unknown default: break } } ``` ### 3. 세션 관리 ```swift @Observable class SharePlayManager { var session: GroupSession? var messenger: GroupSessionMessenger? var isSharePlayActive = false func configureSession() async { for await session in WatchTogetherActivity.sessions() { self.session = session self.isSharePlayActive = true // 메신저 설정 messenger = GroupSessionMessenger(session: session) // 세션 상태 관찰 Task { for await state in session.$state.values { if case .invalidated = state { self.isSharePlayActive = false self.session = nil } } } // 세션 참가 session.join() } } } ``` ## 전체 작동 예제 ```swift import SwiftUI import GroupActivities import AVKit // MARK: - Activity 정의 struct MovieWatchActivity: GroupActivity { let movieID: String let movieTitle: String var metadata: GroupActivityMetadata { var metadata = GroupActivityMetadata() metadata.title = movieTitle metadata.subtitle = "함께 영화 보기" metadata.type = .watchTogether return metadata } } // 동기화할 메시지 struct PlaybackState: Codable { let isPlaying: Bool let currentTime: TimeInterval } // MARK: - SharePlay Manager @Observable class MovieSharePlayManager { var session: GroupSession? var messenger: GroupSessionMessenger? var isSharePlayActive = false var participants: Set = [] private var tasks = Set>() init() { Task { await observeSessions() } } private func observeSessions() async { for await session in MovieWatchActivity.sessions() { cleanUp() self.session = session // 메신저 설정 let messenger = GroupSessionMessenger(session: session) self.messenger = messenger // 참가자 관찰 let participantTask = Task { for await participants in session.$activeParticipants.values { await MainActor.run { self.participants = participants } } } tasks.insert(participantTask) // 세션 상태 관찰 let stateTask = Task { for await state in session.$state.values { await MainActor.run { switch state { case .joined: self.isSharePlayActive = true case .invalidated: self.isSharePlayActive = false self.cleanUp() default: break } } } } tasks.insert(stateTask) // 메시지 수신 let messageTask = Task { for await (message, _) in messenger.messages(of: PlaybackState.self) { await handlePlaybackState(message) } } tasks.insert(messageTask) // 세션 참가 session.join() } } func startSharePlay(movieID: String, title: String) async { let activity = MovieWatchActivity(movieID: movieID, movieTitle: title) switch await activity.prepareForActivation() { case .activationPreferred: do { _ = try await activity.activate() } catch { print("SharePlay 활성화 실패: \(error)") } case .activationDisabled: print("SharePlay가 비활성화됨") case .cancelled: break @unknown default: break } } func sendPlaybackState(isPlaying: Bool, currentTime: TimeInterval) { guard let messenger else { return } let state = PlaybackState(isPlaying: isPlaying, currentTime: currentTime) Task { do { try await messenger.send(state) } catch { print("메시지 전송 실패: \(error)") } } } @MainActor private func handlePlaybackState(_ state: PlaybackState) async { // ViewModel에서 재생 상태 동기화 NotificationCenter.default.post( name: .sharePlayStateReceived, object: state ) } func endSession() { session?.end() cleanUp() } private func cleanUp() { tasks.forEach { $0.cancel() } tasks.removeAll() session = nil messenger = nil participants = [] } } extension Notification.Name { static let sharePlayStateReceived = Notification.Name("sharePlayStateReceived") } // MARK: - Video Player ViewModel @Observable class VideoPlayerViewModel { let movie: Movie var isPlaying = false var currentTime: TimeInterval = 0 var sharePlayManager: MovieSharePlayManager init(movie: Movie, sharePlayManager: MovieSharePlayManager) { self.movie = movie self.sharePlayManager = sharePlayManager observeSharePlay() } private func observeSharePlay() { NotificationCenter.default.addObserver( forName: .sharePlayStateReceived, object: nil, queue: .main ) { [weak self] notification in guard let state = notification.object as? PlaybackState else { return } self?.syncPlayback(state) } } private func syncPlayback(_ state: PlaybackState) { isPlaying = state.isPlaying currentTime = state.currentTime } func togglePlayPause() { isPlaying.toggle() if sharePlayManager.isSharePlayActive { sharePlayManager.sendPlaybackState(isPlaying: isPlaying, currentTime: currentTime) } } func seek(to time: TimeInterval) { currentTime = time if sharePlayManager.isSharePlayActive { sharePlayManager.sendPlaybackState(isPlaying: isPlaying, currentTime: currentTime) } } } struct Movie: Identifiable { let id: String let title: String let url: URL } // MARK: - Views struct MoviePlayerView: View { let movie: Movie @State private var sharePlayManager = MovieSharePlayManager() @State private var viewModel: VideoPlayerViewModel? var body: some View { VStack { // 비디오 플레이어 (실제로는 AVPlayer 사용) Rectangle() .fill(.black) .aspectRatio(16/9, contentMode: .fit) .overlay { Image(systemName: viewModel?.isPlaying == true ? "pause.fill" : "play.fill") .font(.system(size: 50)) .foregroundStyle(.white.opacity(0.8)) } .onTapGesture { viewModel?.togglePlayPause() } // 컨트롤 HStack(spacing: 20) { // 재생/일시정지 Button { viewModel?.togglePlayPause() } label: { Image(systemName: viewModel?.isPlaying == true ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 44)) } Spacer() // SharePlay 상태 if sharePlayManager.isSharePlayActive { HStack { Image(systemName: "shareplay") Text("\(sharePlayManager.participants.count)명 시청 중") } .font(.caption) .foregroundStyle(.green) } // SharePlay 버튼 ShareLink( item: movie.url, preview: SharePreview(movie.title) ) { Image(systemName: "shareplay") .font(.title2) } } .padding() // SharePlay 시작 버튼 if !sharePlayManager.isSharePlayActive { Button { Task { await sharePlayManager.startSharePlay( movieID: movie.id, title: movie.title ) } } label: { Label("SharePlay 시작", systemImage: "shareplay") } .buttonStyle(.borderedProminent) } } .onAppear { viewModel = VideoPlayerViewModel(movie: movie, sharePlayManager: sharePlayManager) } .onDisappear { sharePlayManager.endSession() } } } ``` ## 고급 패턴 ### 1. AVPlayer 동기화 ```swift // CoordinationManager 사용 (iOS 15+) func configureAVPlayerSync() { guard let session else { return } let coordinator = AVPlaybackCoordinator() session.coordinator = coordinator // AVPlayer와 연결 player.playbackCoordinator.coordinateWithSession(session) } ``` ### 2. 커스텀 데이터 동기화 ```swift // 게임 상태 동기화 struct GameState: Codable { let playerPositions: [String: CGPoint] let score: [String: Int] let currentTurn: String } // 신뢰할 수 있는 전송 (순서 보장) try await messenger.send(gameState, to: .all, deliveryMode: .reliable) // 빠른 전송 (실시간, 순서 미보장) try await messenger.send(position, to: .all, deliveryMode: .unreliable) ``` ### 3. 참가자별 메시지 ```swift // 특정 참가자에게만 전송 if let host = participants.first(where: { $0.isLocal == false }) { try await messenger.send(message, to: .only(host)) } ``` ## 주의사항 1. **FaceTime 필요** - SharePlay는 FaceTime 통화 중에만 동작 - 시뮬레이터에서 제한적 테스트 가능 2. **네트워크 지연** - 상태 동기화에 지연 발생 가능 - UI에 버퍼링 표시 권장 3. **세션 정리** - 화면 이탈 시 `session.leave()` 또는 `session.end()` 호출 - 메모리 누수 방지 4. **참가자 제한** - FaceTime 그룹 통화 최대 32명 - 앱별로 적절한 제한 설정 권장 --- # ShazamKit AI Reference > 음악 인식 앱 구현 가이드. 이 문서를 읽고 ShazamKit 코드를 생성할 수 있습니다. ## 개요 ShazamKit은 음악 인식 기능을 제공하는 프레임워크로, Shazam의 방대한 음악 데이터베이스를 활용합니다. 오디오 매칭, 커스텀 카탈로그, 음악 라이브러리 추가 등을 지원합니다. ## 필수 Import ```swift import ShazamKit import AVFoundation // 오디오 캡처용 ``` ## 프로젝트 설정 ### 1. Capability 추가 Xcode > Signing & Capabilities > + ShazamKit ### 2. 권한 설정 ```xml NSMicrophoneUsageDescription 음악을 인식하기 위해 마이크 접근이 필요합니다. ``` ## 핵심 구성요소 ### 1. SHSession (인식 세션) ```swift import ShazamKit let session = SHSession() // 델리게이트 설정 session.delegate = self // SHSessionDelegate func session(_ session: SHSession, didFind match: SHMatch) { // 매칭 성공 if let mediaItem = match.mediaItems.first { print("제목: \(mediaItem.title ?? "알 수 없음")") print("아티스트: \(mediaItem.artist ?? "알 수 없음")") } } func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: Error?) { // 매칭 실패 } ``` ### 2. SHManagedSession (자동 관리 세션) ```swift // iOS 17+ 간편 API let managedSession = SHManagedSession() // 자동으로 마이크 권한 요청 및 오디오 캡처 let result = await managedSession.result() switch result { case .match(let match): print("찾음: \(match.mediaItems.first?.title ?? "")") case .noMatch(_): print("매칭 실패") case .error(let error, _): print("에러: \(error)") } ``` ### 3. SHMediaItem (인식 결과) ```swift let item: SHMediaItem item.title // 곡 제목 item.artist // 아티스트 item.artworkURL // 앨범 아트 URL item.appleMusicURL // Apple Music 링크 item.appleMusicID // Apple Music ID item.isrc // 국제 표준 녹음 코드 item.genres // 장르 배열 item.videoURL // 뮤직비디오 URL (있을 경우) ``` ## 전체 작동 예제 ```swift import SwiftUI import ShazamKit import AVFoundation // MARK: - Shazam Manager @Observable class ShazamManager: NSObject { var isListening = false var matchedSong: SHMediaItem? var errorMessage: String? var isLoading = false private var session: SHSession? private var audioEngine: AVAudioEngine? override init() { super.init() session = SHSession() session?.delegate = self } func startListening() { guard !isListening else { return } matchedSong = nil errorMessage = nil isLoading = true // 오디오 엔진 설정 audioEngine = AVAudioEngine() let inputNode = audioEngine!.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) // 오디오 탭 설치 inputNode.installTap(onBus: 0, bufferSize: 2048, format: recordingFormat) { [weak self] buffer, time in self?.session?.matchStreamingBuffer(buffer, at: time) } // 오디오 세션 설정 do { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.record, mode: .default) try audioSession.setActive(true) try audioEngine?.start() isListening = true } catch { errorMessage = "마이크 접근 실패: \(error.localizedDescription)" isLoading = false } } func stopListening() { audioEngine?.inputNode.removeTap(onBus: 0) audioEngine?.stop() audioEngine = nil isListening = false isLoading = false } } // MARK: - SHSessionDelegate extension ShazamManager: SHSessionDelegate { func session(_ session: SHSession, didFind match: SHMatch) { DispatchQueue.main.async { self.matchedSong = match.mediaItems.first self.isLoading = false self.stopListening() } } func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: Error?) { DispatchQueue.main.async { self.errorMessage = error?.localizedDescription ?? "음악을 찾을 수 없습니다" self.isLoading = false } } } // MARK: - Main View struct ShazamView: View { @State private var manager = ShazamManager() var body: some View { NavigationStack { VStack(spacing: 32) { Spacer() // 결과 또는 상태 표시 if let song = manager.matchedSong { SongResultView(song: song) } else if manager.isLoading { ListeningView() } else if let error = manager.errorMessage { ErrorView(message: error) { manager.errorMessage = nil } } else { ReadyView() } Spacer() // 인식 버튼 Button { if manager.isListening { manager.stopListening() } else { manager.startListening() } } label: { ZStack { Circle() .fill(manager.isListening ? .red : .blue) .frame(width: 100, height: 100) Image(systemName: manager.isListening ? "stop.fill" : "shazam.logo.fill") .font(.system(size: 40)) .foregroundStyle(.white) } } .padding(.bottom, 48) } .padding() .navigationTitle("음악 인식") } } } // MARK: - Song Result View struct SongResultView: View { let song: SHMediaItem var body: some View { VStack(spacing: 16) { // 앨범 아트 AsyncImage(url: song.artworkURL) { image in image .resizable() .aspectRatio(contentMode: .fit) } placeholder: { RoundedRectangle(cornerRadius: 12) .fill(.quaternary) .overlay { Image(systemName: "music.note") .font(.largeTitle) } } .frame(width: 200, height: 200) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 10) // 곡 정보 VStack(spacing: 8) { Text(song.title ?? "알 수 없는 제목") .font(.title2.bold()) Text(song.artist ?? "알 수 없는 아티스트") .font(.title3) .foregroundStyle(.secondary) if let genres = song.genres, !genres.isEmpty { Text(genres.joined(separator: ", ")) .font(.subheadline) .foregroundStyle(.tertiary) } } // 액션 버튼 HStack(spacing: 16) { if let appleMusicURL = song.appleMusicURL { Link(destination: appleMusicURL) { Label("Apple Music", systemImage: "apple.logo") .padding() .background(.pink) .foregroundStyle(.white) .clipShape(Capsule()) } } Button { addToShazamLibrary(song) } label: { Label("라이브러리 추가", systemImage: "plus") .padding() .background(.blue) .foregroundStyle(.white) .clipShape(Capsule()) } } } } func addToShazamLibrary(_ item: SHMediaItem) { Task { do { try await SHMediaLibrary.default.add([item]) } catch { print("라이브러리 추가 실패: \(error)") } } } } // MARK: - Listening View struct ListeningView: View { @State private var isAnimating = false var body: some View { VStack(spacing: 16) { ZStack { ForEach(0..<3) { i in Circle() .stroke(lineWidth: 2) .foregroundStyle(.blue.opacity(0.5)) .scaleEffect(isAnimating ? 2 : 1) .opacity(isAnimating ? 0 : 1) .animation( .easeOut(duration: 1.5) .repeatForever(autoreverses: false) .delay(Double(i) * 0.5), value: isAnimating ) } Image(systemName: "waveform") .font(.system(size: 40)) .foregroundStyle(.blue) } .frame(width: 120, height: 120) Text("듣는 중...") .font(.headline) .foregroundStyle(.secondary) } .onAppear { isAnimating = true } } } // MARK: - Ready View struct ReadyView: View { var body: some View { VStack(spacing: 16) { Image(systemName: "shazam.logo") .font(.system(size: 60)) .foregroundStyle(.blue) Text("버튼을 눌러 음악을 인식하세요") .font(.headline) .foregroundStyle(.secondary) } } } // MARK: - Error View struct ErrorView: View { let message: String let onDismiss: () -> Void var body: some View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 40)) .foregroundStyle(.orange) Text(message) .multilineTextAlignment(.center) .foregroundStyle(.secondary) Button("다시 시도", action: onDismiss) .buttonStyle(.bordered) } } } #Preview { ShazamView() } ``` ## 고급 패턴 ### 1. SHManagedSession (iOS 17+) ```swift // 간편한 자동 관리 세션 @Observable class SimpleShazamManager { var result: SHManagedSession.Result? private let session = SHManagedSession() func recognize() async { // 자동으로 마이크 권한 요청 및 오디오 캡처 result = await session.result() } func cancel() { session.cancel() } } // 사용 struct SimpleShazamView: View { @State private var manager = SimpleShazamManager() var body: some View { Button("인식") { Task { await manager.recognize() } } } } ``` ### 2. 커스텀 카탈로그 ```swift // 자체 오디오 파일로 커스텀 카탈로그 생성 func createCustomCatalog() async throws -> SHCustomCatalog { let catalog = SHCustomCatalog() // 오디오 파일에서 시그니처 생성 let audioURL = Bundle.main.url(forResource: "mysong", withExtension: "mp3")! let signatureGenerator = SHSignatureGenerator() try await signatureGenerator.generateSignature(from: audioURL) if let signature = signatureGenerator.signature { // 메타데이터 연결 let mediaItem = SHMediaItem(properties: [ .title: "My Song", .artist: "My Artist", .artworkURL: URL(string: "https://...")! ]) try catalog.addReferenceSignature(signature, representing: [mediaItem]) } return catalog } // 커스텀 카탈로그로 세션 생성 let session = SHSession(catalog: customCatalog) ``` ### 3. 백그라운드 인식 ```swift // Scene Delegate에서 지속적인 인식 class SceneDelegate: UIResponder, UIWindowSceneDelegate { let session = SHManagedSession() func sceneDidBecomeActive(_ scene: UIScene) { Task { // 계속 인식 for await result in session.results { switch result { case .match(let match): handleMatch(match) case .noMatch: continue case .error(let error, _): print("Error: \(error)") } } } } } ``` ### 4. 파일에서 인식 ```swift // 녹음된 오디오 파일에서 인식 func recognizeFromFile(url: URL) async throws -> SHMatch? { let session = SHSession() // 파일에서 시그니처 생성 let generator = SHSignatureGenerator() try await generator.generateSignature(from: url) guard let signature = generator.signature else { return nil } // 매칭 요청 return try await withCheckedThrowingContinuation { continuation in session.delegate = SignatureDelegate { match in continuation.resume(returning: match) } onError: { error in continuation.resume(throwing: error ?? ShazamError.unknown) } session.match(signature) } } ``` ### 5. Shazam 라이브러리 관리 ```swift // 라이브러리에 곡 추가 func addToLibrary(_ items: [SHMediaItem]) async throws { try await SHMediaLibrary.default.add(items) } // 라이브러리 항목 읽기 (앱에서 추가한 것만) func getLibraryItems() async -> [SHMediaItem] { var items: [SHMediaItem] = [] for await itemCollection in SHMediaLibrary.default.items { items.append(contentsOf: itemCollection) } return items } ``` ## 주의사항 1. **마이크 권한** ```swift // 권한 상태 확인 switch AVAudioSession.sharedInstance().recordPermission { case .granted: startListening() case .denied: showPermissionAlert() case .undetermined: AVAudioSession.sharedInstance().requestRecordPermission { granted in // 처리 } } ``` 2. **API 호출 제한** - Shazam 카탈로그 쿼리에 제한 있음 - 무료 티어 제한 확인 필요 3. **커스텀 카탈로그** - 최대 100개 레퍼런스 시그니처 - 로컬 저장 또는 공유 가능 4. **백그라운드** - 백그라운드 오디오 권한 필요 - 배터리 소모 주의 5. **시뮬레이터** - 마이크 입력 제한 - 파일 기반 테스트 권장 --- # SpriteKit AI Reference > 2D 게임 개발 가이드. 이 문서를 읽고 SpriteKit 코드를 생성할 수 있습니다. ## 개요 SpriteKit은 Apple의 2D 게임 엔진입니다. 스프라이트 렌더링, 물리 시뮬레이션, 파티클 효과, 애니메이션을 지원합니다. ## 필수 Import ```swift import SpriteKit import SwiftUI // SwiftUI 통합 시 ``` ## 핵심 구성요소 ### 1. SKScene (게임 씬) ```swift class GameScene: SKScene { override func didMove(to view: SKView) { // 씬이 표시될 때 호출 setupGame() } override func update(_ currentTime: TimeInterval) { // 매 프레임 호출 (게임 루프) } override func touchesBegan(_ touches: Set, with event: UIEvent?) { // 터치 처리 } } ``` ### 2. SKSpriteNode (스프라이트) ```swift // 이미지로 생성 let player = SKSpriteNode(imageNamed: "player") player.position = CGPoint(x: 100, y: 100) player.size = CGSize(width: 50, height: 50) addChild(player) // 색상으로 생성 let enemy = SKSpriteNode(color: .red, size: CGSize(width: 40, height: 40)) addChild(enemy) ``` ### 3. SKAction (애니메이션) ```swift // 이동 let moveAction = SKAction.move(to: CGPoint(x: 300, y: 300), duration: 1.0) // 회전 let rotateAction = SKAction.rotate(byAngle: .pi * 2, duration: 1.0) // 크기 변경 let scaleAction = SKAction.scale(to: 2.0, duration: 0.5) // 순차 실행 let sequence = SKAction.sequence([moveAction, scaleAction]) // 동시 실행 let group = SKAction.group([moveAction, rotateAction]) // 반복 let repeatForever = SKAction.repeatForever(rotateAction) // 실행 player.run(sequence) ``` ## 전체 작동 예제 ```swift import SpriteKit import SwiftUI // MARK: - Game Scene class SpaceShooterScene: SKScene, SKPhysicsContactDelegate { // 노드 참조 private var player: SKSpriteNode! private var scoreLabel: SKLabelNode! // 게임 상태 private var score = 0 private var isGameOver = false // 물리 카테고리 struct PhysicsCategory { static let none: UInt32 = 0 static let player: UInt32 = 0b1 static let enemy: UInt32 = 0b10 static let bullet: UInt32 = 0b100 } override func didMove(to view: SKView) { setupScene() setupPlayer() setupUI() startSpawning() physicsWorld.contactDelegate = self physicsWorld.gravity = .zero } // MARK: - Setup private func setupScene() { backgroundColor = .black // 별 배경 if let stars = SKEmitterNode(fileNamed: "Stars") { stars.position = CGPoint(x: size.width / 2, y: size.height) stars.zPosition = -1 addChild(stars) } } private func setupPlayer() { player = SKSpriteNode(color: .cyan, size: CGSize(width: 50, height: 50)) player.position = CGPoint(x: size.width / 2, y: 100) player.name = "player" // 물리 바디 player.physicsBody = SKPhysicsBody(rectangleOf: player.size) player.physicsBody?.categoryBitMask = PhysicsCategory.player player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy player.physicsBody?.collisionBitMask = PhysicsCategory.none player.physicsBody?.isDynamic = true addChild(player) } private func setupUI() { scoreLabel = SKLabelNode(fontNamed: "AvenirNext-Bold") scoreLabel.text = "Score: 0" scoreLabel.fontSize = 24 scoreLabel.position = CGPoint(x: size.width / 2, y: size.height - 50) scoreLabel.zPosition = 100 addChild(scoreLabel) } // MARK: - Game Logic private func startSpawning() { let spawnAction = SKAction.run { [weak self] in self?.spawnEnemy() } let waitAction = SKAction.wait(forDuration: 1.0, withRange: 0.5) let sequence = SKAction.sequence([spawnAction, waitAction]) run(SKAction.repeatForever(sequence)) } private func spawnEnemy() { let enemy = SKSpriteNode(color: .red, size: CGSize(width: 40, height: 40)) let randomX = CGFloat.random(in: 50...(size.width - 50)) enemy.position = CGPoint(x: randomX, y: size.height + 50) enemy.name = "enemy" enemy.physicsBody = SKPhysicsBody(rectangleOf: enemy.size) enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy enemy.physicsBody?.contactTestBitMask = PhysicsCategory.bullet | PhysicsCategory.player enemy.physicsBody?.collisionBitMask = PhysicsCategory.none addChild(enemy) // 이동 후 제거 let moveAction = SKAction.moveTo(y: -50, duration: 3.0) let removeAction = SKAction.removeFromParent() enemy.run(SKAction.sequence([moveAction, removeAction])) } private func fireBullet() { let bullet = SKSpriteNode(color: .yellow, size: CGSize(width: 5, height: 20)) bullet.position = CGPoint(x: player.position.x, y: player.position.y + 30) bullet.name = "bullet" bullet.physicsBody = SKPhysicsBody(rectangleOf: bullet.size) bullet.physicsBody?.categoryBitMask = PhysicsCategory.bullet bullet.physicsBody?.contactTestBitMask = PhysicsCategory.enemy bullet.physicsBody?.collisionBitMask = PhysicsCategory.none bullet.physicsBody?.isDynamic = true addChild(bullet) let moveAction = SKAction.moveTo(y: size.height + 50, duration: 0.5) let removeAction = SKAction.removeFromParent() bullet.run(SKAction.sequence([moveAction, removeAction])) } private func enemyDestroyed(at position: CGPoint) { // 폭발 효과 if let explosion = SKEmitterNode(fileNamed: "Explosion") { explosion.position = position addChild(explosion) let wait = SKAction.wait(forDuration: 0.5) let remove = SKAction.removeFromParent() explosion.run(SKAction.sequence([wait, remove])) } // 점수 증가 score += 10 scoreLabel.text = "Score: \(score)" } private func gameOver() { isGameOver = true removeAllActions() let gameOverLabel = SKLabelNode(fontNamed: "AvenirNext-Bold") gameOverLabel.text = "GAME OVER" gameOverLabel.fontSize = 48 gameOverLabel.position = CGPoint(x: size.width / 2, y: size.height / 2) addChild(gameOverLabel) } // MARK: - Touch Handling override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard !isGameOver else { return } fireBullet() } override func touchesMoved(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first, !isGameOver else { return } let location = touch.location(in: self) player.position.x = location.x } // MARK: - Physics Contact func didBegin(_ contact: SKPhysicsContact) { let bodyA = contact.bodyA let bodyB = contact.bodyB // 총알 + 적 if (bodyA.categoryBitMask == PhysicsCategory.bullet && bodyB.categoryBitMask == PhysicsCategory.enemy) || (bodyA.categoryBitMask == PhysicsCategory.enemy && bodyB.categoryBitMask == PhysicsCategory.bullet) { let enemyNode = bodyA.categoryBitMask == PhysicsCategory.enemy ? bodyA.node : bodyB.node let bulletNode = bodyA.categoryBitMask == PhysicsCategory.bullet ? bodyA.node : bodyB.node if let position = enemyNode?.position { enemyDestroyed(at: position) } enemyNode?.removeFromParent() bulletNode?.removeFromParent() } // 플레이어 + 적 if (bodyA.categoryBitMask == PhysicsCategory.player && bodyB.categoryBitMask == PhysicsCategory.enemy) || (bodyA.categoryBitMask == PhysicsCategory.enemy && bodyB.categoryBitMask == PhysicsCategory.player) { gameOver() } } } // MARK: - SwiftUI Integration struct GameView: View { var body: some View { SpriteView(scene: makeScene()) .ignoresSafeArea() } func makeScene() -> SKScene { let scene = SpaceShooterScene() scene.size = UIScreen.main.bounds.size scene.scaleMode = .resizeFill return scene } } ``` ## 고급 패턴 ### 1. 스프라이트 애니메이션 ```swift func setupPlayerAnimation() { let textures = (1...4).map { SKTexture(imageNamed: "player_\($0)") } let animation = SKAction.animate(with: textures, timePerFrame: 0.1) player.run(SKAction.repeatForever(animation)) } ``` ### 2. 타일맵 ```swift func setupTileMap() { guard let tileSet = SKTileSet(named: "GameTiles") else { return } let tileMap = SKTileMapNode( tileSet: tileSet, columns: 20, rows: 20, tileSize: CGSize(width: 32, height: 32) ) // 타일 배치 if let grassTile = tileSet.tileGroups.first(where: { $0.name == "Grass" }) { tileMap.fill(with: grassTile) } addChild(tileMap) } ``` ### 3. 카메라 ```swift func setupCamera() { let camera = SKCameraNode() camera.position = player.position self.camera = camera addChild(camera) } override func update(_ currentTime: TimeInterval) { // 카메라가 플레이어 따라가기 camera?.position = player.position } ``` ### 4. 사운드 ```swift // 효과음 let soundAction = SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false) run(soundAction) // 배경음악 let bgMusic = SKAudioNode(fileNamed: "background.mp3") bgMusic.autoplayLooped = true addChild(bgMusic) ``` ## 주의사항 1. **성능 최적화** - `SKTexture` 아틀라스 사용 - 화면 밖 노드 제거 - `physicsBody` 단순화 2. **좌표계** - 원점이 좌하단 (UIKit과 다름) - `anchorPoint` 기본값 (0.5, 0.5) 3. **씬 전환** ```swift let transition = SKTransition.fade(withDuration: 1.0) view?.presentScene(newScene, transition: transition) ``` 4. **SwiftUI 통합** - `SpriteView(scene:)` 사용 - `isPaused`, `debugOptions` 지원 --- # StoreKit 2 AI Reference > 인앱결제 및 구독 구현 가이드. 이 문서를 읽고 StoreKit 2를 구현할 수 있습니다. ## 개요 StoreKit 2는 Swift Concurrency 기반의 현대적인 인앱결제 프레임워크입니다. 구독, 소모성/비소모성 상품, 프로모션 등을 구현할 수 있습니다. ## 필수 Import ```swift import StoreKit ``` ## 핵심 구성요소 ### 1. Product 조회 ```swift // 상품 ID로 조회 let productIDs = ["premium_monthly", "premium_yearly", "remove_ads"] let products = try await Product.products(for: productIDs) for product in products { print("\(product.displayName): \(product.displayPrice)") } ``` ### 2. 구매 처리 ```swift func purchase(_ product: Product) async throws -> Transaction? { let result = try await product.purchase() switch result { case .success(let verification): // 영수증 검증 let transaction = try checkVerified(verification) // 콘텐츠 제공 await deliverProduct(transaction) // 트랜잭션 완료 await transaction.finish() return transaction case .userCancelled: return nil case .pending: // 승인 대기 (가족 공유 등) return nil @unknown default: return nil } } func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .unverified: throw StoreError.verificationFailed case .verified(let safe): return safe } } ``` ### 3. 트랜잭션 리스너 ```swift // 앱 시작 시 호출 func listenForTransactions() -> Task { return Task.detached { for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) await self.deliverProduct(transaction) await transaction.finish() } catch { print("트랜잭션 실패: \(error)") } } } } ``` ## 전체 작동 예제: 구독 앱 ```swift import SwiftUI import StoreKit // MARK: - Store Manager @Observable class StoreManager { var products: [Product] = [] var purchasedProductIDs: Set = [] var isLoading = false private var updateListenerTask: Task? init() { updateListenerTask = listenForTransactions() Task { await loadProducts() await updatePurchasedProducts() } } deinit { updateListenerTask?.cancel() } // MARK: - 상품 로드 func loadProducts() async { isLoading = true do { products = try await Product.products(for: [ "premium_monthly", "premium_yearly" ]) products.sort { $0.price < $1.price } } catch { print("상품 로드 실패: \(error)") } isLoading = false } // MARK: - 구매 상태 확인 func updatePurchasedProducts() async { for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate == nil { purchasedProductIDs.insert(transaction.productID) } else { purchasedProductIDs.remove(transaction.productID) } } } // MARK: - 구매 func purchase(_ product: Product) async throws -> Bool { let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) purchasedProductIDs.insert(transaction.productID) await transaction.finish() return true case .userCancelled, .pending: return false @unknown default: return false } } // MARK: - 구매 복원 func restore() async throws { try await AppStore.sync() await updatePurchasedProducts() } // MARK: - 프리미엄 여부 var isPremium: Bool { !purchasedProductIDs.isEmpty } // MARK: - 트랜잭션 리스너 private func listenForTransactions() -> Task { Task.detached { for await result in Transaction.updates { if case .verified(let transaction) = result { await self.updatePurchasedProducts() await transaction.finish() } } } } private func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .unverified: throw StoreError.verificationFailed case .verified(let safe): return safe } } } enum StoreError: Error { case verificationFailed } // MARK: - Paywall View struct PaywallView: View { @Environment(StoreManager.self) var store @Environment(\.dismiss) var dismiss var body: some View { NavigationStack { VStack(spacing: 24) { // 헤더 VStack(spacing: 8) { Image(systemName: "crown.fill") .font(.system(size: 60)) .foregroundStyle(.yellow) Text("Premium 구독") .font(.largeTitle.bold()) Text("모든 기능을 무제한으로 사용하세요") .foregroundStyle(.secondary) } .padding(.top, 40) Spacer() // 상품 목록 if store.isLoading { ProgressView() } else { VStack(spacing: 12) { ForEach(store.products) { product in ProductCard(product: product) } } .padding(.horizontal) } Spacer() // 복원 버튼 Button("구매 복원") { Task { try? await store.restore() } } .font(.footnote) // 약관 Text("구독은 자동 갱신됩니다. 언제든 취소할 수 있습니다.") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) .padding(.bottom) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("닫기") { dismiss() } } } } } } struct ProductCard: View { let product: Product @Environment(StoreManager.self) var store var body: some View { Button { Task { try? await store.purchase(product) } } label: { HStack { VStack(alignment: .leading) { Text(product.displayName) .font(.headline) Text(product.description) .font(.caption) .foregroundStyle(.secondary) } Spacer() Text(product.displayPrice) .font(.title3.bold()) } .padding() .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) } } // MARK: - App @main struct SubscriptionApp: App { @State var store = StoreManager() var body: some Scene { WindowGroup { ContentView() .environment(store) } } } ``` ## 구독 상태 확인 ```swift // 현재 구독 상태 for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { print("활성 구독: \(transaction.productID)") print("만료일: \(transaction.expirationDate ?? Date())") } } // 특정 상품 구독 여부 func isSubscribed(to productID: String) async -> Bool { for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result, transaction.productID == productID { return true } } return false } ``` ## 구독 관리 열기 ```swift // 구독 관리 시트 (iOS 15+) .manageSubscriptionsSheet(isPresented: $showManageSubscriptions) // 환불 요청 시트 .refundRequestSheet(for: transactionID, isPresented: $showRefund) ``` ## StoreKit Configuration 파일 Xcode에서 테스트용 상품 정의: 1. File > New > File > StoreKit Configuration File 2. 상품 추가 (+ 버튼) 3. Scheme > Edit Scheme > Options > StoreKit Configuration 선택 ```json // 예시 상품 구조 { "identifier": "premium_monthly", "type": "Auto-Renewable Subscription", "displayName": "월간 구독", "description": "매월 자동 갱신", "price": 4.99, "subscriptionGroupID": "premium" } ``` ## 주의사항 1. **실기기 테스트 필수**: 시뮬레이터는 제한적 2. **Sandbox 계정**: 테스트용 Apple ID 필요 3. **영수증 검증**: 서버 사이드 검증 권장 4. **Transaction.finish()**: 반드시 호출 (안 하면 재구매 불가) 5. **currentEntitlements**: 활성 구독/구매만 반환 ## App Store Connect 설정 1. 앱 > 인앱 구입 > 상품 추가 2. 구독 그룹 생성 (구독의 경우) 3. 가격 및 가용성 설정 4. 앱 내 구입 프로모션 (선택) --- # SwiftData AI Reference > 데이터 영속성 프레임워크 구현 가이드. 이 문서를 읽고 SwiftData CRUD를 구현할 수 있습니다. ## 개요 SwiftData는 Swift 매크로를 활용한 현대적인 데이터 영속성 프레임워크입니다. Core Data의 복잡함 없이 @Model 매크로만으로 데이터 모델을 정의할 수 있습니다. ## 필수 Import ```swift import SwiftData import SwiftUI ``` ## 핵심 구성요소 ### 1. @Model 매크로 (데이터 모델) ```swift @Model final class Task { var title: String var isCompleted: Bool var createdAt: Date var dueDate: Date? var priority: Int // 관계 (1:N) @Relationship(deleteRule: .cascade) var subtasks: [Subtask]? // 역관계 @Relationship(inverse: \Category.tasks) var category: Category? init(title: String, isCompleted: Bool = false, priority: Int = 0) { self.title = title self.isCompleted = isCompleted self.createdAt = Date() self.priority = priority } } @Model final class Category { var name: String var color: String var tasks: [Task]? init(name: String, color: String = "blue") { self.name = name self.color = color } } ``` ### 2. ModelContainer 설정 ```swift @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: [Task.self, Category.self]) } } // 또는 커스텀 설정 let container = try ModelContainer( for: Task.self, Category.self, configurations: ModelConfiguration(isStoredInMemoryOnly: false) ) ``` ### 3. @Query 매크로 (데이터 조회) ```swift struct TaskListView: View { // 기본 쿼리 @Query var tasks: [Task] // 정렬 @Query(sort: \Task.createdAt, order: .reverse) var sortedTasks: [Task] // 필터링 + 정렬 @Query( filter: #Predicate { !$0.isCompleted }, sort: [SortDescriptor(\Task.priority, order: .reverse)] ) var pendingTasks: [Task] var body: some View { List(tasks) { task in Text(task.title) } } } ``` ### 4. ModelContext (CRUD 작업) ```swift struct TaskView: View { @Environment(\.modelContext) private var context // CREATE func addTask(title: String) { let task = Task(title: title) context.insert(task) // 자동 저장 (명시적: try? context.save()) } // UPDATE func toggleTask(_ task: Task) { task.isCompleted.toggle() // 변경 자동 추적 } // DELETE func deleteTask(_ task: Task) { context.delete(task) } } ``` ## 전체 작동 예제: 할일 앱 ```swift import SwiftUI import SwiftData // MARK: - Model @Model final class TodoItem { var title: String var isCompleted: Bool var createdAt: Date init(title: String) { self.title = title self.isCompleted = false self.createdAt = Date() } } // MARK: - App @main struct TodoApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: TodoItem.self) } } // MARK: - View struct ContentView: View { @Environment(\.modelContext) private var context @Query(sort: \TodoItem.createdAt, order: .reverse) var todos: [TodoItem] @State private var newTitle = "" var body: some View { NavigationStack { List { // 입력 필드 HStack { TextField("새 할일", text: $newTitle) Button("추가") { addTodo() } .disabled(newTitle.isEmpty) } // 할일 목록 ForEach(todos) { todo in HStack { Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle") .foregroundStyle(todo.isCompleted ? .green : .gray) .onTapGesture { todo.isCompleted.toggle() } Text(todo.title) .strikethrough(todo.isCompleted) } } .onDelete(perform: deleteTodos) } .navigationTitle("할일 목록") } } private func addTodo() { let todo = TodoItem(title: newTitle) context.insert(todo) newTitle = "" } private func deleteTodos(at offsets: IndexSet) { for index in offsets { context.delete(todos[index]) } } } #Preview { ContentView() .modelContainer(for: TodoItem.self, inMemory: true) } ``` ## 고급 쿼리 ### 동적 필터링 ```swift struct FilteredListView: View { @Query var tasks: [Task] init(showCompleted: Bool) { let predicate = #Predicate { task in showCompleted || !task.isCompleted } _tasks = Query(filter: predicate, sort: \Task.createdAt) } var body: some View { List(tasks) { task in Text(task.title) } } } ``` ### 검색 ```swift struct SearchableListView: View { @Query var tasks: [Task] @State private var searchText = "" init(searchText: String) { if searchText.isEmpty { _tasks = Query() } else { let predicate = #Predicate { task in task.title.localizedStandardContains(searchText) } _tasks = Query(filter: predicate) } } } ``` ### FetchDescriptor (코드에서 직접 쿼리) ```swift func fetchPendingTasks(context: ModelContext) throws -> [Task] { let descriptor = FetchDescriptor( predicate: #Predicate { !$0.isCompleted }, sortBy: [SortDescriptor(\Task.priority, order: .reverse)] ) return try context.fetch(descriptor) } // 개수만 조회 func countPendingTasks(context: ModelContext) throws -> Int { let descriptor = FetchDescriptor( predicate: #Predicate { !$0.isCompleted } ) return try context.fetchCount(descriptor) } ``` ## 관계 (Relationships) ### 1:N 관계 ```swift @Model final class Author { var name: String @Relationship(deleteRule: .cascade) // Author 삭제 시 Book도 삭제 var books: [Book]? init(name: String) { self.name = name } } @Model final class Book { var title: String var author: Author? init(title: String, author: Author? = nil) { self.title = title self.author = author } } ``` ### 사용 ```swift let author = Author(name: "홍길동") let book = Book(title: "첫 번째 책", author: author) context.insert(author) context.insert(book) ``` ## 마이그레이션 ```swift // 스키마 버전 관리 enum SchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) static var models: [any PersistentModel.Type] { [Task.self] } } enum SchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) static var models: [any PersistentModel.Type] { [TaskV2.self] // 새 필드 추가된 모델 } } // 마이그레이션 플랜 enum MigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } static var stages: [MigrationStage] { [migrateV1toV2] } static let migrateV1toV2 = MigrationStage.lightweight( fromVersion: SchemaV1.self, toVersion: SchemaV2.self ) } ``` ## @Transient (저장 제외) ```swift @Model final class User { var name: String var email: String @Transient // 저장되지 않음 var isLoggedIn: Bool = false } ``` ## 주의사항 1. **@Model은 class만**: struct 불가 2. **final 권장**: 상속 시 문제 발생 가능 3. **자동 저장**: 기본적으로 자동 저장 (명시적 save() 호출 가능) 4. **메인 스레드**: UI 작업은 @MainActor 컨텍스트에서 5. **Preview**: `inMemory: true` 사용 권장 ## CloudKit 동기화 ```swift let config = ModelConfiguration( cloudKitDatabase: .private("iCloud.com.myapp") ) let container = try ModelContainer(for: Task.self, configurations: config) ``` --- # SwiftUI + Observation AI Reference > @Observable 상태 관리 패턴 가이드. 이 문서를 읽고 현대적인 SwiftUI 앱을 구현할 수 있습니다. ## 개요 iOS 17부터 `@Observable` 매크로를 사용해 상태 관리를 단순화할 수 있습니다. 기존 `ObservableObject` + `@Published` 조합을 대체합니다. ## 필수 Import ```swift import SwiftUI import Observation // @Observable 사용 시 ``` ## @Observable vs ObservableObject ### 이전 방식 (ObservableObject) ```swift // ❌ 구식 패턴 class OldViewModel: ObservableObject { @Published var count = 0 @Published var name = "" } struct OldView: View { @StateObject var viewModel = OldViewModel() // 또는 @ObservedObject var body: some View { Text("\(viewModel.count)") } } ``` ### 현재 권장 방식 (@Observable) ```swift // ✅ iOS 17+ 권장 패턴 @Observable class ViewModel { var count = 0 var name = "" } struct ModernView: View { @State var viewModel = ViewModel() // @State 사용! var body: some View { Text("\(viewModel.count)") } } ``` ## 핵심 차이점 | 항목 | ObservableObject | @Observable | |------|------------------|-------------| | 프로퍼티 래퍼 | @Published 필요 | 불필요 (자동) | | 뷰 연결 | @StateObject/@ObservedObject | @State | | 환경 주입 | @EnvironmentObject | @Environment | | 변경 추적 | 모든 @Published 변경 시 뷰 갱신 | 사용된 프로퍼티만 추적 | ## 전체 작동 예제 ```swift import SwiftUI import Observation // MARK: - Model struct Task: Identifiable { let id = UUID() var title: String var isCompleted: Bool } // MARK: - ViewModel @Observable class TaskViewModel { var tasks: [Task] = [] var newTaskTitle = "" var pendingCount: Int { tasks.filter { !$0.isCompleted }.count } func addTask() { guard !newTaskTitle.isEmpty else { return } tasks.append(Task(title: newTaskTitle, isCompleted: false)) newTaskTitle = "" } func toggleTask(_ task: Task) { if let index = tasks.firstIndex(where: { $0.id == task.id }) { tasks[index].isCompleted.toggle() } } func deleteTask(_ task: Task) { tasks.removeAll { $0.id == task.id } } } // MARK: - View struct ContentView: View { @State private var viewModel = TaskViewModel() var body: some View { NavigationStack { List { Section { HStack { TextField("새 할일", text: $viewModel.newTaskTitle) Button("추가", action: viewModel.addTask) .disabled(viewModel.newTaskTitle.isEmpty) } } Section("할일 (\(viewModel.pendingCount)개 남음)") { ForEach(viewModel.tasks) { task in TaskRow(task: task, viewModel: viewModel) } } } .navigationTitle("Tasks") } } } struct TaskRow: View { let task: Task let viewModel: TaskViewModel // 참조 전달 (Bindable 불필요) var body: some View { HStack { Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .foregroundStyle(task.isCompleted ? .green : .gray) .onTapGesture { viewModel.toggleTask(task) } Text(task.title) .strikethrough(task.isCompleted) Spacer() Button(role: .destructive) { viewModel.deleteTask(task) } label: { Image(systemName: "trash") } } } } #Preview { ContentView() } ``` ## @Bindable (양방향 바인딩) ```swift @Observable class Settings { var username = "" var notificationsEnabled = true } struct SettingsView: View { @Bindable var settings: Settings // 바인딩 가능하게 래핑 var body: some View { Form { TextField("사용자명", text: $settings.username) Toggle("알림", isOn: $settings.notificationsEnabled) } } } // 사용 struct ParentView: View { @State var settings = Settings() var body: some View { SettingsView(settings: settings) } } ``` ## @Environment로 주입 ```swift // 환경에 등록 @main struct MyApp: App { @State var appState = AppState() var body: some Scene { WindowGroup { ContentView() .environment(appState) // EnvironmentObject가 아님! } } } // 환경에서 읽기 struct SomeView: View { @Environment(AppState.self) var appState // 타입으로 접근 var body: some View { Text(appState.username) } } ``` ## 네트워크 로딩 패턴 ```swift @Observable class DataViewModel { var items: [Item] = [] var isLoading = false var errorMessage: String? func loadData() async { isLoading = true errorMessage = nil do { items = try await APIService.fetchItems() } catch { errorMessage = error.localizedDescription } isLoading = false } } struct DataView: View { @State var viewModel = DataViewModel() var body: some View { Group { if viewModel.isLoading { ProgressView() } else if let error = viewModel.errorMessage { Text("오류: \(error)") } else { List(viewModel.items) { item in Text(item.name) } } } .task { await viewModel.loadData() } } } ``` ## @ObservationIgnored ```swift @Observable class ViewModel { var visibleProperty = "" // 추적됨 @ObservationIgnored var ignoredProperty = "" // 추적 안 됨 (변경해도 뷰 갱신 X) } ``` ## 주의사항 1. **iOS 17+ 전용**: 이전 버전은 ObservableObject 사용 2. **class만 가능**: struct에 @Observable 불가 3. **@State 사용**: @StateObject 아님 4. **성능 향상**: 사용된 프로퍼티만 추적하므로 불필요한 뷰 갱신 감소 5. **Sendable**: @Observable 클래스는 기본적으로 Sendable 아님 ## 마이그레이션 가이드 ```swift // Before class ViewModel: ObservableObject { @Published var data: [Item] = [] } struct MyView: View { @StateObject var viewModel = ViewModel() } // After @Observable class ViewModel { var data: [Item] = [] // @Published 제거 } struct MyView: View { @State var viewModel = ViewModel() // @StateObject → @State } ``` --- # SwiftUI AI Reference > 선언적 UI 프레임워크 핵심 가이드. 이 문서를 읽고 SwiftUI 코드를 생성할 수 있습니다. ## 개요 SwiftUI는 Apple의 선언적 UI 프레임워크입니다. 상태 기반으로 UI가 자동 업데이트되며, 모든 Apple 플랫폼에서 동작합니다. ## 필수 Import ```swift import SwiftUI ``` ## 핵심 구성요소 ### 1. View 기본 구조 ```swift struct ContentView: View { var body: some View { VStack(spacing: 16) { Text("Hello, World!") .font(.title) .foregroundStyle(.primary) Button("탭하기") { print("탭됨") } .buttonStyle(.borderedProminent) } .padding() } } ``` ### 2. 상태 관리 (@State, @Binding) ```swift struct CounterView: View { @State private var count = 0 var body: some View { VStack { Text("\(count)") .font(.largeTitle) HStack { Button("-") { count -= 1 } Button("+") { count += 1 } } // 자식에게 바인딩 전달 StepperView(value: $count) } } } struct StepperView: View { @Binding var value: Int var body: some View { Stepper("값: \(value)", value: $value) } } ``` ### 3. @Observable (iOS 17+) ```swift @Observable class UserViewModel { var name = "" var email = "" var isLoggedIn = false func login() async { // 로그인 로직 isLoggedIn = true } } struct ProfileView: View { @State private var viewModel = UserViewModel() var body: some View { Form { TextField("이름", text: $viewModel.name) TextField("이메일", text: $viewModel.email) Button("로그인") { Task { await viewModel.login() } } .disabled(viewModel.name.isEmpty) } } } ``` ### 4. 네비게이션 (iOS 16+) ```swift struct MainView: View { @State private var path = NavigationPath() var body: some View { NavigationStack(path: $path) { List { NavigationLink("상세 보기", value: "detail") NavigationLink(value: 42) { Text("숫자로 이동") } } .navigationTitle("메인") .navigationDestination(for: String.self) { value in Text("문자열: \(value)") } .navigationDestination(for: Int.self) { number in Text("숫자: \(number)") } } } } ``` ## 전체 작동 예제 ```swift import SwiftUI // MARK: - 모델 struct Task: Identifiable { let id = UUID() var title: String var isCompleted: Bool } // MARK: - ViewModel @Observable class TaskListViewModel { var tasks: [Task] = [] var newTaskTitle = "" var incompleteTasks: [Task] { tasks.filter { !$0.isCompleted } } func addTask() { guard !newTaskTitle.isEmpty else { return } tasks.append(Task(title: newTaskTitle, isCompleted: false)) newTaskTitle = "" } func toggle(_ task: Task) { if let index = tasks.firstIndex(where: { $0.id == task.id }) { tasks[index].isCompleted.toggle() } } func delete(at offsets: IndexSet) { tasks.remove(atOffsets: offsets) } } // MARK: - Views struct TaskListView: View { @State private var viewModel = TaskListViewModel() @State private var showingAddSheet = false var body: some View { NavigationStack { List { ForEach(viewModel.tasks) { task in TaskRowView(task: task) { viewModel.toggle(task) } } .onDelete(perform: viewModel.delete) } .navigationTitle("할 일 (\(viewModel.incompleteTasks.count))") .toolbar { ToolbarItem(placement: .primaryAction) { Button("추가", systemImage: "plus") { showingAddSheet = true } } } .sheet(isPresented: $showingAddSheet) { AddTaskSheet(title: $viewModel.newTaskTitle) { viewModel.addTask() showingAddSheet = false } } .overlay { if viewModel.tasks.isEmpty { ContentUnavailableView( "할 일이 없습니다", systemImage: "checklist", description: Text("+ 버튼을 눌러 추가하세요") ) } } } } } struct TaskRowView: View { let task: Task let onToggle: () -> Void var body: some View { HStack { Button(action: onToggle) { Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .foregroundStyle(task.isCompleted ? .green : .secondary) } .buttonStyle(.plain) Text(task.title) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) } } } struct AddTaskSheet: View { @Binding var title: String let onAdd: () -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { TextField("할 일 제목", text: $title) } .navigationTitle("새 할 일") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("추가") { onAdd() } .disabled(title.isEmpty) } } } .presentationDetents([.medium]) } } ``` ## 고급 패턴 ### 1. 커스텀 ViewModifier ```swift struct CardStyle: ViewModifier { func body(content: Content) -> some View { content .padding() .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 2) } } extension View { func cardStyle() -> some View { modifier(CardStyle()) } } // 사용 Text("카드").cardStyle() ``` ### 2. 애니메이션 ```swift struct AnimatedView: View { @State private var isExpanded = false var body: some View { VStack { RoundedRectangle(cornerRadius: 12) .fill(.blue) .frame(width: isExpanded ? 200 : 100, height: isExpanded ? 200 : 100) Button("토글") { withAnimation(.spring(duration: 0.5, bounce: 0.3)) { isExpanded.toggle() } } } } } ``` ### 3. 제스처 ```swift struct GestureView: View { @State private var offset = CGSize.zero @State private var scale: CGFloat = 1.0 var body: some View { Image(systemName: "star.fill") .font(.system(size: 50)) .offset(offset) .scaleEffect(scale) .gesture( DragGesture() .onChanged { value in offset = value.translation } .onEnded { _ in withAnimation { offset = .zero } } ) .gesture( MagnificationGesture() .onChanged { value in scale = value } ) } } ``` ### 4. 환경 값 ```swift // 커스텀 환경 키 struct ThemeKey: EnvironmentKey { static let defaultValue: Theme = .light } extension EnvironmentValues { var theme: Theme { get { self[ThemeKey.self] } set { self[ThemeKey.self] = newValue } } } // 사용 struct App: View { var body: some View { ContentView() .environment(\.theme, .dark) } } struct ChildView: View { @Environment(\.theme) var theme } ``` ## 주의사항 1. **상태 관리** - `@State`: View 내부 단순 값 - `@Binding`: 부모로부터 받은 값 - `@Observable`: 복잡한 객체 (iOS 17+) - `@Environment`: 환경 값 주입 2. **성능** - `body`는 자주 호출됨 → 가볍게 유지 - 무거운 연산은 ViewModel로 분리 - `id()` 수정자로 강제 재생성 3. **레이아웃** - VStack/HStack/ZStack 조합 - `frame()`, `padding()` 순서 중요 - `GeometryReader`는 꼭 필요할 때만 4. **iOS 17+ 권장 API** - `@Observable` > `ObservableObject` - `NavigationStack` > `NavigationView` - `ContentUnavailableView` 활용 --- # TipKit AI Reference > 기능 팁 및 온보딩 가이드. 이 문서를 읽고 TipKit 코드를 생성할 수 있습니다. ## 개요 TipKit은 앱의 기능을 사용자에게 적절한 시점에 안내하는 프레임워크입니다. 팁 표시 조건, 빈도, 우선순위를 시스템이 자동으로 관리합니다. ## 필수 Import ```swift import TipKit ``` ## 앱 설정 ```swift @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() .task { try? Tips.configure([ .displayFrequency(.immediate), // 또는 .daily, .weekly, .monthly .datastoreLocation(.applicationDefault) ]) } } } } ``` ## 핵심 구성요소 ### 1. 기본 팁 정의 ```swift struct FavoriteTip: Tip { var title: Text { Text("즐겨찾기 추가") } var message: Text? { Text("하트를 탭해서 즐겨찾기에 추가하세요") } var image: Image? { Image(systemName: "heart") } } ``` ### 2. 팁 표시 ```swift struct ContentView: View { let favoriteTip = FavoriteTip() var body: some View { VStack { // 인라인 팁 TipView(favoriteTip) Button { // 액션 } label: { Image(systemName: "heart") } // 팝오버 팁 .popoverTip(favoriteTip) } } } ``` ### 3. 팁 무효화 ```swift struct FavoriteTip: Tip { // ... } // 사용자가 기능 사용 시 팁 닫기 Button("즐겨찾기") { FavoriteTip().invalidate(reason: .actionPerformed) } // 무효화 이유 // .actionPerformed: 사용자가 기능 사용 // .displayCountExceeded: 표시 횟수 초과 // .tipClosed: 사용자가 팁 닫음 ``` ## 전체 작동 예제 ```swift import SwiftUI import TipKit // MARK: - Tips 정의 struct SearchTip: Tip { var title: Text { Text("검색 기능") } var message: Text? { Text("원하는 항목을 빠르게 찾아보세요") } var image: Image? { Image(systemName: "magnifyingglass") } } struct FilterTip: Tip { // 파라미터로 조건 설정 @Parameter static var hasUsedSearch: Bool = false var title: Text { Text("필터 기능") } var message: Text? { Text("카테고리별로 필터링할 수 있어요") } var image: Image? { Image(systemName: "line.3.horizontal.decrease.circle") } // 표시 조건: 검색을 사용한 후에만 var rules: [Rule] { #Rule(Self.$hasUsedSearch) { $0 == true } } } struct ShareTip: Tip { // 이벤트 기반 조건 static let itemViewed = Event(id: "itemViewed") var title: Text { Text("공유하기") } var message: Text? { Text("친구에게 공유해보세요") } var image: Image? { Image(systemName: "square.and.arrow.up") } // 3번 이상 아이템을 본 후에만 var rules: [Rule] { #Rule(Self.itemViewed) { $0.donations.count >= 3 } } // 표시 옵션 var options: [TipOption] { MaxDisplayCount(3) // 최대 3번만 표시 } } struct ProTip: Tip { var title: Text { Text("Pro 기능 ✨") } var message: Text? { Text("더 많은 기능을 사용해보세요") } // 액션 버튼 var actions: [Action] { Action(id: "learn-more", title: "자세히 보기") Action(id: "dismiss", title: "나중에", role: .cancel) } } // MARK: - App @main struct TipDemoApp: App { var body: some Scene { WindowGroup { TipDemoView() .task { try? Tips.configure([ .displayFrequency(.immediate) ]) } } } } // MARK: - Views struct TipDemoView: View { let searchTip = SearchTip() let filterTip = FilterTip() let shareTip = ShareTip() let proTip = ProTip() @State private var searchText = "" @State private var items = ["사과", "바나나", "오렌지", "포도", "수박"] var body: some View { NavigationStack { VStack(spacing: 0) { // 인라인 팁 (상단) TipView(proTip) { action in if action.id == "learn-more" { // Pro 페이지로 이동 } } .tipBackground(Color.blue.opacity(0.1)) .padding() List { ForEach(filteredItems, id: \.self) { item in Text(item) .onTapGesture { // 아이템 조회 이벤트 기록 ShareTip.itemViewed.sendDonation() } } } } .navigationTitle("TipKit 데모") .searchable(text: $searchText, prompt: "검색") .onChange(of: searchText) { _, newValue in if !newValue.isEmpty { // 검색 사용 기록 FilterTip.hasUsedSearch = true searchTip.invalidate(reason: .actionPerformed) } } .toolbar { // 검색 버튼 + 팝오버 팁 Button { // 검색 포커스 } label: { Image(systemName: "magnifyingglass") } .popoverTip(searchTip) // 필터 버튼 + 팝오버 팁 Button { // 필터 시트 } label: { Image(systemName: "line.3.horizontal.decrease.circle") } .popoverTip(filterTip) // 공유 버튼 + 팝오버 팁 Button { shareTip.invalidate(reason: .actionPerformed) } label: { Image(systemName: "square.and.arrow.up") } .popoverTip(shareTip) } } } var filteredItems: [String] { if searchText.isEmpty { return items } return items.filter { $0.contains(searchText) } } } ``` ## 고급 패턴 ### 1. 조건부 규칙 조합 ```swift struct AdvancedTip: Tip { @Parameter static var isLoggedIn: Bool = false @Parameter static var hasCompletedOnboarding: Bool = false static let featureUsed = Event(id: "featureUsed") var title: Text { Text("고급 기능") } var rules: [Rule] { // 로그인 AND 온보딩 완료 AND 기능 2번 이상 사용 #Rule(Self.$isLoggedIn) { $0 == true } #Rule(Self.$hasCompletedOnboarding) { $0 == true } #Rule(Self.featureUsed) { $0.donations.count >= 2 } } } ``` ### 2. 날짜 기반 조건 ```swift struct DailyTip: Tip { static let appOpened = Event(id: "appOpened") var title: Text { Text("오늘의 팁") } var rules: [Rule] { // 오늘 앱을 열었을 때만 #Rule(Self.appOpened) { $0.donations.filter { Calendar.current.isDateInToday($0.date) }.count >= 1 } } } ``` ### 3. 커스텀 스타일 ```swift struct StyledTipView: View { let tip: some Tip var body: some View { TipView(tip) .tipBackground( LinearGradient( colors: [.blue.opacity(0.2), .purple.opacity(0.2)], startPoint: .leading, endPoint: .trailing ) ) .tipImageSize(CGSize(width: 40, height: 40)) .tipCornerRadius(16) } } ``` ### 4. 디버깅 및 테스트 ```swift // 모든 팁 리셋 (개발용) try? Tips.resetDatastore() // 특정 팁 표시 강제 Tips.showAllTipsForTesting() // 팁 숨기기 Tips.hideAllTipsForTesting() // 팁 상태 확인 if myTip.shouldDisplay { // 팁이 표시되어야 함 } ``` ### 5. 팁 그룹 우선순위 ```swift struct HighPriorityTip: Tip { var title: Text { Text("중요한 팁") } var options: [TipOption] { IgnoresDisplayFrequency(true) // 빈도 제한 무시 } } struct LowPriorityTip: Tip { var title: Text { Text("일반 팁") } var options: [TipOption] { MaxDisplayCount(1) // 1번만 표시 } } ``` ## 주의사항 1. **Tips.configure() 필수** - 앱 시작 시 한 번 호출 - 미호출 시 팁이 표시되지 않음 2. **displayFrequency 설정** - `.immediate`: 조건 충족 시 즉시 - `.daily`: 하루 1회 - `.weekly`: 주 1회 - `.monthly`: 월 1회 3. **데이터 저장 위치** ```swift .datastoreLocation(.applicationDefault) // 기본 .datastoreLocation(.groupContainer(identifier: "group.com.app")) // App Group ``` 4. **iOS 17+ 전용** - iOS 16 이하는 사용 불가 - 조건부 import 또는 `@available` 사용 --- # Vision AI Reference > 이미지 분석 및 컴퓨터 비전 가이드. 이 문서를 읽고 Vision 코드를 생성할 수 있습니다. ## 개요 Vision은 이미지와 비디오 분석을 위한 프레임워크입니다. 얼굴 인식, 텍스트 인식(OCR), 바코드 스캔, 이미지 분류 등을 지원합니다. ## 필수 Import ```swift import Vision import UIKit // 또는 SwiftUI ``` ## 핵심 구성요소 ### 1. Vision 요청 구조 ```swift // 1. 요청 생성 let request = VNRecognizeTextRequest { request, error in guard let observations = request.results as? [VNRecognizedTextObservation] else { return } // 결과 처리 } // 2. 핸들러 생성 let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) // 3. 요청 실행 try handler.perform([request]) ``` ### 2. 주요 요청 타입 ```swift // 텍스트 인식 (OCR) let textRequest = VNRecognizeTextRequest() // 얼굴 감지 let faceRequest = VNDetectFaceRectanglesRequest() // 얼굴 랜드마크 (눈, 코, 입 위치) let landmarkRequest = VNDetectFaceLandmarksRequest() // 바코드/QR 감지 let barcodeRequest = VNDetectBarcodesRequest() // 이미지 분류 let classifyRequest = VNClassifyImageRequest() // 객체 감지 let objectRequest = VNDetectRectanglesRequest() // 사람 감지 let humanRequest = VNDetectHumanRectanglesRequest() ``` ## 전체 작동 예제 ### 텍스트 인식 (OCR) ```swift import SwiftUI import Vision import PhotosUI @Observable class TextRecognizer { var recognizedText = "" var isProcessing = false func recognizeText(from image: UIImage) async { guard let cgImage = image.cgImage else { return } isProcessing = true defer { isProcessing = false } let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate // .fast도 가능 request.recognitionLanguages = ["ko-KR", "en-US"] request.usesLanguageCorrection = true let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) guard let observations = request.results else { return } let text = observations.compactMap { observation in observation.topCandidates(1).first?.string }.joined(separator: "\n") await MainActor.run { recognizedText = text } } catch { print("OCR 실패: \(error)") } } } struct TextScannerView: View { @State private var recognizer = TextRecognizer() @State private var selectedItem: PhotosPickerItem? @State private var selectedImage: UIImage? var body: some View { NavigationStack { VStack(spacing: 20) { // 이미지 선택 PhotosPicker(selection: $selectedItem, matching: .images) { if let image = selectedImage { Image(uiImage: image) .resizable() .scaledToFit() .frame(maxHeight: 300) } else { ContentUnavailableView("이미지 선택", systemImage: "photo", description: Text("사진을 선택하세요")) } } // 결과 if recognizer.isProcessing { ProgressView("텍스트 인식 중...") } else if !recognizer.recognizedText.isEmpty { ScrollView { Text(recognizer.recognizedText) .textSelection(.enabled) .padding() } .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } } .padding() .navigationTitle("텍스트 스캐너") .onChange(of: selectedItem) { _, newItem in Task { if let data = try? await newItem?.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedImage = image await recognizer.recognizeText(from: image) } } } } } } ``` ### 바코드/QR 스캐너 ```swift import SwiftUI import Vision import AVFoundation @Observable class BarcodeScanner: NSObject { var scannedCode: String? var isScanning = false private var captureSession: AVCaptureSession? func scan(from image: UIImage) async { guard let cgImage = image.cgImage else { return } let request = VNDetectBarcodesRequest() request.symbologies = [.qr, .ean13, .code128] // 지원할 바코드 타입 let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) if let observation = request.results?.first { await MainActor.run { scannedCode = observation.payloadStringValue } } } catch { print("바코드 스캔 실패: \(error)") } } } // 실시간 카메라 스캔 class CameraBarcodeScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { var onCodeDetected: ((String) -> Void)? private let captureSession = AVCaptureSession() private let videoOutput = AVCaptureVideoDataOutput() private let queue = DispatchQueue(label: "barcode.scanner") func startScanning() { guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device) else { return } captureSession.addInput(input) videoOutput.setSampleBufferDelegate(self, queue: queue) captureSession.addOutput(videoOutput) captureSession.startRunning() } func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } let request = VNDetectBarcodesRequest { [weak self] request, error in if let result = request.results?.first as? VNBarcodeObservation, let payload = result.payloadStringValue { DispatchQueue.main.async { self?.onCodeDetected?(payload) } } } let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:]) try? handler.perform([request]) } } ``` ### 얼굴 감지 ```swift @Observable class FaceDetector { var faces: [VNFaceObservation] = [] func detectFaces(in image: UIImage) async { guard let cgImage = image.cgImage else { return } let request = VNDetectFaceLandmarksRequest() let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) await MainActor.run { faces = request.results ?? [] } } catch { print("얼굴 감지 실패: \(error)") } } } // 얼굴 위치를 이미지 좌표로 변환 extension VNFaceObservation { func boundingBox(in imageSize: CGSize) -> CGRect { let box = self.boundingBox return CGRect( x: box.minX * imageSize.width, y: (1 - box.maxY) * imageSize.height, // Vision은 좌하단 원점 width: box.width * imageSize.width, height: box.height * imageSize.height ) } } ``` ## 고급 패턴 ### 1. 문서 스캔 (iOS 13+) ```swift import VisionKit struct DocumentScannerView: UIViewControllerRepresentable { @Binding var scannedImages: [UIImage] func makeUIViewController(context: Context) -> VNDocumentCameraViewController { let scanner = VNDocumentCameraViewController() scanner.delegate = context.coordinator return scanner } func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate { let parent: DocumentScannerView init(_ parent: DocumentScannerView) { self.parent = parent } func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) { var images: [UIImage] = [] for i in 0.. Float? { guard let cgImage1 = image1.cgImage, let cgImage2 = image2.cgImage else { return nil } let request = VNGenerateImageFeaturePrintRequest() let handler1 = VNImageRequestHandler(cgImage: cgImage1, options: [:]) let handler2 = VNImageRequestHandler(cgImage: cgImage2, options: [:]) do { try handler1.perform([request]) guard let print1 = request.results?.first as? VNFeaturePrintObservation else { return nil } let request2 = VNGenerateImageFeaturePrintRequest() try handler2.perform([request2]) guard let print2 = request2.results?.first as? VNFeaturePrintObservation else { return nil } var distance: Float = 0 try print1.computeDistance(&distance, to: print2) return distance // 낮을수록 유사 } catch { return nil } } ``` ### 3. 실시간 물체 추적 ```swift class ObjectTracker { private var sequenceHandler = VNSequenceRequestHandler() private var trackingRequest: VNTrackObjectRequest? func startTracking(observation: VNDetectedObjectObservation) { trackingRequest = VNTrackObjectRequest(detectedObjectObservation: observation) { [weak self] request, error in guard let result = request.results?.first as? VNDetectedObjectObservation else { return } // 추적된 위치 업데이트 } trackingRequest?.trackingLevel = .accurate } func track(in pixelBuffer: CVPixelBuffer) { guard let request = trackingRequest else { return } try? sequenceHandler.perform([request], on: pixelBuffer) } } ``` ## 주의사항 1. **좌표계 변환** - Vision: 좌하단 원점 (0,0), 정규화 좌표 (0~1) - UIKit: 좌상단 원점 - `boundingBox`를 이미지 크기에 맞게 변환 필요 2. **비동기 처리** - 이미지 분석은 무거움 → 백그라운드에서 실행 - UI 업데이트는 메인 스레드에서 3. **메모리 관리** - 큰 이미지는 리사이즈 후 처리 - 연속 프레임 처리 시 `VNSequenceRequestHandler` 사용 4. **정확도 vs 속도** ```swift // 텍스트 인식 request.recognitionLevel = .accurate // 정확 (느림) request.recognitionLevel = .fast // 빠름 (덜 정확) ``` --- # Visual Intelligence AI Reference > Apple Intelligence 시각 분석 가이드. 이 문서를 읽고 Visual Intelligence 코드를 생성할 수 있습니다. ## 개요 Visual Intelligence는 iOS 18.1+에서 제공하는 Apple Intelligence 기능으로, 카메라 컨트롤 버튼을 통해 실세계 객체를 인식하고 정보를 제공합니다. 앱에서 직접 호출하는 API는 제한적이며, 주로 시스템 기능으로 동작합니다. ## 필수 Import ```swift import Vision // 이미지 분석 import VisionKit // 라이브 텍스트, 시각 조회 import UIKit ``` ## 핵심 기능 Visual Intelligence는 다음을 포함합니다: - **시각 조회 (Visual Look Up)**: 이미지 내 객체 정보 조회 - **라이브 텍스트 (Live Text)**: 실시간 텍스트 인식 - **피사체 분리 (Subject Lifting)**: 배경에서 피사체 추출 ## 핵심 구성요소 ### 1. ImageAnalyzer (VisionKit) ```swift import VisionKit // 이미지 분석기 let analyzer = ImageAnalyzer() let configuration = ImageAnalyzer.Configuration([.text, .visualLookUp]) // 분석 실행 func analyzeImage(_ image: UIImage) async throws -> ImageAnalysis { try await analyzer.analyze(image, configuration: configuration) } ``` ### 2. ImageAnalysisInteraction (시각 조회) ```swift import VisionKit // UIImageView에 상호작용 추가 let interaction = ImageAnalysisInteraction() imageView.addInteraction(interaction) // 분석 결과 설정 interaction.analysis = analysisResult interaction.preferredInteractionTypes = [.visualLookUp, .textSelection] ``` ### 3. 피사체 분리 ```swift // iOS 16+ func extractSubject(from image: UIImage) async throws -> UIImage? { guard let cgImage = image.cgImage else { return nil } let analysis = try await analyzer.analyze(image, configuration: configuration) // 피사체 이미지 추출 guard let subject = try await analysis.subjects.first?.image else { return nil } return UIImage(cgImage: subject) } ``` ## 전체 작동 예제 ```swift import SwiftUI import VisionKit import PhotosUI // MARK: - Visual Intelligence Manager @Observable class VisualIntelligenceManager { var selectedImage: UIImage? var analysis: ImageAnalysis? var isAnalyzing = false var errorMessage: String? var extractedSubject: UIImage? var recognizedText: String = "" var visualLookUpAvailable = false private let analyzer = ImageAnalyzer() var isSupported: Bool { ImageAnalyzer.isSupported } func analyze(_ image: UIImage) async { guard ImageAnalyzer.isSupported else { errorMessage = "이 기기에서는 이미지 분석을 사용할 수 없습니다" return } isAnalyzing = true errorMessage = nil extractedSubject = nil recognizedText = "" do { let configuration = ImageAnalyzer.Configuration([.text, .visualLookUp]) let result = try await analyzer.analyze(image, configuration: configuration) analysis = result // 텍스트 추출 recognizedText = result.transcript // 시각 조회 가능 여부 visualLookUpAvailable = !result.subjects.isEmpty } catch { errorMessage = "분석 실패: \(error.localizedDescription)" } isAnalyzing = false } func extractSubject() async { guard let image = selectedImage, let analysis = analysis else { return } do { if let subject = analysis.subjects.first { let subjectImage = try await subject.image extractedSubject = UIImage(cgImage: subjectImage) } } catch { errorMessage = "피사체 추출 실패: \(error.localizedDescription)" } } } // MARK: - Image Analysis View (UIKit Wrapper) struct ImageAnalysisView: UIViewRepresentable { let image: UIImage let analysis: ImageAnalysis? func makeUIView(context: Context) -> UIImageView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFit imageView.isUserInteractionEnabled = true let interaction = ImageAnalysisInteraction() interaction.preferredInteractionTypes = [.visualLookUp, .textSelection] imageView.addInteraction(interaction) context.coordinator.interaction = interaction return imageView } func updateUIView(_ uiView: UIImageView, context: Context) { uiView.image = image context.coordinator.interaction?.analysis = analysis } func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator { var interaction: ImageAnalysisInteraction? } } // MARK: - Main View struct VisualIntelligenceView: View { @State private var manager = VisualIntelligenceManager() @State private var selectedItem: PhotosPickerItem? @State private var showSubjectSheet = false var body: some View { NavigationStack { ScrollView { VStack(spacing: 20) { // 지원 여부 if !manager.isSupported { ContentUnavailableView( "지원되지 않는 기기", systemImage: "eye.slash", description: Text("이 기기에서는 Visual Intelligence를 사용할 수 없습니다") ) } // 이미지 선택 PhotosPicker(selection: $selectedItem, matching: .images) { if let image = manager.selectedImage { Image(uiImage: image) .resizable() .scaledToFit() .frame(maxHeight: 300) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { RoundedRectangle(cornerRadius: 12) .fill(.quaternary) .frame(height: 200) .overlay { VStack(spacing: 8) { Image(systemName: "photo.badge.plus") .font(.largeTitle) Text("이미지 선택") } .foregroundStyle(.secondary) } } } .padding(.horizontal) // 분석 중 if manager.isAnalyzing { ProgressView("분석 중...") } // 에러 if let error = manager.errorMessage { Label(error, systemImage: "exclamationmark.triangle") .foregroundStyle(.red) .padding() } // 분석 결과 if let image = manager.selectedImage, manager.analysis != nil { VStack(alignment: .leading, spacing: 16) { // 인터랙티브 이미지 (시각 조회 가능) Text("이미지를 탭하여 시각 조회") .font(.caption) .foregroundStyle(.secondary) ImageAnalysisView( image: image, analysis: manager.analysis ) .frame(height: 300) .clipShape(RoundedRectangle(cornerRadius: 12)) // 인식된 텍스트 if !manager.recognizedText.isEmpty { GroupBox("인식된 텍스트") { Text(manager.recognizedText) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } // 피사체 분리 if manager.visualLookUpAvailable { Button { Task { await manager.extractSubject() showSubjectSheet = true } } label: { Label("피사체 분리", systemImage: "person.crop.rectangle") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } } .padding(.horizontal) } } } .navigationTitle("Visual Intelligence") .onChange(of: selectedItem) { _, newItem in Task { if let data = try? await newItem?.loadTransferable(type: Data.self), let image = UIImage(data: data) { manager.selectedImage = image await manager.analyze(image) } } } .sheet(isPresented: $showSubjectSheet) { if let subject = manager.extractedSubject { NavigationStack { VStack { Image(uiImage: subject) .resizable() .scaledToFit() .padding() ShareLink( item: Image(uiImage: subject), preview: SharePreview("추출된 피사체", image: Image(uiImage: subject)) ) { Label("공유", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .padding() } .navigationTitle("피사체") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("닫기") { showSubjectSheet = false } } } } .presentationDetents([.medium]) } } } } } #Preview { VisualIntelligenceView() } ``` ## 고급 패턴 ### 1. 라이브 텍스트 (DataScannerViewController) ```swift import VisionKit struct LiveTextScanner: UIViewControllerRepresentable { @Binding var recognizedText: String @Binding var isPresented: Bool static var isSupported: Bool { DataScannerViewController.isSupported } func makeUIViewController(context: Context) -> DataScannerViewController { let scanner = DataScannerViewController( recognizedDataTypes: [.text()], qualityLevel: .balanced, recognizesMultipleItems: true, isHighFrameRateTrackingEnabled: false, isHighlightingEnabled: true ) scanner.delegate = context.coordinator return scanner } func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) { if isPresented { try? uiViewController.startScanning() } else { uiViewController.stopScanning() } } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, DataScannerViewControllerDelegate { let parent: LiveTextScanner init(_ parent: LiveTextScanner) { self.parent = parent } func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { switch item { case .text(let text): parent.recognizedText = text.transcript default: break } } } } ``` ### 2. VNRecognizeTextRequest (Vision) ```swift import Vision func recognizeText(in image: UIImage) async throws -> String { guard let cgImage = image.cgImage else { throw NSError(domain: "ImageError", code: -1) } return try await withCheckedThrowingContinuation { continuation in let request = VNRecognizeTextRequest { request, error in if let error = error { continuation.resume(throwing: error) return } let observations = request.results as? [VNRecognizedTextObservation] ?? [] let text = observations .compactMap { $0.topCandidates(1).first?.string } .joined(separator: "\n") continuation.resume(returning: text) } request.recognitionLevel = .accurate request.recognitionLanguages = ["ko-KR", "en-US"] let handler = VNImageRequestHandler(cgImage: cgImage) do { try handler.perform([request]) } catch { continuation.resume(throwing: error) } } } ``` ### 3. 객체 분류 (VNClassifyImageRequest) ```swift import Vision func classifyImage(_ image: UIImage) async throws -> [String] { guard let cgImage = image.cgImage else { return [] } return try await withCheckedThrowingContinuation { continuation in let request = VNClassifyImageRequest { request, error in if let error = error { continuation.resume(throwing: error) return } let observations = request.results as? [VNClassificationObservation] ?? [] let labels = observations .filter { $0.confidence > 0.5 } .prefix(5) .map { $0.identifier } continuation.resume(returning: Array(labels)) } let handler = VNImageRequestHandler(cgImage: cgImage) do { try handler.perform([request]) } catch { continuation.resume(throwing: error) } } } ``` ### 4. 바코드/QR 스캔 ```swift struct BarcodeScanner: UIViewControllerRepresentable { @Binding var scannedCode: String? func makeUIViewController(context: Context) -> DataScannerViewController { let scanner = DataScannerViewController( recognizedDataTypes: [ .barcode(symbologies: [.qr, .ean13, .ean8, .code128]) ], qualityLevel: .balanced, recognizesMultipleItems: false, isHighlightingEnabled: true ) scanner.delegate = context.coordinator try? scanner.startScanning() return scanner } func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, DataScannerViewControllerDelegate { let parent: BarcodeScanner init(_ parent: BarcodeScanner) { self.parent = parent } func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { switch item { case .barcode(let barcode): parent.scannedCode = barcode.payloadStringValue default: break } } } } ``` ## 주의사항 1. **기기 요구사항** ```swift // 지원 여부 확인 guard ImageAnalyzer.isSupported else { return } guard DataScannerViewController.isSupported else { return } ``` 2. **Apple Silicon 요구** - Visual Intelligence (카메라 컨트롤): iPhone 16 시리즈만 - 이미지 분석: A12 Bionic 이상 3. **카메라 컨트롤** - 시스템 기능으로만 호출 가능 - 앱에서 직접 트리거 불가 4. **개인정보** - 분석은 온디바이스 처리 - 이미지가 서버로 전송되지 않음 5. **시뮬레이터** - DataScannerViewController 미지원 - 이미지 분석은 일부 지원 --- # WeatherKit AI Reference > 날씨 데이터 앱 구현 가이드. 이 문서를 읽고 WeatherKit 코드를 생성할 수 있습니다. ## 개요 WeatherKit은 Apple의 날씨 서비스로, 현재 날씨, 시간별/일별 예보, 심각한 기상 경보 등을 제공합니다. 월 50만 회 무료 API 호출이 포함되며, Apple Developer 계정이 필요합니다. ## 필수 Import ```swift import WeatherKit import CoreLocation ``` ## 프로젝트 설정 ### 1. Capability 추가 Xcode > Signing & Capabilities > + WeatherKit ### 2. App ID 설정 Apple Developer Console에서 WeatherKit 서비스 활성화 ```xml NSLocationWhenInUseUsageDescription 현재 위치의 날씨를 확인하기 위해 필요합니다. ``` ## 핵심 구성요소 ### 1. WeatherService ```swift import WeatherKit import CoreLocation // 날씨 서비스 인스턴스 let weatherService = WeatherService.shared // 위치 기반 날씨 요청 func getWeather(for location: CLLocation) async throws -> Weather { try await weatherService.weather(for: location) } ``` ### 2. 날씨 데이터 타입 ```swift // 현재 날씨 let current: CurrentWeather = weather.currentWeather current.temperature // Measurement current.apparentTemperature // 체감 온도 current.humidity // 습도 (0.0 ~ 1.0) current.condition // WeatherCondition (sunny, cloudy 등) current.symbolName // SF Symbol 이름 // 시간별 예보 let hourly: Forecast = weather.hourlyForecast for hour in hourly { hour.date hour.temperature hour.precipitationChance } // 일별 예보 let daily: Forecast = weather.dailyForecast for day in daily { day.date day.highTemperature day.lowTemperature day.precipitationChance } ``` ### 3. 기상 경보 ```swift // 심각한 기상 경보 let alerts: [WeatherAlert]? = weather.weatherAlerts for alert in alerts ?? [] { alert.summary alert.severity // .minor, .moderate, .severe, .extreme alert.region } ``` ## 전체 작동 예제 ```swift import SwiftUI import WeatherKit import CoreLocation // MARK: - Weather ViewModel @Observable class WeatherViewModel { var currentWeather: CurrentWeather? var hourlyForecast: [HourWeather] = [] var dailyForecast: [DayWeather] = [] var isLoading = false var errorMessage: String? private let weatherService = WeatherService.shared func fetchWeather(for location: CLLocation) async { isLoading = true errorMessage = nil do { let weather = try await weatherService.weather(for: location) currentWeather = weather.currentWeather hourlyForecast = Array(weather.hourlyForecast.prefix(24)) dailyForecast = Array(weather.dailyForecast.prefix(7)) } catch { errorMessage = "날씨를 불러올 수 없습니다: \(error.localizedDescription)" } isLoading = false } } // MARK: - Location Manager @Observable class LocationManager: NSObject, CLLocationManagerDelegate { var location: CLLocation? var authorizationStatus: CLAuthorizationStatus = .notDetermined private let manager = CLLocationManager() override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyKilometer } func requestLocation() { manager.requestWhenInUseAuthorization() manager.requestLocation() } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { location = locations.first } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { print("Location error: \(error)") } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationStatus = manager.authorizationStatus } } // MARK: - Main View struct WeatherView: View { @State private var viewModel = WeatherViewModel() @State private var locationManager = LocationManager() var body: some View { NavigationStack { ScrollView { if viewModel.isLoading { ProgressView("날씨 불러오는 중...") .padding(.top, 100) } else if let error = viewModel.errorMessage { ContentUnavailableView( "오류 발생", systemImage: "exclamationmark.triangle", description: Text(error) ) } else if let current = viewModel.currentWeather { VStack(spacing: 24) { // 현재 날씨 CurrentWeatherCard(weather: current) // 시간별 예보 HourlyForecastView(forecast: viewModel.hourlyForecast) // 일별 예보 DailyForecastView(forecast: viewModel.dailyForecast) } .padding() } else { ContentUnavailableView( "위치 권한 필요", systemImage: "location.slash", description: Text("날씨를 확인하려면 위치 권한이 필요합니다.") ) .onTapGesture { locationManager.requestLocation() } } } .navigationTitle("날씨") .refreshable { if let location = locationManager.location { await viewModel.fetchWeather(for: location) } } } .task { locationManager.requestLocation() } .onChange(of: locationManager.location) { _, newLocation in if let location = newLocation { Task { await viewModel.fetchWeather(for: location) } } } } } // MARK: - Current Weather Card struct CurrentWeatherCard: View { let weather: CurrentWeather var body: some View { VStack(spacing: 12) { Image(systemName: weather.symbolName) .font(.system(size: 64)) .symbolRenderingMode(.multicolor) Text(weather.temperature.formatted(.measurement(width: .abbreviated))) .font(.system(size: 48, weight: .thin)) Text(weather.condition.description) .font(.title3) .foregroundStyle(.secondary) HStack(spacing: 24) { Label { Text("체감 \(weather.apparentTemperature.formatted(.measurement(width: .abbreviated)))") } icon: { Image(systemName: "thermometer.medium") } Label { Text("\(Int(weather.humidity * 100))%") } icon: { Image(systemName: "humidity") } } .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 32) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) } } // MARK: - Hourly Forecast struct HourlyForecastView: View { let forecast: [HourWeather] var body: some View { VStack(alignment: .leading, spacing: 12) { Label("시간별 예보", systemImage: "clock") .font(.headline) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(forecast, id: \.date) { hour in VStack(spacing: 8) { Text(hour.date.formatted(.dateTime.hour())) .font(.caption) Image(systemName: hour.symbolName) .font(.title2) .symbolRenderingMode(.multicolor) Text(hour.temperature.formatted(.measurement(width: .narrow))) .font(.subheadline) } .frame(width: 60) } } .padding(.horizontal, 4) } } .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) } } // MARK: - Daily Forecast struct DailyForecastView: View { let forecast: [DayWeather] var body: some View { VStack(alignment: .leading, spacing: 12) { Label("7일 예보", systemImage: "calendar") .font(.headline) ForEach(forecast, id: \.date) { day in HStack { Text(day.date.formatted(.dateTime.weekday(.wide))) .frame(width: 80, alignment: .leading) Image(systemName: day.symbolName) .symbolRenderingMode(.multicolor) .frame(width: 32) Spacer() Text(day.lowTemperature.formatted(.measurement(width: .narrow))) .foregroundStyle(.secondary) TemperatureBar( low: day.lowTemperature.value, high: day.highTemperature.value ) .frame(width: 80) Text(day.highTemperature.formatted(.measurement(width: .narrow))) } .font(.subheadline) if day.date != forecast.last?.date { Divider() } } } .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) } } // MARK: - Temperature Bar struct TemperatureBar: View { let low: Double let high: Double var body: some View { GeometryReader { geometry in Capsule() .fill( LinearGradient( colors: [.blue, .yellow, .orange], startPoint: .leading, endPoint: .trailing ) ) .frame(height: 4) .frame(maxHeight: .infinity, alignment: .center) } } } #Preview { WeatherView() } ``` ## 고급 패턴 ### 1. 특정 데이터만 요청 ```swift // 필요한 데이터셋만 요청 (성능 최적화) let (current, hourly) = try await weatherService.weather( for: location, including: .current, .hourly ) // 일별 예보만 요청 let daily = try await weatherService.weather( for: location, including: .daily ) ``` ### 2. Attribution 표시 (필수) ```swift struct WeatherAttributionView: View { var body: some View { VStack { // ... 날씨 UI // Apple Weather 출처 표시 (필수) AsyncImage(url: WeatherService.shared.attribution.combinedMarkDarkURL) { image in image .resizable() .scaledToFit() } placeholder: { EmptyView() } .frame(height: 12) Link("데이터 출처", destination: WeatherService.shared.attribution.legalPageURL) .font(.caption2) } } } ``` ### 3. 기상 경보 처리 ```swift func checkWeatherAlerts(for location: CLLocation) async { do { let weather = try await weatherService.weather(for: location) if let alerts = weather.weatherAlerts, !alerts.isEmpty { for alert in alerts { switch alert.severity { case .extreme, .severe: // 긴급 알림 표시 showUrgentAlert(alert) case .moderate: // 일반 알림 showWarning(alert) case .minor: // 참고 정보 logInfo(alert) default: break } } } } catch { print("Weather alert check failed: \(error)") } } ``` ### 4. UV 지수 및 상세 정보 ```swift // 현재 날씨 상세 정보 let current = weather.currentWeather let uvIndex = current.uvIndex // UV 지수 let visibility = current.visibility // 가시거리 let pressure = current.pressure // 기압 let dewPoint = current.dewPoint // 이슬점 let windSpeed = current.wind.speed // 풍속 let windDirection = current.wind.direction // 풍향 let cloudCover = current.cloudCover // 구름량 (0.0 ~ 1.0) ``` ## 주의사항 1. **Attribution 필수** - WeatherKit 사용 시 Apple Weather 출처 표시 필수 - `WeatherService.shared.attribution` 사용 2. **API 호출 제한** - 무료: 월 50만 회 - 초과 시 유료 (Apple Developer 대시보드에서 확인) 3. **위치 권한** - WeatherKit 자체는 위치 권한 불필요 - 현재 위치 날씨를 위해선 CoreLocation 필요 4. **오프라인 처리** - 네트워크 필요 (오프라인 캐싱 없음) - 적절한 에러 처리 필수 5. **지역 제한** - 일부 국가에서 기상 경보 미지원 - 데이터 가용성은 지역마다 다름 --- # WidgetKit AI Reference > iOS 홈 화면/잠금 화면 위젯 구현 가이드. 이 문서를 읽고 위젯 코드를 생성할 수 있습니다. ## 개요 WidgetKit은 홈 화면과 잠금 화면에 앱 콘텐츠를 표시하는 위젯을 만드는 프레임워크입니다. 위젯은 **Timeline 기반**으로 동작하며, 시스템이 정해진 시간에 콘텐츠를 갱신합니다. ## 필수 Import ```swift import WidgetKit import SwiftUI ``` ## 핵심 구성요소 ### 1. Widget 프로토콜 (진입점) ```swift @main struct MyWidget: Widget { let kind: String = "MyWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: MyProvider()) { entry in MyWidgetView(entry: entry) } .configurationDisplayName("내 위젯") .description("위젯 설명") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } ``` ### 2. TimelineEntry (데이터 모델) ```swift struct MyEntry: TimelineEntry { let date: Date // 필수 let title: String let value: Int } ``` ### 3. TimelineProvider (데이터 제공자) ```swift struct MyProvider: TimelineProvider { // 위젯 갤러리 미리보기용 func placeholder(in context: Context) -> MyEntry { MyEntry(date: Date(), title: "제목", value: 0) } // 위젯 추가 시 미리보기 func getSnapshot(in context: Context, completion: @escaping (MyEntry) -> Void) { let entry = MyEntry(date: Date(), title: "스냅샷", value: 42) completion(entry) } // 실제 타임라인 생성 func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { var entries: [MyEntry] = [] let currentDate = Date() // 1시간마다 갱신되는 5개 엔트리 생성 for hourOffset in 0..<5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! let entry = MyEntry(date: entryDate, title: "항목 \(hourOffset)", value: hourOffset * 10) entries.append(entry) } // .atEnd: 마지막 엔트리 후 새 타임라인 요청 let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) } } ``` ### 4. Widget View (SwiftUI 뷰) ```swift struct MyWidgetView: View { var entry: MyEntry @Environment(\.widgetFamily) var family var body: some View { switch family { case .systemSmall: SmallView(entry: entry) case .systemMedium: MediumView(entry: entry) case .systemLarge: LargeView(entry: entry) default: SmallView(entry: entry) } } } struct SmallView: View { let entry: MyEntry var body: some View { VStack { Text(entry.title) .font(.headline) Text("\(entry.value)") .font(.largeTitle) } .containerBackground(.fill.tertiary, for: .widget) } } ``` ## 전체 작동 예제: 날씨 위젯 ```swift import WidgetKit import SwiftUI // MARK: - Entry struct WeatherEntry: TimelineEntry { let date: Date let city: String let temperature: Int let condition: String let icon: String } // MARK: - Provider struct WeatherProvider: TimelineProvider { func placeholder(in context: Context) -> WeatherEntry { WeatherEntry(date: Date(), city: "서울", temperature: 20, condition: "맑음", icon: "sun.max.fill") } func getSnapshot(in context: Context, completion: @escaping (WeatherEntry) -> Void) { let entry = WeatherEntry(date: Date(), city: "서울", temperature: 23, condition: "구름 조금", icon: "cloud.sun.fill") completion(entry) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { // 실제로는 API 호출 let entry = WeatherEntry(date: Date(), city: "서울", temperature: 25, condition: "맑음", icon: "sun.max.fill") // 15분 후 갱신 let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())! let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) completion(timeline) } } // MARK: - View struct WeatherWidgetView: View { var entry: WeatherEntry @Environment(\.widgetFamily) var family var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Image(systemName: entry.icon) .font(.title) .foregroundStyle(.yellow) Spacer() } Spacer() Text("\(entry.temperature)°") .font(.system(size: family == .systemSmall ? 40 : 56, weight: .bold)) Text(entry.city) .font(.caption) .foregroundStyle(.secondary) } .padding() .containerBackground(for: .widget) { LinearGradient(colors: [.blue, .cyan], startPoint: .top, endPoint: .bottom) } } } // MARK: - Widget @main struct WeatherWidget: Widget { let kind: String = "WeatherWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: WeatherProvider()) { entry in WeatherWidgetView(entry: entry) } .configurationDisplayName("날씨") .description("현재 날씨를 확인하세요") .supportedFamilies([.systemSmall, .systemMedium]) } } #Preview(as: .systemSmall) { WeatherWidget() } timeline: { WeatherEntry(date: Date(), city: "서울", temperature: 25, condition: "맑음", icon: "sun.max.fill") } ``` ## 인터랙티브 위젯 (iOS 17+) ```swift import AppIntents // 버튼 액션 정의 struct RefreshIntent: AppIntent { static var title: LocalizedStringResource = "새로고침" func perform() async throws -> some IntentResult { // 데이터 갱신 로직 WidgetCenter.shared.reloadTimelines(ofKind: "WeatherWidget") return .result() } } // 뷰에서 사용 struct InteractiveWidgetView: View { var body: some View { Button(intent: RefreshIntent()) { Label("새로고침", systemImage: "arrow.clockwise") } } } ``` ## 설정 가능한 위젯 (AppIntentConfiguration) ```swift import AppIntents // 설정 옵션 정의 struct CitySelection: AppIntent, WidgetConfigurationIntent { static var title: LocalizedStringResource = "도시 선택" @Parameter(title: "도시") var city: String? static var parameterSummary: some ParameterSummary { Summary("선택한 도시: \(\.$city)") } } // Provider 수정 struct ConfigurableProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> WeatherEntry { ... } func snapshot(for configuration: CitySelection, in context: Context) async -> WeatherEntry { ... } func timeline(for configuration: CitySelection, in context: Context) async -> Timeline { let city = configuration.city ?? "서울" // city를 사용해 데이터 가져오기 ... } } // Widget 수정 struct ConfigurableWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration(kind: "ConfigurableWidget", intent: CitySelection.self, provider: ConfigurableProvider()) { entry in WeatherWidgetView(entry: entry) } } } ``` ## 잠금 화면 위젯 ```swift .supportedFamilies([ .systemSmall, .systemMedium, .accessoryCircular, // 잠금 화면 원형 .accessoryRectangular, // 잠금 화면 직사각형 .accessoryInline // 잠금 화면 인라인 (시계 위) ]) // 잠금 화면용 뷰 struct LockScreenView: View { @Environment(\.widgetFamily) var family var body: some View { switch family { case .accessoryCircular: Gauge(value: 0.7) { Image(systemName: "thermometer") } .gaugeStyle(.accessoryCircularCapacity) case .accessoryRectangular: VStack(alignment: .leading) { Text("서울") .font(.headline) Text("25°") .font(.title) } case .accessoryInline: Label("서울 25°", systemImage: "sun.max.fill") default: EmptyView() } } } ``` ## 주의사항 1. **위젯은 앱이 아님**: 독립 실행 불가, 탭하면 앱으로 이동 2. **Timeline 기반**: 실시간 업데이트 X, 시스템이 정해진 시간에 갱신 3. **메모리 제한**: 작은 메모리 할당, 무거운 작업 금지 4. **containerBackground 필수** (iOS 17+): `.containerBackground(for: .widget)` 5. **Widget Extension 타겟 필요**: File > New > Target > Widget Extension ## 위젯 갱신 트리거 ```swift // 특정 위젯 갱신 WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget") // 모든 위젯 갱신 WidgetCenter.shared.reloadAllTimelines() ``` ## 파일 구조 ``` MyApp/ ├── MyApp/ │ └── MyApp.swift └── MyWidgetExtension/ ├── MyWidget.swift ├── MyWidgetBundle.swift (여러 위젯 시) └── Assets.xcassets ``` --- # Wi-Fi Aware AI Reference > Wi-Fi Aware 기반 기기 발견 가이드. 이 문서를 읽고 Wi-Fi Aware 코드를 생성할 수 있습니다. ## 개요 Wi-Fi Aware(NAN - Neighbor Awareness Networking)는 iOS 18+에서 지원하는 근거리 기기 발견 기술입니다. 인터넷이나 액세스 포인트 없이 Wi-Fi를 통해 주변 기기를 발견하고 직접 연결할 수 있습니다. ## 필수 Import ```swift import DeviceDiscoveryUI import Network ``` ## 프로젝트 설정 ```xml NSLocalNetworkUsageDescription 주변 기기를 찾기 위해 로컬 네트워크 접근이 필요합니다. NSBonjourServices _myapp._tcp _myapp._udp ``` ### Capability 추가 - Wireless Accessory Configuration (필요 시) ## 핵심 구성요소 ### 1. DeviceDiscoveryUI (SwiftUI) ```swift import SwiftUI import DeviceDiscoveryUI struct DevicePickerView: View { @State private var selectedEndpoint: NWEndpoint? var body: some View { DevicePicker( browseDescriptor: .applicationService(name: "MyApp"), parameters: .applicationService ) { endpoint in // 기기 선택됨 selectedEndpoint = endpoint connectToDevice(endpoint) } label: { Label("기기 찾기", systemImage: "antenna.radiowaves.left.and.right") } fallback: { // Wi-Fi Aware 미지원 시 대체 UI Text("이 기기에서는 Wi-Fi Aware를 사용할 수 없습니다") } parameters: { // 브라우즈 파라미터 커스터마이징 $0.includePeerToPeer = true } } func connectToDevice(_ endpoint: NWEndpoint) { let connection = NWConnection(to: endpoint, using: .applicationService) connection.start(queue: .main) } } ``` ### 2. NWBrowser (기기 탐색) ```swift import Network class WiFiAwareManager { private var browser: NWBrowser? private var listener: NWListener? func startBrowsing() { let descriptor = NWBrowser.Descriptor.applicationService(name: "MyApp") let params = NWParameters.applicationService browser = NWBrowser(for: descriptor, using: params) browser?.browseResultsChangedHandler = { results, changes in for result in results { switch result.endpoint { case .service(let name, let type, let domain, _): print("발견: \(name).\(type).\(domain)") default: break } } } browser?.stateUpdateHandler = { state in print("브라우저 상태: \(state)") } browser?.start(queue: .main) } } ``` ### 3. NWListener (서비스 광고) ```swift func startAdvertising() throws { let params = NWParameters.applicationService listener = try NWListener(using: params) listener?.service = NWListener.Service( name: "MyDevice", type: "_myapp._tcp" ) listener?.newConnectionHandler = { connection in self.handleConnection(connection) } listener?.stateUpdateHandler = { state in print("리스너 상태: \(state)") } listener?.start(queue: .main) } ``` ## 전체 작동 예제 ```swift import SwiftUI import DeviceDiscoveryUI import Network // MARK: - Wi-Fi Aware Manager @Observable class WiFiAwareManager { var discoveredDevices: [DiscoveredDevice] = [] var isAdvertising = false var isBrowsing = false var connectedDevice: DiscoveredDevice? var receivedMessages: [String] = [] private var browser: NWBrowser? private var listener: NWListener? private var connection: NWConnection? private let serviceName = "WiFiAwareDemo" private let queue = DispatchQueue(label: "wifi.aware") // MARK: - 광고 시작 func startAdvertising() { do { let params = NWParameters.applicationService listener = try NWListener(using: params) listener?.service = NWListener.Service( name: UIDevice.current.name, type: "_\(serviceName)._tcp" ) listener?.stateUpdateHandler = { [weak self] state in DispatchQueue.main.async { self?.isAdvertising = state == .ready } } listener?.newConnectionHandler = { [weak self] connection in self?.handleIncomingConnection(connection) } listener?.start(queue: queue) } catch { print("광고 시작 실패: \(error)") } } func stopAdvertising() { listener?.cancel() listener = nil isAdvertising = false } // MARK: - 탐색 시작 func startBrowsing() { let descriptor = NWBrowser.Descriptor.applicationService(name: serviceName) let params = NWParameters.applicationService browser = NWBrowser(for: descriptor, using: params) browser?.stateUpdateHandler = { [weak self] state in DispatchQueue.main.async { self?.isBrowsing = state == .ready } } browser?.browseResultsChangedHandler = { [weak self] results, changes in DispatchQueue.main.async { self?.discoveredDevices = results.compactMap { result in if case .service(let name, _, _, _) = result.endpoint { return DiscoveredDevice(name: name, endpoint: result.endpoint) } return nil } } } browser?.start(queue: queue) } func stopBrowsing() { browser?.cancel() browser = nil isBrowsing = false discoveredDevices.removeAll() } // MARK: - 연결 func connect(to device: DiscoveredDevice) { let params = NWParameters.applicationService connection = NWConnection(to: device.endpoint, using: params) connection?.stateUpdateHandler = { [weak self] state in DispatchQueue.main.async { switch state { case .ready: self?.connectedDevice = device self?.startReceiving() case .failed, .cancelled: self?.connectedDevice = nil default: break } } } connection?.start(queue: queue) } func disconnect() { connection?.cancel() connection = nil connectedDevice = nil } // MARK: - 메시지 송수신 func send(_ message: String) { guard let data = message.data(using: .utf8) else { return } connection?.send(content: data, completion: .contentProcessed { error in if let error = error { print("전송 실패: \(error)") } }) } private func startReceiving() { connection?.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in if let data = content, let message = String(data: data, encoding: .utf8) { DispatchQueue.main.async { self?.receivedMessages.append(message) } } if !isComplete && error == nil { self?.startReceiving() } } } private func handleIncomingConnection(_ newConnection: NWConnection) { // 기존 연결이 있으면 새 연결 거부 if connection != nil { newConnection.cancel() return } connection = newConnection connection?.stateUpdateHandler = { [weak self] state in DispatchQueue.main.async { switch state { case .ready: self?.connectedDevice = DiscoveredDevice(name: "수신 연결", endpoint: newConnection.endpoint!) self?.startReceiving() case .failed, .cancelled: self?.connectedDevice = nil default: break } } } connection?.start(queue: queue) } } // MARK: - Discovered Device struct DiscoveredDevice: Identifiable, Hashable { let id = UUID() let name: String let endpoint: NWEndpoint func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: DiscoveredDevice, rhs: DiscoveredDevice) -> Bool { lhs.id == rhs.id } } // MARK: - Main View struct WiFiAwareView: View { @State private var manager = WiFiAwareManager() @State private var messageToSend = "" var body: some View { NavigationStack { List { // 상태 섹션 Section("상태") { Toggle("광고", isOn: Binding( get: { manager.isAdvertising }, set: { $0 ? manager.startAdvertising() : manager.stopAdvertising() } )) Toggle("탐색", isOn: Binding( get: { manager.isBrowsing }, set: { $0 ? manager.startBrowsing() : manager.stopBrowsing() } )) } // 발견된 기기 if manager.isBrowsing { Section("발견된 기기") { if manager.discoveredDevices.isEmpty { Text("검색 중...") .foregroundStyle(.secondary) } else { ForEach(manager.discoveredDevices) { device in Button { manager.connect(to: device) } label: { HStack { Image(systemName: "iphone") Text(device.name) Spacer() if manager.connectedDevice == device { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) } } } } } } } // DevicePicker UI Section("시스템 UI") { DevicePicker( browseDescriptor: .applicationService(name: "WiFiAwareDemo"), parameters: .applicationService ) { endpoint in print("선택됨: \(endpoint)") } label: { Label("기기 선택", systemImage: "antenna.radiowaves.left.and.right") } fallback: { Text("Wi-Fi Aware 미지원") .foregroundStyle(.secondary) } } // 연결된 기기와 메시징 if let device = manager.connectedDevice { Section("연결됨: \(device.name)") { HStack { TextField("메시지", text: $messageToSend) Button("전송") { manager.send(messageToSend) messageToSend = "" } .disabled(messageToSend.isEmpty) } Button("연결 해제", role: .destructive) { manager.disconnect() } } } // 수신 메시지 if !manager.receivedMessages.isEmpty { Section("수신 메시지") { ForEach(manager.receivedMessages.indices, id: \.self) { index in Text(manager.receivedMessages[index]) .font(.system(.body, design: .monospaced)) } } } } .navigationTitle("Wi-Fi Aware") } } } #Preview { WiFiAwareView() } ``` ## 고급 패턴 ### 1. 커스텀 DevicePicker 스타일 ```swift DevicePicker( browseDescriptor: .applicationService(name: "MyApp"), parameters: .applicationService ) { endpoint in handleSelection(endpoint) } label: { // 커스텀 레이블 HStack { Image(systemName: "wifi") Text("주변 기기 연결") } .padding() .background(.blue) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) } fallback: { // 대체 UI Button("Bluetooth로 연결") { // MultipeerConnectivity 또는 CoreBluetooth 사용 } } parameters: { params in // 파라미터 커스터마이징 params.includePeerToPeer = true params.requiredInterfaceType = .wifi } ``` ### 2. TXT 레코드로 추가 정보 전달 ```swift // 서비스 광고 시 메타데이터 추가 func advertiseWithMetadata() throws { let params = NWParameters.applicationService listener = try NWListener(using: params) // TXT 레코드 설정 let txtRecord = NWTXTRecord() txtRecord["version"] = "1.0" txtRecord["capabilities"] = "video,audio" listener?.service = NWListener.Service( name: "MyDevice", type: "_myapp._tcp", txtRecord: txtRecord ) listener?.start(queue: .main) } // 브라우징 시 메타데이터 읽기 browser?.browseResultsChangedHandler = { results, _ in for result in results { if case .service(_, _, _, let interface) = result.endpoint { // TXT 레코드 접근은 연결 후 가능 } } } ``` ### 3. 파일 전송 ```swift func sendFile(url: URL, over connection: NWConnection) { guard let data = try? Data(contentsOf: url) else { return } // 파일 크기 먼저 전송 var size = UInt64(data.count) let sizeData = Data(bytes: &size, count: 8) connection.send(content: sizeData, completion: .contentProcessed { _ in // 파일 데이터 전송 connection.send(content: data, completion: .contentProcessed { error in if let error = error { print("파일 전송 실패: \(error)") } }) }) } func receiveFile(from connection: NWConnection) { // 파일 크기 수신 connection.receive(minimumIncompleteLength: 8, maximumLength: 8) { content, _, _, _ in guard let sizeData = content else { return } let size = sizeData.withUnsafeBytes { $0.load(as: UInt64.self) } // 파일 데이터 수신 connection.receive(minimumIncompleteLength: Int(size), maximumLength: Int(size)) { content, _, _, _ in if let data = content { // 파일 저장 self.saveFile(data) } } } } ``` ## 주의사항 1. **iOS 버전** - Wi-Fi Aware: iOS 18+ - DeviceDiscoveryUI: iOS 16+ 2. **기기 지원** - 모든 기기가 Wi-Fi Aware 지원하지 않음 - `fallback` 뷰 필수 제공 3. **전력 소비** - Wi-Fi Aware는 배터리 소모 큼 - 필요 시에만 활성화 4. **거리 제한** - 일반적으로 수십 미터 범위 - 환경에 따라 다름 5. **시뮬레이터** - Wi-Fi Aware 미지원 - 실기기 테스트 필수