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.
| Context | What 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:
- Asks your provider for a timeline (a list of “show this at this time” entries).
- Renders each entry to a static image at the entry’s date.
- 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
| Family | Use cases |
|---|---|
.systemSmall | Single stat, single action |
.systemMedium | Header + list, side-by-side |
.systemLarge | Mini feed, multiple stats |
.systemExtraLarge | iPad only |
.accessoryCircular | Lock Screen circle (iOS 16+) |
.accessoryRectangular | Lock Screen rectangle |
.accessoryInline | Text-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
- “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.
- “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. - “Widgets can show animations.” Only the system-provided transition between entries. SwiftUI animations don’t run; what you draw is captured as a snapshot.
- “
reloadTimelinesupdates the widget immediately.” It schedules a reload. The OS may delay actual rendering. - “Interactive widgets are mini-apps now.” No.
ButtonandToggleinvoke anAppIntentand 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
kSecAttrAccessGroupinstead.
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
TimelineProviderthat supplies dated entries, and updates only at those dates or when the host app callsWidgetCenter.reloadTimelines. Limited interaction in iOS 17+ viaAppIntent-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
BGAppRefreshTaskto pull a recent price snapshot every 15 minutes; on update it writes to the App Group and callsreloadTimelines. For market-hours-only refresh, schedule the background task with policy. Outside market hours, return a single entry withTimeline(.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.ContentStatecarries the current leg, gate, ETA. Server pushes via APNs withapns-push-type: liveactivityper leg event;staleDateis 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; letstaleDatedo 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