← All posts

iOS · WidgetKit

How iOS Widgets (WidgetKit) Work: Timelines, App Groups and Interactivity

A practical tour of WidgetKit — the timeline model, reload policies, families, sharing data with the app, interactive widgets with App Intents and Lock Screen widgets.

By Markandeyulu Nistala··8 min read WidgetKitSwiftUIApp Intents

What a widget really is

A widget looks like a small live view on the Home Screen, but that’s not what happens under the hood. WidgetKit widgets are SwiftUI views that the system renders ahead of time from a timeline of snapshots, then displays at the right moment. Your widget code doesn’t keep running; it runs briefly in a separate widget extension process, produces a list of timeline entries, and then WidgetKit archives and shows them.

That model explains most of the rules you’ll hit: widgets are SwiftUI-only (since iOS 14), views are non-scrolling and mostly non-interactive, and anything dynamic has to be described up front as data in an entry. I should be upfront: the examples in this post are a small generic demo (a “day streak” counter), not code from any app I’ve shipped.

Architecture at a glance

A widget has three pieces: your app, the widget extension (a separate target with its own process) and WidgetKit / the system, which decides when to call the extension and renders what it returns. The app and extension don’t share memory or a sandbox, so they exchange data through an App Group.

Your appmain targetWidget extensionTimelineProviderWidgetKitsystemshared data (App Group)reloadTimelines(ofKind:)asks for a timelineTimeline(entries, policy)
The app tells WidgetKit when data changed; WidgetKit asks the extension for entries, then renders them on the Home Screen / Lock Screen.

A widget is declared with the Widget protocol and a configuration:

  • StaticConfiguration — no user-editable options.
  • AppIntentConfiguration (iOS 17) — user-configurable through an AppIntent; replaces the older SiriKit-based IntentConfiguration.
  • ActivityConfiguration — Live Activities, which build on the same machinery but are a separate feature.

Several widgets can live in one extension through a WidgetBundle, which is also the extension’s @main entry point.

Timelines: entries, providers and policies

The heart of WidgetKit is the TimelineProvider protocol with three methods:

  • placeholder(in:) — synchronous, returns sample data. The system shows it (redacted) while real content loads.
  • getSnapshot(in:completion:) — one entry for transient situations like the widget gallery. Check context.isPreview; the gallery needs quick, representative data.
  • getTimeline(in:completion:) — the real work: return a Timeline of TimelineEntry values plus a reload policy.

A TimelineEntry is just a struct with a date (when to display it) and whatever your view needs. You can return many future entries at once — for example one per hour — and WidgetKit swaps them in at the right time without waking your code.

import WidgetKit
import SwiftUI

// 1. The data for ONE moment in time
struct StreakEntry: TimelineEntry {
    let date: Date          // required: when WidgetKit should show this entry
    let days: Int
}

// 2. Produces entries
struct StreakProvider: TimelineProvider {
    // Shown while the widget is loading / redacted in the gallery.
    func placeholder(in context: Context) -> StreakEntry {
        StreakEntry(date: .now, days: 7)
    }

    // A single entry used for the widget gallery and transient situations.
    func getSnapshot(in context: Context, completion: @escaping (StreakEntry) -> Void) {
        completion(context.isPreview ? placeholder(in: context) : load())
    }

    // The real timeline: entries + a policy for when to ask again.
    func getTimeline(in context: Context, completion: @escaping (Timeline<StreakEntry>) -> Void) {
        let entry = load()
        let tomorrow = Calendar.current.startOfDay(
            for: Calendar.current.date(byAdding: .day, value: 1, to: .now)!)
        completion(Timeline(entries: [entry], policy: .after(tomorrow)))
    }

    private func load() -> StreakEntry {
        StreakEntry(date: .now, days: SharedStore.defaults.integer(forKey: "days"))
    }
}

The reload policy tells WidgetKit when to ask for a new timeline:

PolicyMeaning
.atEndAsk again after the last entry’s date passes
.after(Date)Ask again no earlier than this date
.neverDon’t ask again; the app (or an intent) must call reloadTimelines
It’s a request, not a promise. Reloads are budgeted by the system based on how often the user looks at the widget, battery and other factors. Apple’s guidance is a typical budget on the order of 40–70 refreshes per day for a widget, and it varies. Design for “eventually fresh”: put multiple future entries in each timeline and don’t rely on minute-by-minute updates.

For clocks, countdowns and relative times you don’t need reloads at all — a Text with a date style updates on its own:

// A live countdown/stopwatch that updates every second with ZERO timeline reloads:
Text(entry.date.addingTimeInterval(3600), style: .timer)

// Relative text ("in 2 hours") also stays current on its own:
Text(entry.date.addingTimeInterval(7200), style: .relative)

Families and SwiftUI views

Each widget declares the sizes it supports with supportedFamilies, and the view reads @Environment(\.widgetFamily) to adapt its layout.

FamilyWhereSince
.systemSmall / .systemMedium / .systemLargeHome Screen, Today View, StandBy (small), iPad, MaciOS 14
.systemExtraLargeiPad onlyiPadOS 15
.accessoryCircular / .accessoryRectangular / .accessoryInlineLock Screen, Apple Watch complicationsiOS 16

The view is plain SwiftUI. In iOS 17 you must provide a containerBackground(for: .widget) so the system can draw (and remove) the background correctly in contexts like StandBy and Lock Screen; contentMarginsDisabled() on the configuration lets you draw edge to edge.

// 3. A SwiftUI view per entry, adapted to the widget family
struct StreakWidgetView: View {
    @Environment(\.widgetFamily) private var family
    let entry: StreakEntry

    var body: some View {
        switch family {
        case .accessoryCircular:                       // Lock Screen
            Gauge(value: Double(min(entry.days, 30)), in: 0...30) {
                Image(systemName: "flame.fill")
            } currentValueLabel: {
                Text("\(entry.days)")
            }
            .gaugeStyle(.accessoryCircularCapacity)

        case .accessoryRectangular:
            VStack(alignment: .leading) {
                Text("Streak").font(.headline)
                Text("\(entry.days) days").font(.title3.bold())
            }

        case .systemMedium:
            HStack(spacing: 16) {
                Image(systemName: "flame.fill").font(.largeTitle).foregroundStyle(.orange)
                VStack(alignment: .leading) {
                    Text("\(entry.days)").font(.system(size: 44, weight: .bold, design: .rounded))
                    Text("day streak").foregroundStyle(.secondary)
                }
                Spacer()
            }

        default:                                       // .systemSmall and others
            VStack(alignment: .leading) {
                Image(systemName: "flame.fill").foregroundStyle(.orange)
                Text("\(entry.days)").font(.system(size: 40, weight: .bold, design: .rounded))
                Text("day streak").font(.caption).foregroundStyle(.secondary)
            }
        }
    }
}

// 4. The widget itself, and the bundle that is the extension's entry point
struct StreakWidget: Widget {
    let kind = "StreakWidget"          // used later to reload this widget

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: StreakProvider()) { entry in
            StreakWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)   // iOS 17+
        }
        .configurationDisplayName("Streak")
        .description("Your current streak at a glance.")
        .supportedFamilies([.systemSmall, .systemMedium,
                            .accessoryCircular, .accessoryRectangular])
    }
}

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

Xcode 15’s #Preview macro can preview a whole timeline in the canvas, which makes iterating on entries much faster:

#Preview(as: .systemSmall) {
    StreakWidget()
} timeline: {
    StreakEntry(date: .now, days: 3)
    StreakEntry(date: .now.addingTimeInterval(3600), days: 4)
}

Sharing data with App Groups

The widget extension is a different process with its own sandbox. To show your app’s data, enable the App Groups capability on both the app and the extension targets with the same group identifier, then read and write through that shared container:

  • UserDefaults(suiteName:) for small values (as below).
  • FileManager.containerURL(forSecurityApplicationGroupIdentifier:) for files, a SQLite/Core Data/SwiftData store or cached images.
  • A shared keychain access group if you need credentials in the extension.

After the app changes something the widget displays, tell WidgetKit. reloadTimelines(ofKind:) reloads one widget kind; reloadAllTimelines() reloads everything. Prefer targeted reloads — they’re cheaper and kinder to your budget.

// Compiled into BOTH the app target and the widget extension target.
enum SharedStore {
    static let suite = "group.com.example.streak"      // App Group ID (enable the capability on both targets)
    static var defaults: UserDefaults { UserDefaults(suiteName: suite)! }
}

// In the app, after the data changes:
import WidgetKit

func saveStreak(_ days: Int) {
    SharedStore.defaults.set(days, forKey: "days")
    WidgetCenter.shared.reloadTimelines(ofKind: "StreakWidget")   // or reloadAllTimelines()
}

Network calls belong in getTimeline — do the async work there and call completion when you’re done — not in the view. Download images there too: AsyncImage doesn’t work in widgets because the view is rendered once, not kept alive.

Interactive widgets with App Intents (iOS 17)

Before iOS 17 a widget could only deep-link into the app (widgetURL(_:) for the whole widget, Link for regions in medium and large). iOS 17 adds real interactivity: Button(intent:) and Toggle(isOn:intent:) run an AppIntent directly from the widget, without launching the app.

import AppIntents

struct AddDayIntent: AppIntent {
    static let title: LocalizedStringResource = "Add a day"

    func perform() async throws -> some IntentResult {
        let d = SharedStore.defaults
        d.set(d.integer(forKey: "days") + 1, forKey: "days")
        return .result()          // WidgetKit then reloads the widget's timeline
    }
}

// Inside a widget view (iOS 17+): a real button that runs the intent without opening the app
Button(intent: AddDayIntent()) {
    Label("Add day", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
  • The intent’s perform() runs in the background; when it finishes, WidgetKit reloads the widget’s timeline so the new state is rendered.
  • Keep intents fast and idempotent. Update shared storage, and let the timeline reload do the UI work.
  • Only buttons and toggles are interactive — no text fields, sliders or gestures.
  • Animations are limited to what WidgetKit supports, such as contentTransition(.numericText()) and simple transitions when an intent changes state.

For user-configurable widgets, iOS 17 introduces AppIntentConfiguration with an AppIntentTimelineProvider, whose methods are async:

// A configurable widget (iOS 17+): the user edits parameters via "Edit Widget"
struct StreakConfigIntent: WidgetConfigurationIntent {
    static let title: LocalizedStringResource = "Streak options"

    @Parameter(title: "Show goal", default: true)
    var showGoal: Bool
}

struct ConfigurableProvider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> StreakEntry { StreakEntry(date: .now, days: 7) }

    func snapshot(for configuration: StreakConfigIntent, in context: Context) async -> StreakEntry {
        StreakEntry(date: .now, days: SharedStore.defaults.integer(forKey: "days"))
    }

    func timeline(for configuration: StreakConfigIntent, in context: Context) async -> Timeline<StreakEntry> {
        let entry = StreakEntry(date: .now, days: SharedStore.defaults.integer(forKey: "days"))
        return Timeline(entries: [entry], policy: .never)   // the app reloads it when data changes
    }
}

// AppIntentConfiguration(kind: "StreakWidget", intent: StreakConfigIntent.self,
//                        provider: ConfigurableProvider()) { entry in ... }

Lock Screen widgets

Lock Screen widgets (iOS 16) reuse the exact same WidgetKit pipeline — you just add the .accessory* families. They’re small, monochrome-leaning and rendered in different modes, so design for glanceability:

  • .accessoryCircular — great for a Gauge or a short value; .accessoryRectangular — a few short lines; .accessoryInline — one line of text and an optional symbol above the clock.
  • Read @Environment(\.widgetRenderingMode) (.fullColor, .accented, .vibrant) and mark key elements with widgetAccentable() so tinting looks right.
  • AccessoryWidgetBackground() gives the standard translucent backdrop for circular widgets.
  • The same widgets appear in StandBy (iOS 17) and as Apple Watch complications in watchOS 9+ when built into a watch target.

Limitations to plan for

  • Not a running app. No timers, background loops or continuous updates; all updates come from timelines, intents or system-supported self-updating text.
  • Refresh budget. Reload frequency is controlled by the system. Don’t promise real-time data to your users.
  • No scrolling, text input, gestures, video or maps interaction in the widget itself; only buttons/toggles (iOS 17+) and links.
  • Tight memory limits in the extension. Resize images before putting them in entries, and don’t load more data than the widget needs.
  • Different process. Anything the widget reads must live in the App Group, a shared keychain group, or come from the network inside getTimeline.
  • Privacy on the Lock Screen. Content can be visible while the device is locked; use redacted(reason:) or privacySensitive() for sensitive values.

Checklist and takeaways

  1. Add a Widget Extension target and enable the same App Group on the app and extension.
  2. Model one TimelineEntry per moment; return several entries in a single timeline where you can.
  3. Pick a realistic reload policy; call WidgetCenter.shared.reloadTimelines(ofKind:) from the app when data changes.
  4. Support the families that make sense and adapt the SwiftUI view with widgetFamily.
  5. Use containerBackground on iOS 17, and add Button(intent:) only for small, fast actions.
  6. Test the gallery (snapshot), the placeholder, a dark/tinted rendering and the Lock Screen.

Building widgets for your own app and want to compare notes? Get in touch.