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
- WidgetKit Architecture
- Building a TimelineProvider
- Widget Families and Lock Screen Widgets
- Refreshing Widgets from Your App
- Interactive Widgets with AppIntent (iOS 17+)
- Live Activities and Dynamic Island (iOS 16.2+)
- Advanced Usage
- When to Use (and When Not To)
- Summary
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:
Widget— the entry point conforming to theWidgetprotocol. Declares configuration and supported families.TimelineProvider— fetches data and returns an array of entries with timestamps.- 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 callsWidgetCenter.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:
| Family | Placement | Shape |
|---|---|---|
.systemSmall | Home Screen | Square |
.systemMedium | Home Screen | Wide rectangle |
.systemLarge | Home Screen | Tall rectangle |
.systemExtraLarge | iPad Home Screen | Very wide |
.accessoryCircular | Lock Screen / Watch | Circle |
.accessoryRectangular | Lock Screen | Wide short rectangle |
.accessoryInline | Lock 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 availableor@availablechecks.
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 (
ActivityAttributesproperties) — 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:
- Lock Screen / Notification Banner — Full-width, up to ~180 points tall. Your main canvas.
- Dynamic Island compact — Two small regions flanking the TrueDepth camera. One for the leading side, one for the trailing side.
- Dynamic Island expanded — User long-presses the island. You get a larger pill.
- 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:
- In Xcode, enable the App Groups capability on both your main app target and widget extension target.
- Use the same group identifier (e.g.,
group.com.yourcompany.pixartracker). - 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)
| Scenario | Recommendation |
|---|---|
| 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 end | Avoid Live Activities — they’re capped at 8 hours |
| Complex interactive UI | Avoid — widgets are not mini-apps; use app clips or shortcuts |
| Lock Screen glanceable data | Use .accessoryCircular or .accessoryRectangular |
| Push-driven server updates for Live Activity | Use push token approach + ActivityKit Push Notifications API |
| Fallback for devices without Dynamic Island | Always 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. TimelineReloadPolicycontrols when the system calls your provider again — choose.atEnd,.after(date), or.neverbased on your update pattern, and reload explicitly from the app withWidgetCenter.- iOS 16 Lock Screen widgets use
.accessoryCircularand.accessoryRectangularfamilies with@Environment(\.widgetRenderingMode)for adaptive rendering. - Interactive widgets (iOS 17+) use
AppIntentto run lightweight actions without launching your app. - Live Activities separate static
ActivityAttributesfrom dynamicContentState— start withActivity.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
UserDefaultssuite.
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.