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.

install
source · Clone the upstream repo
git clone https://github.com/dpearson2699/swift-ios-skills
Claude Code · Install into ~/.claude/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"
manifest: skills/widgetkit/SKILL.md
source content

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

1. Create a new widget

  1. Add a Widget Extension target in Xcode (File > New > Target > Widget Extension).
  2. Enable App Groups for shared data between the app and widget extension.
  3. Define a
    TimelineEntry
    struct with a
    date
    property and display data.
  4. Implement a
    TimelineProvider
    (static) or
    AppIntentTimelineProvider
    (configurable).
  5. Build the widget view using SwiftUI, adapting layout per
    WidgetFamily
    .
  6. Declare the
    Widget
    conforming struct with a configuration and supported families.
  7. Register all widgets in a
    WidgetBundle
    annotated with
    @main
    .

2. Add a Live Activity

  1. Define an
    ActivityAttributes
    struct with a nested
    ContentState
    .
  2. Add
    NSSupportsLiveActivities = YES
    to the app's Info.plist.
  3. Create an
    ActivityConfiguration
    in the widget bundle with Lock Screen content and Dynamic Island closures.
  4. Start the activity with
    Activity.request(attributes:content:pushType:)
    .
  5. Update with
    activity.update(_:)
    and end with
    activity.end(_:dismissalPolicy:)
    .

3. Add a Control Center control

  1. Define an
    AppIntent
    for the action.
  2. Create a
    ControlWidgetButton
    or
    ControlWidgetToggle
    in the widget bundle.
  3. Use
    StaticControlConfiguration
    or
    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

ModifierPurpose
.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)

FamilyPlatform
.systemSmall
iOS, iPadOS, macOS, CarPlay (iOS 26+)
.systemMedium
iOS, iPadOS, macOS
.systemLarge
iOS, iPadOS, macOS
.systemExtraLarge
iPadOS only

Accessory Families (Lock Screen / watchOS)

FamilyPlatform
.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

RegionPosition
.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
.

ModeDescription
.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

  1. Using IntentTimelineProvider instead of AppIntentTimelineProvider.

    IntentTimelineProvider
    is the older SiriKit Intents-based provider. Prefer
    AppIntentTimelineProvider
    with the App Intents framework for new widgets.

  2. 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 appropriate
    TimelineReloadPolicy
    values.

  3. 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.

  4. Performing network calls in placeholder().

    placeholder(in:)
    must return synchronously with sample data. Use
    getTimeline
    or
    timeline(for:in:)
    for async work.

  5. Missing NSSupportsLiveActivities Info.plist key. Live Activities will not start without

    NSSupportsLiveActivities = YES
    in the host app's Info.plist.

  6. Using the deprecated contentState API. Use

    ActivityContent
    for all
    Activity.request
    ,
    update
    , and
    end
    calls. The
    contentState
    -based methods are deprecated.

  7. Not handling the stale state. Check

    context.isStale
    in Live Activity views and show a fallback (e.g., "Updating...") when content is outdated.

  8. 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.

  9. Ignoring accessory rendering modes. Lock Screen widgets render in

    .vibrant
    or
    .accented
    mode, not
    .fullColor
    . Test with
    @Environment(\.widgetRenderingMode)
    and avoid relying on color alone.

  10. 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
  • @main
    is on the
    WidgetBundle
    , not on individual widgets
  • placeholder(in:)
    returns synchronously;
    getSnapshot
    /
    snapshot(for:in:)
    fast when
    isPreview
  • Timeline reload policy matches update frequency;
    reloadTimelines(ofKind:)
    only on data change
  • Layout adapts per
    WidgetFamily
    ; accessory widgets tested in
    .vibrant
    mode
  • Interactive widgets use
    AppIntent
    with
    Button
    /
    Toggle
    only
  • Live Activity:
    NSSupportsLiveActivities = YES
    ;
    ActivityContent
    used; Dynamic Island closures implemented
  • activity.end(_:dismissalPolicy:)
    called; controls use
    StaticControlConfiguration
    /
    AppIntentControlConfiguration
  • Timeline entries and Intent types are Sendable; tested on device

References