WidgetKit and ActivityKit
Build home screen widgets, Lock Screen widgets, Live Activities, Dynamic Island presentations, Control Center controls, and StandBy surfaces for iOS 26+.
See references/widgetkit-advanced.md for timeline strategies, push-based
updates, Xcode setup, and advanced patterns.
Contents
- Workflow
- Widget Protocol and WidgetBundle
- Configuration Types
- TimelineProvider
- AppIntentTimelineProvider
- Widget Families
- Interactive Widgets (iOS 17+)
- Live Activities and Dynamic Island
- Control Center Widgets (iOS 18+)
- Lock Screen Widgets
- StandBy Mode
- iOS 26 Additions
- Common Mistakes
- Review Checklist
- References
Workflow
1. Create a new widget
- Add a Widget Extension target in Xcode (File > New > Target > Widget Extension).
- Enable App Groups for shared data between the app and widget extension.
- Define a
TimelineEntrystruct with adateproperty and display data. - Implement a
TimelineProvider(static) orAppIntentTimelineProvider(configurable). - Build the widget view using SwiftUI, adapting layout per
WidgetFamily. - Declare the
Widgetconforming struct with a configuration and supported families. - Register all widgets in a
WidgetBundleannotated with@main.
2. Add a Live Activity
- Define an
ActivityAttributesstruct with a nestedContentState. - Add
NSSupportsLiveActivities = YESto the app's Info.plist. - Create an
ActivityConfigurationin the widget bundle with Lock Screen content and Dynamic Island closures. - Start the activity with
Activity.request(attributes:content:pushType:). - Update with
activity.update(_:)and end withactivity.end(_:dismissalPolicy:).
3. Add a Control Center control
- Define an
AppIntentfor the action. - Create a
ControlWidgetButtonorControlWidgetTogglein the widget bundle. - Use
StaticControlConfigurationorAppIntentControlConfiguration.
4. Review existing widget code
Run through the Review Checklist at the end of this document.
Widget Protocol and WidgetBundle
Widget
Every widget conforms to the Widget protocol and returns a WidgetConfiguration
from its body.
struct OrderStatusWidget: Widget {
let kind: String = "OrderStatusWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: OrderProvider()) { entry in
OrderWidgetView(entry: entry)
}
.configurationDisplayName("Order Status")
.description("Track your current order.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
WidgetBundle
Use WidgetBundle to expose multiple widgets from a single extension.
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
OrderStatusWidget()
FavoritesWidget()
DeliveryActivityWidget() // Live Activity
QuickActionControl() // Control Center
}
}
Configuration Types
Use StaticConfiguration for non-configurable widgets. Use AppIntentConfiguration
(recommended) for configurable widgets paired with AppIntentTimelineProvider.
// Static
StaticConfiguration(kind: "MyWidget", provider: MyProvider()) { entry in
MyWidgetView(entry: entry)
}
// Configurable
AppIntentConfiguration(kind: "ConfigWidget", intent: SelectCategoryIntent.self,
provider: CategoryProvider()) { entry in
CategoryWidgetView(entry: entry)
}
Shared Modifiers
| Modifier | Purpose |
|---|---|
.configurationDisplayName(_:) | Name shown in the widget gallery |
.description(_:) | Description shown in the widget gallery |
.supportedFamilies(_:) | Array of WidgetFamily values |
.supplementalActivityFamilies(_:) | Live Activity sizes (.small, .medium) |
TimelineProvider
For static (non-configurable) widgets. Uses completion handlers. Three required methods:
struct WeatherProvider: TimelineProvider {
typealias Entry = WeatherEntry
func placeholder(in context: Context) -> WeatherEntry {
WeatherEntry(date: .now, temperature: 72, condition: "Sunny")
}
func getSnapshot(in context: Context, completion: @escaping (WeatherEntry) -> Void) {
let entry = context.isPreview
? placeholder(in: context)
: WeatherEntry(date: .now, temperature: currentTemp, condition: currentCondition)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WeatherEntry>) -> Void) {
Task {
let weather = await WeatherService.shared.fetch()
let entry = WeatherEntry(date: .now, temperature: weather.temp, condition: weather.condition)
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
}
AppIntentTimelineProvider
For configurable widgets. Uses async/await natively. Receives user intent configuration.
struct CategoryProvider: AppIntentTimelineProvider {
typealias Entry = CategoryEntry
typealias Intent = SelectCategoryIntent
func placeholder(in context: Context) -> CategoryEntry {
CategoryEntry(date: .now, categoryName: "Sample", items: [])
}
func snapshot(for config: SelectCategoryIntent, in context: Context) async -> CategoryEntry {
let items = await DataStore.shared.items(for: config.category)
return CategoryEntry(date: .now, categoryName: config.category.name, items: items)
}
func timeline(for config: SelectCategoryIntent, in context: Context) async -> Timeline<CategoryEntry> {
let items = await DataStore.shared.items(for: config.category)
let entry = CategoryEntry(date: .now, categoryName: config.category.name, items: items)
return Timeline(entries: [entry], policy: .atEnd)
}
}
Widget Families
System Families (Home Screen)
| Family | Platform |
|---|---|
.systemSmall | iOS, iPadOS, macOS, CarPlay (iOS 26+) |
.systemMedium | iOS, iPadOS, macOS |
.systemLarge | iOS, iPadOS, macOS |
.systemExtraLarge | iPadOS only |
Accessory Families (Lock Screen / watchOS)
| Family | Platform |
|---|---|
.accessoryCircular | iOS, watchOS |
.accessoryRectangular | iOS, watchOS |
.accessoryInline | iOS, watchOS |
.accessoryCorner | watchOS only |
Adapt layout per family using @Environment(\.widgetFamily):
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall: CompactView(entry: entry)
case .systemMedium: DetailedView(entry: entry)
case .accessoryCircular: CircularView(entry: entry)
default: FullView(entry: entry)
}
}
Interactive Widgets (iOS 17+)
Use Button and Toggle with AppIntent conforming types to perform actions
directly from a widget without launching the app.
struct ToggleFavoriteIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Favorite"
@Parameter(title: "Item ID") var itemID: String
func perform() async throws -> some IntentResult {
await DataStore.shared.toggleFavorite(itemID)
return .result()
}
}
struct InteractiveWidgetView: View {
let entry: FavoriteEntry
var body: some View {
HStack {
Text(entry.itemName)
Spacer()
Button(intent: ToggleFavoriteIntent(itemID: entry.itemID)) {
Image(systemName: entry.isFavorite ? "star.fill" : "star")
}
}
.padding()
}
}
Live Activities and Dynamic Island
ActivityAttributes
Define the static and dynamic data model.
struct DeliveryAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var driverName: String
var estimatedDeliveryTime: ClosedRange<Date>
var currentStep: DeliveryStep
}
var orderNumber: Int
var restaurantName: String
}
ActivityConfiguration
Provide Lock Screen content and Dynamic Island closures in the widget bundle.
struct DeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
VStack(alignment: .leading) {
Text(context.attributes.restaurantName).font(.headline)
HStack {
Text("Driver: \(context.state.driverName)")
Spacer()
Text(timerInterval: context.state.estimatedDeliveryTime, countsDown: true)
}
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "box.truck.fill").font(.title2)
}
DynamicIslandExpandedRegion(.trailing) {
Text(timerInterval: context.state.estimatedDeliveryTime, countsDown: true)
.font(.caption)
}
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.restaurantName).font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
ForEach(DeliveryStep.allCases, id: \.self) { step in
Image(systemName: step.icon)
.foregroundStyle(step <= context.state.currentStep ? .primary : .tertiary)
}
}
}
} compactLeading: {
Image(systemName: "box.truck.fill")
} compactTrailing: {
Text(timerInterval: context.state.estimatedDeliveryTime, countsDown: true)
.frame(width: 40).monospacedDigit()
} minimal: {
Image(systemName: "box.truck.fill")
}
}
}
}
Dynamic Island Regions
| Region | Position |
|---|---|
.leading | Left of the TrueDepth camera; wraps below |
.trailing | Right of the TrueDepth camera; wraps below |
.center | Directly below the camera |
.bottom | Below all other regions |
Starting, Updating, and Ending
// Start
let attributes = DeliveryAttributes(orderNumber: 123, restaurantName: "Pizza Place")
let state = DeliveryAttributes.ContentState(
driverName: "Alex",
estimatedDeliveryTime: Date()...Date().addingTimeInterval(1800),
currentStep: .preparing
)
let content = ActivityContent(state: state, staleDate: nil, relevanceScore: 75)
let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
// Update (optionally with alert)
let updated = ActivityContent(state: newState, staleDate: nil, relevanceScore: 90)
await activity.update(updated)
await activity.update(updated, alertConfiguration: AlertConfiguration(
title: "Order Update", body: "Your driver is nearby!", sound: .default
))
// End
let final = ActivityContent(state: finalState, staleDate: nil, relevanceScore: 0)
await activity.end(final, dismissalPolicy: .after(.now.addingTimeInterval(3600)))
Control Center Widgets (iOS 18+)
// Button control
struct OpenCameraControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "OpenCamera") {
ControlWidgetButton(action: OpenCameraIntent()) {
Label("Camera", systemImage: "camera.fill")
}
}
.displayName("Open Camera")
}
}
// Toggle control with value provider
struct FlashlightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Flashlight", provider: FlashlightValueProvider()) { value in
ControlWidgetToggle(isOn: value, action: ToggleFlashlightIntent()) {
Label("Flashlight", systemImage: value ? "flashlight.on.fill" : "flashlight.off.fill")
}
}
.displayName("Flashlight")
}
}
Lock Screen Widgets
Use accessory families and AccessoryWidgetBackground.
struct StepsWidget: Widget {
let kind = "StepsWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: StepsProvider()) { entry in
ZStack {
AccessoryWidgetBackground()
VStack {
Image(systemName: "figure.walk")
Text("\(entry.stepCount)").font(.headline)
}
}
}
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
StandBy Mode
.systemSmall widgets automatically appear in StandBy (iPhone on charger in
landscape). Use @Environment(\.widgetLocation) for conditional rendering:
@Environment(\.widgetLocation) var location
// location == .standBy, .homeScreen, .lockScreen, .carPlay, etc.
iOS 26 Additions
Liquid Glass Support
Adapt widgets to the Liquid Glass visual style using WidgetAccentedRenderingMode.
| Mode | Description |
|---|---|
.accented | Accented rendering for Liquid Glass |
.accentedDesaturated | Accented with desaturation |
.desaturated | Fully desaturated |
.fullColor | Full-color rendering |
WidgetPushHandler
Enable push-based timeline reloads without scheduled polling.
struct MyWidgetPushHandler: WidgetPushHandler {
func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) {
let tokenString = pushInfo.token.map { String(format: "%02x", $0) }.joined()
// Send tokenString to your server
}
}
CarPlay Widgets
.systemSmall widgets render in CarPlay on iOS 26+. Ensure small widget layouts
are legible at a glance for driver safety.
Common Mistakes
-
Using IntentTimelineProvider instead of AppIntentTimelineProvider.
IntentTimelineProvideris deprecated. UseAppIntentTimelineProviderwith the App Intents framework. -
Exceeding the refresh budget. Widgets have a daily refresh limit. Do not call
WidgetCenter.shared.reloadTimelines(ofKind:)on every minor data change. Batch updates and use appropriateTimelineReloadPolicyvalues. -
Forgetting App Groups for shared data. The widget extension runs in a separate process. Use
UserDefaults(suiteName:)or a shared App Group container for data the widget reads. -
Performing network calls in placeholder().
placeholder(in:)must return synchronously with sample data. UsegetTimelineortimeline(for:in:)for async work. -
Missing NSSupportsLiveActivities Info.plist key. Live Activities will not start without
NSSupportsLiveActivities = YESin the host app's Info.plist. -
Using the deprecated contentState API. Use
ActivityContentfor allActivity.request,update, andendcalls. ThecontentState-based methods are deprecated. -
Not handling the stale state. Check
context.isStalein Live Activity views and show a fallback (e.g., "Updating...") when content is outdated. -
Putting heavy logic in the widget view. Widget views are rendered in a size-limited process. Pre-compute data in the timeline provider and pass display-ready values through the entry.
-
Ignoring accessory rendering modes. Lock Screen widgets render in
.vibrantor.accentedmode, not.fullColor. Test with@Environment(\.widgetRenderingMode)and avoid relying on color alone. -
Not testing on device. Dynamic Island and StandBy behavior differ significantly from Simulator. Always verify on physical hardware.
Review Checklist
- Widget extension target has App Groups entitlement matching the main app
-
@mainis on theWidgetBundle, not on individual widgets -
placeholder(in:)returns synchronously;getSnapshot/snapshot(for:in:)fast whenisPreview - Timeline reload policy matches update frequency;
reloadTimelines(ofKind:)only on data change - Layout adapts per
WidgetFamily; accessory widgets tested in.vibrantmode - Interactive widgets use
AppIntentwithButton/Toggleonly - Live Activity:
NSSupportsLiveActivities = YES;ActivityContentused; Dynamic Island closures implemented -
activity.end(_:dismissalPolicy:)called; controls useStaticControlConfiguration/AppIntentControlConfiguration - Timeline entries and Intent types are Sendable; tested on device
References
- Advanced guide:
references/widgetkit-advanced.md - Apple docs: WidgetKit | ActivityKit | Keeping a widget up to date