| name | widgetkit-live-activities |
| description | Expert guidance for WidgetKit widgets and ActivityKit Live Activities on Apple platforms. Use when developers mention: (1) home screen / lock screen / StandBy widgets, (2) Live Activities or Dynamic Island, (3) widget timelines, TimelineProvider, or reload budgets, (4) interactive widgets with App Intents buttons/toggles, (5) Control Center controls (ControlWidget), (6) updating a Live Activity via APNs push. Not for Siri/Shortcuts intents themselves (see app-intents skill). |
| license | MIT |
| metadata | {"author":"ulk","version":"1.0","source":"ulk (interne) — extrait des sections natives d'isaac (27), agents/mobile/27-isaac.md","source-version":"ios-26","maintainer":"izo"} |
WidgetKit & Live Activities
Scope: WidgetKit (home screen, lock screen, StandBy, watchOS complications via WidgetKit), ActivityKit Live Activities (lock screen + Dynamic Island), Control Center controls. Timeline design, reload budgets, interactivity via App Intents, push-driven updates.
Out of scope: the App Intents framework itself (→ swift-app-intents), APNs server setup (→ docs/api/push.md), watchOS-only complications design (→ watchos-design-guidelines).
Decision Tree
| Need | API | Min OS |
|---|
| Glanceable state on home/lock screen | Widget + TimelineProvider | iOS 14 (modern baseline: 17) |
| Live event tracking (delivery, score, timer) | ActivityKit Live Activity | iOS 16.1 |
| Dynamic Island presence | Live Activity dynamicIsland closure | iOS 16.1 |
| Buttons/toggles inside a widget | Button(intent:) / Toggle(isOn:intent:) | iOS 17 |
| Control Center / lock screen control | ControlWidget | iOS 18 |
| Push-updated widget/activity | pushTokenUpdates + APNs liveactivity type | iOS 16.1 (push-to-start: 17.2) |
Core Rules (non-negotiable)
- A widget is a timeline, not a mini-app. Generate entries ahead of time in
getTimeline; never rely on frequent reloads. The system budget is roughly 40–70 refreshes/day — design entries so the widget stays correct between reloads (e.g. use Text(timerInterval:) for countdowns, not per-second entries).
- Never do async work in
placeholder(in:). It must return instantly with static sample data.
- All widget interactivity goes through App Intents.
Button(intent:) / Toggle(isOn:intent:) only — no URLSession side effects from the view, no custom gestures. The intent's perform() runs in your process; end it by reloading timelines (WidgetCenter.shared.reloadTimelines(ofKind:)).
- Share state via App Group, never via UserDefaults.standard. The widget extension is a separate process: use
UserDefaults(suiteName:) or a shared SwiftData/Core Data container. Keep tokens in Keychain with an access group (see swift-security).
ActivityAttributes split: immutable context in the attributes, mutable state in ContentState. ContentState must stay small (< 4 KB) — it travels in APNs payloads.
- Always provide all Dynamic Island regions (
compactLeading, compactTrailing, minimal, expanded) and a lock-screen presentation. Test on devices without Dynamic Island.
- End activities deterministically. Call
activity.end(_:dismissalPolicy:) (or send "event": "end" via push). A stale Live Activity is a user-trust bug; the system force-ends after 8 hours (up to 12 h on the lock screen).
- Deep-link with
widgetURL/Link, never assume the app is running. Handle the URL in onOpenURL and restore full context.
Canonical Patterns
Timeline provider (iOS 17+, AppIntentTimelineProvider)
struct StatusProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> StatusEntry { .sample }
func snapshot(for configuration: StatusConfigIntent, in context: Context) async -> StatusEntry {
(try? await fetchEntry(configuration)) ?? .sample
}
func timeline(for configuration: StatusConfigIntent, in context: Context) async -> Timeline<StatusEntry> {
let entry = (try? await fetchEntry(configuration)) ?? .sample
return Timeline(entries: [entry], policy: .after(.now + 30 * 60))
}
}
Live Activity lifecycle
let attributes = DeliveryAttributes(orderID: order.id)
let state = DeliveryAttributes.ContentState(eta: eta, step: .preparing)
let activity = try Activity.request(
attributes: attributes,
content: .init(state: state, staleDate: .now + 60 * 30),
pushType: .token
)
Task {
for await token in activity.pushTokenUpdates {
await api.registerLiveActivityToken(token, orderID: order.id)
}
}
await activity.update(.init(state: newState, staleDate: .now + 60 * 30))
await activity.end(.init(state: finalState, staleDate: nil), dismissalPolicy: .after(.now + 60 * 5))
APNs update payload (apns-push-type: liveactivity, topic <bundle-id>.push-type.liveactivity):
{ "aps": { "timestamp": 1699999999, "event": "update",
"content-state": { "eta": "2026-07-11T12:45:00Z", "step": "delivering" },
"stale-date": 1700001800, "relevance-score": 100 } }
content-state keys must match ContentState's Codable encoding exactly — a mismatch silently drops the update.
Interactive widget button
struct MarkDoneIntent: AppIntent {
static let title: LocalizedStringResource = "Mark done"
@Parameter(title: "Task ID") var taskID: String
func perform() async throws -> some IntentResult {
try await TaskStore.shared.markDone(taskID)
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
return .result()
}
}
Button(intent: MarkDoneIntent(taskID: entry.task.id)) { Image(systemName: "checkmark") }
Common AI Mistakes
| ❌ Generated pattern | Why it fails | ✅ Correct |
|---|
Timer/onReceive inside a widget view | Widgets are archived views; timers never fire | Text(timerInterval:), Text(_:style: .relative) |
.atEnd policy with a single "now" entry | Reload-storm, budget exhausted by noon | Multi-entry timeline + .after(date) |
Fetching in placeholder | Blocks gallery, rejected patterns | Static sample data |
| One push token per activity assumed stable | Tokens rotate | Consume pushTokenUpdates stream continuously |
Button { } action closure in widget | Silently non-interactive | Button(intent:) (iOS 17+) |
| Live Activity started from the background ad hoc | Not allowed | Foreground start, or push-to-start (iOS 17.2+, pushToStartTokenUpdates) |
Secrets read from UserDefaults.standard | Separate process — empty | App Group defaults / Keychain access group |
Checklist before shipping