⏰ AlarmKit
시스템 알람 앱과 완벽하게 통합되는 알람 관리
iOS 18+🆕 2024
✨ AlarmKit이란?
AlarmKit은 iOS 18에서 도입된 프레임워크로, 서드파티 앱에서 시스템 알람 앱과 동일한 수준의 알람 기능을 제공합니다. 앱에서 알람을 생성, 수정, 삭제하고 시스템 시계 앱의 알람 목록에 통합되어 표시됩니다. 반복 알람, 스누즈, 알람음 선택 등 네이티브 알람의 모든 기능을 지원하며, 사용자에게 일관된 경험을 제공합니다.
💡 핵심 기능: 시스템 알람 통합 · 반복 알람 · 스누즈 설정 · 커스텀 알람음 · 진동 패턴 · 라벨 및 메모 · iCloud 동기화 · Live Activity 지원
🎯 1. 기본 설정
AlarmKit을 초기화하고 권한을 요청합니다.
AlarmManager.swift — 기본 설정
import SwiftUI import UserNotifications // AlarmKit 관리자 @Observable class AlarmManager { var alarms: [Alarm] = [] var isAuthorized = false // 알림 권한 요청 func requestAuthorization() async -> Bool { do { let granted = try await UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound, .criticalAlert]) await MainActor.run { isAuthorized = granted } return granted } catch { print("❌ 권한 요청 실패: \(error)") return false } } // 알람 목록 불러오기 func loadAlarms() async { // 시스템 알람과 통합된 알람 목록 // 실제로는 AlarmKit API 사용 alarms = [ Alarm( time: Date(), label: "기상 알람", isEnabled: true, repeatDays: [1, 2, 3, 4, 5] ) ] } } // 알람 모델 struct Alarm: Identifiable { let id = UUID() var time: Date var label: String var isEnabled: Bool var repeatDays: [Int] // 0=일요일, 1=월요일... var sound: AlarmSound = .default var snoozeEnabled: Bool = true var vibrationPattern: VibrationPattern = .default } enum AlarmSound: String, CaseIterable { case `default` = "기본" case radar = "레이더" case chimes = "차임벨" case bells = "종" } enum VibrationPattern: String, CaseIterable { case `default` = "기본" case rapid = "빠른 진동" case heartbeat = "심장 박동" }
⏰ 2. 알람 생성
새로운 알람을 생성하고 시스템에 등록합니다.
CreateAlarmView.swift — 알람 생성
import SwiftUI struct CreateAlarmView: View { @Environment(\.dismiss) var dismiss @State private var alarmTime = Date() @State private var label = "" @State private var selectedDays: Set<Int> = [] @State private var selectedSound: AlarmSound = .default @State private var snoozeEnabled = true let weekdays = ["일", "월", "화", "수", "목", "금", "토"] var body: some View { NavigationStack { Form { Section { // 시간 선택 DatePicker( "시간", selection: $alarmTime, displayedComponents: .hourAndMinute ) .datePickerStyle(.wheel) .labelsHidden() } Section("라벨") { TextField("알람 이름", text: $label) } Section("반복") { HStack(spacing: 8) { ForEach(0.<7, id: \.self) { day in Button { toggleDay(day) } label: { Text(weekdays[day]) .font(.caption) .frame(width: 40, height: 40) .background( selectedDays.contains(day) ? Color.blue : Color.gray.opacity(0.2) ) .foregroundStyle( selectedDays.contains(day) ? .white : .primary ) .clipShape(Circle()) } } } .buttonStyle(.plain) } Section("알람음") { Picker("소리", selection: $selectedSound) { ForEach(AlarmSound.allCases, id: \.self) { sound in Text(sound.rawValue).tag(sound) } } } Section { Toggle("스누즈", isOn: $snoozeEnabled) } } .navigationTitle("알람 추가") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("저장") { saveAlarm() } } } } } func toggleDay(_ day: Int) { if selectedDays.contains(day) { selectedDays.remove(day) } else { selectedDays.insert(day) } } func saveAlarm() { let alarm = Alarm( time: alarmTime, label: label.isEmpty ? "알람" : label, isEnabled: true, repeatDays: Array(selectedDays).sorted(), sound: selectedSound, snoozeEnabled: snoozeEnabled ) Task { await scheduleAlarm(alarm) } dismiss() } func scheduleAlarm(_ alarm: Alarm) async { // AlarmKit API로 알람 등록 print("⏰ 알람 생성: \(alarm.label) at \(alarm.time)") // UNNotificationRequest 생성 let content = UNMutableNotificationContent() content.title = alarm.label content.sound = .default content.interruptionLevel = .timeSensitive // 반복 설정 if alarm.repeatDays.isEmpty { // 일회성 알람 let components = Calendar.current.dateComponents( [.hour, .minute], from: alarm.time ) let trigger = UNCalendarNotificationTrigger( dateMatching: components, repeats: false ) let request = UNNotificationRequest( identifier: alarm.id.uuidString, content: content, trigger: trigger ) try? await UNUserNotificationCenter.current().add(request) } else { // 반복 알람 (각 요일마다 별도 등록) for day in alarm.repeatDays { var components = Calendar.current.dateComponents( [.hour, .minute], from: alarm.time ) components.weekday = day + 1 // 1=일요일 let trigger = UNCalendarNotificationTrigger( dateMatching: components, repeats: true ) let request = UNNotificationRequest( identifier: "\(alarm.id.uuidString)-\(day)", content: content, trigger: trigger ) try? await UNUserNotificationCenter.current().add(request) } } } }
📋 3. 알람 목록
등록된 모든 알람을 표시하고 관리합니다.
AlarmListView.swift — 알람 목록
import SwiftUI struct AlarmListView: View { @State private var manager = AlarmManager() @State private var showCreateAlarm = false var body: some View { NavigationStack { List { if manager.alarms.isEmpty { ContentUnavailableView { Label("알람 없음", systemImage: "alarm") } description: { Text("+ 버튼을 눌러 알람을 추가하세요") } } else { ForEach(manager.alarms) { alarm in AlarmRow(alarm: alarm) { toggleAlarm(alarm) } } .onDelete { indexSet in deleteAlarms(at: indexSet) } } } .navigationTitle("알람") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showCreateAlarm = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showCreateAlarm) { CreateAlarmView() } .task { await manager.requestAuthorization() await manager.loadAlarms() } } } func toggleAlarm(_ alarm: Alarm) { guard let index = manager.alarms.firstIndex(where: { $0.id == alarm.id }) else { return } manager.alarms[index].isEnabled.toggle() Task { if manager.alarms[index].isEnabled { await scheduleAlarm(manager.alarms[index]) } else { await cancelAlarm(manager.alarms[index]) } } } func deleteAlarms(at offsets: IndexSet) { for index in offsets { let alarm = manager.alarms[index] Task { await cancelAlarm(alarm) } } manager.alarms.remove(atOffsets: offsets) } func scheduleAlarm(_ alarm: Alarm) async { print("⏰ 알람 활성화: \(alarm.label)") } func cancelAlarm(_ alarm: Alarm) async { // 알람 취소 var identifiers = [alarm.id.uuidString] // 반복 알람인 경우 모든 요일 취소 for day in alarm.repeatDays { identifiers.append("\(alarm.id.uuidString)-\(day)") } UNUserNotificationCenter.current() .removePendingNotificationRequests(withIdentifiers: identifiers) print("🔕 알람 비활성화: \(alarm.label)") } } struct AlarmRow: View { let alarm: Alarm let onToggle: () -> Void var timeString: String { let formatter = DateFormatter() formatter.timeStyle = .short return formatter.string(from: alarm.time) } var repeatText: String { if alarm.repeatDays.isEmpty { return "한 번" } let weekdays = ["일", "월", "화", "수", "목", "금", "토"] let dayNames = alarm.repeatDays.map { weekdays[$0] } // 매일 if alarm.repeatDays.count == 7 { return "매일" } // 주중 if Set(alarm.repeatDays) == Set([1, 2, 3, 4, 5]) { return "주중" } // 주말 if Set(alarm.repeatDays) == Set([0, 6]) { return "주말" } return dayNames.joined(separator: ", ") } var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { Text(timeString) .font(.system(size: 40, weight: .light)) .foregroundStyle(alarm.isEnabled ? .primary : .secondary) if !alarm.label.isEmpty { Text(alarm.label) .font(.headline) } Text(repeatText) .font(.subheadline) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { alarm.isEnabled }, set: { _ in onToggle() } )) .labelsHidden() } .padding(.vertical, 8) } }
⏱️ 4. 스누즈 기능
알람이 울릴 때 스누즈를 처리합니다.
SnoozeHandler.swift — 스누즈 처리
import SwiftUI import UserNotifications @Observable class SnoozeHandler: NSObject, UNUserNotificationCenterDelegate { var snoozeDuration: TimeInterval = 9 * 60 // 9분 override init() { super.init() UNUserNotificationCenter.current().delegate = self } // 알림이 화면에 표시될 때 func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { // 알람 화면 표시 return [.banner, .sound, .list] } // 알림 액션 처리 func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { switch response.actionIdentifier { case "SNOOZE_ACTION": await snoozeAlarm(response.notification) case "STOP_ACTION", UNNotificationDefaultActionIdentifier: stopAlarm(response.notification) default: break } } // 스누즈 알람 등록 func snoozeAlarm(_ notification: UNNotification) async { let content = UNMutableNotificationContent() content.title = notification.request.content.title content.body = "스누즈" content.sound = .default content.interruptionLevel = .timeSensitive // 9분 후 다시 알람 let trigger = UNTimeIntervalNotificationTrigger( timeInterval: snoozeDuration, repeats: false ) let request = UNNotificationRequest( identifier: "\(notification.request.identifier)-snooze", content: content, trigger: trigger ) try? await UNUserNotificationCenter.current().add(request) print("😴 스누즈: 9분 후 다시 알람") } // 알람 중지 func stopAlarm(_ notification: UNNotification) { print("⏹️ 알람 중지") } } // 알람 액션 카테고리 등록 extension UNUserNotificationCenter { static func registerAlarmActions() { let snoozeAction = UNNotificationAction( identifier: "SNOOZE_ACTION", title: "스누즈", options: [] ) let stopAction = UNNotificationAction( identifier: "STOP_ACTION", title: "중지", options: [.destructive] ) let category = UNNotificationCategory( identifier: "ALARM_CATEGORY", actions: [snoozeAction, stopAction], intentIdentifiers: [], options: [.customDismissAction] ) UNUserNotificationCenter.current().setNotificationCategories([category]) } }
🔔 5. Live Activity 통합
알람이 울릴 때 Dynamic Island와 잠금 화면에 표시합니다.
AlarmActivityView.swift — Live Activity
import SwiftUI import ActivityKit // 알람 Activity 속성 struct AlarmActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { var timeRemaining: TimeInterval var isSnoozed: Bool } var alarmLabel: String var alarmTime: Date } // Dynamic Island 뷰 struct AlarmActivityView: View { let context: ActivityViewContext<AlarmActivityAttributes> var body: some View { VStack { HStack { Image(systemName: "alarm.fill") .foregroundStyle(.orange) VStack(alignment: .leading) { Text(context.attributes.alarmLabel) .font(.headline) if context.state.isSnoozed { Text("스누즈됨") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Text(timeRemainingText) .font(.title2) .fontWeight(.semibold) .monospacedDigit() } .padding() } } var timeRemainingText: String { let remaining = Int(context.state.timeRemaining) let minutes = remaining / 60 let seconds = remaining % 60 return String(format: "%02d:%02d", minutes, seconds) } } // Live Activity 시작 func startAlarmActivity(alarm: Alarm) async { let attributes = AlarmActivityAttributes( alarmLabel: alarm.label, alarmTime: alarm.time ) let initialState = AlarmActivityAttributes.ContentState( timeRemaining: 60, isSnoozed: false ) do { let activity = try Activity.request( attributes: attributes, content: .init(state: initialState, staleDate: nil) ) print("✅ Live Activity 시작: \(activity.id)") } catch { print("❌ Live Activity 실패: \(error)") } }
🎵 6. 커스텀 알람음
사용자 지정 알람음과 진동 패턴을 설정합니다.
CustomSoundPicker.swift — 알람음 선택
import SwiftUI import AVFoundation struct CustomSoundPickerView: View { @Binding var selectedSound: AlarmSound @State private var audioPlayer: AVAudioPlayer? var body: some View { List { Section("기본 알람음") { ForEach(AlarmSound.allCases, id: \.self) { sound in HStack { Text(sound.rawValue) Spacer() if selectedSound == sound { Image(systemName: "checkmark") .foregroundStyle(.blue) } Button { previewSound(sound) } label: { Image(systemName: "play.circle") } .buttonStyle(.borderless) } .contentShape(Rectangle()) .onTapGesture { selectedSound = sound } } } Section("커스텀 알람음") { Button { importCustomSound() } label: { Label("사운드 가져오기", systemImage: "plus.circle") } } } .navigationTitle("알람음") } func previewSound(_ sound: AlarmSound) { // 시스템 사운드 재생 AudioServicesPlaySystemSound(1005) // 예시 } func importCustomSound() { print("🎵 커스텀 사운드 가져오기") } }
💡 HIG 가이드라인
- 시스템 통합: 시계 앱의 알람과 완벽하게 통합되어야 함
- 명확한 정보: 알람 시간, 반복 설정을 명확히 표시
- 쉬운 토글: 알람 활성화/비활성화를 빠르게 전환
- 스누즈 지원: 표준 9분 스누즈 제공
- Critical Alert: 중요한 알람은 Critical Alert 사용 고려
🎯 실전 활용
- 수면 추적 앱: 기상 알람과 취침 알림
- 약 복용 알림: 정확한 시간에 반복 알람
- 운동 앱: 운동 시간 알림
- 할 일 앱: 마감 시간 알람
- 명상 앱: 정기적인 명상 시간 알림
📚 더 알아보기
⚡️ 팁: AlarmKit은 시스템 시계 앱과 완벽하게 통합됩니다. 앱에서 생성한 알람은 시계 앱에서도 보이며, 사용자가 어디서든 관리할 수 있습니다. Critical Alert 권한을 요청하면 무음 모드에서도 알람이 울립니다. Live Activity를 활용하면 Dynamic Island에서 알람 상태를 실시간으로 확인할 수 있습니다.