Designing custom AlarmKit interfaces in SwiftUI

Designing custom AlarmKit interfaces in SwiftUI

Discover how to create and personalize user interfaces for alarms using AlarmKit with SwiftUI.

With AlarmKit, developers can easily schedule alarms within their SwiftUI applications, enabling time-based user experiences. But beyond just scheduling, AlarmKit also provides some options to customize the appearance and the content of the alarm.

In this article, we’ll explore how we can customize the appearance and behavior of alarms and what kind of contextual information they display to create a more engaging and personalized experience for your users.

Custom Alarm Buttons

AlarmKit makes it easy to customize the actions users see when an alarm is triggered. Each alert can display up to two buttons:

  • Stop Button — the main action (e.g., “Done”, “Stop”)
  • Secondary Button — an optional action (e.g., “Remind Me”)

You can configure these buttons using the ‎AlarmPresentation.Alert initializer:

let alert = AlarmPresentation.Alert(
    title: "☕️ Your \(selectedCoffeeType.name) is ready!",
    stopButton: AlarmButton(
        text: "Done",
        textColor: .white,
        systemImageName: "checkmark.circle.fill"
    ),
    secondaryButton: AlarmButton(
        text: "Remind Me",
        textColor: .white,
        systemImageName: "clock.arrow.trianglehead.2.counterclockwise.rotate.90"
    )
)

Both buttons support SF Symbols for icons and customizable text color, ensuring a consistent and familiar look for your users.

Secondary Button Behavior

AlarmKit extends the functionality of the secondary button with the ‎secondaryButtonBehavior parameter. This lets you define what happens when the user taps the secondary action:

  • .countdown: makes the secondary button a repeat button for the alarm
  • .custom: executes a custom action, letting you define personalized user experiences

Customizing Colors with Tint

While button text color can be set individually, AlarmKit also supports a global tint color for the alarm interface via the ‎AlarmAttributes configuration.

The ‎tintColor sets the accent across the alarm’s UI, making it easy to create a cohesive look and feel.

let attributes = AlarmAttributes<CoffeeMetadata>(
    presentation: AlarmPresentation(
        alert: alert,
    ),
    metadata: CoffeeMetadata(
        coffeeType: selectedCoffeeType.name,
        brewTime: minutes
    ),
    tintColor: .brown
)

Adding Custom Metadata

To provide meaningful context when an alarm triggers, AlarmKit allows you to attach metadata. Define your own struct conforming to ‎AlarmMetadata and pass it into the alarm’s attributes.

For example, to display details about a coffee brew:

struct CoffeeMetadata: AlarmMetadata {
    var coffeeType: String
    var brewTime: Int
}

While the AlarmMetadata protocol is flexible and allows you to define custom data types for alarm-related metadata, it does have some limitations due to its inheritance from CodableHashable, and Sendable. For example, types like Color(from SwiftUI) do not conform to all of these protocols—specifically, Color is not Codable or Sendable, and therefore cannot be used directly in a type that conforms to AlarmMetadata

Integration with SwiftUI

We can take advantage of all these possibilities to create time-based user experiences in our apps. The following example shows a simple application for preparing coffee using alarms:

import SwiftUI
import AlarmKit

struct ContentView: View {
    private let alarmManager = AlarmManager.shared
    
    @State private var selectedCoffeeType: CoffeeType = .espresso
    @State private var customMinutes: Int = 1
    @State private var useCustomTime = false
    
    var body: some View {
        VStack(spacing: 30) {
            
            VStack(spacing: 15) {
                Text("☕️ Choose Your Coffee")
                    .font(.title2)
                    .fontWeight(.semibold)
                
                Picker("Coffee Type", selection: $selectedCoffeeType) {
                    ForEach(CoffeeType.allCases, id: \.self) { coffee in
                        Text("\(coffee.icon) \(coffee.name)")
                            .tag(coffee)
                    }
                }
                .pickerStyle(.menu)
                .padding()
                .background(Color.brown.opacity(0.1))
                .cornerRadius(10)
            }

            VStack(spacing: 15) {
                Toggle("Custom Time", isOn: $useCustomTime)
                    .font(.headline)
                
                if useCustomTime {
                    HStack {
                        Text("Minutes:")
                        Stepper("\(customMinutes)", value: $customMinutes, in: 1...30)
                            .font(.title3)
                            .fontWeight(.medium)
                    }
                } else {
                    Text("Default: \(selectedCoffeeType.defaultMinutes) minutes")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            }
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(10)
            

            Button(action: {
                Task {
                    await scheduleCoffeeTimer()
                }
            }) {
                HStack {
                    Image(systemName: "timer")
                    Text("Start Coffee Timer")
                        .fontWeight(.semibold)
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.brown)
                .foregroundColor(.white)
                .cornerRadius(12)
                .font(.title3)
            }
        }
        .padding()
        .task {
            guard await requestPermission() else { return }
        }
    }
    
    private func requestPermission() async -> Bool {
        switch alarmManager.authorizationState {
        case .notDetermined:
            do {
                return try await alarmManager.requestAuthorization() == .authorized
            } catch {
                return false
            }
        case .authorized:
            return true
        case .denied:
            return false
            
        @unknown default:
            return false
        }
    }
    
    private func scheduleCoffeeTimer() async {
        let minutes = useCustomTime ? customMinutes : selectedCoffeeType.defaultMinutes
        let duration = TimeInterval(minutes * 60)
        
        let alert = AlarmPresentation.Alert(
            title: "\(selectedCoffeeType.icon) Your \(selectedCoffeeType.name) is ready!",
            stopButton: AlarmButton(
                text: "Done",
                textColor: .white,
                systemImageName: "checkmark.circle.fill"
            ),
            secondaryButton: AlarmButton(text: "Repeat", textColor: .white, systemImageName: "swift"),
            secondaryButtonBehavior: .countdown
            
        )

        let attributes = AlarmAttributes<CoffeeMetadata>(
            presentation: AlarmPresentation(
                alert: alert,
            ),
            metadata: CoffeeMetadata(
                coffeeType: selectedCoffeeType.name,
                brewTime: minutes
            ),
            tintColor: .brown
        )
        
        do {
            let timerAlarm = try await alarmManager.schedule(
                id: UUID(),
                configuration: .timer(
                    duration: duration,
                    attributes: attributes
                )
            )
            print("Coffee timer scheduled for \(minutes) minutes")
        } catch {
            print("Scheduling error: \(error)")
        }
    }
}

enum CoffeeType: String, CaseIterable {
    case espresso = "espresso"
    case americano = "americano"
    case latte = "latte"
    case cappuccino = "cappuccino"
    case pourOver = "pour_over"
    
    var name: String {
        switch self {
        case .espresso: return "Espresso"
        case .americano: return "Americano"
        case .latte: return "Latte"
        case .cappuccino: return "Cappuccino"
        case .pourOver: return "Pour Over"
        }
    }
    
    var icon: String {
        switch self {
        case .espresso: return "☕️"
        case .americano: return "☕️"
        case .latte: return "🥛"
        case .cappuccino: return "☕️"
        case .pourOver: return "☕️"
        }
    }
    
    var defaultMinutes: Int {
        switch self {
        case .espresso: return 1
        case .americano: return 4
        case .latte: return 3
        case .cappuccino: return 2
        case .pourOver: return 3
        }
    }
}

nonisolated
struct CoffeeMetadata: AlarmMetadata {
    var coffeeType: String
    var brewTime: Int
}

By customizing buttons, applying tint colors, and attaching metadata, you can create alarm experiences that are more connected and coherent with your app.