πŸ‡ΊπŸ‡Έ EN

⏰ AlarmKit

⭐ λ‚œμ΄λ„: ⭐⭐ ⏱️ μ˜ˆμƒ μ‹œκ°„: 1h πŸ“‚ iOS 26

μ‹œμŠ€ν…œ μ•ŒλžŒ μ•±κ³Ό μ™„λ²½ν•˜κ²Œ ν†΅ν•©λ˜λŠ” μ•ŒλžŒ 관리

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 κ°€μ΄λ“œλΌμΈ

🎯 μ‹€μ „ ν™œμš©

πŸ“š 더 μ•Œμ•„λ³΄κΈ°

⚑️ 팁: AlarmKit은 μ‹œμŠ€ν…œ μ‹œκ³„ μ•±κ³Ό μ™„λ²½ν•˜κ²Œ ν†΅ν•©λ©λ‹ˆλ‹€. μ•±μ—μ„œ μƒμ„±ν•œ μ•ŒλžŒμ€ μ‹œκ³„ μ•±μ—μ„œλ„ 보이며, μ‚¬μš©μžκ°€ μ–΄λ””μ„œλ“  관리할 수 μžˆμŠ΅λ‹ˆλ‹€. Critical Alert κΆŒν•œμ„ μš”μ²­ν•˜λ©΄ 무음 λͺ¨λ“œμ—μ„œλ„ μ•ŒλžŒμ΄ μšΈλ¦½λ‹ˆλ‹€. Live Activityλ₯Ό ν™œμš©ν•˜λ©΄ Dynamic Islandμ—μ„œ μ•ŒλžŒ μƒνƒœλ₯Ό μ‹€μ‹œκ°„μœΌλ‘œ 확인할 수 μžˆμŠ΅λ‹ˆλ‹€.

πŸ“Ž Apple 곡식 자료

πŸ“˜ 곡식 λ¬Έμ„œ 🎬 WWDC μ„Έμ…˜