Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
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 push notifications to trigger timeline reloads without scheduled polling.
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)
}
}
}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.
Equivalent handler for Control Center controls:
struct MyControlPushHandler: ControlPushHandler {
func pushTokensDidChange(controls: [ControlPushInfo]) {
for control in controls {
let tokenString = control.token.map { String(format: "%02x", $0) }.joined()
Task {
try await ServerAPI.shared.register(controlPushToken: tokenString)
}
}
}
}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 .systemMedium and larger widgets.
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: .systemSmall widgets support only widgetURL, not Link.
struct SelectCategoryIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Select Category"
static var description: IntentDescription = "Choose a category to display."
@Parameter(title: "Category")
var category: CategoryEntity
init() {}
init(category: CategoryEntity) {
self.category = category
}
}struct CategoryEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Category")
static var defaultQuery = CategoryQuery()
var id: String
var name: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
struct CategoryQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [CategoryEntity] {
await DataStore.shared.categories(for: identifiers)
}
func suggestedEntities() async throws -> [CategoryEntity] {
await DataStore.shared.allCategories()
}
func defaultResult() async -> CategoryEntity? {
await DataStore.shared.defaultCategory()
}
}Provide pre-configured suggestions for the widget gallery:
func recommendations() -> [AppIntentRecommendation<SelectCategoryIntent>] {
let categories: [(String, CategoryEntity)] = [
("Groceries", .groceries),
("Work Tasks", .work),
]
return categories.map { name, entity in
let intent = SelectCategoryIntent(category: entity)
return AppIntentRecommendation(intent: intent, description: name)
}
}@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.
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)
}
}let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .channel("delivery-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.
| 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.
func relevance() async -> WidgetRelevance<SelectCategoryIntent> {
let topCategory = await DataStore.shared.mostActiveCategory()
let intent = SelectCategoryIntent(category: topCategory)
return WidgetRelevance(intent, score: 80)
}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: automatically dismissed after a period
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token,
style: .transient
)Use .transient for short-lived notifications like sports scores or transit
arrivals that do not need persistent display.
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.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
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