WidgetKit and Live Activities: Extending Your App to the Home Screen and Lock Screen


Your app lives on the home screen as a static icon. Meanwhile, users spend 80% of their phone time outside your app — glancing at notifications, checking the lock screen, scrolling through widgets. WidgetKit and Live Activities give your app a persistent, dynamic presence exactly where users already look, without requiring them to tap anything.

This post covers the full surface area of WidgetKit — timelines, widget families including Lock Screen, interactive widgets — and ActivityKit’s Live Activities with Dynamic Island. We’ll build a Pixar Film Tracker as our running example. This post assumes you’re comfortable with SwiftUI and your app’s lifecycle. We won’t cover push notification delivery for Live Activities; that’s covered in Push Notifications.

Contents

The Problem

Consider a Pixar Film Tracker app. A user is watching Coco and wants to track which scene they’re on. Today, that means launching the app, navigating to the now-playing screen, checking the progress, then switching back to whatever they were doing. Three taps minimum, every time.

This is the exact gap widgets and Live Activities were designed to fill. But many teams treat them as an afterthought — tacking on a widget that just shows a static “open the app” button, or skipping Live Activities entirely because the ActivityKit API looked intimidating. The result is a missed engagement opportunity, and often a 1-star review complaining the widget “doesn’t do anything.”

The root issue is architectural: widgets are not mini-apps. They have no persistent runtime, no network calls during rendering, and no event-driven updates. Understanding this constraint is the key to building widgets that actually work.

// ❌ Common mistake: treating a widget like a view that fetches its own data
struct FilmWidgetEntryView: View {
    // This @State will never update — widgets don't have a live runtime
    @State private var currentFilm: PixarFilm?

    var body: some View {
        // URLSession calls here will silently fail or be killed by the system
        Text(currentFilm?.title ?? "Loading...")
            .onAppear {
                Task { currentFilm = try? await FilmAPI.fetchCurrentFilm() }
            }
    }
}

Widgets are rendered from a snapshot of data at a point in time. All data fetching happens in the TimelineProvider, not in the view.

WidgetKit Architecture

Apple Docs: WidgetKit — WidgetKit framework overview

A WidgetKit extension is a separate process from your main app. The system calls your TimelineProvider to fetch a series of TimelineEntry values — each entry represents the widget’s state at a specific moment. The system renders your SwiftUI view for each entry and displays them at the scheduled times. No code runs between renders.

The three components that make up every widget:

  1. Widget — the entry point conforming to the Widget protocol. Declares configuration and supported families.
  2. TimelineProvider — fetches data and returns an array of entries with timestamps.
  3. Entry View — a SwiftUI view that renders a single TimelineEntry.
import WidgetKit
import SwiftUI

// The Widget entry point — registered in your widget extension's @main
@available(iOS 14.0, *)
struct PixarFilmWidget: Widget {
    let kind: String = "PixarFilmWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: FilmTimelineProvider()) { entry in
            FilmWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget) // Required iOS 17+
        }
        .configurationDisplayName("Pixar Now Playing")
        .description("Shows the Pixar film you're currently watching.")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}

StaticConfiguration is used when the widget has no user-configurable parameters. For user-configurable widgets (like “show me films from this watchlist”), use AppIntentConfiguration introduced in iOS 17, which replaced the older IntentConfiguration.

Defining a TimelineEntry

A TimelineEntry is a struct that pairs a Date (when to display it) with your custom data payload:

import WidgetKit

struct FilmEntry: TimelineEntry {
    let date: Date
    let film: PixarFilm?
    let watchProgress: Double // 0.0–1.0

    // A placeholder entry used during widget gallery previews
    static let placeholder = FilmEntry(
        date: .now,
        film: PixarFilm(title: "Coco", year: 2017, posterName: "coco-poster"),
        watchProgress: 0.42
    )
}

struct PixarFilm: Codable, Identifiable {
    let id: String
    let title: String
    let year: Int
    let posterName: String
}

Building a TimelineProvider

The TimelineProvider protocol requires three methods. Understanding what each one is for prevents the most common WidgetKit bugs.

import WidgetKit

struct FilmTimelineProvider: TimelineProvider {
    // Called immediately when the widget is first placed or the system needs
    // a fast render. Must return synchronously — no async work here.
    func placeholder(in context: Context) -> FilmEntry {
        .placeholder
    }

    // Called for the widget gallery and when the system needs a single entry
    // quickly. You CAN do async work here; the system waits a short time.
    func getSnapshot(in context: Context, completion: @escaping (FilmEntry) -> Void) {
        Task {
            let film = try? await FilmStore.shared.fetchCurrentFilm()
            let progress = FilmStore.shared.watchProgress()
            completion(FilmEntry(date: .now, film: film, watchProgress: progress))
        }
    }

    // The main method. Fetch your data, build a series of entries, and tell
    // the system when to call you again via TimelineReloadPolicy.
    func getTimeline(in context: Context, completion: @escaping (Timeline<FilmEntry>) -> Void) {
        Task {
            let film = try? await FilmStore.shared.fetchCurrentFilm()
            let progress = FilmStore.shared.watchProgress()

            // Build entries for the next hour in 15-minute increments
            var entries: [FilmEntry] = []
            let now = Date.now
            for minuteOffset in stride(from: 0, through: 60, by: 15) {
                let entryDate = Calendar.current.date(
                    byAdding: .minute,
                    value: minuteOffset,
                    to: now
                )!
                entries.append(FilmEntry(date: entryDate, film: film, watchProgress: progress))
            }

            // .atEnd: system calls getTimeline again when the last entry expires
            // .after(date): system calls again at a specific date
            // .never: don't refresh until the app explicitly requests it
            let timeline = Timeline(entries: entries, policy: .atEnd)
            completion(timeline)
        }
    }
}

Choosing a Reload Policy

TimelineReloadPolicy is one of the most consequential decisions in widget design:

  • .atEnd — refresh after the last entry is displayed. Good for content that changes on a predictable schedule (sports scores, countdowns).
  • .after(date) — refresh at a specific future date. Good for content with known expiry (a film recommendation that changes at midnight).
  • .never — only refresh when your app calls WidgetCenter.shared.reloadTimelines(ofKind:). Best for user-driven changes (the user marks a film as watched).

Warning: The system does not guarantee refreshes will happen exactly on schedule. Budget constraints and low-power mode can delay refreshes significantly. Design your widget to gracefully handle stale data — show the last-known state rather than an error.

The Entry View

The entry view is a plain SwiftUI view. Keep it lightweight — no @State, no side effects, no async calls:

import SwiftUI
import WidgetKit

struct FilmWidgetEntryView: View {
    var entry: FilmEntry
    @Environment(\.widgetFamily) var family

    var body: some View {
        switch family {
        case .systemSmall:
            SmallFilmView(entry: entry)
        case .systemMedium:
            MediumFilmView(entry: entry)
        default:
            LargeFilmView(entry: entry)
        }
    }
}

struct SmallFilmView: View {
    let entry: FilmEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            if let film = entry.film {
                Text(film.title)
                    .font(.headline)
                    .lineLimit(2)
                Text("\(Int(entry.watchProgress * 100))% watched")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            } else {
                Text("No film selected")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
    }
}

Widget Families and Lock Screen Widgets

Apple Docs: WidgetFamily — All supported widget sizes

Beyond the standard home screen sizes, iOS 16 introduced Lock Screen widgets using the accessory families:

FamilyPlacementShape
.systemSmallHome ScreenSquare
.systemMediumHome ScreenWide rectangle
.systemLargeHome ScreenTall rectangle
.systemExtraLargeiPad Home ScreenVery wide
.accessoryCircularLock Screen / WatchCircle
.accessoryRectangularLock ScreenWide short rectangle
.accessoryInlineLock Screen (above clock)Single-line text

Lock Screen widgets use system-provided rendering modes. Use @Environment(\.widgetRenderingMode) to adapt your colors:

@available(iOS 16.0, *)
struct FilmAccessoryView: View {
    let entry: FilmEntry
    @Environment(\.widgetRenderingMode) var renderingMode

    var body: some View {
        // accessoryCircular: keep it simple — one value, no text
        ZStack {
            AccessoryWidgetBackground()
            if let film = entry.film {
                // Gauge shows watch progress as an arc
                Gauge(value: entry.watchProgress) {
                    Image(systemName: "film")
                }
                .gaugeStyle(.accessoryCircularCapacity)
            } else {
                Image(systemName: "film")
            }
        }
    }
}

To support Lock Screen widgets, add the accessory families to your supportedFamilies and extend your entry view’s switch:

.supportedFamilies([
    .systemSmall, .systemMedium, .systemLarge,
    .accessoryCircular, .accessoryRectangular // ← iOS 16+
])

Refreshing Widgets from Your App

When your app’s data changes — the user marks a film as watched, a background sync completes — notify WidgetKit immediately rather than waiting for the next scheduled refresh.

Apple Docs: WidgetCenter — WidgetKit

import WidgetKit

final class FilmStore {
    static let shared = FilmStore()

    func markFilmWatched(_ film: PixarFilm) async {
        // 1. Persist the change
        await persistenceLayer.save(film, watched: true)

        // 2. Immediately reload widgets — the system calls getTimeline again
        WidgetCenter.shared.reloadTimelines(ofKind: "PixarFilmWidget")

        // Or reload all widget timelines from this app:
        // WidgetCenter.shared.reloadAllTimelines()
    }
}

Use reloadTimelines(ofKind:) over reloadAllTimelines() when possible — it’s more targeted and costs less battery budget.

Interactive Widgets with AppIntent (iOS 17+)

Apple Docs: AppIntent — App Intents framework

iOS 17 allows Button and Toggle controls in widgets. Taps run an AppIntent — a lightweight action that executes in a separate process without launching your full app.

First, define the intent:

import AppIntents
import WidgetKit

@available(iOS 17.0, *)
struct MarkFilmWatchedIntent: AppIntent {
    static var title: LocalizedStringResource = "Mark Film as Watched"

    // Parameters are encoded into the button and decoded when the intent runs
    @Parameter(title: "Film ID")
    var filmID: String

    @Parameter(title: "Film Title")
    var filmTitle: String

    func perform() async throws -> some IntentResult {
        // This runs in the widget extension process, not your main app
        await FilmStore.shared.markFilmWatched(id: filmID)

        // Reload the widget after the action completes
        WidgetCenter.shared.reloadTimelines(ofKind: "PixarFilmWidget")

        return .result()
    }
}

Then use it in your entry view with a Button:

@available(iOS 17.0, *)
struct MediumFilmView: View {
    let entry: FilmEntry

    var body: some View {
        HStack {
            if let film = entry.film {
                VStack(alignment: .leading) {
                    Text(film.title).font(.headline)
                    Text("\(Int(entry.watchProgress * 100))% complete")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                // Button runs the intent — no app launch required
                Button(
                    intent: MarkFilmWatchedIntent(
                        filmID: film.id,
                        filmTitle: film.title
                    )
                ) {
                    Image(systemName: "checkmark.circle.fill")
                        .font(.title2)
                        .foregroundStyle(.green)
                }
                .buttonStyle(.plain)
            }
        }
        .padding()
    }
}

Note: AppIntent-powered buttons are only available in iOS 17+. Wrap interactive widget code in @available(iOS 17.0, *) and provide a non-interactive fallback view for earlier OS versions using #if available or @available checks.

Live Activities and Dynamic Island (iOS 16.2+)

Apple Docs: ActivityKit — ActivityKit framework

Live Activities are different from widgets in one fundamental way: they are event-driven and can receive real-time updates from your app (or via push notifications). They’re ideal for anything with a defined start and end — a food delivery, a sports match, or a film currently being watched.

Defining ActivityAttributes

The ActivityAttributes protocol separates your data into two parts:

  • Static data (ActivityAttributes properties) — set once when the activity starts. The film title, poster, Remy’s restaurant name.
  • Dynamic data (ContentState) — updated throughout the activity’s lifetime. The current scene, elapsed time, playback state.
import ActivityKit

@available(iOS 16.2, *)
struct FilmPlaybackAttributes: ActivityAttributes {
    // ContentState: updated dynamically via Activity.update()
    public struct ContentState: Codable, Hashable {
        var currentScene: String
        var elapsedMinutes: Int
        var isPlaying: Bool
        var chapterProgress: Double // 0.0–1.0
    }

    // Static: set once at activity creation, never changes
    var filmTitle: String
    var posterImageName: String
    var totalRuntime: Int // minutes
}

Starting a Live Activity

import ActivityKit

@available(iOS 16.2, *)
final class FilmPlaybackController {

    private var currentActivity: Activity<FilmPlaybackAttributes>?

    func startActivity(for film: PixarFilm) {
        // Check that Live Activities are enabled on this device
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            print("Live Activities not available")
            return
        }

        let attributes = FilmPlaybackAttributes(
            filmTitle: film.title,
            posterImageName: film.posterName,
            totalRuntime: film.runtime
        )

        let initialState = FilmPlaybackAttributes.ContentState(
            currentScene: "Opening Credits",
            elapsedMinutes: 0,
            isPlaying: true,
            chapterProgress: 0.0
        )

        do {
            currentActivity = try Activity.request(
                attributes: attributes,
                content: .init(state: initialState, staleDate: nil),
                pushType: nil // Use .token for push-based updates
            )
            print("Started Live Activity: \(currentActivity?.id ?? "unknown")")
        } catch {
            print("Failed to start Live Activity: \(error)")
        }
    }

    func updateActivity(scene: String, elapsed: Int, progress: Double) async {
        let updatedState = FilmPlaybackAttributes.ContentState(
            currentScene: scene,
            elapsedMinutes: elapsed,
            isPlaying: true,
            chapterProgress: progress
        )

        await currentActivity?.update(
            ActivityContent(state: updatedState, staleDate: Date.now.addingTimeInterval(300))
        )
    }

    func endActivity() async {
        await currentActivity?.end(nil, dismissalPolicy: .immediate)
        currentActivity = nil
    }
}

Designing the Live Activity UI

Live Activities appear in three contexts, each with its own layout:

  1. Lock Screen / Notification Banner — Full-width, up to ~180 points tall. Your main canvas.
  2. Dynamic Island compact — Two small regions flanking the TrueDepth camera. One for the leading side, one for the trailing side.
  3. Dynamic Island expanded — User long-presses the island. You get a larger pill.
  4. Dynamic Island minimal — When two activities are active simultaneously. A tiny circular indicator.
import ActivityKit
import SwiftUI
import WidgetKit

@available(iOS 16.2, *)
struct FilmPlaybackLiveActivityView: View {
    let context: ActivityViewContext<FilmPlaybackAttributes>

    var body: some View {
        // This view is used for the Lock Screen presentation
        HStack(spacing: 12) {
            // Static poster image
            Image(context.attributes.posterImageName)
                .resizable()
                .scaledToFill()
                .frame(width: 50, height: 50)
                .clipShape(RoundedRectangle(cornerRadius: 8))

            VStack(alignment: .leading, spacing: 4) {
                Text(context.attributes.filmTitle)
                    .font(.headline)
                    .lineLimit(1)
                Text(context.state.currentScene)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)

                // Chapter progress bar
                ProgressView(value: context.state.chapterProgress)
                    .tint(.orange)
            }

            Spacer()

            // Play/pause indicator
            Image(systemName: context.state.isPlaying ? "pause.fill" : "play.fill")
                .font(.title3)
                .foregroundStyle(.primary)
        }
        .padding()
        .activityBackgroundTint(.black.opacity(0.85))
    }
}

@available(iOS 16.2, *)
struct FilmPlaybackLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: FilmPlaybackAttributes.self) { context in
            // Lock Screen and notification banner UI
            FilmPlaybackLiveActivityView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded: user long-pressed the island
                DynamicIslandExpandedRegion(.leading) {
                    Image(context.attributes.posterImageName)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 40, height: 40)
                        .clipShape(Circle())
                }
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing) {
                        Text("\(context.state.elapsedMinutes) min")
                            .font(.caption2)
                        Image(systemName: context.state.isPlaying ? "pause.fill" : "play.fill")
                    }
                }
                DynamicIslandExpandedRegion(.bottom) {
                    HStack {
                        Text(context.attributes.filmTitle)
                            .font(.caption)
                            .fontWeight(.semibold)
                        Text("·")
                        Text(context.state.currentScene)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .lineLimit(1)
                    }
                }
            } compactLeading: {
                // Compact: the left side of the island
                Image(systemName: "film")
                    .foregroundStyle(.orange)
            } compactTrailing: {
                // Compact: the right side of the island
                Text("\(context.state.elapsedMinutes)m")
                    .font(.caption2)
                    .monospacedDigit()
            } minimal: {
                // Minimal: when two activities are competing
                Image(systemName: "film")
                    .foregroundStyle(.orange)
            }
            .widgetURL(URL(string: "pixartracker://film/\(context.attributes.filmTitle)"))
        }
    }
}

Advanced Usage

Sharing Data Between App and Widget Extension

Your widget runs in a separate process. To share data, you need an App Group:

  1. In Xcode, enable the App Groups capability on both your main app target and widget extension target.
  2. Use the same group identifier (e.g., group.com.yourcompany.pixartracker).
  3. Store data in the shared container using UserDefaults(suiteName:) or a shared file URL.
// In your main app — write to the shared container
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.pixartracker")
sharedDefaults?.set(try? JSONEncoder().encode(currentFilm), forKey: "currentFilm")
WidgetCenter.shared.reloadAllTimelines()

// In your TimelineProvider — read from the shared container
func getTimeline(in context: Context, completion: @escaping (Timeline<FilmEntry>) -> Void) {
    let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.pixartracker")
    let filmData = sharedDefaults?.data(forKey: "currentFilm")
    let film = filmData.flatMap { try? JSONDecoder().decode(PixarFilm.self, from: $0) }

    let entry = FilmEntry(date: .now, film: film, watchProgress: 0.5)
    completion(Timeline(entries: [entry], policy: .atEnd))
}

Widget Previews with #Preview

Xcode 15 introduced a new preview macro for widgets that replaces the older PreviewProvider:

#Preview(as: .systemMedium) {
    PixarFilmWidget()
} timeline: {
    FilmEntry(
        date: .now,
        film: PixarFilm(title: "Up", year: 2009, posterName: "up-poster"),
        watchProgress: 0.67
    )
    FilmEntry(
        date: .now.addingTimeInterval(900),
        film: PixarFilm(title: "WALL-E", year: 2008, posterName: "walle-poster"),
        watchProgress: 0.12
    )
}

Push-Based Live Activity Updates

For server-driven updates (a Live Activity showing a delivery from Ratatouille’s restaurant), request a push token and send updates via the ActivityKit Push Notifications API:

@available(iOS 16.2, *)
func startActivityWithPushSupport(for film: PixarFilm) async {
    let attributes = FilmPlaybackAttributes(
        filmTitle: film.title,
        posterImageName: film.posterName,
        totalRuntime: film.runtime
    )
    let initialState = FilmPlaybackAttributes.ContentState(
        currentScene: "Opening Credits",
        elapsedMinutes: 0,
        isPlaying: true,
        chapterProgress: 0.0
    )

    do {
        let activity = try Activity.request(
            attributes: attributes,
            content: .init(state: initialState, staleDate: nil),
            pushType: .token // ← Opt into push-based updates
        )

        // Observe the push token for server registration
        for await tokenData in activity.pushTokenUpdates {
            let token = tokenData.map { String(format: "%02x", $0) }.joined()
            await registerPushTokenWithServer(token, activityID: activity.id)
        }
    } catch {
        print("Failed to start push-enabled activity: \(error)")
    }
}

When to Use (and When Not To)

ScenarioRecommendation
Content that updates on a fixed schedule (daily quote, weather)Use WidgetKit with .after(date) reload policy
Content driven by user actions (mark watched, rate film)Use WidgetKit with .never + reloadTimelines from app
Ongoing real-time event with a defined end (film playing, food delivery)Use Live Activity — that’s exactly the use case
Persistent background monitoring with no clear endAvoid Live Activities — they’re capped at 8 hours
Complex interactive UIAvoid — widgets are not mini-apps; use app clips or shortcuts
Lock Screen glanceable dataUse .accessoryCircular or .accessoryRectangular
Push-driven server updates for Live ActivityUse push token approach + ActivityKit Push Notifications API
Fallback for devices without Dynamic IslandAlways implement the Lock Screen UI — it’s required and runs on all devices

Warning: Live Activities are limited to 8 hours from creation. After that the system ends them automatically. Design your update logic to handle this gracefully — never display a “stale” activity as if it’s current.

Summary

  • WidgetKit widgets are rendered from snapshots: all data fetching belongs in TimelineProvider, never in the view.
  • TimelineReloadPolicy controls when the system calls your provider again — choose .atEnd, .after(date), or .never based on your update pattern, and reload explicitly from the app with WidgetCenter.
  • iOS 16 Lock Screen widgets use .accessoryCircular and .accessoryRectangular families with @Environment(\.widgetRenderingMode) for adaptive rendering.
  • Interactive widgets (iOS 17+) use AppIntent to run lightweight actions without launching your app.
  • Live Activities separate static ActivityAttributes from dynamic ContentState — start with Activity.request(), update with .update(), end with .end().
  • Dynamic Island requires four distinct layouts: compact leading, compact trailing, expanded, and minimal.
  • Share data between your app and widget extension using an App Group with a shared UserDefaults suite.

Now that your app has a persistent presence outside its own UI, push notifications are the natural companion — they can drive Live Activity updates in real time and reach users even when no widget or activity is active. Check out Push Notifications for the full delivery and entitlement setup.