
Widgetkit
- 2.8k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
widgetkit is a Swift iOS skill for Home Screen, Lock Screen, Control Center, and timeline-driven WidgetKit extensions.
About
WidgetKit guides home screen, Lock Screen, StandBy, CarPlay, and Control Center widgets for iOS 26 plus with timeline providers, configurable AppIntentTimelineProvider widgets, interactive controls, push reload budgets, deep links, Smart Stack relevance, and Liquid Glass rendering notes. Workflow steps add a Widget Extension target, enable App Groups, define TimelineEntry, implement TimelineProvider or AppIntentTimelineProvider, build per WidgetFamily SwiftUI views, declare Widget configurations, and register everything in a WidgetBundle with main. Interactive widgets use Button and Toggle in views while intent modeling stays in sibling app-intents skills; ActivityConfiguration registers in the bundle but Live Activity depth belongs in activitykit. Control Center controls pair AppIntent or SetValueIntent with ControlWidgetButton or ControlWidgetToggle via StaticControlConfiguration or AppIntentControlConfiguration. Sections cover widget URL deep links, Smart Stack relevance, iOS 26 additions, common mistakes, and a review checklist before shipping extension code. Adjacent ActivityKit and App Intents guidance is scoped only where it connects directly to WidgetKit surfaces.
- Widget Extension setup with App Groups and WidgetBundle registration.
- TimelineProvider versus AppIntentTimelineProvider configuration.
- Lock Screen, StandBy, CarPlay, and Control Center widget families.
- Interactive Button and Toggle widgets with intent handoff boundaries.
- Deep links, Smart Stack relevance, and iOS 26 Liquid Glass notes.
Widgetkit by the numbers
- 2,818 all-time installs (skills.sh)
- +120 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #62 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
widgetkit capabilities & compatibility
- Capabilities
- widget extension target and app groups setup · static and appintent timeline providers · multi family swiftui widget layouts · interactive button and toggle widget wiring · control center controlwidget patterns · deep links and smart stack relevance
- Use cases
- frontend · ui design
- Platforms
- macOS
- Pricing
- Free
What widgetkit says it does
Enable App Groups for shared data between the app and widget extension.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill widgetkitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.8k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I implement WidgetKit widgets with timelines, interactive controls, and extension setup on iOS 26+?
Build WidgetKit home, Lock Screen, StandBy, CarPlay, and Control Center widgets with timelines, App Intents, and deep links.
Who is it for?
iOS developers adding widgets, Lock Screen complications, or Control Center controls to a SwiftUI app.
Skip if: Skip for UIKit-only apps without extensions, full Live Activity APNs depth, or server push infrastructure alone.
When should I use this skill?
User builds WidgetKit widgets, timeline providers, Control Center controls, or widget deep links.
What you get
Widget extension with TimelineEntry, provider, family layouts, bundle registration, and deep link handling.
- WidgetBundle
- TimelineProvider implementation
- Control Center control widgets
By the numbers
- Documents 8 WidgetFamily layout sizes across iOS, iPadOS, watchOS, and CarPlay
- Cites Apple WidgetKit budget of 40–70 timeline refreshes per day
- Includes a 10-item widget review checklist in the skill body
Files
WidgetKit
Build home screen widgets, Lock Screen widgets, Control Center controls, and StandBy or CarPlay widget surfaces for iOS 26+.
Keep adjacent-framework guidance scoped to WidgetKit integration. Include ActivityKit and App Intents only where they connect directly to WidgetKit surfaces; hand off full lifecycle, APNs content-state, Siri/Shortcuts/Spotlight, or entity-modeling work to sibling activitykit or app-intents skills.
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+)
- ActivityConfiguration Handoff
- Control Center Widgets (iOS 18+)
- Lock Screen Widgets
- StandBy Mode
- Widget URL Handling and Deep Links
- Smart Stack Relevance
- Design Patterns
- iOS 26 Additions
- Common Mistakes
- Review Checklist
- References
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. Integrate adjacent surfaces
1. Register an ActivityConfiguration in the widget bundle when the app has a Live Activity, but keep ActivityAttributes, request/update/end, APNs content-state, and Dynamic Island layout depth in activitykit. 2. Place Button, Toggle, ControlWidgetButton, and ControlWidgetToggle in WidgetKit views or controls, but keep intent modeling, entities, queries, Siri, Shortcuts, and Spotlight in app-intents.
3. Add a Control Center control
1. Reuse an AppIntent/OpenIntent for a button, or a SetValueIntent for a toggle. 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() // ActivityConfiguration handoff
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
| Family | Platform |
|---|---|
.systemSmall | iOS, iPadOS, macOS, CarPlay (iOS 26+) |
.systemMedium | iOS, iPadOS, macOS |
.systemLarge | iOS, iPadOS, macOS |
.systemExtraLarge | iPadOS only |
.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 intent types available to the widget extension or shared code. WidgetKit owns the view placement; app-intents owns intent modeling and behavior.
struct InteractiveWidgetView: View {
let entry: FavoriteEntry
var body: some View {
Button(intent: ToggleFavoriteIntent(itemID: entry.itemID)) {
Image(systemName: entry.isFavorite ? "star.fill" : "star")
}
}
}ActivityConfiguration Handoff
WidgetKit registers Live Activity surfaces in the widget extension. Keep this section to registration and rendering handoff; use activitykit for ActivityAttributes, lifecycle, push updates, and full Dynamic Island patterns.
struct DeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
DeliveryLiveActivityView(context: context)
} dynamicIsland: { context in
DeliveryDynamicIsland(context: context)
}
}
}Control Center Widgets (iOS 18+)
WidgetKit owns control configuration, placement, kind, display name, push handler, and extension registration. Control actions and value intents belong in app-intents.
struct OpenCameraControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "OpenCamera") {
ControlWidgetButton(action: OpenCameraIntent()) {
Label("Camera", systemImage: "camera.fill")
}
}
.displayName("Open Camera")
}
}
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
Small system widgets can appear in StandBy and CarPlay. Use @Environment(\.widgetLocation) for conditional rendering:
@Environment(\.widgetLocation) var location
// location == .standBy, .homeScreen, .lockScreen, .carPlay, etc.Widget URL Handling and Deep Links
Use one .widgetURL(_:) as the whole-widget fallback route. Use Link for deliberate subtargets only where the family and layout support them, including .accessoryRectangular, .systemSmall, and larger system widgets. For small widgets, prefer one clear fallback; avoid multiple Link targets unless the visual affordance and hit areas remain unambiguous.
Never attach multiple widgetURL modifiers in the hierarchy.
Smart Stack Relevance
Use TimelineEntryRelevance(score:duration:) on timeline entries for timely iPhone and iPad Smart Stack relevance. Keep scores on a consistent positive scale; zero or lower means not relevant.
For configurable widgets, donate App Intents that correspond to user actions or widget parameters from app-side code, such as with intent.donate() or IntentDonationManager. Keep AppEntity and EntityQuery design in app-intents.
On watchOS, contextual relevance uses WidgetRelevance([WidgetRelevanceAttribute(...)]) from the provider relevance() callback. That path is not used by iPhone or iPad Smart Stacks.
Design Patterns
- Prefer `Gauge` over manual arcs. Use
.gaugeStyle(.accessoryCircular)for
Lock Screen circular widgets and .linearCapacity for home screen capacity bars. The system handles styling, accessibility, and rendering-mode adaptation.
- Use `.containerBackground(_:for: .widget)` (iOS 17+) for widget backgrounds
instead of padding and background modifiers.
- Use `Canvas` for dense visualizations like sparklines or mini bar charts.
The lack of per-element accessibility is acceptable since the entire widget surface is a single tap target.
- Match timeline refresh to data granularity. Apple budgets
40–70 refreshes per day with entries at least 5 minutes apart. Use Text(timerInterval:countsDown:) for live countdowns instead of burning timeline entries.
See references/widgetkit-advanced.md for code examples and detailed guidance on each pattern.
iOS 26 Additions
Liquid Glass Support
Adapt widgets to Liquid Glass with @Environment(\.widgetRenderingMode), .widgetAccentable(), and Image.widgetAccentedRenderingMode(_:). In .vibrant, the system maps content into the material style, so avoid relying on original colors alone.
Push Reload Handlers
Widget push reloads:
- Add Push Notifications capability to the widget extension target.
- Keep the
WidgetPushHandlertype in the widget extension target or shared
code linked into it, not only in the main app target.
- Register the handler with
.pushHandler(...)on the widget configuration. - Do not use User Notifications registration to obtain widget push tokens;
WidgetKit supplies tokens through pushTokenDidChange(_:widgets:).
- Use
apns-push-type: widgets, topic suffix.push-type.widgets, and
aps.content-changed.
- Treat push as a budgeted, opportunistic reload signal, not state delivery and
not the only freshness model. Timelines, reload policies, shared storage or refetch, and app-triggered WidgetCenter reloads remain the fallback path.
Control push reloads:
- Register a
ControlPushHandlerwith.pushHandler(...)on the
ControlWidgetConfiguration.
pushTokensDidChange(controls:)receives[ControlInfo]; read tokens from
each control's pushInfo.
- Use
apns-push-type: controls, topic suffix.push-type.controls, and
aps.content-changed.
CarPlay Widgets
Small system widgets can appear in CarPlay on iOS 26+. Ensure layouts are legible at a glance; taps and controls depend on vehicle touch support and, for opening the app, CarPlay integration.
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. Letting WidgetKit absorb sibling-skill work. Keep full Live Activity lifecycle in activitykit and full App Intent modeling in app-intents.
6. Treating WidgetKit push payloads as state. Widget and control pushes are reload signals. Persist state in shared storage or refetch it in the provider.
7. Registering widget pushes through User Notifications. Widget push tokens come from WidgetKit handlers, not UNUserNotificationCenter.
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. StandBy, CarPlay, and accessory rendering 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 extension-available App Intents with
Button/Toggleonly - [ ] One
.widgetURL(_:)fallback is used;Linksubtargets are family-appropriate - [ ] Widget push handlers live in the widget extension/shared code and do not use User Notifications token registration
- [ ] Widget/control pushes supplement timelines and shared-state/refetch fallbacks
- [ ] Smart Stack relevance uses timeline relevance and app-side intent donations where useful
- [ ] Live Activity lifecycle and App Intent modeling are handed off to sibling skills
- [ ] Controls use
StaticControlConfiguration/AppIntentControlConfiguration - [ ] Timeline entries and Intent types are Sendable; tested on device
References
- Advanced guide: references/widgetkit-advanced.md
- Apple docs: WidgetKit | Keeping a widget up to date | Smart Stack visibility
{
"skill_name": "widgetkit",
"evals": [
{
"id": 0,
"name": "widget-push-reload-review",
"prompt": "Review this iOS 26 WidgetKit plan before implementation: we will add a WidgetPushHandler type in the app target, send APNs broadcast pushes on a channel whenever server data changes, and rely on those pushes instead of timelines for freshness. What needs to change?",
"expected_output": "A correction-focused review that explains the correct WidgetKit push handler registration, widget-extension capability, APNs headers, budget behavior, and the boundary with ActivityKit broadcast channels.",
"files": [],
"expectations": [
"Says `WidgetPushHandler` belongs with the widget configuration and must be registered with `.pushHandler(...)` on the `WidgetConfiguration`.",
"Mentions adding the Push Notifications capability to the widget extension target for WidgetKit push updates.",
"Specifies the WidgetKit APNs contract: `apns-push-type: widgets`, topic suffix `.push-type.widgets`, and an `aps.content-changed` payload.",
"Warns that WidgetKit push notifications are budgeted or opportunistic and supplement timelines rather than replacing them.",
"Rejects ActivityKit broadcast channels as a WidgetKit timeline update mechanism."
]
},
{
"id": 1,
"name": "control-toggle-push-review",
"prompt": "We are building a Control Center garage-door toggle. The action is a plain AppIntent with no value parameter, remote server updates use ControlPushHandler.pushTokensDidChange(controls: [ControlPushInfo]), and APNs sends `apns-push-type: widgets`. Review the plan and provide corrected Swift/API guidance.",
"expected_output": "A WidgetKit controls review that distinguishes button and toggle intents, fixes ControlPushHandler types, and names the correct control reload APNs headers.",
"files": [],
"expectations": [
"Requires the `ControlWidgetToggle` action to conform to `SetValueIntent` with a Boolean `value` parameter.",
"Explains that `ControlPushHandler.pushTokensDidChange(controls:)` receives `[ControlInfo]`, with push tokens available through each control's push info.",
"States that push-capable controls register the handler with `.pushHandler(...)` on the `ControlWidgetConfiguration`.",
"Specifies the remote control APNs contract: `apns-push-type: controls`, topic suffix `.push-type.controls`, and an `aps.content-changed` payload.",
"Keeps generic App Intents exposition brief and focused on WidgetKit control integration."
]
},
{
"id": 2,
"name": "deep-link-rendering-boundary",
"prompt": "Audit this widget design note: small widgets can only use widgetURL, not Link; Liquid Glass support means picking a WidgetAccentedRenderingMode enum case globally; and Smart Stack relevance should return WidgetRelevance(intent, score: 80) from AppIntentTimelineProvider on iPhone. Fix the note without drifting into full ActivityKit or App Intents docs.",
"expected_output": "A bounded WidgetKit correction that fixes deep links, accented rendering, and Smart Stack relevance while preserving sibling-skill boundaries.",
"files": [],
"expectations": [
"Corrects that `Link` can be used in `.systemSmall`, `.accessoryRectangular`, and larger system widgets, with one `widgetURL(_:)` for the general surface.",
"Frames Liquid Glass and accented rendering around `widgetRenderingMode`, `.widgetAccentable()`, and `Image.widgetAccentedRenderingMode(_:)` rather than a global enum choice.",
"Rejects the invalid `WidgetRelevance(intent, score:)` initializer.",
"Explains that iPhone and iPad Smart Stacks use `TimelineEntryRelevance` and intent donation, while watchOS contextual relevance uses `WidgetRelevance([WidgetRelevanceAttribute(...)])`.",
"Keeps ActivityKit, App Intents, and APNs details limited to handoff boundaries that affect the widget note."
]
}
]
}
WidgetKit Advanced Reference
This reference is WidgetKit-first. ActivityKit and App Intents details appear only where they affect widget bundles, Live Activity registration, controls, or Smart Stack visibility; use sibling activitykit and app-intents skills for full lifecycle, APNs content-state, Siri/Shortcuts/Spotlight, and entity-query design.
Contents
- Timeline Strategies
- Push-Based Widget and Control Reloads
- Widget URL Handling and Deep Links
- Intent-Driven Widget Configuration
- Multiple Widget Support in WidgetBundle
- Widget Previews and Snapshots
- AccessoryWidgetBackground
- Dynamic Island Expanded Layout Patterns
- Alert Configuration for Live Activities
- Push Notification Support for Live Activities
- ActivityAuthorizationInfo
- Widget Performance Best Practices
- Xcode Setup
- Widget Relevance and Smart Stacks
- ActivityState Lifecycle
- ActivityStyle
- Dismissal Policies
- Querying Active Widgets and Activities
- Design Patterns
- Apple Documentation Links
Timeline Strategies
TimelineReloadPolicy
Control when WidgetKit requests a new timeline after the current entries expire.
| Policy | Behavior | Use When |
|---|---|---|
.atEnd | Requests a new timeline after the last entry's date. Default. | Data changes unpredictably. |
.after(Date) | Requests a new timeline after a specific date. | Data updates on a known schedule (market hours, flights). |
.never | No automatic refresh. App must trigger manually. | Data changes only from user action. |
Multiple Timeline Entries
Pre-generate entries for known future states to reduce refresh requests and conserve the daily budget.
func timeline(for configuration: Intent, in context: Context) async -> Timeline<StockEntry> {
var entries: [StockEntry] = []
let now = Date()
// Generate hourly entries for the next 6 hours
for hourOffset in 0..<6 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: now)!
let price = await StockService.shared.projectedPrice(at: entryDate, for: configuration.symbol)
entries.append(StockEntry(date: entryDate, symbol: configuration.symbol.name, price: price))
}
let nextRefresh = Calendar.current.date(byAdding: .hour, value: 6, to: now)!
return Timeline(entries: entries, policy: .after(nextRefresh))
}Triggering Manual Reloads
// Reload a specific widget kind
WidgetCenter.shared.reloadTimelines(ofKind: "OrderStatusWidget")
// Reload all widgets
WidgetCenter.shared.reloadAllTimelines()Call reloadTimelines(ofKind:) only when displayed data actually changes. Each call counts against the daily refresh budget.
Refresh Budget
Each configured widget has a daily refresh limit. Exemptions apply for:
- Foreground app usage
- Active media sessions
- Standard location service usage
WidgetKit does not impose refresh limits when debugging in Xcode.
Push-Based Widget and Control Reloads
WidgetPushHandler
Use WidgetKit push notifications as a budgeted, opportunistic reload signal in addition to normal timelines. Add the Push Notifications capability to the widget extension, implement WidgetPushHandler, and register the handler on the widget configuration with .pushHandler(...).
struct MyWidgetPushHandler: WidgetPushHandler {
func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) {
let tokenString = pushInfo.token.map { String(format: "%02x", $0) }.joined()
Task {
try await ServerAPI.shared.register(widgetPushToken: tokenString)
}
}
}
struct CaffeineTrackerWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CaffeineTracker", provider: Provider()) { entry in
CaffeineTrackerView(entry: entry)
}
.configurationDisplayName("Caffeine Tracker")
.pushHandler(MyWidgetPushHandler.self)
}
}Server-Side Integration
Send an APNs push with the widget's push token. The system calls your TimelineProvider.getTimeline or AppIntentTimelineProvider.timeline(for:in:) when the push arrives. Use apns-push-type: widgets, an apns-topic of <bundleID>.push-type.widgets, and an aps payload with "content-changed": true. WidgetKit push notifications cannot use broadcast channels. Treat this as a reload signal; keep durable state in shared storage or refetch it when the provider runs.
ControlPushHandler
Controls use their own push handler and APNs push type. Register the handler on the ControlWidgetConfiguration with .pushHandler(...).
struct GarageDoorControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "GarageDoor") {
ControlWidgetButton(action: OpenGarageDoorIntent()) {
Label("Garage", systemImage: "door.garage.open")
}
}
.pushHandler(MyControlPushHandler.self)
}
}
struct MyControlPushHandler: ControlPushHandler {
func pushTokensDidChange(controls: [ControlInfo]) {
for control in controls {
guard let token = control.pushInfo?.token else { continue }
let tokenString = token.map { String(format: "%02x", $0) }.joined()
Task {
try await ServerAPI.shared.register(controlPushToken: tokenString)
}
}
}
}For remote control reloads, use apns-push-type: controls, an apns-topic of <bundleID>.push-type.controls, and an aps payload with "content-changed": true. Do not encode the control's new state as a custom payload key and expect WidgetKit to apply it; update shared state through the app, server, or control action, then let the value provider read it.
For ControlWidgetToggle, the action must conform to SetValueIntent with a Boolean value. The system fills value with the new toggle state.
struct ToggleFlashlightIntent: SetValueIntent {
static var title: LocalizedStringResource = "Toggle Flashlight"
@Parameter(title: "On")
var value: Bool
func perform() async throws -> some IntentResult {
try await FlashlightController.shared.setEnabled(value)
return .result()
}
}Widget URL Handling and Deep Links
widgetURL(_:)
Set a single URL for the entire widget. Tapping anywhere opens the app with this URL.
struct SmallWidgetView: View {
let entry: OrderEntry
var body: some View {
VStack {
Text(entry.orderName)
Text(entry.status)
}
.widgetURL(URL(string: "myapp://orders/\(entry.orderID)")!)
}
}Link (Multiple Targets)
Use Link for multiple tap targets in .accessoryRectangular, .systemSmall, and larger system widgets. You can combine one widgetURL(_:) for the general surface with Link controls for specific subregions.
struct MediumWidgetView: View {
let entry: OrderListEntry
var body: some View {
VStack {
ForEach(entry.orders) { order in
Link(destination: URL(string: "myapp://orders/\(order.id)")!) {
HStack {
Text(order.name)
Spacer()
Text(order.status)
}
}
}
}
}
}Handling in the App
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
DeepLinkRouter.shared.handle(url)
}
}
}
}Important: If the view hierarchy includes more than one widgetURL(_:), the behavior is undefined. Use Link for additional targets.
Intent-Driven Widget Configuration
WidgetKit uses WidgetConfigurationIntent as the configuration type for AppIntentConfiguration and AppIntentTimelineProvider. Keep the intent type available to the widget extension or a shared framework linked into it. Design of AppEntity, EntityQuery, Siri, Shortcuts, Spotlight, and parameter resolution belongs in the sibling app-intents skill.
WidgetKit integration points to review here:
AppIntentConfiguration(kind:intent:provider:content:)uses the intent type.AppIntentTimelineProviderreceives that intent insnapshotandtimeline.recommendations()may returnAppIntentRecommendationvalues for the
widget gallery.
Do not expand this section into full intent/entity examples; route that work to app-intents.
Multiple Widget Support in WidgetBundle
Declaring Multiple Widgets
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
OrderStatusWidget() // Home Screen widget
FavoritesWidget() // Configurable widget
StepsAccessoryWidget() // Lock Screen widget
DeliveryActivityWidget() // Live Activity
QuickActionControl() // Control Center
}
}Conditional Widgets
Include widgets conditionally based on platform or availability:
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
CoreWidget()
if #available(iOS 18, *) {
QuickActionControl()
}
}
}Widget Previews and Snapshots
Xcode Previews
#Preview("Small", as: .systemSmall) {
OrderStatusWidget()
} timeline: {
OrderEntry(date: .now, orderName: "Pizza", status: "Preparing")
OrderEntry(date: .now.addingTimeInterval(600), orderName: "Pizza", status: "Delivering")
}
#Preview("Circular", as: .accessoryCircular) {
StepsAccessoryWidget()
} timeline: {
StepsEntry(date: .now, stepCount: 4200)
}Live Activity Previews
#Preview("Lock Screen", as: .content, using: DeliveryAttributes.preview) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(
driverName: "Alex",
estimatedDeliveryTime: Date()...Date().addingTimeInterval(900),
currentStep: .delivering
)
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: DeliveryAttributes.preview) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(
driverName: "Alex",
estimatedDeliveryTime: Date()...Date().addingTimeInterval(900),
currentStep: .delivering
)
}Snapshot Best Practices
- Return sample data immediately in
placeholder(in:)-- it must be synchronous. - In
getSnapshot/snapshot(for:in:), checkcontext.isPreview: - When
true, return representative sample data quickly. - When
false, return the current real state.
// WRONG: Performing a network call in placeholder
func placeholder(in context: Context) -> MyEntry {
// Compilation error: placeholder must be synchronous
let data = await fetchData()
return MyEntry(date: .now, data: data)
}
// CORRECT: Return static sample data
func placeholder(in context: Context) -> MyEntry {
MyEntry(date: .now, data: SampleData.placeholder)
}AccessoryWidgetBackground
Provide the standard translucent background for Lock Screen widgets.
struct CircularStepsView: View {
let steps: Int
var body: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: "figure.walk")
.font(.caption)
Text("\(steps)")
.font(.headline)
.widgetAccentable()
}
}
}
}Rendering Mode Awareness
Lock Screen widgets render in .vibrant or .accented mode. Adapt content:
@Environment(\.widgetRenderingMode) var renderingMode
var body: some View {
switch renderingMode {
case .fullColor:
ColorfulView()
case .vibrant, .accented:
MonochromeView()
@unknown default:
MonochromeView()
}
}Use .widgetAccentable() to mark views that should receive the accent tint in .accented rendering mode.
For images that need special treatment in accented mode, use Image.widgetAccentedRenderingMode(_:). Reserve .fullColor for content such as album art or book covers where preserving the original image matters.
Image("album-art")
.resizable()
.widgetAccentedRenderingMode(.fullColor)Dynamic Island Expanded Layout Patterns
Full Layout Example
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading) {
Image(systemName: "airplane")
.font(.title2)
Text("UA 1234")
.font(.caption2)
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing) {
Text("SFO")
.font(.title3.bold())
Text("On Time")
.font(.caption2)
.foregroundStyle(.green)
}
}
DynamicIslandExpandedRegion(.center) {
Text("San Francisco to New York")
.font(.caption)
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
ProgressView(value: 0.45)
.tint(.blue)
HStack {
Text("Departed 2:30 PM")
Spacer()
Text("Arrives 10:45 PM")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
} compactLeading: {
Image(systemName: "airplane")
} compactTrailing: {
Text("2h 15m")
.monospacedDigit()
} minimal: {
Image(systemName: "airplane")
}Vertical Placement
Control vertical alignment within expanded regions:
DynamicIslandExpandedRegion(.leading) {
Text("Top")
.dynamicIsland(verticalPlacement: .belowIfTooWide)
}Content Margins
Override margins for specific Dynamic Island modes:
.contentMargins(.trailing, 20, for: .expanded)
.contentMargins(.bottom, 16, for: .expanded)Keyline Tint
Apply a subtle tint to the Dynamic Island border:
DynamicIsland { /* ... */ }
.keylineTint(.blue)Alert Configuration for Live Activities
Trigger a visible and audible alert when updating a Live Activity:
let alert = AlertConfiguration(
title: "Delivery Update",
body: "Your order is out for delivery!",
sound: .default
)
await activity.update(updatedContent, alertConfiguration: alert)Custom Alert Sound
let alert = AlertConfiguration(
title: "Score Update",
body: "Goal! The score is now 2-1.",
sound: .named("goal-horn.aiff")
)Place the sound file in the app bundle. Use .default when no custom sound is needed.
Push Notification Support for Live Activities
Registering for Push Updates
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token // Enable push updates
)
// Observe token changes
Task {
for await token in activity.pushTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
try await ServerAPI.shared.registerActivityToken(tokenString, activityID: activity.id)
}
}Push-to-Start (Remote Activity Creation)
// Observe the push-to-start token
Task {
for await token in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
try await ServerAPI.shared.registerPushToStartToken(tokenString)
}
}Channel-Based ActivityKit Push (iOS 18+)
ActivityKit broadcast channels are for Live Activity updates, not WidgetKit timeline push notifications. Pass a valid base64-encoded channel ID that your server created through APNs channel management.
let channelID = "ZGVsaXZlcnktdXBkYXRlcw=="
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .channel(channelID)
)APNs Payload Format for Live Activity Updates
{
"aps": {
"timestamp": 1234567890,
"event": "update",
"content-state": {
"driverName": "Alex",
"estimatedDeliveryTime": {
"lowerBound": 1234567890,
"upperBound": 1234568790
},
"currentStep": "delivering"
},
"alert": {
"title": "Delivery Update",
"body": "Your driver is nearby!"
}
}
}The content-state must match the ContentState Codable structure exactly.
Info.plist Keys
| Key | Value | Purpose |
|---|---|---|
NSSupportsLiveActivities | YES | Enable Live Activities |
NSSupportsLiveActivitiesFrequentUpdates | YES | Enable frequent push updates (budget increase) |
ActivityAuthorizationInfo
Check whether Live Activities are permitted before attempting to start one.
let authInfo = ActivityAuthorizationInfo()
// Check permission synchronously
if authInfo.areActivitiesEnabled {
try Activity.request(attributes: attributes, content: content, pushType: .token)
}
// Observe permission changes
Task {
for await enabled in authInfo.activityEnablementUpdates {
if enabled {
// Activities became available
}
}
}
// Check frequent push support
if authInfo.frequentPushesEnabled {
// Safe to use frequent push updates
}Error Handling
do {
let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
} catch let error as ActivityAuthorizationError {
switch error {
case .denied:
// User disabled Live Activities in Settings
break
case .globalMaximumExceeded:
// Too many Live Activities across all apps
break
case .targetMaximumExceeded:
// Too many Live Activities for this app
break
default:
break
}
}Widget Performance Best Practices
Data Preparation
Pre-compute display values in the timeline provider. Pass display-ready data through the entry.
// WRONG: Heavy computation in the widget view
struct MyWidgetView: View {
let entry: RawDataEntry
var body: some View {
let processed = HeavyProcessor.process(entry.rawData) // Slow
Text(processed.summary)
}
}
// CORRECT: Pre-compute in the provider
func timeline(for configuration: Intent, in context: Context) async -> Timeline<ProcessedEntry> {
let raw = await DataStore.shared.fetch()
let processed = HeavyProcessor.process(raw)
let entry = ProcessedEntry(date: .now, summary: processed.summary, value: processed.value)
return Timeline(entries: [entry], policy: .atEnd)
}Memory Constraints
Widget extensions run with strict memory limits. Avoid:
- Loading large images directly in the widget view
- Storing large data sets in the entry
- Creating complex view hierarchies
Image Handling
// WRONG: Loading a full-resolution image
Image(uiImage: UIImage(contentsOfFile: fullResPath)!)
// CORRECT: Use a pre-resized thumbnail stored in the shared container
Image(uiImage: UIImage(contentsOfFile: thumbnailPath)!)
.resizable()
.aspectRatio(contentMode: .fill)Shared Data with App Groups
// In the main app: write data
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
defaults?.set(encodedData, forKey: "widgetData")
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
// In the widget provider: read data
func timeline(for configuration: Intent, in context: Context) async -> Timeline<MyEntry> {
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
let data = defaults?.data(forKey: "widgetData")
// Decode and build entry
}For larger datasets, use a shared SQLite database or Core Data store in the App Group container.
Xcode Setup
Adding a Widget Extension Target
1. File > New > Target > Widget Extension. 2. Name the extension (e.g., "MyAppWidgets"). 3. Select "Include Configuration App Intent" for configurable widgets. 4. Select "Include Live Activity" if building Live Activities.
Entitlements
| Entitlement | Purpose |
|---|---|
App Groups (com.apple.security.application-groups) | Share data between app and widget |
Push Notifications (aps-environment) | Required for push-based Live Activity updates |
App Groups Configuration
1. Enable "App Groups" capability on both the main app target and the widget extension target. 2. Create a shared group identifier (e.g., group.com.example.myapp). 3. Use UserDefaults(suiteName:) or FileManager.containerURL(forSecurityApplicationGroupIdentifier:) for shared storage.
Build Schemes
- Use the widget extension scheme to debug widget rendering.
- Select "Widget" as the run destination to launch the widget directly.
- Use "Preview" in Xcode canvas for rapid iteration.
Common Xcode Issues
// ERROR: "Widget extension must include at least one widget"
// FIX: Ensure @main is on the WidgetBundle, not a widget struct.
// ERROR: "No such module 'WidgetKit'"
// FIX: Ensure the widget extension target links WidgetKit and SwiftUI frameworks.
// ERROR: "The operation couldn't be completed. (ActivityKit.ActivityAuthorizationError error 3.)"
// FIX: Add NSSupportsLiveActivities = YES to the HOST APP's Info.plist (not the extension).Widget Relevance and Smart Stacks
TimelineEntryRelevance
Score entries to surface widgets in Smart Stacks when relevant:
struct GameEntry: TimelineEntry {
var date: Date
var score: String
var isLive: Bool
var relevance: TimelineEntryRelevance? {
isLive ? TimelineEntryRelevance(score: 100, duration: 3600) : nil
}
}Higher scores make the widget more likely to surface. The duration specifies how long the relevance lasts.
WidgetRelevance (AppIntentTimelineProvider)
On iPhone and iPad, prefer TimelineEntryRelevance on timeline entries and donate App Intents that match configurable widget parameters. Smart Stacks on iPhone and iPad don't use the timeline provider's relevance() callback.
On watchOS, use relevance() only when providing RelevanceKit contextual clues. Return WidgetRelevance([WidgetRelevanceAttribute(...)]); there is no WidgetRelevance(intent, score:) initializer.
ActivityState Lifecycle
Track the full lifecycle of a Live Activity:
Task {
for await state in activity.activityStateUpdates {
switch state {
case .active:
// Activity is running and visible
break
case .pending:
// Requested but not yet displayed (iOS 26+)
break
case .stale:
// Content is outdated; update or end
break
case .ended:
// Ended but may still be visible on Lock Screen
break
case .dismissed:
// Fully removed from UI; clean up resources
break
@unknown default:
break
}
}
}ActivityStyle
Control Live Activity persistence behavior (iOS 18+):
// Standard: persists until explicitly ended
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token,
style: .standard
)
// Transient: appears in Dynamic Island's extended presentation and ends
// automatically when the user leaves that interaction context.
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token,
style: .transient
)Use .transient for short interactions that should not persist as a standard Live Activity after the user locks the device, collapses the Dynamic Island, leaves the app, or performs other tasks outside the Dynamic Island.
Dismissal Policies
Control when an ended Live Activity disappears from the Lock Screen:
// System-determined timing (default)
await activity.end(finalContent, dismissalPolicy: .default)
// Remove immediately
await activity.end(finalContent, dismissalPolicy: .immediate)
// Remove after a specific date (max 4 hours)
let removalDate = Date().addingTimeInterval(3600)
await activity.end(finalContent, dismissalPolicy: .after(removalDate))Querying Active Widgets and Activities
Current Widget Configurations
let widgets = try await WidgetCenter.shared.currentConfigurations()
for widget in widgets {
print("Kind: \(widget.kind), Family: \(widget.family)")
}Current Live Activities
let activities = Activity<DeliveryAttributes>.activities
for activity in activities {
print("ID: \(activity.id), State: \(activity.activityState)")
}Observing New Activities
Task {
for await activity in Activity<DeliveryAttributes>.activityUpdates {
print("New activity started: \(activity.id)")
}
}Design Patterns
Prefer Gauge for Value Indicators
Use Gauge (iOS 16+) instead of manual Circle or Path arcs to show a value within a range. The system handles styling, accessibility, and rendering-mode adaptation automatically.
.accessoryCircular— open ring with center value label, matches the system
complication style. Use for accessoryCircular Lock Screen widgets.
.linearCapacity— horizontal bar that fills leading to trailing. Use for
home screen widgets when a capacity bar fits.
// accessoryCircular Lock Screen widget
struct StepsCircularView: View {
let entry: StepsEntry
var body: some View {
Gauge(value: Double(entry.stepCount), in: 0...10000) {
Image(systemName: "figure.walk")
} currentValueLabel: {
Text("\(entry.stepCount)")
}
.gaugeStyle(.accessoryCircular)
}
}
// Home screen capacity bar
Gauge(value: storageUsed, in: 0...storageTotal) {
Text("Storage")
} currentValueLabel: {
Text(storageUsed, format: .byteCount(style: .file))
}
.gaugeStyle(.linearCapacity)Use containerBackground for Widget Backgrounds
.containerBackground(_:for: .widget) (iOS 17+) is the designated way to set widget backgrounds. Replaces older padding and background patterns. The system uses this placement to correctly render backgrounds across all widget surfaces.
struct OrderWidgetView: View {
let entry: OrderEntry
var body: some View {
VStack(alignment: .leading) {
Text(entry.orderName).font(.headline)
Text(entry.status).foregroundStyle(.secondary)
}
.containerBackground(.fill.tertiary, for: .widget)
}
}Use Canvas for Dense Visualizations
Use Canvas for sparklines, mini bar charts, or heat maps inside widgets. The lack of per-element accessibility is acceptable since the entire widget surface is a single tap target.
struct SparklineView: View {
let values: [Double]
var body: some View {
Canvas { context, size in
guard values.count > 1 else { return }
let maxVal = values.max() ?? 1
let step = size.width / CGFloat(values.count - 1)
var path = Path()
for (i, value) in values.enumerated() {
let x = step * CGFloat(i)
let y = size.height * (1 - value / maxVal)
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
else { path.addLine(to: CGPoint(x: x, y: y)) }
}
context.stroke(path, with: .color(.blue), lineWidth: 2)
}
}
}Match Timeline Refresh to Data Granularity
Apple budgets 40–70 refreshes per day for frequently viewed widgets, with entries at least 5 minutes apart. Align reload cadence to how often the underlying data actually changes.
- Generate entries for as many future dates as possible to reduce reload requests.
- Use
.after(date)when data updates on a known schedule (market hours, transit). - Use
.neverwhen data only changes from user action. - Use
Text(timerInterval:countsDown:)for live countdowns instead of burning
timeline entries on every tick.
Apple Documentation Links
- WidgetKit
- ActivityKit
- TimelineProvider
- AppIntentTimelineProvider
- ActivityAttributes
- ActivityConfiguration
- DynamicIsland
- ControlWidgetButton
- ControlWidgetToggle
- Keeping a widget up to date
- Updating widgets with WidgetKit push notifications
- Updating controls locally and remotely
- Linking to specific app scenes
- Adding StandBy and CarPlay support
- Optimizing for accented rendering and Liquid Glass
- Increasing widget visibility in Smart Stacks
Related skills
How it compares
Pick widgetkit over generic SwiftUI skills when the work is specifically WidgetKit extension targets, timeline budgets, or Control Center controls—not general in-app SwiftUI screens.
FAQ
What is required for shared app data?
Enable App Groups so the app and widget extension can read the same shared container.
Where do Live Activity details belong?
Register ActivityConfiguration in the bundle but implement ActivityKit lifecycle in the activitykit skill.
How expose multiple widgets?
Register each Widget in a WidgetBundle struct annotated with @main.
Is Widgetkit safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.