7.2 — WidgetKit

Opening scenario

The designer hands you a beautiful Lock Screen widget mock and says: “Just like the Apple Weather one.” You smile politely. Then you discover widgets aren’t tiny views — they’re a separate process, sandboxed from your app, that the OS snapshots into an image and shows. They can’t run code on their own. They can’t network freely. They get woken on a schedule the OS controls. Welcome to WidgetKit, the framework where you trade interactivity for residency on the user’s most precious screen real estate.

ContextWhat it usually means
Reads “TimelineProvider”Has shipped a basic widget
Reads “widget families”Knows the size matrix
Reads “AppIntent configuration”Has done iOS 17+ customization
Reads “Live Activity”Has shipped one for Dynamic Island
Reads “interactive widget”Knows what iOS 17 unlocked (and didn’t)

Concept → Why → How → Code

Concept

A widget is a separate extension target that defines a SwiftUI view, a provider that produces timeline entries (data + dates), and a configuration. iOS:

  1. Asks your provider for a timeline (a list of “show this at this time” entries).
  2. Renders each entry to a static image at the entry’s date.
  3. Optionally re-asks for a new timeline later.

Widgets do not run continuously. The view body re-renders only when a timeline entry’s date triggers. There is no scrolling, no gestures (other than tap-to-deeplink, and from iOS 17 limited Button/Toggle via AppIntent).

Why

Widgets are the highest-value real estate Apple gives a third-party developer outside the app icon. Apple’s own widgets dominate the default Home Screen; a high-quality widget is a strong retention lever. From iOS 17 they’re also the foundation for StandBy and the Watch face complications.

How — a minimal widget

Add target: File → New → Target → Widget Extension. The template gives you a struct conforming to Widget.

import WidgetKit
import SwiftUI

struct StreakProvider: TimelineProvider {
    typealias Entry = StreakEntry

    func placeholder(in context: Context) -> StreakEntry {
        StreakEntry(date: .now, days: 0)
    }

    func getSnapshot(in context: Context, completion: @escaping (StreakEntry) -> Void) {
        completion(StreakEntry(date: .now, days: 42))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<StreakEntry>) -> Void) {
        let entries = (0..<6).map { offset in
            let date = Calendar.current.date(byAdding: .hour, value: offset, to: .now)!
            return StreakEntry(date: date, days: StreakStore.shared.currentStreak())
        }
        let timeline = Timeline(entries: entries, policy: .after(.now.addingTimeInterval(60 * 60 * 6)))
        completion(timeline)
    }
}

struct StreakEntry: TimelineEntry {
    let date: Date
    let days: Int
}

struct StreakWidgetView: View {
    let entry: StreakEntry

    var body: some View {
        VStack {
            Text("Streak").font(.caption).foregroundStyle(.secondary)
            Text("\(entry.days)").font(.system(size: 48, weight: .bold, design: .rounded))
            Text("days").font(.caption2)
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

struct StreakWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "StreakWidget", provider: StreakProvider()) { entry in
            StreakWidgetView(entry: entry)
        }
        .configurationDisplayName("Habit Streak")
        .description("Shows your current habit streak.")
        .supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular])
    }
}

@main
struct StreakBundle: WidgetBundle {
    var body: some Widget { StreakWidget() }
}

Widget families

FamilyUse cases
.systemSmallSingle stat, single action
.systemMediumHeader + list, side-by-side
.systemLargeMini feed, multiple stats
.systemExtraLargeiPad only
.accessoryCircularLock Screen circle (iOS 16+)
.accessoryRectangularLock Screen rectangle
.accessoryInlineText-only above the clock

Shared data via App Group

The widget runs in a separate process. To share data with the host app, enable an App Group capability on both targets, then read/write via UserDefaults(suiteName:), a shared file, or a shared SwiftData store.

extension UserDefaults {
    static let shared = UserDefaults(suiteName: "group.com.yourname.app")!
}

// In the app, after data changes:
UserDefaults.shared.set(currentStreak, forKey: "streak")
WidgetCenter.shared.reloadTimelines(ofKind: "StreakWidget")

WidgetCenter.reloadTimelines is how you tell the system: my data changed, please re-ask my provider.

AppIntentConfiguration (iOS 17+)

User-customizable widgets use an AppIntent for parameters:

import AppIntents

struct PickHabit: AppIntent, WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Choose Habit"

    @Parameter(title: "Habit") var habit: HabitEntity?

    func perform() async throws -> some IntentResult { .result() }
}

struct ConfigurableStreakWidget: Widget {
    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: "ConfigStreak",
                                intent: PickHabit.self,
                                provider: ConfigurableStreakProvider()) { entry in
            ConfigurableStreakView(entry: entry)
        }
        .supportedFamilies([.systemSmall])
    }
}

HabitEntity conforms to AppEntity (covered in 7.9).

Interactive widgets (iOS 17+)

Button and Toggle in widget views can invoke an AppIntent — that’s the only interaction model.

struct LogToday: AppIntent {
    static var title: LocalizedStringResource = "Log Habit"
    @Parameter var habitID: String

    func perform() async throws -> some IntentResult {
        await HabitStore.shared.log(id: habitID, on: .now)
        return .result()
    }
}

struct InteractiveStreakView: View {
    let entry: StreakEntry
    var body: some View {
        VStack(spacing: 8) {
            Text("\(entry.days)").font(.title.bold())
            Button(intent: LogToday(habitID: entry.habitID)) {
                Label("Log today", systemImage: "checkmark.circle.fill")
            }
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

No gestures, no scrolling, no text fields — just buttons and toggles invoking intents.

Live Activities

Live Activities are widgets with a continuous “now” entry shown on the Lock Screen and the Dynamic Island.

import ActivityKit

struct DeliveryAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var minutesAway: Int
        var status: String
    }
    var orderNumber: String
}

// Starting from the host app:
let attributes = DeliveryAttributes(orderNumber: "A123")
let initialState = DeliveryAttributes.ContentState(minutesAway: 25, status: "Preparing")
let content = ActivityContent(state: initialState, staleDate: .now.addingTimeInterval(60 * 60))
let activity = try Activity.request(attributes: attributes,
                                     content: content,
                                     pushType: .token)
let pushToken = await activity.pushTokenUpdates.first(where: { _ in true })

Update via APNs with apns-push-type: liveactivity from your server, using the per-activity push token. The widget extension contains a LiveActivityConfiguration declaring small/medium/expanded layouts.

In the wild

  • Apple Weather, Calendar, Reminders — the bar most teams compare against.
  • Carrot Weather — power-user widget customization, every parameter exposed via AppIntent.
  • Things 3 — clean Lock Screen complication showing today’s count; tap deep-links into the project.
  • Uber Eats, DoorDash, Lyft — Live Activities for order/ride status.
  • Spotify, Music — Live Activities (Now Playing) on the Dynamic Island.

Common misconceptions

  1. “My widget can use a Timer to update every second.” It cannot. Updates happen at timeline entry dates the system honors loosely; high frequency drains the widget budget and the OS will throttle you.
  2. “I’ll fetch from the network in getTimeline.” You can, but you have a tight budget (a few seconds) and the system runs the provider sparingly. Prefer pre-computed data via App Group, refreshed by background tasks in the host app.
  3. “Widgets can show animations.” Only the system-provided transition between entries. SwiftUI animations don’t run; what you draw is captured as a snapshot.
  4. reloadTimelines updates the widget immediately.” It schedules a reload. The OS may delay actual rendering.
  5. “Interactive widgets are mini-apps now.” No. Button and Toggle invoke an AppIntent and the widget re-snapshots. There’s still no input field, no scrolling, no live data.

Seasoned engineer’s take

Widgets are 80% data plumbing and 20% UI. The UI is constrained enough that designers get there quickly; the hard part is keeping the shared-data store fresh without burning battery or hitting the network on a schedule the OS hates. My usual architecture:

  • Host app owns truth. Background refresh / push wakes the app, writes the latest payload to the App Group store, calls WidgetCenter.reloadTimelines.
  • Widget reads, never writes. Provider reads from the App Group, returns a 4–8 hour timeline of pre-computed entries.
  • Static data first, dynamic second. A widget that shows yesterday’s data is still useful; a widget that shows a spinner is broken.

Live Activities deserve their own paragraph: the engineering cost is real (server push infra, foreground/background state, stale-date semantics), the reward is enormous when shipped well. Don’t ship one unless you can commit to keeping the data fresh for the full lifecycle.

TIP: When debugging a widget, edit scheme → Run → Executable → choose your widget extension and pick “Ask on Launch.” This attaches the debugger to the widget process directly, with print statements you can read.

WARNING: Never put authentication tokens in the App Group store unencrypted. Other extensions in the same group (Share, Notification Service) can read them. Use Keychain with kSecAttrAccessGroup instead.

Interview corner

Junior: “What is a widget and how is it different from an app screen?”

A widget is an extension that exposes a small SwiftUI view to the Home/Lock Screen. The system renders it as a snapshot; it doesn’t run continuously. It receives data via a TimelineProvider that supplies dated entries, and updates only at those dates or when the host app calls WidgetCenter.reloadTimelines. Limited interaction in iOS 17+ via AppIntent-bound buttons and toggles.

Mid: “Design a stock-price widget that updates as fresh as possible without draining battery.”

Provider returns a timeline of, say, 30 entries one minute apart. Hosting app runs BGAppRefreshTask to pull a recent price snapshot every 15 minutes; on update it writes to the App Group and calls reloadTimelines. For market-hours-only refresh, schedule the background task with policy. Outside market hours, return a single entry with Timeline(.after(marketOpenDate)). For paid-tier users with push entitlement, additionally subscribe to push-driven refreshes that wake the app and reload.

Senior: “Design Live Activities for a multi-leg flight tracker — boarding, takeoff, mid-flight, landing — across 8 hours.”

ActivityAttributes.ContentState carries the current leg, gate, ETA. Server pushes via APNs with apns-push-type: liveactivity per leg event; staleDate is set to the next expected event +30 min so the Lock Screen can dim if data goes silent. Dynamic Island compact, expanded, and minimal variants all show different fidelities of the same state — never lie about freshness; let staleDate do its job. End the activity from the host or from the final push (ActivityUIDismissalPolicy.after(.now + 5*60)). Cap concurrent activities (Apple’s limit) — if multiple flights, end the oldest. Watch for token rotation: pushTokenUpdates is a stream, must be persisted to your backend each time.

Red flag: “We refresh the widget every minute using a Timer.”

Demonstrates the candidate fundamentally misunderstands the widget process model. There is no continuous runtime; there is no Timer that fires while the widget is on-screen. The OS controls update cadence. This single sentence is enough to fail a widget-focused interview.

Lab preview

Lab 7.2 — Widget extension walks you through adding a WidgetKit extension to an existing app, wiring App Group data sharing, and implementing a Live Activity end-to-end with push token registration.


Next: 7.3 — WeatherKit