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.
| Widget | Live Activity | |
|---|---|---|
| Dauer | dauerhaft | ein Ereignis, höchstens etwa 8 Stunden aktiv |
| Daten | Zeitleiste vom Provider | Updates von der App oder per Push |
| Ort | Home-Bildschirm, Sperrbildschirm | Sperrbildschirm, Dynamic Island, StandBy |
Vorbereitung
- 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.
- 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.
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:
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 (
compactLeadingundcompactTrailing): 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,.centerund.bottom.
Der Countdown
Das Herzstück ist der Countdown, der von selbst herunterzählt:
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
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
#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:
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 mitcontext.isStaleetwa „Fertig!“ zeigen.activity.update(_:): neuer Zustand. Asynchron, deshalbawait.activity.end(_:dismissalPolicy:): beendet die Aktivität. Mit.immediateverschwindet sie sofort, mit.defaultbleibt sie noch eine Weile mit dem letzten Stand auf dem Sperrbildschirm, mit.after(datum)bis zu einem Zeitpunkt.
Im Rezept-Detail:
@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.
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
Ü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.
Ü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!
// 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.
Ü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.
ActivityAttributestrennt feste Attribute vom veränderlichenContentState. Die Datei gehört zu App und Extension.ActivityConfigurationbeschreibt Sperrbildschirm sowie kompakte, minimale und erweiterte Dynamic Island.Text(timerInterval:countsDown:)zählt von selbst herunter: Endzeit statt Restzeit speichern.Activity.request,updateundendstarten, aktualisieren und beenden. VorherareActivitiesEnabledprüfen.staleDateundcontext.isStalefür den Zustand nach Ablauf.NSSupportsLiveActivitiesin der Info der App.
Spickzettel
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