Lektion 4.19 · Stufe 4 – Profi

Live Activities

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du Live Activities bauen: laufende Ereignisse auf dem Sperrbildschirm und in der Dynamic Island zeigen. Du definierst Attribute und veränderlichen Zustand mit ActivityAttributes, gestaltest die Darstellungen für Sperrbildschirm sowie kompakte, minimale und erweiterte Dynamic Island, startest, aktualisierst und beendest Aktivitäten aus der App und zeigst Countdowns, die ohne Aktualisierung weiterlaufen.

Das Problem: Etwas läuft, und man will es im Blick behalten

Du kochst ein Rezept aus deiner Rezept-App aus Projekt D. Die Nudeln brauchen noch acht Minuten. Das iPhone liegt gesperrt auf der Arbeitsfläche. Wie viel Zeit ist noch übrig?

Bisher müsstest du das iPhone entsperren, die App öffnen und nachsehen. Mit einer Live Activity steht der Countdown direkt auf dem Sperrbildschirm und, auf iPhones mit Dynamic Island, oben in der Dynamic Island, auch während du eine andere App benutzt.

Live Activities sind für Ereignisse mit Anfang und Ende, deren Fortschritt man verfolgen will:

  • ein Koch- oder Trainings-Timer,
  • eine Essenslieferung („Fahrer ist unterwegs, noch 12 Minuten“),
  • ein laufendes Fußballspiel,
  • eine Fahrt mit Bus oder Taxi,
  • ein Upload oder Download.

Wie Live Activities funktionieren

Live Activities sind eng mit Widgets (Lektion 4.18) verwandt: Sie werden mit SwiftUI gestaltet, leben in einer Widget-Extension und werden vom System dargestellt, nicht von deiner laufenden App.

Der Unterschied: Ein Widget wird über eine Zeitleiste versorgt. Eine Live Activity wird von deiner App gestartet, mit neuen Daten aktualisiert und am Ende beendet.

WidgetLive Activity
Dauerdauerhaftein Ereignis, höchstens etwa 8 Stunden aktiv
DatenZeitleiste vom ProviderUpdates von der App oder per Push
OrtHome-Bildschirm, SperrbildschirmSperrbildschirm, Dynamic Island, StandBy

Vorbereitung

  1. Hast du noch keine Widget-Extension, leg eine an (Lektion 4.18). Beim Anlegen kannst du Include Live Activity anhaken, dann erzeugt Xcode ein Gerüst.
  2. Im App-Target unter Info einen Eintrag hinzufügen: Supports Live Activities (NSSupportsLiveActivities) mit dem Wert YES.

Schritt 1: Attribute und Zustand

Eine Live Activity hat zwei Arten von Daten:

  • Attribute: Was sich nie ändert, solange die Aktivität läuft. Beim Koch-Timer etwa der Name des Rezepts und des Schritts.
  • Zustand (ContentState): Was sich ändern kann. Etwa die Endzeit des Timers oder ob er pausiert ist.
Swift
import ActivityKit
import Foundation

struct CookingTimerAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var endDate: Date
        var isPaused: Bool
        var remainingWhenPaused: TimeInterval
    }

    var recipeName: String
    var stepName: String
}

Diese Datei muss zu beiden Targets gehören, App und Widget-Extension: Die App startet die Aktivität, die Extension stellt sie dar. Hake sie im File Inspector unter Target Membership für beide an.

Endzeit statt Restzeit

Gespeichert wird nicht „noch 8 Minuten“, sondern „fertig um 18:42 Uhr“. Aus der Endzeit kann die Anzeige selbst sekundengenau herunterzählen, ohne dass die App jede Sekunde eine Aktualisierung schicken muss. Das spart Akku, und das System erlaubt ohnehin nicht beliebig viele Aktualisierungen.

Schritt 2: Die Darstellung

In der Widget-Extension beschreibst du, wie die Aktivität aussieht. Dafür gibt es ActivityConfiguration:

Swift
import ActivityKit
import WidgetKit
import SwiftUI

struct CookingTimerLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: CookingTimerAttributes.self) { context in
            // Sperrbildschirm und Banner
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // erweiterte Ansicht (langes Drücken)
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.attributes.recipeName, systemImage: "fork.knife")
                        .font(.caption)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    TimerText(state: context.state)
                        .font(.title2.monospacedDigit().bold())
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Text(context.attributes.stepName)
                        .font(.headline)
                }
            } compactLeading: {
                Image(systemName: "timer")
                    .foregroundStyle(.orange)
            } compactTrailing: {
                TimerText(state: context.state)
                    .frame(maxWidth: 52)
                    .monospacedDigit()
            } minimal: {
                Image(systemName: "timer")
                    .foregroundStyle(.orange)
            }
        }
    }
}

Die Closures bekommen einen context mit context.attributes (die festen Daten) und context.state (der aktuelle Zustand).

Die Dynamic Island hat drei Darstellungen:

  • Kompakt (compactLeading und compactTrailing): links und rechts neben der Kamera, wenn genau eine Aktivität läuft. Sehr wenig Platz: ein Symbol links, ein Wert rechts.
  • Minimal (minimal): ein winziger Kreis, wenn mehrere Aktivitäten gleichzeitig laufen. Nur ein Symbol oder ein sehr kurzer Wert.
  • Erweitert (der erste Block): wenn der Nutzer lange auf die Dynamic Island drückt. Aufgeteilt in Bereiche: .leading, .trailing, .center und .bottom.

Der Countdown

Das Herzstück ist der Countdown, der von selbst herunterzählt:

Swift
struct TimerText: View {
    let state: CookingTimerAttributes.ContentState

    var body: some View {
        if state.isPaused {
            Text(Duration.seconds(state.remainingWhenPaused), format: .time(pattern: .minuteSecond))
        } else {
            Text(timerInterval: Date.now...state.endDate, countsDown: true)
        }
    }
}

Text(timerInterval:countsDown:) zeigt die verbleibende Zeit bis endDate und aktualisiert sich jede Sekunde von selbst, ohne dass die App läuft. Ist er pausiert, wird die gespeicherte Restzeit fest angezeigt.

Der Sperrbildschirm

Swift
struct LockScreenView: View {
    let context: ActivityViewContext<CookingTimerAttributes>

    var body: some View {
        HStack(spacing: 16) {
            Image(systemName: "frying.pan.fill")
                .font(.largeTitle)
                .foregroundStyle(.orange)

            VStack(alignment: .leading, spacing: 2) {
                Text(context.attributes.recipeName)
                    .font(.headline)
                Text(context.attributes.stepName)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            TimerText(state: context.state)
                .font(.title.monospacedDigit().bold())
                .multilineTextAlignment(.trailing)
        }
        .padding()
        .activityBackgroundTint(.black.opacity(0.6))
        .activitySystemActionForegroundColor(.orange)
    }
}
  • .activityBackgroundTint: der Hintergrund auf dem Sperrbildschirm.
  • .activitySystemActionForegroundColor: die Farbe der Systemschaltflächen, etwa „Schließen“.

Vergiss nicht, CookingTimerLiveActivity() im WidgetBundle der Extension einzutragen.

Die Vorschau

Swift
#Preview("Sperrbildschirm", as: .content, using: CookingTimerAttributes(recipeName: "Spaghetti", stepName: "Nudeln kochen")) {
    CookingTimerLiveActivity()
} contentStates: {
    CookingTimerAttributes.ContentState(endDate: .now.addingTimeInterval(480), isPaused: false, remainingWhenPaused: 0)
}

Mit as: .dynamicIsland(.compact), .dynamicIsland(.expanded) und .dynamicIsland(.minimal) siehst du die anderen Darstellungen.

Schritt 3: Starten, aktualisieren, beenden

Jetzt die App-Seite. Am besten bündelst du alles in einem kleinen Objekt:

Swift
import ActivityKit

@Observable
class CookingTimerController {
    private var activity: Activity<CookingTimerAttributes>?

    var isRunning: Bool { activity != nil }

    func start(recipe: String, step: String, minutes: Int) {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }

        let attributes = CookingTimerAttributes(recipeName: recipe, stepName: step)
        let state = CookingTimerAttributes.ContentState(
            endDate: .now.addingTimeInterval(TimeInterval(minutes * 60)),
            isPaused: false,
            remainingWhenPaused: 0
        )

        do {
            activity = try Activity.request(
                attributes: attributes,
                content: ActivityContent(state: state, staleDate: state.endDate)
            )
        } catch {
            print("Live Activity konnte nicht gestartet werden: \(error)")
        }
    }

    func pause() async {
        guard let activity else { return }
        let remaining = max(activity.content.state.endDate.timeIntervalSinceNow, 0)
        let state = CookingTimerAttributes.ContentState(endDate: activity.content.state.endDate,
                                                        isPaused: true,
                                                        remainingWhenPaused: remaining)
        await activity.update(ActivityContent(state: state, staleDate: nil))
    }

    func stop() async {
        guard let activity else { return }
        await activity.end(nil, dismissalPolicy: .immediate)
        self.activity = nil
    }
}

Die wichtigsten Aufrufe:

  • ActivityAuthorizationInfo().areActivitiesEnabled: Der Nutzer kann Live Activities in den Einstellungen abschalten. Prüfe das vor dem Start.
  • Activity.request(attributes:content:): startet die Aktivität. Sie erscheint sofort auf dem Sperrbildschirm und in der Dynamic Island.
  • ActivityContent(state:staleDate:): der Zustand plus ein Zeitpunkt, ab dem die Daten als veraltet gelten. Hier die Endzeit. Danach kann die Darstellung mit context.isStale etwa „Fertig!“ zeigen.
  • activity.update(_:): neuer Zustand. Asynchron, deshalb await.
  • activity.end(_:dismissalPolicy:): beendet die Aktivität. Mit .immediate verschwindet sie sofort, mit .default bleibt sie noch eine Weile mit dem letzten Stand auf dem Sperrbildschirm, mit .after(datum) bis zu einem Zeitpunkt.

Im Rezept-Detail:

Swift
@State private var timer = CookingTimerController()

Button(timer.isRunning ? "Timer stoppen" : "8-Minuten-Timer starten", systemImage: "timer") {
    if timer.isRunning {
        Task { await timer.stop() }
    } else {
        timer.start(recipe: meal.name, step: "Nudeln kochen", minutes: 8)
    }
}

Erwartetes Ergebnis

Ein Tipp auf „Timer starten“, dann Wischen zum Home-Bildschirm: Oben in der Dynamic Island erscheint links ein orangefarbenes Timer-Symbol und rechts der herunterzählende Countdown. Auf dem Sperrbildschirm steht eine Karte mit Rezeptname, Schritt und großem Countdown.

Screenshot folgt: Live Activity auf dem Sperrbildschirm und in der Dynamic Island

Nach einem Neustart der App

Läuft eine Aktivität noch, wenn die App beendet und neu gestartet wird, ist die Variable activity leer. Mit Activity<CookingTimerAttributes>.activities bekommst du alle noch laufenden Aktivitäten deiner App und kannst sie wieder übernehmen, etwa beim Start mit activity = Activity<CookingTimerAttributes>.activities.first.

Gestaltungsregeln

Apple hat klare Vorstellungen davon, wie Live Activities aussehen sollen:

  • Das Wichtigste zuerst: Die kompakte Ansicht zeigt nur das, was man im Vorbeigehen wissen will. Beim Timer: die Zeit.
  • Kein Ersatz für Benachrichtigungen: Live Activities sind für laufende Ereignisse, nicht für Werbung oder Nachrichten.
  • Ein Ende: Beende Aktivitäten, sobald das Ereignis vorbei ist. Das System beendet sie spätestens nach acht Stunden selbst.
  • Lesbar auf jedem Hintergrund: Der Sperrbildschirm kann hell oder dunkel sein, und im StandBy-Modus wird die Aktivität groß und rötlich gedimmt dargestellt.

Typische Fehler

Aktivität startet nicht

NSSupportsLiveActivities fehlt in der Info des App-Targets, oder der Nutzer hat Live Activities abgeschaltet. Prüfe areActivitiesEnabled und den Fehler aus request.

Cannot find 'CookingTimerAttributes' in scope

Die Attribut-Datei gehört nicht zu beiden Targets. Hake App und Widget-Extension unter Target Membership an.

Countdown bleibt stehen

Statt Text(timerInterval:) wird ein fester Text wie „8:00“ angezeigt, der sich nur bei Updates ändert. Verwende die Timer-Darstellung mit einer Endzeit.

Dynamic Island zeigt abgeschnittene Inhalte

Die kompakte Ansicht ist sehr schmal. Begrenze Breiten mit .frame(maxWidth:) und verwende .monospacedDigit() für Zahlen, damit sie nicht springen.

FAQ

Anfänger

Was bedeutet das überhaupt?

Brauche ich ein iPhone mit Dynamic Island? Nein. Auf iPhones ohne Dynamic Island erscheinen Live Activities auf dem Sperrbildschirm und als Banner. Im Simulator kannst du ein Modell mit Dynamic Island wählen, um alle Darstellungen zu testen.

Kann man auf eine Live Activity tippen? Ja. Ein Tipp öffnet die App, mit .widgetURL an einer bestimmten Stelle. Seit iOS 17 sind außerdem Buttons und Schalter mit App Intents möglich, etwa „Pause“ direkt in der Live Activity (Lektion 4.20).

Fortgeschritten

Warum verhält es sich so?

Wie aktualisiere ich eine Aktivität, wenn die App nicht läuft? Über Push-Benachrichtigungen: Beim Start gibst du pushType: .token an, bekommst ein Token und schickst es an deinen Server. Der Server kann die Aktivität dann über Apples Push-Dienst aktualisieren, etwa bei einem Spielstand, der sich im Stadion ändert. Dafür brauchst du ein Backend und die Push-Fähigkeit im Projekt.

Was zeigt context.isStale? Ob das staleDate überschritten ist. Beim Timer bedeutet das: Die Zeit ist abgelaufen. Die Darstellung kann dann statt eines Countdowns „Fertig!“ zeigen, auch wenn die App gerade nicht läuft und kein Update schicken kann.

Profi

Was passiert unter der Haube?

Wie viele Updates darf ich schicken? Updates aus der App im Vordergrund sind großzügig möglich. Für Push-Updates gibt es ein Budget, das das System nach Nutzung verteilt. Für häufige Updates, etwa bei Sportereignissen, kann man ein höheres Budget beantragen (NSSupportsLiveActivitiesFrequentUpdates). Das System kann trotzdem drosseln, deshalb sind Endzeiten und Text(timerInterval:) so wertvoll.

Wie starte ich eine Live Activity per Push, ohne dass die App läuft? Seit iOS 17.2 gibt es Push-to-Start: Die App registriert sich mit Activity<…>.pushToStartTokenUpdates für ein Start-Token. Der Server kann dann eine Aktivität starten, etwa wenn eine Bestellung aufgegeben wurde, auch wenn die App geschlossen ist.

Übungen

Anfänger

Übung 1: Der Koch-Timer

Baue den Koch-Timer aus dieser Lektion nach: Attribute, Darstellung für Sperrbildschirm und Dynamic Island, Starten und Stoppen aus einer einfachen View mit zwei Buttons. Teste im Simulator mit einem iPhone mit Dynamic Island.

Fortgeschritten

Übung 2: Pause und Fertig

Ergänze den Timer um „Pausieren“ und „Fortsetzen“. Beim Fortsetzen wird eine neue Endzeit aus der gespeicherten Restzeit berechnet. Ist die Zeit abgelaufen (context.isStale), zeigt die Aktivität „Fertig!“ mit einem Häkchen statt des Countdowns.

Lösung Übung 2 (Ausschnitt)Erst selbst probieren!
Swift
// Controller
func resume() async {
    guard let activity, activity.content.state.isPaused else { return }
    let remaining = activity.content.state.remainingWhenPaused
    let newEnd = Date.now.addingTimeInterval(remaining)
    let state = CookingTimerAttributes.ContentState(endDate: newEnd, isPaused: false, remainingWhenPaused: 0)
    await activity.update(ActivityContent(state: state, staleDate: newEnd))
}

// Darstellung
struct TimerText: View {
    let context: ActivityViewContext<CookingTimerAttributes>

    var body: some View {
        if context.isStale {
            Label("Fertig!", systemImage: "checkmark.circle.fill")
                .foregroundStyle(.green)
        } else if context.state.isPaused {
            Text(Duration.seconds(context.state.remainingWhenPaused), format: .time(pattern: .minuteSecond))
        } else {
            Text(timerInterval: Date.now...context.state.endDate, countsDown: true)
        }
    }
}

Beim Pausieren gibt es kein staleDate, weil die Zeit nicht abläuft. Beim Fortsetzen wird die neue Endzeit wieder zum staleDate.

Profi

Übung 3: Mehrere Schritte

Erweitere die Rezept-App so, dass ein Rezept mehrere zeitgesteuerte Schritte haben kann („Wasser kochen: 5 Min.“, „Nudeln: 8 Min.“, „Soße: 10 Min.“). Die Live Activity zeigt den aktuellen Schritt und seinen Countdown, im erweiterten Zustand außerdem „Schritt 2 von 3“ und einen Fortschrittsbalken. Ist ein Schritt fertig, schaltet die App beim nächsten Öffnen zum nächsten Schritt weiter.

Zusammenfassung

  • Live Activities zeigen laufende Ereignisse mit Anfang und Ende auf dem Sperrbildschirm und in der Dynamic Island.
  • ActivityAttributes trennt feste Attribute vom veränderlichen ContentState. Die Datei gehört zu App und Extension.
  • ActivityConfiguration beschreibt Sperrbildschirm sowie kompakte, minimale und erweiterte Dynamic Island.
  • Text(timerInterval:countsDown:) zählt von selbst herunter: Endzeit statt Restzeit speichern.
  • Activity.request, update und end starten, aktualisieren und beenden. Vorher areActivitiesEnabled prüfen.
  • staleDate und context.isStale für den Zustand nach Ablauf. NSSupportsLiveActivities in der Info der App.

Spickzettel

Swift
struct TimerAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable { var endDate: Date }
    var title: String
}

// Widget-Extension
ActivityConfiguration(for: TimerAttributes.self) { context in
    LockScreenView(context: context)
} dynamicIsland: { context in
    DynamicIsland {
        DynamicIslandExpandedRegion(.trailing) { Text(timerInterval: .now...context.state.endDate) }
    } compactLeading: {
        Image(systemName: "timer")
    } compactTrailing: {
        Text(timerInterval: .now...context.state.endDate).frame(maxWidth: 52)
    } minimal: {
        Image(systemName: "timer")
    }
}

// App
let activity = try Activity.request(
    attributes: TimerAttributes(title: "Nudeln"),
    content: ActivityContent(state: .init(endDate: end), staleDate: end))
await activity.update(ActivityContent(state: newState, staleDate: nil))
await activity.end(nil, dismissalPolicy: .immediate)

// Info (App): NSSupportsLiveActivities = YES

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.