# 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 미지원
- 실기기 테스트 필수