Platform integrations · advanced

Widgets and WidgetKit

Widgets are not tiny live apps — they are timelines of pre-rendered snapshots, and that inversion drives every design decision.

12 min read12 min practice0/2 exercises4 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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 (via WidgetCenter.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.

Timeline generation for a meetings widget — one run, a whole morning of correct snapshots.

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 via Link (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.
Check yourself

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

Write the code

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.

Solution unlocks after 3 attempts

Spend the budget where views happen

Write the code

Given a daily refresh budget and the hours users actually look at the widget, schedule refreshes into the highest-value hours.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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.

Checkpoint

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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.