evals/evals.json
{
"skill_name": "widgetkit",
"evals": [
{
"id": 0,
"name": "widget-push-reload-review",
"prompt": "Review this iOS 26 WidgetKit plan before implementation: we will add a WidgetPushHandler type in the app target, send APNs broadcast pushes on a channel whenever server data changes, and rely on those pushes instead of timelines for freshness. What needs to change?",
"expected_output": "A correction-focused review that explains the correct WidgetKit push handler registration, widget-extension capability, APNs headers, budget behavior, and the boundary with ActivityKit broadcast channels.",
"files": [],
"expectations": [
"Says `WidgetPushHandler` belongs with the widget configuration and must be registered with `.pushHandler(...)` on the `WidgetConfiguration`.",
"Mentions adding the Push Notifications capability to the widget extension target for WidgetKit push updates.",
"Specifies the WidgetKit APNs contract: `apns-push-type: widgets`, topic suffix `.push-type.widgets`, and an `aps.content-changed` payload.",
"Warns that WidgetKit push notifications are budgeted or opportunistic and supplement timelines rather than replacing them.",
"Rejects ActivityKit broadcast channels as a WidgetKit timeline update mechanism."
]
},
{
"id": 1,
"name": "control-toggle-push-review",
"prompt": "We are building a Control Center garage-door toggle. The action is a plain AppIntent with no value parameter, remote server updates use ControlPushHandler.pushTokensDidChange(controls: [ControlPushInfo]), and APNs sends `apns-push-type: widgets`. Review the plan and provide corrected Swift/API guidance.",
"expected_output": "A WidgetKit controls review that distinguishes button and toggle intents, fixes ControlPushHandler types, and names the correct control reload APNs headers.",
"files": [],
"expectations": [
"Requires the `ControlWidgetToggle` action to conform to `SetValueIntent` with a Boolean `value` parameter.",
"Explains that `ControlPushHandler.pushTokensDidChange(controls:)` receives `[ControlInfo]`, with push tokens available through each control's push info.",
"States that push-capable controls register the handler with `.pushHandler(...)` on the `ControlWidgetConfiguration`.",
"Specifies the remote control APNs contract: `apns-push-type: controls`, topic suffix `.push-type.controls`, and an `aps.content-changed` payload.",
"Keeps generic App Intents exposition brief and focused on WidgetKit control integration."
]
},
{
"id": 2,
"name": "deep-link-rendering-boundary",
"prompt": "Audit this widget design note: small widgets can only use widgetURL, not Link; Liquid Glass support means picking a WidgetAccentedRenderingMode enum case globally; and Smart Stack relevance should return WidgetRelevance(intent, score: 80) from AppIntentTimelineProvider on iPhone. Fix the note without drifting into full ActivityKit or App Intents docs.",
"expected_output": "A bounded WidgetKit correction that fixes deep links, accented rendering, and Smart Stack relevance while preserving sibling-skill boundaries.",
"files": [],
"expectations": [
"Corrects that `Link` can be used in `.systemSmall`, `.accessoryRectangular`, and larger system widgets, with one `widgetURL(_:)` for the general surface.",
"Frames Liquid Glass and accented rendering around `widgetRenderingMode`, `.widgetAccentable()`, and `Image.widgetAccentedRenderingMode(_:)` rather than a global enum choice.",
"Rejects the invalid `WidgetRelevance(intent, score:)` initializer.",
"Explains that iPhone and iPad Smart Stacks use `TimelineEntryRelevance` and intent donation, while watchOS contextual relevance uses `WidgetRelevance([WidgetRelevanceAttribute(...)])`.",
"Keeps ActivityKit, App Intents, and APNs details limited to handoff boundaries that affect the widget note."
]
}
]
}
references/widgetkit-advanced.md
# WidgetKit Advanced Reference
This reference is WidgetKit-first. ActivityKit and App Intents details appear
only where they affect widget bundles, Live Activity registration, controls, or
Smart Stack visibility; use sibling `activitykit` and `app-intents` skills for
full lifecycle, APNs content-state, Siri/Shortcuts/Spotlight, and entity-query
design.
## Contents
- [Timeline Strategies](#timeline-strategies)
- [Push-Based Widget and Control Reloads](#push-based-widget-and-control-reloads)
- [Widget URL Handling and Deep Links](#widget-url-handling-and-deep-links)
- [Intent-Driven Widget Configuration](#intent-driven-widget-configuration)
- [Multiple Widget Support in WidgetBundle](#multiple-widget-support-in-widgetbundle)
- [Widget Previews and Snapshots](#widget-previews-and-snapshots)
- [AccessoryWidgetBackground](#accessorywidgetbackground)
- [Dynamic Island Expanded Layout Patterns](#dynamic-island-expanded-layout-patterns)
- [Alert Configuration for Live Activities](#alert-configuration-for-live-activities)
- [Push Notification Support for Live Activities](#push-notification-support-for-live-activities)
- [ActivityAuthorizationInfo](#activityauthorizationinfo)
- [Widget Performance Best Practices](#widget-performance-best-practices)
- [Xcode Setup](#xcode-setup)
- [Widget Relevance and Smart Stacks](#widget-relevance-and-smart-stacks)
- [ActivityState Lifecycle](#activitystate-lifecycle)
- [ActivityStyle](#activitystyle)
- [Dismissal Policies](#dismissal-policies)
- [Querying Active Widgets and Activities](#querying-active-widgets-and-activities)
- [Design Patterns](#design-patterns)
- [Apple Documentation Links](#apple-documentation-links)
## Timeline Strategies
### TimelineReloadPolicy
Control when WidgetKit requests a new timeline after the current entries expire.
| Policy | Behavior | Use When |
|---|---|---|
| `.atEnd` | Requests a new timeline after the last entry's date. Default. | Data changes unpredictably. |
| `.after(Date)` | Requests a new timeline after a specific date. | Data updates on a known schedule (market hours, flights). |
| `.never` | No automatic refresh. App must trigger manually. | Data changes only from user action. |
### Multiple Timeline Entries
Pre-generate entries for known future states to reduce refresh requests and
conserve the daily budget.
```swift
func timeline(for configuration: Intent, in context: Context) async -> Timeline<StockEntry> {
var entries: [StockEntry] = []
let now = Date()
// Generate hourly entries for the next 6 hours
for hourOffset in 0..<6 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: now)!
let price = await StockService.shared.projectedPrice(at: entryDate, for: configuration.symbol)
entries.append(StockEntry(date: entryDate, symbol: configuration.symbol.name, price: price))
}
let nextRefresh = Calendar.current.date(byAdding: .hour, value: 6, to: now)!
return Timeline(entries: entries, policy: .after(nextRefresh))
}
```
### Triggering Manual Reloads
```swift
// Reload a specific widget kind
WidgetCenter.shared.reloadTimelines(ofKind: "OrderStatusWidget")
// Reload all widgets
WidgetCenter.shared.reloadAllTimelines()
```
Call `reloadTimelines(ofKind:)` only when displayed data actually changes. Each
call counts against the daily refresh budget.
### Refresh Budget
Each configured widget has a daily refresh limit. Exemptions apply for:
- Foreground app usage
- Active media sessions
- Standard location service usage
WidgetKit does not impose refresh limits when debugging in Xcode.
## Push-Based Widget and Control Reloads
### WidgetPushHandler
Use WidgetKit push notifications as a budgeted, opportunistic reload signal in
addition to normal timelines. Add the Push Notifications capability to the
widget extension, implement `WidgetPushHandler`, and register the handler on the
widget configuration with `.pushHandler(...)`.
```swift
struct MyWidgetPushHandler: WidgetPushHandler {
func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) {
let tokenString = pushInfo.token.map { String(format: "%02x", $0) }.joined()
Task {
try await ServerAPI.shared.register(widgetPushToken: tokenString)
}
}
}
struct CaffeineTrackerWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CaffeineTracker", provider: Provider()) { entry in
CaffeineTrackerView(entry: entry)
}
.configurationDisplayName("Caffeine Tracker")
.pushHandler(MyWidgetPushHandler.self)
}
}
```
### Server-Side Integration
Send an APNs push with the widget's push token. The system calls your
`TimelineProvider.getTimeline` or `AppIntentTimelineProvider.timeline(for:in:)`
when the push arrives. Use `apns-push-type: widgets`, an `apns-topic` of
`<bundleID>.push-type.widgets`, and an `aps` payload with
`"content-changed": true`. WidgetKit push notifications cannot use broadcast
channels. Treat this as a reload signal; keep durable state in shared storage
or refetch it when the provider runs.
### ControlPushHandler
Controls use their own push handler and APNs push type. Register the handler on
the `ControlWidgetConfiguration` with `.pushHandler(...)`.
```swift
struct GarageDoorControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "GarageDoor") {
ControlWidgetButton(action: OpenGarageDoorIntent()) {
Label("Garage", systemImage: "door.garage.open")
}
}
.pushHandler(MyControlPushHandler.self)
}
}
struct MyControlPushHandler: ControlPushHandler {
func pushTokensDidChange(controls: [ControlInfo]) {
for control in controls {
guard let token = control.pushInfo?.token else { continue }
let tokenString = token.map { String(format: "%02x", $0) }.joined()
Task {
try await ServerAPI.shared.register(controlPushToken: tokenString)
}
}
}
}
```
For remote control reloads, use `apns-push-type: controls`, an `apns-topic` of
`<bundleID>.push-type.controls`, and an `aps` payload with
`"content-changed": true`. Do not encode the control's new state as a custom
payload key and expect WidgetKit to apply it; update shared state through the
app, server, or control action, then let the value provider read it.
For `ControlWidgetToggle`, the action must conform to `SetValueIntent` with a
Boolean value. The system fills `value` with the new toggle state.
```swift
struct ToggleFlashlightIntent: SetValueIntent {
static var title: LocalizedStringResource = "Toggle Flashlight"
@Parameter(title: "On")
var value: Bool
func perform() async throws -> some IntentResult {
try await FlashlightController.shared.setEnabled(value)
return .result()
}
}
```
## Widget URL Handling and Deep Links
### widgetURL(_:)
Set a single URL for the entire widget. Tapping anywhere opens the app with this URL.
```swift
struct SmallWidgetView: View {
let entry: OrderEntry
var body: some View {
VStack {
Text(entry.orderName)
Text(entry.status)
}
.widgetURL(URL(string: "myapp://orders/\(entry.orderID)")!)
}
}
```
### Link (Multiple Targets)
Use `Link` for multiple tap targets in `.accessoryRectangular`, `.systemSmall`,
and larger system widgets. You can combine one `widgetURL(_:)` for the general
surface with `Link` controls for specific subregions.
```swift
struct MediumWidgetView: View {
let entry: OrderListEntry
var body: some View {
VStack {
ForEach(entry.orders) { order in
Link(destination: URL(string: "myapp://orders/\(order.id)")!) {
HStack {
Text(order.name)
Spacer()
Text(order.status)
}
}
}
}
}
}
```
### Handling in the App
```swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
DeepLinkRouter.shared.handle(url)
}
}
}
}
```
**Important:** If the view hierarchy includes more than one `widgetURL(_:)`,
the behavior is undefined. Use `Link` for additional targets.
## Intent-Driven Widget Configuration
WidgetKit uses `WidgetConfigurationIntent` as the configuration type for
`AppIntentConfiguration` and `AppIntentTimelineProvider`. Keep the intent type
available to the widget extension or a shared framework linked into it. Design
of `AppEntity`, `EntityQuery`, Siri, Shortcuts, Spotlight, and parameter
resolution belongs in the sibling `app-intents` skill.
WidgetKit integration points to review here:
- `AppIntentConfiguration(kind:intent:provider:content:)` uses the intent type.
- `AppIntentTimelineProvider` receives that intent in `snapshot` and `timeline`.
- `recommendations()` may return `AppIntentRecommendation` values for the
widget gallery.
Do not expand this section into full intent/entity examples; route that work to
`app-intents`.
## Multiple Widget Support in WidgetBundle
### Declaring Multiple Widgets
```swift
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
OrderStatusWidget() // Home Screen widget
FavoritesWidget() // Configurable widget
StepsAccessoryWidget() // Lock Screen widget
DeliveryActivityWidget() // Live Activity
QuickActionControl() // Control Center
}
}
```
### Conditional Widgets
Include widgets conditionally based on platform or availability:
```swift
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
CoreWidget()
if #available(iOS 18, *) {
QuickActionControl()
}
}
}
```
## Widget Previews and Snapshots
### Xcode Previews
```swift
#Preview("Small", as: .systemSmall) {
OrderStatusWidget()
} timeline: {
OrderEntry(date: .now, orderName: "Pizza", status: "Preparing")
OrderEntry(date: .now.addingTimeInterval(600), orderName: "Pizza", status: "Delivering")
}
#Preview("Circular", as: .accessoryCircular) {
StepsAccessoryWidget()
} timeline: {
StepsEntry(date: .now, stepCount: 4200)
}
```
### Live Activity Previews
```swift
#Preview("Lock Screen", as: .content, using: DeliveryAttributes.preview) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(
driverName: "Alex",
estimatedDeliveryTime: Date()...Date().addingTimeInterval(900),
currentStep: .delivering
)
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: DeliveryAttributes.preview) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(
driverName: "Alex",
estimatedDeliveryTime: Date()...Date().addingTimeInterval(900),
currentStep: .delivering
)
}
```
### Snapshot Best Practices
- Return sample data immediately in `placeholder(in:)` -- it must be synchronous.
- In `getSnapshot` / `snapshot(for:in:)`, check `context.isPreview`:
- When `true`, return representative sample data quickly.
- When `false`, return the current real state.
```swift
// WRONG: Performing a network call in placeholder
func placeholder(in context: Context) -> MyEntry {
// Compilation error: placeholder must be synchronous
let data = await fetchData()
return MyEntry(date: .now, data: data)
}
// CORRECT: Return static sample data
func placeholder(in context: Context) -> MyEntry {
MyEntry(date: .now, data: SampleData.placeholder)
}
```
## AccessoryWidgetBackground
Provide the standard translucent background for Lock Screen widgets.
```swift
struct CircularStepsView: View {
let steps: Int
var body: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: "figure.walk")
.font(.caption)
Text("\(steps)")
.font(.headline)
.widgetAccentable()
}
}
}
}
```
### Rendering Mode Awareness
Lock Screen widgets render in `.vibrant` or `.accented` mode. Adapt content:
```swift
@Environment(\.widgetRenderingMode) var renderingMode
var body: some View {
switch renderingMode {
case .fullColor:
ColorfulView()
case .vibrant, .accented:
MonochromeView()
@unknown default:
MonochromeView()
}
}
```
Use `.widgetAccentable()` to mark views that should receive the accent tint in
`.accented` rendering mode.
For images that need special treatment in accented mode, use
`Image.widgetAccentedRenderingMode(_:)`. Reserve `.fullColor` for content such
as album art or book covers where preserving the original image matters.
```swift
Image("album-art")
.resizable()
.widgetAccentedRenderingMode(.fullColor)
```
## Dynamic Island Expanded Layout Patterns
### Full Layout Example
```swift
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading) {
Image(systemName: "airplane")
.font(.title2)
Text("UA 1234")
.font(.caption2)
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing) {
Text("SFO")
.font(.title3.bold())
Text("On Time")
.font(.caption2)
.foregroundStyle(.green)
}
}
DynamicIslandExpandedRegion(.center) {
Text("San Francisco to New York")
.font(.caption)
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
ProgressView(value: 0.45)
.tint(.blue)
HStack {
Text("Departed 2:30 PM")
Spacer()
Text("Arrives 10:45 PM")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
} compactLeading: {
Image(systemName: "airplane")
} compactTrailing: {
Text("2h 15m")
.monospacedDigit()
} minimal: {
Image(systemName: "airplane")
}
```
### Vertical Placement
Control vertical alignment within expanded regions:
```swift
DynamicIslandExpandedRegion(.leading) {
Text("Top")
.dynamicIsland(verticalPlacement: .belowIfTooWide)
}
```
### Content Margins
Override margins for specific Dynamic Island modes:
```swift
.contentMargins(.trailing, 20, for: .expanded)
.contentMargins(.bottom, 16, for: .expanded)
```
### Keyline Tint
Apply a subtle tint to the Dynamic Island border:
```swift
DynamicIsland { /* ... */ }
.keylineTint(.blue)
```
## Alert Configuration for Live Activities
Trigger a visible and audible alert when updating a Live Activity:
```swift
let alert = AlertConfiguration(
title: "Delivery Update",
body: "Your order is out for delivery!",
sound: .default
)
await activity.update(updatedContent, alertConfiguration: alert)
```
### Custom Alert Sound
```swift
let alert = AlertConfiguration(
title: "Score Update",
body: "Goal! The score is now 2-1.",
sound: .named("goal-horn.aiff")
)
```
Place the sound file in the app bundle. Use `.default` when no custom sound is needed.
## Push Notification Support for Live Activities
### Registering for Push Updates
```swift
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token // Enable push updates
)
// Observe token changes
Task {
for await token in activity.pushTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
try await ServerAPI.shared.registerActivityToken(tokenString, activityID: activity.id)
}
}
```
### Push-to-Start (Remote Activity Creation)
```swift
// Observe the push-to-start token
Task {
for await token in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
try await ServerAPI.shared.registerPushToStartToken(tokenString)
}
}
```
### Channel-Based ActivityKit Push (iOS 18+)
ActivityKit broadcast channels are for Live Activity updates, not WidgetKit
timeline push notifications. Pass a valid base64-encoded channel ID that your
server created through APNs channel management.
```swift
let channelID = "ZGVsaXZlcnktdXBkYXRlcw=="
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .channel(channelID)
)
```
### APNs Payload Format for Live Activity Updates
```json
{
"aps": {
"timestamp": 1234567890,
"event": "update",
"content-state": {
"driverName": "Alex",
"estimatedDeliveryTime": {
"lowerBound": 1234567890,
"upperBound": 1234568790
},
"currentStep": "delivering"
},
"alert": {
"title": "Delivery Update",
"body": "Your driver is nearby!"
}
}
}
```
The `content-state` must match the `ContentState` Codable structure exactly.
### Info.plist Keys
| Key | Value | Purpose |
|---|---|---|
| `NSSupportsLiveActivities` | `YES` | Enable Live Activities |
| `NSSupportsLiveActivitiesFrequentUpdates` | `YES` | Enable frequent push updates (budget increase) |
## ActivityAuthorizationInfo
Check whether Live Activities are permitted before attempting to start one.
```swift
let authInfo = ActivityAuthorizationInfo()
// Check permission synchronously
if authInfo.areActivitiesEnabled {
try Activity.request(attributes: attributes, content: content, pushType: .token)
}
// Observe permission changes
Task {
for await enabled in authInfo.activityEnablementUpdates {
if enabled {
// Activities became available
}
}
}
// Check frequent push support
if authInfo.frequentPushesEnabled {
// Safe to use frequent push updates
}
```
### Error Handling
```swift
do {
let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
} catch let error as ActivityAuthorizationError {
switch error {
case .denied:
// User disabled Live Activities in Settings
break
case .globalMaximumExceeded:
// Too many Live Activities across all apps
break
case .targetMaximumExceeded:
// Too many Live Activities for this app
break
default:
break
}
}
```
## Widget Performance Best Practices
### Data Preparation
Pre-compute display values in the timeline provider. Pass display-ready data
through the entry.
```swift
// WRONG: Heavy computation in the widget view
struct MyWidgetView: View {
let entry: RawDataEntry
var body: some View {
let processed = HeavyProcessor.process(entry.rawData) // Slow
Text(processed.summary)
}
}
// CORRECT: Pre-compute in the provider
func timeline(for configuration: Intent, in context: Context) async -> Timeline<ProcessedEntry> {
let raw = await DataStore.shared.fetch()
let processed = HeavyProcessor.process(raw)
let entry = ProcessedEntry(date: .now, summary: processed.summary, value: processed.value)
return Timeline(entries: [entry], policy: .atEnd)
}
```
### Memory Constraints
Widget extensions run with strict memory limits. Avoid:
- Loading large images directly in the widget view
- Storing large data sets in the entry
- Creating complex view hierarchies
### Image Handling
```swift
// WRONG: Loading a full-resolution image
Image(uiImage: UIImage(contentsOfFile: fullResPath)!)
// CORRECT: Use a pre-resized thumbnail stored in the shared container
Image(uiImage: UIImage(contentsOfFile: thumbnailPath)!)
.resizable()
.aspectRatio(contentMode: .fill)
```
### Shared Data with App Groups
```swift
// In the main app: write data
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
defaults?.set(encodedData, forKey: "widgetData")
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
// In the widget provider: read data
func timeline(for configuration: Intent, in context: Context) async -> Timeline<MyEntry> {
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
let data = defaults?.data(forKey: "widgetData")
// Decode and build entry
}
```
For larger datasets, use a shared SQLite database or Core Data store in the
App Group container.
## Xcode Setup
### Adding a Widget Extension Target
1. File > New > Target > Widget Extension.
2. Name the extension (e.g., "MyAppWidgets").
3. Select "Include Configuration App Intent" for configurable widgets.
4. Select "Include Live Activity" if building Live Activities.
### Entitlements
| Entitlement | Purpose |
|---|---|
| App Groups (`com.apple.security.application-groups`) | Share data between app and widget |
| Push Notifications (`aps-environment`) | Required for push-based Live Activity updates |
### App Groups Configuration
1. Enable "App Groups" capability on both the main app target and the widget
extension target.
2. Create a shared group identifier (e.g., `group.com.example.myapp`).
3. Use `UserDefaults(suiteName:)` or `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`
for shared storage.
### Build Schemes
- Use the widget extension scheme to debug widget rendering.
- Select "Widget" as the run destination to launch the widget directly.
- Use "Preview" in Xcode canvas for rapid iteration.
### Common Xcode Issues
```text
// ERROR: "Widget extension must include at least one widget"
// FIX: Ensure @main is on the WidgetBundle, not a widget struct.
// ERROR: "No such module 'WidgetKit'"
// FIX: Ensure the widget extension target links WidgetKit and SwiftUI frameworks.
// ERROR: "The operation couldn't be completed. (ActivityKit.ActivityAuthorizationError error 3.)"
// FIX: Add NSSupportsLiveActivities = YES to the HOST APP's Info.plist (not the extension).
```
## Widget Relevance and Smart Stacks
### TimelineEntryRelevance
Score entries to surface widgets in Smart Stacks when relevant:
```swift
struct GameEntry: TimelineEntry {
var date: Date
var score: String
var isLive: Bool
var relevance: TimelineEntryRelevance? {
isLive ? TimelineEntryRelevance(score: 100, duration: 3600) : nil
}
}
```
Higher scores make the widget more likely to surface. The `duration` specifies
how long the relevance lasts.
### WidgetRelevance (AppIntentTimelineProvider)
On iPhone and iPad, prefer `TimelineEntryRelevance` on timeline entries and
donate App Intents that match configurable widget parameters. Smart Stacks on
iPhone and iPad don't use the timeline provider's `relevance()` callback.
On watchOS, use `relevance()` only when providing RelevanceKit contextual clues.
Return `WidgetRelevance([WidgetRelevanceAttribute(...)])`; there is no
`WidgetRelevance(intent, score:)` initializer.
## ActivityState Lifecycle
Track the full lifecycle of a Live Activity:
```swift
Task {
for await state in activity.activityStateUpdates {
switch state {
case .active:
// Activity is running and visible
break
case .pending:
// Requested but not yet displayed (iOS 26+)
break
case .stale:
// Content is outdated; update or end
break
case .ended:
// Ended but may still be visible on Lock Screen
break
case .dismissed:
// Fully removed from UI; clean up resources
break
@unknown default:
break
}
}
}
```
## ActivityStyle
Control Live Activity persistence behavior (iOS 18+):
```swift
// Standard: persists until explicitly ended
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token,
style: .standard
)
// Transient: appears in Dynamic Island's extended presentation and ends
// automatically when the user leaves that interaction context.
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token,
style: .transient
)
```
Use `.transient` for short interactions that should not persist as a standard
Live Activity after the user locks the device, collapses the Dynamic Island,
leaves the app, or performs other tasks outside the Dynamic Island.
## Dismissal Policies
Control when an ended Live Activity disappears from the Lock Screen:
```swift
// System-determined timing (default)
await activity.end(finalContent, dismissalPolicy: .default)
// Remove immediately
await activity.end(finalContent, dismissalPolicy: .immediate)
// Remove after a specific date (max 4 hours)
let removalDate = Date().addingTimeInterval(3600)
await activity.end(finalContent, dismissalPolicy: .after(removalDate))
```
## Querying Active Widgets and Activities
### Current Widget Configurations
```swift
let widgets = try await WidgetCenter.shared.currentConfigurations()
for widget in widgets {
print("Kind: \(widget.kind), Family: \(widget.family)")
}
```
### Current Live Activities
```swift
let activities = Activity<DeliveryAttributes>.activities
for activity in activities {
print("ID: \(activity.id), State: \(activity.activityState)")
}
```
### Observing New Activities
```swift
Task {
for await activity in Activity<DeliveryAttributes>.activityUpdates {
print("New activity started: \(activity.id)")
}
}
```
## Design Patterns
### Prefer Gauge for Value Indicators
Use `Gauge` (iOS 16+) instead of manual `Circle` or `Path` arcs to show a value
within a range. The system handles styling, accessibility, and rendering-mode
adaptation automatically.
- `.accessoryCircular` — open ring with center value label, matches the system
complication style. Use for `accessoryCircular` Lock Screen widgets.
- `.linearCapacity` — horizontal bar that fills leading to trailing. Use for
home screen widgets when a capacity bar fits.
```swift
// accessoryCircular Lock Screen widget
struct StepsCircularView: View {
let entry: StepsEntry
var body: some View {
Gauge(value: Double(entry.stepCount), in: 0...10000) {
Image(systemName: "figure.walk")
} currentValueLabel: {
Text("\(entry.stepCount)")
}
.gaugeStyle(.accessoryCircular)
}
}
// Home screen capacity bar
Gauge(value: storageUsed, in: 0...storageTotal) {
Text("Storage")
} currentValueLabel: {
Text(storageUsed, format: .byteCount(style: .file))
}
.gaugeStyle(.linearCapacity)
```
### Use containerBackground for Widget Backgrounds
`.containerBackground(_:for: .widget)` (iOS 17+) is the designated way to set
widget backgrounds. Replaces older padding and background patterns. The system
uses this placement to correctly render backgrounds across all widget surfaces.
```swift
struct OrderWidgetView: View {
let entry: OrderEntry
var body: some View {
VStack(alignment: .leading) {
Text(entry.orderName).font(.headline)
Text(entry.status).foregroundStyle(.secondary)
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
```
### Use Canvas for Dense Visualizations
Use `Canvas` for sparklines, mini bar charts, or heat maps inside widgets. The
lack of per-element accessibility is acceptable since the entire widget surface
is a single tap target.
```swift
struct SparklineView: View {
let values: [Double]
var body: some View {
Canvas { context, size in
guard values.count > 1 else { return }
let maxVal = values.max() ?? 1
let step = size.width / CGFloat(values.count - 1)
var path = Path()
for (i, value) in values.enumerated() {
let x = step * CGFloat(i)
let y = size.height * (1 - value / maxVal)
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
else { path.addLine(to: CGPoint(x: x, y: y)) }
}
context.stroke(path, with: .color(.blue), lineWidth: 2)
}
}
}
```
### Match Timeline Refresh to Data Granularity
Apple budgets
[40–70 refreshes per day](https://sosumi.ai/documentation/widgetkit/keeping-a-widget-up-to-date)
for frequently viewed widgets, with entries at least 5 minutes apart. Align
reload cadence to how often the underlying data actually changes.
- Generate entries for as many future dates as possible to reduce reload requests.
- Use `.after(date)` when data updates on a known schedule (market hours, transit).
- Use `.never` when data only changes from user action.
- Use `Text(timerInterval:countsDown:)` for live countdowns instead of burning
timeline entries on every tick.
## Apple Documentation Links
- [WidgetKit](https://sosumi.ai/documentation/widgetkit)
- [ActivityKit](https://sosumi.ai/documentation/activitykit)
- [TimelineProvider](https://sosumi.ai/documentation/widgetkit/timelineprovider)
- [AppIntentTimelineProvider](https://sosumi.ai/documentation/widgetkit/appintenttimelineprovider)
- [ActivityAttributes](https://sosumi.ai/documentation/activitykit/activityattributes)
- [ActivityConfiguration](https://sosumi.ai/documentation/widgetkit/activityconfiguration)
- [DynamicIsland](https://sosumi.ai/documentation/widgetkit/dynamicisland)
- [ControlWidgetButton](https://sosumi.ai/documentation/widgetkit/controlwidgetbutton)
- [ControlWidgetToggle](https://sosumi.ai/documentation/widgetkit/controlwidgettoggle)
- [Keeping a widget up to date](https://sosumi.ai/documentation/widgetkit/keeping-a-widget-up-to-date)
- [Updating widgets with WidgetKit push notifications](https://sosumi.ai/documentation/widgetkit/updating-widgets-with-widgetkit-push-notifications)
- [Updating controls locally and remotely](https://sosumi.ai/documentation/widgetkit/updating-controls-locally-and-remotely)
- [Linking to specific app scenes](https://sosumi.ai/documentation/widgetkit/linking-to-specific-app-scenes-from-your-widget-or-live-activity)
- [Adding StandBy and CarPlay support](https://sosumi.ai/documentation/widgetkit/adding-standby-and-carplay-support-to-your-widget)
- [Optimizing for accented rendering and Liquid Glass](https://sosumi.ai/documentation/widgetkit/optimizing-your-widget-for-accented-rendering-mode-and-liquid-glass)
- [Increasing widget visibility in Smart Stacks](https://sosumi.ai/documentation/widgetkit/widget-suggestions-in-smart-stacks)
SKILL.md
---
name: widgetkit
description: "Implement, review, or improve WidgetKit widgets and controls. Use when building Home Screen, Lock Screen, StandBy, or CarPlay widgets with timeline providers; configurable widgets with AppIntentTimelineProvider; interactive widgets or Control Center controls with Button/Toggle wiring; WidgetKit push reloads, refresh budgets, deep links, Smart Stack relevance, Liquid Glass/accented rendering, widget extension setup, WidgetBundle, App Groups, and entitlements."
---
# WidgetKit
Build home screen widgets, Lock Screen widgets, Control Center controls, and
StandBy or CarPlay widget surfaces for iOS 26+.
Keep adjacent-framework guidance scoped to WidgetKit integration. Include
ActivityKit and App Intents only where they connect directly to WidgetKit
surfaces; hand off full lifecycle, APNs content-state, Siri/Shortcuts/Spotlight,
or entity-modeling work to sibling `activitykit` or `app-intents` skills.
See [references/widgetkit-advanced.md](references/widgetkit-advanced.md) for timeline strategies, push-based
updates, Xcode setup, and advanced patterns.
## Contents
- [Workflow](#workflow)
- [Widget Protocol and WidgetBundle](#widget-protocol-and-widgetbundle)
- [Configuration Types](#configuration-types)
- [TimelineProvider](#timelineprovider)
- [AppIntentTimelineProvider](#appintenttimelineprovider)
- [Widget Families](#widget-families)
- [Interactive Widgets (iOS 17+)](#interactive-widgets-ios-17)
- [ActivityConfiguration Handoff](#activityconfiguration-handoff)
- [Control Center Widgets (iOS 18+)](#control-center-widgets-ios-18)
- [Lock Screen Widgets](#lock-screen-widgets)
- [StandBy Mode](#standby-mode)
- [Widget URL Handling and Deep Links](#widget-url-handling-and-deep-links)
- [Smart Stack Relevance](#smart-stack-relevance)
- [Design Patterns](#design-patterns)
- [iOS 26 Additions](#ios-26-additions)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
- [References](#references)
## Workflow
### 1. Create a new widget
1. Add a Widget Extension target in Xcode (File > New > Target > Widget Extension).
2. Enable App Groups for shared data between the app and widget extension.
3. Define a `TimelineEntry` struct with a `date` property and display data.
4. Implement a `TimelineProvider` (static) or `AppIntentTimelineProvider` (configurable).
5. Build the widget view using SwiftUI, adapting layout per `WidgetFamily`.
6. Declare the `Widget` conforming struct with a configuration and supported families.
7. Register all widgets in a `WidgetBundle` annotated with `@main`.
### 2. Integrate adjacent surfaces
1. Register an `ActivityConfiguration` in the widget bundle when the app has a
Live Activity, but keep `ActivityAttributes`, request/update/end, APNs
`content-state`, and Dynamic Island layout depth in `activitykit`.
2. Place `Button`, `Toggle`, `ControlWidgetButton`, and `ControlWidgetToggle`
in WidgetKit views or controls, but keep intent modeling, entities, queries,
Siri, Shortcuts, and Spotlight in `app-intents`.
### 3. Add a Control Center control
1. Reuse an `AppIntent`/`OpenIntent` for a button, or a `SetValueIntent` for a toggle.
2. Create a `ControlWidgetButton` or `ControlWidgetToggle` in the widget bundle.
3. Use `StaticControlConfiguration` or `AppIntentControlConfiguration`.
### 4. Review existing widget code
Run through the Review Checklist at the end of this document.
## Widget Protocol and WidgetBundle
### Widget
Every widget conforms to the `Widget` protocol and returns a `WidgetConfiguration`
from its `body`.
```swift
struct OrderStatusWidget: Widget {
let kind: String = "OrderStatusWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: OrderProvider()) { entry in
OrderWidgetView(entry: entry)
}
.configurationDisplayName("Order Status")
.description("Track your current order.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
```
### WidgetBundle
Use `WidgetBundle` to expose multiple widgets from a single extension.
```swift
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
OrderStatusWidget()
FavoritesWidget()
DeliveryActivityWidget() // ActivityConfiguration handoff
QuickActionControl() // Control Center
}
}
```
## Configuration Types
Use `StaticConfiguration` for non-configurable widgets. Use `AppIntentConfiguration`
(recommended) for configurable widgets paired with `AppIntentTimelineProvider`.
```swift
// Static
StaticConfiguration(kind: "MyWidget", provider: MyProvider()) { entry in
MyWidgetView(entry: entry)
}
// Configurable
AppIntentConfiguration(kind: "ConfigWidget", intent: SelectCategoryIntent.self,
provider: CategoryProvider()) { entry in
CategoryWidgetView(entry: entry)
}
```
### Shared Modifiers
| Modifier | Purpose |
|---|---|
| `.configurationDisplayName(_:)` | Name shown in the widget gallery |
| `.description(_:)` | Description shown in the widget gallery |
| `.supportedFamilies(_:)` | Array of `WidgetFamily` values |
| `.supplementalActivityFamilies(_:)` | Live Activity sizes (`.small`, `.medium`) |
## TimelineProvider
For static (non-configurable) widgets. Uses completion handlers. Three required methods:
```swift
struct WeatherProvider: TimelineProvider {
typealias Entry = WeatherEntry
func placeholder(in context: Context) -> WeatherEntry {
WeatherEntry(date: .now, temperature: 72, condition: "Sunny")
}
func getSnapshot(in context: Context, completion: @escaping (WeatherEntry) -> Void) {
let entry = context.isPreview
? placeholder(in: context)
: WeatherEntry(date: .now, temperature: currentTemp, condition: currentCondition)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WeatherEntry>) -> Void) {
Task {
let weather = await WeatherService.shared.fetch()
let entry = WeatherEntry(date: .now, temperature: weather.temp, condition: weather.condition)
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
}
```
## AppIntentTimelineProvider
For configurable widgets. Uses async/await natively. Receives user intent configuration.
```swift
struct CategoryProvider: AppIntentTimelineProvider {
typealias Entry = CategoryEntry
typealias Intent = SelectCategoryIntent
func placeholder(in context: Context) -> CategoryEntry {
CategoryEntry(date: .now, categoryName: "Sample", items: [])
}
func snapshot(for config: SelectCategoryIntent, in context: Context) async -> CategoryEntry {
let items = await DataStore.shared.items(for: config.category)
return CategoryEntry(date: .now, categoryName: config.category.name, items: items)
}
func timeline(for config: SelectCategoryIntent, in context: Context) async -> Timeline<CategoryEntry> {
let items = await DataStore.shared.items(for: config.category)
let entry = CategoryEntry(date: .now, categoryName: config.category.name, items: items)
return Timeline(entries: [entry], policy: .atEnd)
}
}
```
## Widget Families
| Family | Platform |
|---|---|
| `.systemSmall` | iOS, iPadOS, macOS, CarPlay (iOS 26+) |
| `.systemMedium` | iOS, iPadOS, macOS |
| `.systemLarge` | iOS, iPadOS, macOS |
| `.systemExtraLarge` | iPadOS only |
| `.accessoryCircular` | iOS, watchOS |
| `.accessoryRectangular` | iOS, watchOS |
| `.accessoryInline` | iOS, watchOS |
| `.accessoryCorner` | watchOS only |
Adapt layout per family using `@Environment(\.widgetFamily)`:
```swift
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall: CompactView(entry: entry)
case .systemMedium: DetailedView(entry: entry)
case .accessoryCircular: CircularView(entry: entry)
default: FullView(entry: entry)
}
}
```
## Interactive Widgets (iOS 17+)
Use `Button` and `Toggle` with intent types available to the widget extension or
shared code. WidgetKit owns the view placement; `app-intents` owns intent
modeling and behavior.
```swift
struct InteractiveWidgetView: View {
let entry: FavoriteEntry
var body: some View {
Button(intent: ToggleFavoriteIntent(itemID: entry.itemID)) {
Image(systemName: entry.isFavorite ? "star.fill" : "star")
}
}
}
```
## ActivityConfiguration Handoff
WidgetKit registers Live Activity surfaces in the widget extension. Keep this
section to registration and rendering handoff; use `activitykit` for
`ActivityAttributes`, lifecycle, push updates, and full Dynamic Island patterns.
```swift
struct DeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
DeliveryLiveActivityView(context: context)
} dynamicIsland: { context in
DeliveryDynamicIsland(context: context)
}
}
}
```
## Control Center Widgets (iOS 18+)
WidgetKit owns control configuration, placement, kind, display name, push
handler, and extension registration. Control actions and value intents belong in
`app-intents`.
```swift
struct OpenCameraControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "OpenCamera") {
ControlWidgetButton(action: OpenCameraIntent()) {
Label("Camera", systemImage: "camera.fill")
}
}
.displayName("Open Camera")
}
}
struct FlashlightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Flashlight", provider: FlashlightValueProvider()) { value in
ControlWidgetToggle(isOn: value, action: ToggleFlashlightIntent()) {
Label("Flashlight", systemImage: value ? "flashlight.on.fill" : "flashlight.off.fill")
}
}
.displayName("Flashlight")
}
}
```
## Lock Screen Widgets
Use accessory families and `AccessoryWidgetBackground`.
```swift
struct StepsWidget: Widget {
let kind = "StepsWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: StepsProvider()) { entry in
ZStack {
AccessoryWidgetBackground()
VStack {
Image(systemName: "figure.walk")
Text("\(entry.stepCount)").font(.headline)
}
}
}
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
```
## StandBy Mode
Small system widgets can appear in StandBy and CarPlay. Use
`@Environment(\.widgetLocation)` for conditional rendering:
```swift
@Environment(\.widgetLocation) var location
// location == .standBy, .homeScreen, .lockScreen, .carPlay, etc.
```
## Widget URL Handling and Deep Links
Use one `.widgetURL(_:)` as the whole-widget fallback route. Use `Link` for
deliberate subtargets only where the family and layout support them, including
`.accessoryRectangular`, `.systemSmall`, and larger system widgets. For small
widgets, prefer one clear fallback; avoid multiple `Link` targets unless the
visual affordance and hit areas remain unambiguous.
Never attach multiple `widgetURL` modifiers in the hierarchy.
## Smart Stack Relevance
Use `TimelineEntryRelevance(score:duration:)` on timeline entries for timely
iPhone and iPad Smart Stack relevance. Keep scores on a consistent positive
scale; zero or lower means not relevant.
For configurable widgets, donate App Intents that correspond to user actions or
widget parameters from app-side code, such as with `intent.donate()` or
`IntentDonationManager`. Keep `AppEntity` and `EntityQuery` design in
`app-intents`.
On watchOS, contextual relevance uses
`WidgetRelevance([WidgetRelevanceAttribute(...)])` from the provider
`relevance()` callback. That path is not used by iPhone or iPad Smart Stacks.
## Design Patterns
- **Prefer `Gauge` over manual arcs.** Use `.gaugeStyle(.accessoryCircular)` for
Lock Screen circular widgets and `.linearCapacity` for home screen capacity bars.
The system handles styling, accessibility, and rendering-mode adaptation.
- **Use `.containerBackground(_:for: .widget)`** (iOS 17+) for widget backgrounds
instead of padding and background modifiers.
- **Use `Canvas` for dense visualizations** like sparklines or mini bar charts.
The lack of per-element accessibility is acceptable since the entire widget
surface is a single tap target.
- **Match timeline refresh to data granularity.** The budget is dynamic and opportunistic; schedule useful future entries, avoid unnecessary reloads, and use `Text(timerInterval:countsDown:)` for live countdowns. Load the advanced reference for current budget guidance.
See [references/widgetkit-advanced.md](references/widgetkit-advanced.md) for
code examples and detailed guidance on each pattern.
## iOS 26 Additions
### Liquid Glass Support
Adapt widgets to Liquid Glass with `@Environment(\.widgetRenderingMode)`,
`.widgetAccentable()`, and `Image.widgetAccentedRenderingMode(_:)`. In
`.vibrant`, the system maps content into the material style, so avoid relying on
original colors alone.
### Push Reload Handlers
Widget push reloads:
- Add Push Notifications capability to the widget extension target.
- Keep the `WidgetPushHandler` type in the widget extension target or shared
code linked into it, not only in the main app target.
- Register the handler with `.pushHandler(...)` on the widget configuration.
- Do not use User Notifications registration to obtain widget push tokens;
WidgetKit supplies tokens through `pushTokenDidChange(_:widgets:)`.
- Use `apns-push-type: widgets`, topic suffix `.push-type.widgets`, and
`aps.content-changed`.
- Treat push as a budgeted, opportunistic reload signal, not state delivery and
not the only freshness model. Timelines, reload policies, shared storage or
refetch, and app-triggered `WidgetCenter` reloads remain the fallback path.
Control push reloads:
- Register a `ControlPushHandler` with `.pushHandler(...)` on the
`ControlWidgetConfiguration`.
- `pushTokensDidChange(controls:)` receives `[ControlInfo]`; read tokens from
each control's `pushInfo`.
- Use `apns-push-type: controls`, topic suffix `.push-type.controls`, and
`aps.content-changed`.
### CarPlay Widgets
Small system widgets can appear in CarPlay on iOS 26+. Ensure layouts are
legible at a glance; taps and controls depend on vehicle touch support and, for
opening the app, CarPlay integration.
## Common Mistakes
1. **Using IntentTimelineProvider instead of AppIntentTimelineProvider.**
`IntentTimelineProvider` is the older SiriKit Intents-based provider. Prefer
`AppIntentTimelineProvider` with the App Intents framework for new widgets.
2. **Exceeding the refresh budget.** Widgets have a daily refresh limit. Do not
call `WidgetCenter.shared.reloadTimelines(ofKind:)` on every minor data change.
Batch updates and use appropriate `TimelineReloadPolicy` values.
3. **Forgetting App Groups for shared data.** The widget extension runs in a
separate process. Use `UserDefaults(suiteName:)` or a shared App Group
container for data the widget reads.
4. **Performing network calls in placeholder().** `placeholder(in:)` must return
synchronously with sample data. Use `getTimeline` or `timeline(for:in:)` for
async work.
5. **Treating WidgetKit push payloads as state.** Widget and control pushes are
reload signals. Persist state in shared storage or refetch it in the provider.
6. **Registering widget pushes through User Notifications.** Widget push tokens
come from WidgetKit handlers, not `UNUserNotificationCenter`.
7. **Putting heavy logic in the widget view.** Widget views are rendered in a
size-limited process. Pre-compute data in the timeline provider and pass
display-ready values through the entry.
8. **Ignoring accessory rendering modes.** Lock Screen widgets render in
`.vibrant` or `.accented` mode, not `.fullColor`. Test with
`@Environment(\.widgetRenderingMode)` and avoid relying on color alone.
9. **Not testing on device.** StandBy, CarPlay, and accessory rendering differ
significantly from Simulator. Always verify on physical hardware.
## Review Checklist
- [ ] Widget extension target has App Groups entitlement matching the main app
- [ ] `@main` is on the `WidgetBundle`, not on individual widgets
- [ ] `placeholder(in:)` returns synchronously; `getSnapshot`/`snapshot(for:in:)` fast when `isPreview`
- [ ] Timeline reload policy matches update frequency; `reloadTimelines(ofKind:)` only on data change
- [ ] Layout adapts per `WidgetFamily`; accessory widgets tested in `.vibrant` mode
- [ ] Interactive widgets use extension-available App Intents with `Button`/`Toggle` only
- [ ] One `.widgetURL(_:)` fallback is used; `Link` subtargets are family-appropriate
- [ ] Widget push handlers live in the widget extension/shared code and do not use User Notifications token registration
- [ ] Widget/control pushes supplement timelines and shared-state/refetch fallbacks
- [ ] Smart Stack relevance uses timeline relevance and app-side intent donations where useful
- [ ] Live Activity lifecycle and App Intent modeling are handed off to sibling skills
- [ ] Controls use `StaticControlConfiguration`/`AppIntentControlConfiguration`
- [ ] Timeline entries and Intent types are Sendable; tested on device
## References
- Advanced guide: [references/widgetkit-advanced.md](references/widgetkit-advanced.md)
- Apple docs: [WidgetKit](https://sosumi.ai/documentation/widgetkit) | [Keeping a widget up to date](https://sosumi.ai/documentation/widgetkit/keeping-a-widget-up-to-date) | [Smart Stack visibility](https://sosumi.ai/documentation/widgetkit/widget-suggestions-in-smart-stacks)