Swift-ios-skills widgetkit
Implement, review, or improve widgets, Live Activities, and controls using WidgetKit and ActivityKit. Use when building home screen, Lock Screen, or StandBy widgets with timeline providers; when creating interactive widgets with Button/Toggle and AppIntent actions; when adding Live Activities with Dynamic Island layouts (compact, minimal, expanded); when building Control Center widgets with ControlWidgetButton/ControlWidgetToggle; when configuring widget families, refresh budgets, deep links, push-based reloads, or Liquid Glass rendering; or when setting up widget extensions, App Groups, and entitlements.
git clone https://github.com/dpearson2699/swift-ios-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/dpearson2699/swift-ios-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/widgetkit" ~/.claude/skills/dpearson2699-swift-ios-skills-widgetkit && rm -rf "$T"
skills/widgetkit/SKILL.mdWidgetKit 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
struct with aTimelineEntry
property and display data.date - Implement a
(static) orTimelineProvider
(configurable).AppIntentTimelineProvider - Build the widget view using SwiftUI, adapting layout per
.WidgetFamily - Declare the
conforming struct with a configuration and supported families.Widget - Register all widgets in a
annotated withWidgetBundle
.@main
2. Add a Live Activity
- Define an
struct with a nestedActivityAttributes
.ContentState - Add
to the app's Info.plist.NSSupportsLiveActivities = YES - Create an
in the widget bundle with Lock Screen content and Dynamic Island closures.ActivityConfiguration - Start the activity with
.Activity.request(attributes:content:pushType:) - Update with
and end withactivity.update(_:)
.activity.end(_:dismissalPolicy:)
3. Add a Control Center control
- Define an
for the action.AppIntent - Create a
orControlWidgetButton
in the widget bundle.ControlWidgetToggle - Use
orStaticControlConfiguration
.AppIntentControlConfiguration
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 |
|---|---|
| Name shown in the widget gallery |
| Description shown in the widget gallery |
| Array of values |
| Live Activity sizes (, ) |
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 |
|---|---|
| iOS, iPadOS, macOS, CarPlay (iOS 26+) |
| iOS, iPadOS, macOS |
| iOS, iPadOS, macOS |
| iPadOS only |
Accessory Families (Lock Screen / watchOS)
| Family | Platform |
|---|---|
| iOS, watchOS |
| iOS, watchOS |
| iOS, watchOS |
| 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 |
|---|---|
| Left of the TrueDepth camera; wraps below |
| Right of the TrueDepth camera; wraps below |
| Directly below the camera |
| 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 rendering for Liquid Glass |
| Accented with desaturation |
| Fully desaturated |
| 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.
is the older SiriKit Intents-based provider. PreferIntentTimelineProvider
with the App Intents framework for new widgets.AppIntentTimelineProvider -
Exceeding the refresh budget. Widgets have a daily refresh limit. Do not call
on every minor data change. Batch updates and use appropriateWidgetCenter.shared.reloadTimelines(ofKind:)
values.TimelineReloadPolicy -
Forgetting App Groups for shared data. The widget extension runs in a separate process. Use
or a shared App Group container for data the widget reads.UserDefaults(suiteName:) -
Performing network calls in placeholder().
must return synchronously with sample data. Useplaceholder(in:)
orgetTimeline
for async work.timeline(for:in:) -
Missing NSSupportsLiveActivities Info.plist key. Live Activities will not start without
in the host app's Info.plist.NSSupportsLiveActivities = YES -
Using the deprecated contentState API. Use
for allActivityContent
,Activity.request
, andupdate
calls. Theend
-based methods are deprecated.contentState -
Not handling the stale state. Check
in Live Activity views and show a fallback (e.g., "Updating...") when content is outdated.context.isStale -
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
or.vibrant
mode, not.accented
. Test with.fullColor
and avoid relying on color alone.@Environment(\.widgetRenderingMode) -
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
-
is on the@main
, not on individual widgetsWidgetBundle -
returns synchronously;placeholder(in:)
/getSnapshot
fast whensnapshot(for:in:)isPreview - Timeline reload policy matches update frequency;
only on data changereloadTimelines(ofKind:) - Layout adapts per
; accessory widgets tested inWidgetFamily
mode.vibrant - Interactive widgets use
withAppIntent
/Button
onlyToggle - Live Activity:
;NSSupportsLiveActivities = YES
used; Dynamic Island closures implementedActivityContent -
called; controls useactivity.end(_:dismissalPolicy:)
/StaticControlConfigurationAppIntentControlConfiguration - 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