# WidgetKit: extended patterns

Companion to the `widgetkit` skill. Everything here assumes iOS 26 SDKs unless a
heading says otherwise.

## Contents

- [Timeline Strategies](#timeline-strategies)
- [Reloading timelines by push](#reloading-timelines-by-push)
- [Deep Links and Widget URLs](#deep-links-and-widget-urls)
- [User-configurable widgets through App Intents](#user-configurable-widgets-through-app-intents)
- [Multiple Widget Support](#multiple-widget-support)
- [Previewing widgets and their snapshot state](#previewing-widgets-and-their-snapshot-state)
- [AccessoryWidgetBackground](#accessorywidgetbackground)
- [Lock Screen Accessory Widget Example](#lock-screen-accessory-widget-example)
- [Live Activity Full ActivityConfiguration Example](#live-activity-full-activityconfiguration-example)
- [Control Center Control Examples](#control-center-control-examples)
- [Laying out the expanded Dynamic Island](#laying-out-the-expanded-dynamic-island)
- [Live Activity alerts](#live-activity-alerts)
- [Live Activity Push Updates](#live-activity-push-updates)
- [ActivityAuthorizationInfo](#activityauthorizationinfo)
- [Keeping widgets fast](#keeping-widgets-fast)
- [Xcode Setup](#xcode-setup)
- [Smart Stack relevance](#smart-stack-relevance)
- [ActivityState Lifecycle](#activitystate-lifecycle)
- [ActivityStyle](#activitystyle)
- [Dismissal Policies](#dismissal-policies)
- [Finding which widgets and activities are live](#finding-which-widgets-and-activities-are-live)
- [Design Patterns](#design-patterns)
- [Further reading from Apple](#further-reading-from-apple)

## Timeline Strategies

### Reload policies

| Policy | What happens | Pick it when |
|---|---|---|
| `.atEnd` | The system asks for a new timeline once the last entry's date passes. This is the default. | Data changes at unpredictable times |
| `.after(Date)` | The system asks again after the given date | You know the schedule, such as trading hours or a booked delivery slot |
| `.never` | No automatic refresh; the app reloads it | Only a user action in the app changes the data |

### Plan several entries ahead

One timeline can carry many future entries. Projecting values forward means
fewer reloads.

```swift
func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<ParkingEntry>) -> Void) {
    let base = ParkingForecast.current()
    let calendar = Calendar.current
    let entries = (0..<6).compactMap { offset -> ParkingEntry? in
        guard let slot = calendar.date(byAdding: .hour, value: offset, to: .now) else { return nil }
        return ParkingEntry(date: slot, freeSpaces: base.projectedSpaces(at: slot))
    }
    let refresh = calendar.date(byAdding: .minute, value: 360, to: .now) ?? .now
    completion(Timeline(entries: entries, policy: .after(refresh)))
}
```

### Reloading from the app

```swift
WidgetCenter.shared.reloadTimelines(ofKind: "ParkingWidget")
WidgetCenter.shared.reloadAllTimelines()
```

The first call reloads one widget kind; the second reloads all of them. Both
cost budget.

### Budget rules

- Every configured widget gets its own daily allowance.
- Reloads are free when the app is frontmost, when it has an active media
  session, or when it uses the standard location service.
- A widget run from Xcode's debugger has no refresh limit, so budget problems
  only show up outside the debugger.

## Reloading timelines by push

From iOS 26, a `WidgetPushHandler` receives a token for APNs. Send it to your
server.

```swift
struct ScoreboardPushHandler: WidgetPushHandler {
    func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) {
        let hex = hexString(pushInfo.token)
        Task { await ScoreboardAPI.shared.registerWidgetToken(hex) }
    }
}
```

`hexString(_:)` is a small helper used throughout this file:

```swift
func hexString(_ bytes: Data) -> String {
    bytes.reduce(into: "") { $0 += String(format: "%02x", $1) }
}
```

When the server sends an APNs push addressed to that token, the system calls
`getTimeline` or `timeline(for:in:)` again.

Controls have their own handler, available from iOS 18, that receives every
control at once, as `ControlInfo` values; each carries its token in an optional
`pushInfo`:

```swift
struct GaragePushHandler: ControlPushHandler {
    func pushTokensDidChange(controls: [ControlInfo]) {
        for control in controls {
            guard let token = control.pushInfo?.token else { continue }
            let hex = hexString(token)
            Task { await GarageAPI.shared.registerControlToken(hex) }
        }
    }
}
```

## Deep Links and Widget URLs

- `.widgetURL(_:)` gives the whole widget one destination.
- `Link(destination:)` adds separate tap targets, but only in `.systemMedium` and
  larger. A `.systemSmall` widget supports `widgetURL` only.

```swift
VStack {
    Link(destination: URL(string: "harbor://berth/7")!) { BerthRow(number: 7) }
    Link(destination: URL(string: "harbor://berth/9")!) { BerthRow(number: 9) }
}
.widgetURL(URL(string: "harbor://berths"))
```

The app picks the URL up on its scene content:

```swift
WindowGroup {
    HarborRootView()
        .onOpenURL { url in router.open(url) }
}
```

## User-configurable widgets through App Intents

```swift
struct PickStationIntent: WidgetConfigurationIntent {
    static let title: LocalizedStringResource = "Choose Station"
    static let description = IntentDescription("Pick the weather station to show.")

    @Parameter(title: "Station")
    var station: StationEntity

    init() {}
    init(station: StationEntity) { self.station = station }
}

struct StationEntity: AppEntity {
    static let typeDisplayRepresentation: TypeDisplayRepresentation = "Station"
    static let defaultQuery = StationQuery()

    let id: String
    let name: String

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
    }
}

struct StationQuery: EntityQuery {
    func entities(for identifiers: [String]) async throws -> [StationEntity] {
        await StationCatalog.shared.stations(matching: identifiers)
    }

    func suggestedEntities() async throws -> [StationEntity] {
        await StationCatalog.shared.nearby()
    }

    func defaultResult() async -> StationEntity? {
        await StationCatalog.shared.nearby().first
    }
}
```

Preconfigured gallery options come from the provider:

```swift
func recommendations() -> [AppIntentRecommendation<PickStationIntent>] {
    StationCatalog.featured.map { station in
        AppIntentRecommendation(intent: PickStationIntent(station: station), description: station.name)
    }
}
```

For intent and entity design beyond this, see the `app-intents` skill.

## Multiple Widget Support

A bundle can mix every kind of widget, and it can gate newer ones by OS:

```swift
@main
struct GardenWidgets: WidgetBundle {
    var body: some Widget {
        SoilMoistureWidget()
        PickBedWidget()
        SoilLockScreenWidget()
        WateringActivityWidget()
        if #available(iOS 18, *) {
            SprinklerControl()
        }
    }
}
```

## Previewing widgets and their snapshot state

```swift
#Preview("Moisture", as: .systemSmall) {
    SoilMoistureWidget()
} timeline: {
    SoilEntry(date: .now, percent: 62)
    SoilEntry(date: .now.addingTimeInterval(3600), percent: 48)
}

#Preview("Lock Screen", as: .accessoryCircular) {
    SoilLockScreenWidget()
} timeline: {
    SoilEntry(date: .now, percent: 62)
}
```

Live Activities preview against attributes plus a list of states:

```swift
#Preview("Watering", as: .content, using: WateringAttributes.preview) {
    WateringActivityWidget()
} contentStates: {
    WateringAttributes.ContentState(zone: 1, remaining: 300)
    WateringAttributes.ContentState(zone: 2, remaining: 60)
}

#Preview("Compact", as: .dynamicIsland(.compact), using: WateringAttributes.preview) {
    WateringActivityWidget()
} contentStates: {
    WateringAttributes.ContentState(zone: 1, remaining: 300)
}
```

`placeholder(in:)` is synchronous, so writing `await` inside it does not
compile. In the snapshot method, return sample data when `context.isPreview` is
true and the real current state otherwise.

## AccessoryWidgetBackground

```swift
ZStack {
    AccessoryWidgetBackground()
    VStack(spacing: 0) {
        Image(systemName: "leaf")
            .widgetAccentable()
        Text("\(entry.percent)%")
    }
}
```

`AccessoryWidgetBackground()` draws the standard translucent disc or panel.
`.widgetAccentable()` marks the views that should pick up the tint in `.accented`
mode.

Branch on the rendering mode when full color and monochrome need different views.
`WidgetRenderingMode` is a struct with static values, not an enum, so the switch
ends in a plain `default` (`@unknown default` does not compile here):

```swift
struct SoilBadge: View {
    @Environment(\.widgetRenderingMode) private var mode
    let entry: SoilEntry

    var body: some View {
        switch mode {
        case .fullColor:
            ColorfulSoilBadge(entry: entry)
        case .vibrant, .accented:
            MonochromeSoilBadge(entry: entry)
        default:
            MonochromeSoilBadge(entry: entry)
        }
    }
}
```

## Lock Screen Accessory Widget Example

```swift
struct SoilLockScreenWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "SoilLockScreen", provider: SoilProvider()) { entry in
            ZStack {
                AccessoryWidgetBackground()
                SoilBadge(entry: entry)
            }
        }
        .configurationDisplayName("Soil")
        .description("Moisture of your main bed.")
        .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
    }
}
```

## Live Activity Full ActivityConfiguration Example

```swift
struct RepairActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: RepairAttributes.self) { context in
            VStack(alignment: .leading) {
                Text("Ticket \(context.attributes.ticketNumber)")
                    .font(.headline)
                Text(context.state.stage.title)
                Text(timerInterval: context.state.window, countsDown: true)
                    .font(.title2.monospacedDigit())
            }
            .padding()
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "wrench.and.screwdriver")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(timerInterval: context.state.window, countsDown: true)
                        .monospacedDigit()
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.stage.title)
                        .lineLimit(1)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    HStack {
                        ForEach(RepairStage.allCases, id: \.self) { stage in
                            Capsule()
                                .fill(stage <= context.state.stage ? .primary : .tertiary)
                                .frame(height: 4)
                        }
                    }
                }
            } compactLeading: {
                Image(systemName: "wrench.and.screwdriver")
            } compactTrailing: {
                Text(timerInterval: context.state.window, countsDown: true)
                    .monospacedDigit()
                    .frame(width: 44)
            } minimal: {
                Image(systemName: "wrench")
            }
        }
    }
}
```

The compact trailing timer gets a fixed width and monospaced digits so it does
not jitter as it counts. `RepairStage` is assumed `Comparable` and `CaseIterable`.

## Control Center Control Examples

A toggle reads current state from a value provider and flips it through an intent:

```swift
struct SprinklerControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "SprinklerControl", provider: SprinklerStateProvider()) { isRunning in
            ControlWidgetToggle(isOn: isRunning, action: ToggleSprinklerIntent()) {
                Label("Sprinkler", systemImage: isRunning ? "sprinkler.and.droplets.fill" : "sprinkler")
            }
        }
        .displayName("Sprinkler")
    }
}

struct SprinklerStateProvider: ControlValueProvider {
    var previewValue: Bool { false }

    func currentValue() async throws -> Bool {
        await SprinklerHub.shared.isRunning
    }
}

struct ToggleSprinklerIntent: SetValueIntent {
    static let title: LocalizedStringResource = "Toggle Sprinkler"

    @Parameter(title: "Running")
    var value: Bool

    func perform() async throws -> some IntentResult {
        await SprinklerHub.shared.setRunning(value)
        return .result()
    }
}
```

## Laying out the expanded Dynamic Island

A ferry crossing shows how the regions split the work:

```swift
DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        VStack(alignment: .leading) {
            Text(context.attributes.fromPort).font(.headline)
            Text(context.state.departure, style: .time).font(.caption)
        }
    }
    DynamicIslandExpandedRegion(.trailing) {
        VStack(alignment: .trailing) {
            Text(context.attributes.toPort).font(.headline)
            Text(context.state.arrival, style: .time).font(.caption)
        }
    }
    DynamicIslandExpandedRegion(.center) {
        Text(context.state.statusLine).lineLimit(1)
    }
    DynamicIslandExpandedRegion(.bottom) {
        ProgressView(value: context.state.progress)
    }
} compactLeading: {
    Image(systemName: "ferry")
} compactTrailing: {
    Text(context.state.arrival, style: .timer).monospacedDigit()
} minimal: {
    Image(systemName: "ferry")
}
.keylineTint(.teal)
.contentMargins(.trailing, 20, for: .expanded)
.contentMargins(.bottom, 16, for: .expanded)
```

- `.dynamicIsland(verticalPlacement: .belowIfTooWide)` on a view inside an
  expanded region lets it drop under the camera when it does not fit beside it.
- `.contentMargins(_:_:for:)` overrides the default margins for one presentation
  mode.
- `.keylineTint(_:)` on the `DynamicIsland` colors its outline.

## Live Activity alerts

An update can alert the user visibly and audibly:

```swift
let alert = AlertConfiguration(
    title: "Ferry boarding",
    body: "Gate B is open.",
    sound: .default
)
await activity.update(content, alertConfiguration: alert)
```

For a custom sound, bundle the file with the app and pass `.named("horn.aiff")`.

## Live Activity Push Updates

Request with `pushType: .token` and forward each token the activity reports.
`Activity` is a class that is not `Sendable`, so start this task from main-actor
code such as a view or an `@MainActor` model; in a nonisolated function Swift 6
rejects capturing `activity` in the `Task`:

```swift
Task {
    for await data in activity.pushTokenUpdates {
        let hex = hexString(data)
        await FerryAPI.shared.register(activityToken: hex, activityID: activity.id)
    }
}
```

Push-to-start tokens come from the activity type:

```swift
Task {
    for await data in Activity<FerryAttributes>.pushToStartTokenUpdates {
        let hex = hexString(data)
        await FerryAPI.shared.register(startToken: hex)
    }
}
```

From iOS 18 a broadcast channel works too: `pushType: .channel("ferry-route-12")`.

Update payload:

```json
{
  "aps": {
    "timestamp": 1790400000,
    "event": "update",
    "content-state": {
      "statusLine": "Crossing",
      "progress": 0.4,
      "window": { "lowerBound": 1790399400, "upperBound": 1790401800 }
    },
    "alert": { "title": "Halfway", "body": "Arriving in 20 minutes." }
  }
}
```

`content-state` has to mirror the `ContentState` Codable layout key for key; a
`ClosedRange` encodes as `lowerBound` and `upperBound`. Info.plist keys on the
app target:

| Key | Effect |
|---|---|
| `NSSupportsLiveActivities` = YES | Allows Live Activities at all |
| `NSSupportsLiveActivitiesFrequentUpdates` = YES | Raises the push update budget for frequent updates |

The `live-activities` skill goes deeper on the push contract.

## ActivityAuthorizationInfo

```swift
let info = ActivityAuthorizationInfo()
guard info.areActivitiesEnabled else { return }

Task {
    for await enabled in info.activityEnablementUpdates {
        await MainActor.run { settings.liveActivitiesOn = enabled }
    }
}

let canPushOften = info.frequentPushesEnabled

do {
    _ = try Activity.request(attributes: crossing, content: opening, pushType: .token)
} catch let error as ActivityAuthorizationError {
    switch error {
    case .denied: showSettingsHint()
    case .globalMaximumExceeded: showTryLater()
    case .targetMaximumExceeded: endOldestActivity()
    default: showGenericFailure()
    }
}
```

- `.denied`: the user switched Live Activities off in Settings.
- `.globalMaximumExceeded`: too many activities are running across all apps.
- `.targetMaximumExceeded`: this app already runs its maximum.

## Keeping widgets fast

- Do the math in the provider. The view should receive values ready to draw, not
  raw records.
- Extensions have tight memory caps. Avoid big images in the view, large
  datasets in entries and deep view trees.
- Store small, pre-resized thumbnails in the shared container and draw them with
  `.resizable()` and `.aspectRatio(contentMode: .fill)` rather than loading
  full-resolution files.

Sharing through an App Group:

```swift
// App side
let suite = UserDefaults(suiteName: "group.org.sample.garden")
suite?.set(62, forKey: "soilPercent")
WidgetCenter.shared.reloadTimelines(ofKind: "SoilMoisture")

// Provider side
let percent = UserDefaults(suiteName: "group.org.sample.garden")?.integer(forKey: "soilPercent") ?? 0
```

For bigger data, keep a SQLite or Core Data store inside the App Group container.

## Xcode Setup

- New target options: tick "Include Configuration App Intent" for a configurable
  widget and "Include Live Activity" to get a Live Activity scaffold.
- Entitlements: `com.apple.security.application-groups` to share data between
  targets, Push Notifications (`aps-environment`) when Live Activities update by push.
- Add the App Group to both targets with the same identifier, such as
  `group.org.sample.garden`, then read it through `UserDefaults(suiteName:)` or
  `FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)`.
- Debug by running the widget extension scheme, picking the "Widget" destination,
  or using the canvas preview.

| Error | Fix |
|---|---|
| "Widget extension must include at least one widget" | Put `@main` on the `WidgetBundle` |
| "No such module 'WidgetKit'" | Link WidgetKit and SwiftUI in the extension target |
| `ActivityKit.ActivityAuthorizationError error 3` | The `NSSupportsLiveActivities = YES` key belongs in the main app target's Info.plist; setting it on the extension does nothing |

## Smart Stack relevance

An entry can tell Smart Stacks how important it is and for how long:

```swift
struct MatchEntry: TimelineEntry {
    let date: Date
    let score: String
    let isLive: Bool

    var relevance: TimelineEntryRelevance? {
        isLive ? TimelineEntryRelevance(score: 90, duration: 2 * 3600) : nil
    }
}
```

A higher score makes the stack more likely to rotate the widget up; `duration`
says how long the score holds.

Configurable widgets can also report relevance from the provider (iOS 18+). The
method returns a `WidgetRelevance` built from an array of
`WidgetRelevanceAttribute` values, each pairing one intent configuration with a
RelevanceKit context such as a date window. `.date(interval:kind:)` below needs
iOS 26; on iOS 18 the date window is `.date(from:to:)`:

```swift
import RelevanceKit

func relevance() async -> WidgetRelevance<PickTeamIntent> {
    let fixtures = await FixtureStore.shared.upcoming()
    let attributes = fixtures.map { fixture in
        WidgetRelevanceAttribute(
            configuration: PickTeamIntent(team: fixture.team),
            context: .date(interval: fixture.window, kind: .scheduled)
        )
    }
    return WidgetRelevance(attributes)
}
```

`.date(interval:kind:)` is the iOS 26 form; on iOS 18 use `.date(from:to:)`,
which iOS 26 deprecates.

## ActivityState Lifecycle

```swift
Task {
    for await phase in activity.activityStateUpdates {
        switch phase {
        case .active: break
        case .pending: showQueuedBadge()
        case .stale: await refreshOrEnd(activity)
        case .ended: markFinished()
        case .dismissed: cleanUp(activity.id)
        @unknown default: break
        }
    }
}
```

| State | Meaning |
|---|---|
| `.active` | Running and visible |
| `.pending` | Requested but not on screen yet (iOS 26+) |
| `.stale` | Content is out of date; update it or end it |
| `.ended` | Finished, but may still sit on the Lock Screen |
| `.dismissed` | Gone from screen; release resources |

## ActivityStyle

```swift
_ = try Activity.request(attributes: goalAlert, content: snapshot, pushType: nil, style: .transient)
```

`.standard` stays until you end it. `.transient` (iOS 18+) is removed by the
system after a while; use it for short bursts such as a goal alert or a bus
arriving.

## Dismissal Policies

| Policy | Effect |
|---|---|
| `.default` | The system chooses when to remove it |
| `.immediate` | Removed right away |
| `.after(date)` | Removed at the given date, at most 4 hours after ending |

## Finding which widgets and activities are live

```swift
let configs = try await WidgetCenter.shared.currentConfigurations()
for info in configs {
    logger.debug("\(info.kind) \(String(describing: info.family))")
}

for activity in Activity<FerryAttributes>.activities {
    logger.debug("\(activity.id) \(String(describing: activity.activityState))")
}

Task {
    for await started in Activity<FerryAttributes>.activityUpdates {
        observe(started)
    }
}
```

`currentConfigurations()` lists placed widgets with their `kind` and `family`.
`Activity<T>.activities` returns running activities, and `activityUpdates`
reports ones started later, including by push. `logger` is an `os.Logger`.

## Design Patterns

### Gauge (iOS 16+)

`.accessoryCircular` draws an open ring with the value in the middle, the same
look as system complications. `.linearCapacity` fills from leading to trailing.

```swift
Gauge(value: Double(entry.steps), in: 0...10000) {
    Image(systemName: "figure.walk")
} currentValueLabel: {
    Text(entry.steps, format: .number.notation(.compactName))
}
.gaugeStyle(.accessoryCircular)

Gauge(value: entry.usedBytes, in: 0...entry.totalBytes) {
    Text("Backup")
} currentValueLabel: {
    Text(Int64(entry.usedBytes).formatted(.byteCount(style: .file)))
}
.gaugeStyle(.linearCapacity)
```

### Container background

```swift
TideSummary(entry: entry)
    .containerBackground(.fill.tertiary, for: .widget)
```

The system knows where the widget is placed and draws the background correctly
on every surface, including removing it where the surface wants none.

### Canvas sparkline

```swift
struct Sparkline: View {
    let values: [Double]

    var body: some View {
        Canvas { context, size in
            guard values.count >= 2, let low = values.min(), let high = values.max() else { return }
            let span = max(high - low, .ulpOfOne)
            let spacing = size.width / Double(values.count - 1)
            var path = Path()
            for (index, value) in values.enumerated() {
                let point = CGPoint(
                    x: Double(index) * spacing,
                    y: size.height * (1 - CGFloat((value - low) / span))
                )
                index == 0 ? path.move(to: point) : path.addLine(to: point)
            }
            context.stroke(path, with: .color(.green), lineWidth: 1.5)
        }
    }
}
```

### Refresh budget in practice

- Widgets people look at often get about 40 to 70 refreshes a day, with entries
  no closer than 5 minutes.
- Put as many future entries into each timeline as you can predict.
- Use `.after(date)` for scheduled data and `.never` for data only the user changes.
- Let `Text(timerInterval:countsDown:)` or a timer text style count down instead
  of writing an entry per second.

## Further reading from Apple

- [WidgetKit](https://developer.apple.com/documentation/widgetkit)
- [ActivityKit](https://developer.apple.com/documentation/activitykit)
- [TimelineProvider](https://developer.apple.com/documentation/widgetkit/timelineprovider)
- [AppIntentTimelineProvider](https://developer.apple.com/documentation/widgetkit/appintenttimelineprovider)
- [ActivityAttributes](https://developer.apple.com/documentation/activitykit/activityattributes)
- [ActivityConfiguration](https://developer.apple.com/documentation/widgetkit/activityconfiguration)
- [DynamicIsland](https://developer.apple.com/documentation/widgetkit/dynamicisland)
- [ControlWidgetButton](https://developer.apple.com/documentation/widgetkit/controlwidgetbutton)
- [ControlWidgetToggle](https://developer.apple.com/documentation/widgetkit/controlwidgettoggle)
- [Keeping a widget up to date](https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date)
- [Adding StandBy and CarPlay support to your widget](https://developer.apple.com/documentation/widgetkit/adding-standby-and-carplay-support-to-your-widget)
- [Optimizing your widget for accented rendering mode and Liquid Glass](https://developer.apple.com/documentation/widgetkit/optimizing-your-widget-for-accented-rendering-mode-and-liquid-glass)
