WWDC Quick Look đź’“ By SwiftGGTeam
Wake up to the AlarmKit API

Wake up to the AlarmKit API

Watch original video

Highlight

iOS 26 uses AlarmKit to open up to third-party apps the kind of alarm that breaks through silent mode and Focus. Paired with Live Activity, it shows a countdown on the Lock Screen and Dynamic Island, and lets you wire up custom button behavior through App Intents.


Core content

Third-party apps have always run into the same wall: local notifications get muted by silent mode and Focus, so the user never hears them when the time is up. For cooking timers, wake-up alarms, and long-run pace reminders — anything that has to ring — developers either tell users to turn off silent mode or take the gray path of push plus background audio. Until now, system-level alarms were the exclusive domain of Apple’s own Clock app.

AlarmKit in iOS 26 tears down that wall. The framework gives third-party alarms the same priority as system alarms: when they ring, they pierce silent mode and the current Focus, and they show up on the Lock Screen, Dynamic Island, StandBy, and Apple Watch. The developer declares NSAlarmKitUsageDescription in Info.plist, and once the user grants permission, the app can schedule alarms with AlarmManager.shared.schedule(id:configuration:). An alarm has four parts: a countdown duration, a schedule (fixed time or relative recurrence), a presentation (title, buttons, color), and an optional custom sound. The Live Activity is not decoration — it is required. As soon as an alarm has a countdown, the app must implement the matching Live Activity, or the system falls back to its default presentation.


Detail

Before scheduling an alarm, check authorization. AlarmManager.shared.authorizationState returns one of .notDetermined / .authorized / .denied (02:41).

import AlarmKit

func checkAuthorization() {
  switch AlarmManager.shared.authorizationState {
    case .notDetermined:
      // Manually request authorization
    case .authorized:
      // Proceed with scheduling
    case .denied:
      // Inform status is not authorized
  }
}

Key points:

  • AlarmManager.shared is the singleton entry point. All scheduling, querying, and canceling go through it.
  • If the state is .notDetermined, you can call requestAuthorization to prompt explicitly, or let the system prompt automatically on the first schedule call.
  • In the .denied state, tell the user in the UI that the alarm will not ring — do not let them assume that setting it is enough.

The countdown duration is described by Alarm.CountdownDuration, split into preAlert and postAlert (04:08).

let countdownDuration = Alarm.CountdownDuration(preAlert: (10 * 60), postAlert: (5 * 60))

Key points:

  • preAlert is the countdown before the first ring, in seconds. For a 10-minute steak, write 10 * 60.
  • postAlert is the countdown before the alarm rings again after the user taps “repeat” or “snooze”.
  • Both values are optional. A pure point-in-time alarm with no countdown can omit them.

There are two kinds of schedule. Fixed scheduling, Alarm.Schedule.fixed(_:), takes an absolute Date and does not shift with the time zone (04:40).

let keynoteDateComponents = DateComponents(
  calendar: .current,
  year: 2025, month: 6, day: 9,
  hour: 9, minute: 41)
let keynoteDate = Calendar.current.date(from: keynoteDateComponents)!
let scheduleFixed = Alarm.Schedule.fixed(keynoteDate)

Relative scheduling, Alarm.Schedule.Relative, takes a time and a recurrence rule, and drifts with the device’s time zone (05:13).

let time = Alarm.Schedule.Relative.Time(hour: 7, minute: 0)
let recurrence = Alarm.Schedule.Relative.Recurrence.weekly([
  .monday, .wednesday, .friday
])
let schedule = Alarm.Schedule.Relative(time: time, repeats: recurrence)

Key points:

  • Use fixed for travel alarms — fly to Tokyo and the alarm still rings at the absolute time set in your home zone.
  • Use Relative for a weekday wake-up — across time zones it auto-adjusts to 7 a.m. local.
  • Recurrence.weekly([.monday, ...]) uses a Set to express the days of the week, which reads naturally.

The presentation is built around the two-layer structure of AlarmAttributes and AlarmConfiguration. The minimal “show an alert with a single dismiss button” config looks like this (05:43):

func scheduleAlarm() async throws {
    typealias AlarmConfiguration = AlarmManager.AlarmConfiguration<CookingData>

    let id = UUID()
    let duration = Alarm.CountdownDuration(preAlert: (10 * 60), postAlert: (5 * 60))

    let stopButton = AlarmButton(
        text: "Dismiss",
        textColor: .white,
        systemImageName: "stop.circle")

    let alertPresentation = AlarmPresentation.Alert(
        title: "Food Ready!",
        stopButton: stopButton)

    let attributes = AlarmAttributes<CookingData>(
        presentation: AlarmPresentation(alert: alertPresentation),
        tintColor: Color.green)

    let alarmConfiguration = AlarmConfiguration(
        countdownDuration: duration,
        attributes: attributes)

    try await AlarmManager.shared.schedule(id: id, configuration: alarmConfiguration)
}

Key points:

  • The app generates and stores the id: UUID — canceling and querying the alarm later both rely on it.
  • An AlarmButton has three pieces: text, text color, and an SF Symbol name. The SF Symbol shows up in the Dynamic Island compact mode.
  • The generic parameter on AlarmAttributes<CookingData> is your custom metadata type. Passing Never works, but custom metadata gives you more flexibility.
  • tintColor controls the main color of the countdown in the system presentation.

To make a “repeat” button start the countdown again, add a secondaryButton and set secondaryButtonBehavior to .countdown (07:17). To have a button jump into the app and run custom logic, set the behavior to .custom and supply a LiveActivityIntent (14:09):

let secondaryIntent = OpenInApp(alarmID: id.uuidString)

let openButton = AlarmButton(
  text: "Open",
  textColor: .white,
  systemImageName: "arrow.right.circle.fill")

let alertPresentation = AlarmPresentation.Alert(
  title: "Food Ready!",
  stopButton: stopButton,
  secondaryButton: openButton,
  secondaryButtonBehavior: .custom)

// ...

let alarmConfiguration = AlarmConfiguration(
  countdownDuration: duration,
  attributes: attributes,
  secondaryIntent: secondaryIntent)

public struct OpenInApp: LiveActivityIntent {
    public func perform() async throws -> some IntentResult { .result() }
    public static var title: LocalizedStringResource = "Open App"
    public static var openAppWhenRun = true

    @Parameter(title: "alarmID")
    public var alarmID: String

    public init(alarmID: String) { self.alarmID = alarmID }
    public init() { self.alarmID = "" }
}

Key points:

  • Only secondaryButtonBehavior: .custom paired with a secondaryIntent lets you run app logic straight from the Lock Screen.
  • A LiveActivityIntent must have a no-argument init() — the system needs it to deserialize.
  • openAppWhenRun = true brings the app to the foreground after the intent runs.
  • alarmID comes in through @Parameter, so the intent knows which alarm fired.

The countdown itself needs a Live Activity to render it. The generic parameter on ActivityConfiguration points to AlarmAttributes<CookingData>, and the view branches on context.state.mode to render the .countdown, .paused, and .alert states (09:15):

struct AlarmLiveActivity: Widget {
  var body: some WidgetConfiguration {
    ActivityConfiguration(for: AlarmAttributes<CookingData>.self) { context in
      switch context.state.mode {
      case .countdown:
        countdownView(context)
      case .paused:
        pausedView(context)
      case .alert:
        alertView(context)
      }
    } dynamicIsland: { context in
      DynamicIsland {
        DynamicIslandExpandedRegion(.leading)  { leadingView(context)  }
        DynamicIslandExpandedRegion(.trailing) { trailingView(context) }
      } compactLeading:  { compactLeadingView(context)  }
        compactTrailing: { compactTrailingView(context) }
        minimal:         { minimalView(context)         }
    }
  }
}

Key points:

  • The AlarmKit Live Activity goes in the app’s Widget Extension and is written the same way as a regular ActivityKit Live Activity.
  • context.state.mode is a state machine the system feeds in for you — no need to manage it yourself.
  • Provide views for all three Dynamic Island sizes — compact, minimal, and expanded — or the system will show a blank.
  • Custom metadata is read through context.attributes.metadata, and can drive things like switching SF Symbols (for example, swapping the icon between fry and bake).

Takeaways

  • What to do: add AlarmKit countdowns to cooking and baking apps, replacing local notifications from UNUserNotificationCenter.

    • Why it matters: local notifications go silent under mute and Focus, but AlarmKit breaks through; an overcooked steak is far worse than a popup the app forgot.
    • How to start: add NSAlarmKitUsageDescription to Info.plist with copy like “Used for cooking timers”; set preAlert to the recipe step duration and leave postAlert at 1–2 minutes as a backstop.
  • What to do: in cross-time-zone travel apps, set the boarding alarm with Alarm.Schedule.fixed.

    • Why it matters: a relative schedule drifts across time zones, but boarding time is absolute and must not drift; only a fixed schedule guarantees the alarm fires at the time you wrote down at home, even when you land in Tokyo.
    • How to start: build DateComponents for the boarding time in the flight’s local time zone, convert it to a Date, and pass it to Alarm.Schedule.fixed(_:); save the alarm id in the trip database so a rebooking can cancel and re-schedule.
  • What to do: in long-run and Pomodoro apps, use secondaryButtonBehavior: .custom so a Lock Screen button runs the next step directly.

    • Why it matters: unlocking with sweaty hands mid-run is a chore; a Lock Screen button that jumps to the next interval is much smoother than “open the app and look for the button”.
    • How to start: define a LiveActivityIntent and pass alarmID as a @Parameter; in perform(), read the next pace from the workout database and schedule the next alarm.
  • What to do: in wake-up alarm apps, combine relative scheduling with Recurrence.weekly to set Monday through Friday once.

    • Why it matters: users hate adjusting their alarm manually every day; a single weekly recurrence rings at 7 a.m. local even when they travel across time zones.
    • How to start: set the time with Alarm.Schedule.Relative.Time(hour:minute:) and list the weekdays with .weekly([.monday, .tuesday, ...]); weekend rules go in a separate alarm.

Comments

GitHub Issues · utterances