Widgets and WidgetKit
Widgets are not tiny live apps — they are timelines of pre-rendered snapshots, and that inversion drives every design decision.
By the end you will be able to
- Explain the timeline model and why widgets cannot just "update themselves"
- Implement timeline-generation logic with sensible entries and reload policy
- Choose widget families and interactivity (App Intents) appropriately
A junior teammate plans a widget: "a Timer that refreshes the view every second, like our app screen". Why is the plan dead on arrival?
The timeline model
A widget extension supplies three things: an entry (the data for one snapshot), a provider (which produces entries), and a view (SwiftUI, rendering one entry):
struct StandupEntry: TimelineEntry {
let date: Date // WHEN this snapshot becomes current
let nextMeeting: String
let minutesUntil: Int
}
struct StandupProvider: TimelineProvider {
func placeholder(in context: Context) -> StandupEntry {
StandupEntry(date: .now, nextMeeting: "Standup", minutesUntil: 25)
}
func getSnapshot(in context: Context, completion: @escaping (StandupEntry) -> Void) {
completion(placeholder(in: context)) // the widget-gallery preview
}
func getTimeline(in context: Context, completion: @escaping (Timeline<StandupEntry>) -> Void) {
let entries = makeEntries(from: .now) // ← the interesting part
completion(Timeline(entries: entries, policy: .atEnd))
}
}
The three provider calls have distinct jobs: placeholder is the instant, data-free skeleton; getSnapshot is the gallery preview (fast, plausible fake data is fine); getTimeline is the real work — produce entries covering the future, plus a reload policy saying when to ask again:
.atEnd— call me when the last entry is reached..after(date)— call me at this date regardless..never— only the app updates me (viaWidgetCenter.shared.reloadTimelines).
The budget, and thinking in entries
Reloads are budgeted — rule of thumb, roughly 40–70 timeline refreshes per day for a frequently viewed widget. You cannot poll your way to freshness. The skill is making one provider run yield many correct future entries.
The standup widget is the canonical example: you know the whole day's meetings at 9am, so you emit an entry per state change — "in 25 min", "in 10 min", "now", "next: 2pm design review" — each timed to the moment it becomes true. Zero further refreshes; perfectly current all day. Countdown-style displays get even cheaper: Text(meetingDate, style: .relative) counts down inside a static snapshot — the system animates date text without any entries at all.
Count what one provider run bought: four snapshots covering two hours, each flipping at exactly the right minute, with zero intermediate refreshes spent. And the evening case shows the other half of the craft — when there is nothing to show until tomorrow, .after(tomorrow 6am) parks the widget instead of burning budget on "no meetings" reloads all night.
Families, and designing for a glance
Widgets declare which sizes they support, and the view adapts:
Widget configuration: .supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular])
// In the view:
@Environment(\.widgetFamily) private var family
systemSmall/Medium/Large on the home screen; accessoryCircular/Rectangular/Inline on the lock screen and watch — the accessory families render in limited colour and tiny space, which forces the right discipline everywhere: one fact per size. A small widget that answers one question at a glance beats a small dashboard every time; the medium family earns a second fact, not a table.
Two integration points complete the picture:
- Tapping: the whole widget deep-links via
.widgetURL(_:)(small), or regions link viaLink(medium/large) — landing the user on the relevant screen, which is the navigation-path payoff from the lists lesson. - Interactivity (iOS 17+):
Button(intent: CompleteTaskIntent(id:))executes an App Intent in your app's code without opening the app, then the system re-renders the timeline. Toggles and buttons only — the machinery exists for quick actions, not embedded apps.
Your stock-price widget shows stale prices and users complain. The provider requests .after(now + 60) every minute. What actually happens, and what is the honest design?
Emit the state-change entries
A pomodoro widget: 25 minutes work, 5 minutes break, repeating. Generate the entries for cycles full cycles from t=0 — one entry per state change — and the right policy.
Spend the budget where views happen
Given a daily refresh budget and the hours users actually look at the widget, schedule refreshes into the highest-value hours.
Design the timeline for a transit widget ("next departures from Home stop"): what one provider run at 7:00 emits, where the state-change entries fall, the reload policy across the day (rush hour vs midnight), what the snapshot shows when data is stale, and which single tap target it deep-links to.
You can now:
- Explain snapshots-not-processes and design entries around state changes
- Implement timeline generation with policies that respect the budget
- Use date-styled text for free liveness, families for each surface
- Wire deep links and App-Intent buttons
Next up: notifications — the other surface that reaches users outside the app.