β° AlarmKit
μμ€ν μλ μ±κ³Ό μλ²½νκ² ν΅ν©λλ μλ κ΄λ¦¬
β¨ AlarmKitμ΄λ?
AlarmKitμ iOS 18μμ λμ λ νλ μμν¬λ‘, μλνν° μ±μμ μμ€ν μλ μ±κ³Ό λμΌν μμ€μ μλ κΈ°λ₯μ μ 곡ν©λλ€. μ±μμ μλμ μμ±, μμ , μμ νκ³ μμ€ν μκ³ μ±μ μλ λͺ©λ‘μ ν΅ν©λμ΄ νμλ©λλ€. λ°λ³΅ μλ, μ€λμ¦, μλμ μ ν λ± λ€μ΄ν°λΈ μλμ λͺ¨λ κΈ°λ₯μ μ§μνλ©°, μ¬μ©μμκ² μΌκ΄λ κ²½νμ μ 곡ν©λλ€.
π― 1. κΈ°λ³Έ μ€μ
AlarmKitμ μ΄κΈ°ννκ³ κΆνμ μμ²ν©λλ€.
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. μλ μμ±
μλ‘μ΄ μλμ μμ±νκ³ μμ€ν μ λ±λ‘ν©λλ€.
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. μλ λͺ©λ‘
λ±λ‘λ λͺ¨λ μλμ νμνκ³ κ΄λ¦¬ν©λλ€.
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. μ€λμ¦ κΈ°λ₯
μλμ΄ μΈλ¦΄ λ μ€λμ¦λ₯Ό μ²λ¦¬ν©λλ€.
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μ μ κΈ νλ©΄μ νμν©λλ€.
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. 컀μ€ν μλμ
μ¬μ©μ μ§μ μλμκ³Ό μ§λ ν¨ν΄μ μ€μ ν©λλ€.
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 μ¬μ© κ³ λ €
π― μ€μ νμ©
- μλ©΄ μΆμ μ±: κΈ°μ μλκ³Ό μ·¨μΉ¨ μλ¦Ό
- μ½ λ³΅μ© μλ¦Ό: μ νν μκ°μ λ°λ³΅ μλ
- μ΄λ μ±: μ΄λ μκ° μλ¦Ό
- ν μΌ μ±: λ§κ° μκ° μλ
- λͺ μ μ±: μ κΈ°μ μΈ λͺ μ μκ° μλ¦Ό