Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
90
90%
Does it follow best practices?
Impact
—
Average score across 248 eval scenarios
Advisory
Suggest reviewing before use
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.
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. |
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))
}// 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.
Each configured widget has a daily refresh limit. Exemptions apply for:
WidgetKit does not impose refresh limits when debugging in Xcode.
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)
}
}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.
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()
}
}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)")!)
}
}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)
}
}
}
}
}
}@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.
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.AppIntentTimelineProvider receives that intent in snapshot and timeline.recommendations() may return AppIntentRecommendation values for the
widget gallery.Do not expand this section into full intent/entity examples; route that work to
app-intents.
@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
}
}Include widgets conditionally based on platform or availability:
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
CoreWidget()
if #available(iOS 18, *) {
QuickActionControl()
}
}
}#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)
}#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
)
}placeholder(in:) -- it must be synchronous.getSnapshot / snapshot(for:in:), check context.isPreview:
true, return representative sample data quickly.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)
}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()
}
}
}
}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)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")
}Control vertical alignment within expanded regions:
DynamicIslandExpandedRegion(.leading) {
Text("Top")
.dynamicIsland(verticalPlacement: .belowIfTooWide)
}Override margins for specific Dynamic Island modes:
.contentMargins(.trailing, 20, for: .expanded)
.contentMargins(.bottom, 16, for: .expanded)Apply a subtle tint to the Dynamic Island border:
DynamicIsland { /* ... */ }
.keylineTint(.blue)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)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.
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)
}
}// 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)
}
}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)
){
"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.
| Key | Value | Purpose |
|---|---|---|
NSSupportsLiveActivities | YES | Enable Live Activities |
NSSupportsLiveActivitiesFrequentUpdates | YES | Enable frequent push updates (budget increase) |
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
}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
}
}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)
}Widget extensions run with strict memory limits. Avoid:
// 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)// 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.
| 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 |
group.com.example.myapp).UserDefaults(suiteName:) or FileManager.containerURL(forSecurityApplicationGroupIdentifier:)
for shared storage.// 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).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.
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.
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
}
}
}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.
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))let widgets = try await WidgetCenter.shared.currentConfigurations()
for widget in widgets {
print("Kind: \(widget.kind), Family: \(widget.family)")
}let activities = Activity<DeliveryAttributes>.activities
for activity in activities {
print("ID: \(activity.id), State: \(activity.activityState)")
}Task {
for await activity in Activity<DeliveryAttributes>.activityUpdates {
print("New activity started: \(activity.id)")
}
}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).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 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)
}
}
}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.
.after(date) when data updates on a known schedule (market hours, transit)..never when data only changes from user action.Text(timerInterval:countsDown:) for live countdowns instead of burning
timeline entries on every tick..tessl-plugin
skills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
references
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references