This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
1287 lines
40 KiB
Markdown
1287 lines
40 KiB
Markdown
---
|
||
name: axiom-extensions-widgets-ref
|
||
description: Use when implementing widgets, Live Activities, Control Center controls, or app extensions - comprehensive API reference for WidgetKit, ActivityKit, App Groups, and extension lifecycle for iOS 14+
|
||
license: MIT
|
||
compatibility: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+
|
||
metadata:
|
||
version: "1.0.0"
|
||
---
|
||
|
||
# Extensions & Widgets API Reference
|
||
|
||
## Overview
|
||
|
||
This skill provides comprehensive API reference for Apple's widget and extension ecosystem:
|
||
|
||
- **Standard Widgets** (iOS 14+) — Home Screen, Lock Screen, StandBy widgets
|
||
- **Interactive Widgets** (iOS 17+) — Buttons and toggles with App Intents
|
||
- **Live Activities** (iOS 16.1+) — Real-time updates on Lock Screen and Dynamic Island
|
||
- **Control Center Widgets** (iOS 18+) — System-wide quick controls
|
||
- **Liquid Glass Widgets** (iOS 26+) — Accented rendering, glass effects, container backgrounds
|
||
- **visionOS Widgets** (visionOS 2+) — Mounting styles, textures, proximity awareness
|
||
- **App Extensions** — Shared data, lifecycle, entitlements
|
||
|
||
Widgets are SwiftUI **archived snapshots** rendered on a timeline by the system. Extensions are sandboxed executables bundled with your app.
|
||
|
||
## When to Use This Skill
|
||
|
||
✅ **Use this skill when**:
|
||
- Implementing any type of widget (Home Screen, Lock Screen, StandBy)
|
||
- Creating Live Activities for ongoing events
|
||
- Building Control Center controls
|
||
- Sharing data between app and extensions
|
||
- Understanding widget timelines and refresh policies
|
||
- Integrating widgets with App Intents
|
||
- Adopting Liquid Glass rendering in widgets
|
||
- Supporting watchOS or visionOS widgets
|
||
- Implementing visionOS mounting styles, textures, or proximity awareness
|
||
|
||
❌ **Do NOT use this skill for**:
|
||
- Pure App Intents questions (use **app-intents-ref** skill)
|
||
- SwiftUI layout issues (use **swiftui-layout** skill)
|
||
- Performance optimization (use **swiftui-performance** skill)
|
||
- Debugging crashes (use **xcode-debugging** skill)
|
||
|
||
## Related Skills
|
||
|
||
- **app-intents-ref** — App Intents for interactive widgets and configuration
|
||
- **swift-concurrency** — Async/await patterns for widget data loading
|
||
- **swiftui-performance** — Optimizing widget rendering
|
||
- **swiftui-layout** — Complex widget layouts
|
||
- **extensions-widgets** — Discipline skill with anti-patterns and debugging
|
||
|
||
## Key Terminology
|
||
|
||
- **Timeline** — Series of entries defining when/what content to display; system shows entries at specified times
|
||
- **TimelineProvider** — Protocol supplying timeline entries (placeholder, snapshot, timeline generation)
|
||
- **TimelineEntry** — Struct with widget data + display date
|
||
- **Timeline Budget** — Daily limit (40-70) for timeline reloads
|
||
- **Budget-Exempt** — Reloads that don't count (user-initiated, app foregrounding, system-initiated)
|
||
- **Widget Family** — Size/shape (systemSmall, systemMedium, accessoryCircular, etc.)
|
||
- **App Groups** — Entitlement for shared data container between app and extensions
|
||
- **ActivityAttributes** — Static data (set once) + dynamic ContentState (updated during lifecycle)
|
||
- **ContentState** — Changing part of ActivityAttributes; must be under 4KB total
|
||
- **Dynamic Island** — iPhone 14 Pro+ Live Activity display; compact, minimal, and expanded sizes
|
||
- **ControlWidget** — iOS 18+ widgets for Control Center, Lock Screen, and Action Button
|
||
- **Supplemental Activity Families** — Enables Live Activities on Apple Watch or CarPlay
|
||
|
||
---
|
||
|
||
# Part 1: Standard Widgets (iOS 14+)
|
||
|
||
## Widget Configuration Types
|
||
|
||
### StaticConfiguration
|
||
|
||
For widgets that don't require user configuration.
|
||
|
||
```swift
|
||
@main
|
||
struct MyWidget: Widget {
|
||
let kind: String = "MyWidget"
|
||
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
||
MyWidgetEntryView(entry: entry)
|
||
}
|
||
.configurationDisplayName("My Widget")
|
||
.description("This widget displays...")
|
||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||
}
|
||
}
|
||
```
|
||
|
||
### AppIntentConfiguration (iOS 17+)
|
||
|
||
For widgets with user configuration using App Intents.
|
||
|
||
```swift
|
||
struct MyConfigurableWidget: Widget {
|
||
let kind: String = "MyConfigurableWidget"
|
||
|
||
var body: some WidgetConfiguration {
|
||
AppIntentConfiguration(
|
||
kind: kind,
|
||
intent: SelectProjectIntent.self,
|
||
provider: Provider()
|
||
) { entry in
|
||
MyWidgetEntryView(entry: entry)
|
||
}
|
||
.configurationDisplayName("Project Status")
|
||
.description("Shows your selected project")
|
||
}
|
||
}
|
||
```
|
||
|
||
**Migration from IntentConfiguration**: iOS 16 and earlier used `IntentConfiguration` with SiriKit intents. Migrate to `AppIntentConfiguration` for iOS 17+.
|
||
|
||
### ActivityConfiguration
|
||
|
||
For Live Activities (covered in Live Activities section).
|
||
|
||
## Choosing the Right Configuration
|
||
|
||
No user configuration needed? Use `StaticConfiguration`. Simple static options? Use `AppIntentConfiguration` with `WidgetConfigurationIntent`. Dynamic options from app data? Use `AppIntentConfiguration` + `EntityQuery`.
|
||
|
||
**Quick Reference**:
|
||
- **StaticConfiguration** — No customization (weather, battery status)
|
||
- **AppIntentConfiguration** (simple) — Fixed options (timer presets, theme selection)
|
||
- **AppIntentConfiguration** (EntityQuery) — Dynamic list from app data (project/contact/playlist picker)
|
||
- **ActivityConfiguration** — Live ongoing events (delivery tracking, workout progress, sports scores)
|
||
|
||
## Widget Families
|
||
|
||
### System Families (Home Screen)
|
||
- **`systemSmall`** (~170×170, iOS 14+) — Single piece of info, icon
|
||
- **`systemMedium`** (~360×170, iOS 14+) — Multiple data points, chart
|
||
- **`systemLarge`** (~360×380, iOS 14+) — Detailed view, list
|
||
- **`systemExtraLarge`** (~720×380, iOS 15+ iPad only) — Rich layouts, multiple views
|
||
|
||
### Accessory Families (Lock Screen, iOS 16+)
|
||
- **`accessoryCircular`** (~48×48pt) — Circular complication, icon or gauge
|
||
- **`accessoryRectangular`** (~160×72pt) — Above clock, text + icon
|
||
- **`accessoryInline`** (single line) — Above date, text only
|
||
|
||
### Example: Supporting Multiple Families
|
||
|
||
```swift
|
||
struct MyWidget: Widget {
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
|
||
if #available(iOSApplicationExtension 16.0, *) {
|
||
switch entry.family {
|
||
case .systemSmall:
|
||
SmallWidgetView(entry: entry)
|
||
case .systemMedium:
|
||
MediumWidgetView(entry: entry)
|
||
case .accessoryCircular:
|
||
CircularWidgetView(entry: entry)
|
||
case .accessoryRectangular:
|
||
RectangularWidgetView(entry: entry)
|
||
default:
|
||
Text("Unsupported")
|
||
}
|
||
} else {
|
||
LegacyWidgetView(entry: entry)
|
||
}
|
||
}
|
||
.supportedFamilies([
|
||
.systemSmall,
|
||
.systemMedium,
|
||
.accessoryCircular,
|
||
.accessoryRectangular
|
||
])
|
||
}
|
||
}
|
||
```
|
||
|
||
## Timeline System
|
||
|
||
### TimelineProvider Protocol
|
||
|
||
Provides entries that define when the system should render your widget.
|
||
|
||
```swift
|
||
struct Provider: TimelineProvider {
|
||
// Placeholder while loading
|
||
func placeholder(in context: Context) -> SimpleEntry {
|
||
SimpleEntry(date: Date(), emoji: "😀")
|
||
}
|
||
|
||
// Shown in widget gallery
|
||
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
|
||
let entry = SimpleEntry(date: Date(), emoji: "📷")
|
||
completion(entry)
|
||
}
|
||
|
||
// Actual timeline
|
||
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
|
||
var entries: [SimpleEntry] = []
|
||
let currentDate = Date()
|
||
|
||
// Create entry every hour for 5 hours
|
||
for hourOffset in 0 ..< 5 {
|
||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
|
||
let entry = SimpleEntry(date: entryDate, emoji: "⏰")
|
||
entries.append(entry)
|
||
}
|
||
|
||
let timeline = Timeline(entries: entries, policy: .atEnd)
|
||
completion(timeline)
|
||
}
|
||
}
|
||
```
|
||
|
||
### TimelineReloadPolicy
|
||
|
||
Controls when the system requests a new timeline:
|
||
- **`.atEnd`** — Reload after last entry
|
||
- **`.after(date)`** — Reload at specific date
|
||
- **`.never`** — No automatic reload (manual only)
|
||
|
||
### Manual Reload
|
||
|
||
```swift
|
||
import WidgetKit
|
||
|
||
// Reload all widgets of this kind
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
|
||
// Reload specific kind
|
||
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
|
||
```
|
||
|
||
## Performance & Budget Quick Reference
|
||
|
||
### Timeline Refresh Budget
|
||
- **Daily budget**: 40-70 reloads/day (varies by system load and engagement)
|
||
- **Budget-exempt**: User-initiated reload, app foregrounding, widget added, system reboot
|
||
- **Strategic** (4x/hour) — ~48 reloads/day, low battery impact
|
||
- **Aggressive** (12x/hour) — Budget exhausted by 6 PM, high impact
|
||
- **On-demand only** — 5-10 reloads/day, minimal impact
|
||
- Reload on significant data changes and time-based events. Avoid speculative or cosmetic reloads.
|
||
|
||
```swift
|
||
// ✅ GOOD: Strategic intervals (15-60 min)
|
||
let entries = (0..<8).map { offset in
|
||
let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
|
||
return SimpleEntry(date: date, data: data)
|
||
}
|
||
```
|
||
|
||
### Memory Limits
|
||
- ~30MB for standard widgets, ~50MB for Live Activities — system terminates if exceeded
|
||
- Load only what you need (e.g., `loadRecentItems(limit: 10)`, not entire database)
|
||
|
||
### Network Requests
|
||
**Never make network requests in widget views** — they won't complete before rendering. Fetch data in `getTimeline()` instead.
|
||
|
||
### Timeline Generation
|
||
Complete `getTimeline()` in under 5 seconds. Cache expensive computations in the main app, read pre-computed data from shared container, limit to 10-20 entries.
|
||
|
||
### View Rendering
|
||
Precompute everything in `TimelineEntry`, keep views simple. No expensive operations in `body`.
|
||
|
||
### Images
|
||
- Use asset catalog images or SF Symbols (fast)
|
||
- Small images from shared container are acceptable
|
||
- `AsyncImage` does NOT work in widgets
|
||
- Large images cause memory termination
|
||
|
||
---
|
||
|
||
# Part 2: Interactive Widgets (iOS 17+)
|
||
|
||
## Button and Toggle
|
||
|
||
Interactive widgets use SwiftUI `Button` and `Toggle` with App Intents.
|
||
|
||
### Button with App Intent
|
||
|
||
```swift
|
||
Button(intent: IncrementIntent()) {
|
||
Label("Increment", systemImage: "plus.circle")
|
||
}
|
||
```
|
||
|
||
The intent updates shared data via App Groups in its `perform()` method. See **axiom-app-intents-ref** for full `AppIntent` definition syntax.
|
||
|
||
### Toggle with App Intent
|
||
|
||
Same pattern as Button — use a `Toggle` bound to state, invoke intent on change:
|
||
|
||
```swift
|
||
Toggle(isOn: $isEnabled) {
|
||
Text("Feature")
|
||
}
|
||
.onChange(of: isEnabled) { newValue in
|
||
Task { try? await ToggleFeatureIntent(enabled: newValue).perform() }
|
||
}
|
||
```
|
||
|
||
The intent follows the same `AppIntent` structure with a `@Parameter(title: "Enabled") var enabled: Bool`. See **axiom-app-intents-ref** for full `AppIntent` definition syntax.
|
||
|
||
## invalidatableContent Modifier
|
||
|
||
Provides visual feedback during App Intent execution.
|
||
|
||
```swift
|
||
struct MyWidgetView: View {
|
||
var entry: Provider.Entry
|
||
|
||
var body: some View {
|
||
VStack {
|
||
Text(entry.status)
|
||
.invalidatableContent() // Dims during intent execution
|
||
|
||
Button(intent: RefreshIntent()) {
|
||
Image(systemName: "arrow.clockwise")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Effect**: Content with `.invalidatableContent()` becomes slightly transparent while the associated intent executes, providing user feedback.
|
||
|
||
## Animation System
|
||
|
||
### contentTransition for Numeric Text
|
||
|
||
```swift
|
||
Text("\(entry.value)")
|
||
.contentTransition(.numericText(value: Double(entry.value)))
|
||
```
|
||
|
||
**Effect**: Numbers smoothly count up or down instead of instantly changing.
|
||
|
||
### View Transitions
|
||
|
||
```swift
|
||
VStack {
|
||
if entry.showDetail {
|
||
DetailView()
|
||
.transition(.scale.combined(with: .opacity))
|
||
}
|
||
}
|
||
.animation(.spring(response: 0.3), value: entry.showDetail)
|
||
```
|
||
|
||
---
|
||
|
||
# Part 3: Configurable Widgets (iOS 17+)
|
||
|
||
## WidgetConfigurationIntent
|
||
|
||
Define configuration parameters for your widget.
|
||
|
||
```swift
|
||
import AppIntents
|
||
|
||
struct SelectProjectIntent: WidgetConfigurationIntent {
|
||
static var title: LocalizedStringResource = "Select Project"
|
||
static var description = IntentDescription("Choose which project to display")
|
||
|
||
@Parameter(title: "Project")
|
||
var project: ProjectEntity?
|
||
|
||
// Provide default value
|
||
static var parameterSummary: some ParameterSummary {
|
||
Summary("Show \(\.$project)")
|
||
}
|
||
}
|
||
```
|
||
|
||
## Entity and EntityQuery
|
||
|
||
Provide dynamic options for configuration.
|
||
|
||
```swift
|
||
struct ProjectEntity: AppEntity {
|
||
var id: String
|
||
var name: String
|
||
|
||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project")
|
||
|
||
var displayRepresentation: DisplayRepresentation {
|
||
DisplayRepresentation(title: "\(name)")
|
||
}
|
||
}
|
||
|
||
struct ProjectQuery: EntityQuery {
|
||
func entities(for identifiers: [String]) async throws -> [ProjectEntity] {
|
||
// Return projects matching these IDs
|
||
return await ProjectStore.shared.projects(withIDs: identifiers)
|
||
}
|
||
|
||
func suggestedEntities() async throws -> [ProjectEntity] {
|
||
// Return all available projects
|
||
return await ProjectStore.shared.allProjects()
|
||
}
|
||
}
|
||
```
|
||
|
||
## Using Configuration in Provider
|
||
|
||
```swift
|
||
struct Provider: AppIntentTimelineProvider {
|
||
func timeline(for configuration: SelectProjectIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
||
let project = configuration.project // Use selected project
|
||
let entries = await generateEntries(for: project)
|
||
return Timeline(entries: entries, policy: .atEnd)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# Part 4: Live Activities (iOS 16.1+)
|
||
|
||
## ActivityAttributes
|
||
|
||
Defines static and dynamic data for a Live Activity.
|
||
|
||
```swift
|
||
import ActivityKit
|
||
|
||
struct PizzaDeliveryAttributes: ActivityAttributes {
|
||
// Static data - set when activity starts, never changes
|
||
struct ContentState: Codable, Hashable {
|
||
// Dynamic data - updated throughout activity lifecycle
|
||
var status: DeliveryStatus
|
||
var estimatedDeliveryTime: Date
|
||
var driverName: String?
|
||
}
|
||
|
||
// Static attributes
|
||
var orderNumber: String
|
||
var pizzaType: String
|
||
}
|
||
```
|
||
|
||
**Key constraint**: `ActivityAttributes` total data size must be under **4KB** to start successfully.
|
||
|
||
## Starting Activities
|
||
|
||
### Request Authorization
|
||
|
||
```swift
|
||
import ActivityKit
|
||
|
||
let authorizationInfo = ActivityAuthorizationInfo()
|
||
let areActivitiesEnabled = authorizationInfo.areActivitiesEnabled
|
||
```
|
||
|
||
### Start an Activity
|
||
|
||
```swift
|
||
let attributes = PizzaDeliveryAttributes(
|
||
orderNumber: "12345",
|
||
pizzaType: "Pepperoni"
|
||
)
|
||
|
||
let initialState = PizzaDeliveryAttributes.ContentState(
|
||
status: .preparing,
|
||
estimatedDeliveryTime: Date().addingTimeInterval(30 * 60)
|
||
)
|
||
|
||
let activity = try Activity.request(
|
||
attributes: attributes,
|
||
content: ActivityContent(state: initialState, staleDate: nil),
|
||
pushType: nil // or .token for push notifications
|
||
)
|
||
```
|
||
|
||
## Error Handling
|
||
|
||
### Common Activity Errors
|
||
|
||
Always check `ActivityAuthorizationInfo().areActivitiesEnabled` before requesting. Handle these errors from `Activity.request()`:
|
||
|
||
- **`ActivityAuthorizationError`** — User denied Live Activities permission
|
||
- **`ActivityError.dataTooLarge`** — ActivityAttributes exceeds 4KB; reduce attribute size
|
||
- **`ActivityError.tooManyActivities`** — System limit reached (typically 2-3 simultaneous)
|
||
|
||
Store `activity.id` after successful request for later updates.
|
||
|
||
## Updating Activities
|
||
|
||
### Update with New Content
|
||
|
||
```swift
|
||
// Find active activity by stored ID
|
||
guard let activity = Activity<PizzaDeliveryAttributes>.activities
|
||
.first(where: { $0.id == storedActivityID }) else { return }
|
||
|
||
let updatedState = PizzaDeliveryAttributes.ContentState(
|
||
status: .onTheWay,
|
||
estimatedDeliveryTime: Date().addingTimeInterval(10 * 60),
|
||
driverName: "John"
|
||
)
|
||
|
||
await activity.update(
|
||
ActivityContent(
|
||
state: updatedState,
|
||
staleDate: Date().addingTimeInterval(60) // Mark stale after 1 min
|
||
)
|
||
)
|
||
```
|
||
|
||
### Alert Configuration
|
||
|
||
```swift
|
||
await activity.update(updatedContent, alertConfiguration: AlertConfiguration(
|
||
title: "Pizza is here!",
|
||
body: "Your \(attributes.pizzaType) pizza has arrived",
|
||
sound: .default
|
||
))
|
||
```
|
||
|
||
### Monitoring Activity Lifecycle
|
||
|
||
Use `activity.activityStateUpdates` async sequence to observe state changes (`.active`, `.ended`, `.dismissed`, `.stale`). Clean up stored activity IDs on `.ended` or `.dismissed`. Cancel the monitoring task in `deinit`.
|
||
|
||
## Ending Activities
|
||
|
||
### Dismissal Policies
|
||
|
||
```swift
|
||
await activity.end(
|
||
ActivityContent(state: finalState, staleDate: nil),
|
||
dismissalPolicy: .default
|
||
)
|
||
```
|
||
|
||
Dismissal policy options:
|
||
- **`.immediate`** — Removes instantly
|
||
- **`.default`** — Stays on Lock Screen for ~4 hours
|
||
- **`.after(date)`** — Removes at specific time (e.g., `.after(Date().addingTimeInterval(3600))`)
|
||
|
||
## Push Notifications for Live Activities
|
||
|
||
### Request Push Token
|
||
|
||
```swift
|
||
let activity = try Activity.request(
|
||
attributes: attributes,
|
||
content: initialContent,
|
||
pushType: .token // Request push token
|
||
)
|
||
|
||
// Monitor for push token
|
||
for await pushToken in activity.pushTokenUpdates {
|
||
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
|
||
// Send to your server
|
||
await sendTokenToServer(tokenString, activityID: activity.id)
|
||
}
|
||
```
|
||
|
||
### Frequent Push Updates (iOS 18.2+)
|
||
|
||
Standard limit is ~10-12 pushes/hour. For live events (sports, stocks), add the `com.apple.developer.activity-push-notification-frequent-updates` entitlement for significantly higher limits.
|
||
|
||
---
|
||
|
||
# Part 5: Dynamic Island (iOS 16.1+)
|
||
|
||
## Presentation Types
|
||
|
||
Live Activities appear in the Dynamic Island with three size classes:
|
||
|
||
### Compact (Leading + Trailing)
|
||
|
||
Shown when another Live Activity is expanded or when multiple activities are active.
|
||
|
||
```swift
|
||
DynamicIsland {
|
||
DynamicIslandExpandedRegion(.leading) {
|
||
Image(systemName: "timer")
|
||
}
|
||
DynamicIslandExpandedRegion(.trailing) {
|
||
Text("\(entry.timeRemaining)")
|
||
}
|
||
// ...
|
||
} compactLeading: {
|
||
Image(systemName: "timer")
|
||
} compactTrailing: {
|
||
Text("\(entry.timeRemaining)")
|
||
.frame(width: 40)
|
||
}
|
||
```
|
||
|
||
### Minimal
|
||
|
||
Shown when more than two Live Activities are active (circular avatar).
|
||
|
||
```swift
|
||
DynamicIsland {
|
||
// ...
|
||
} minimal: {
|
||
Image(systemName: "timer")
|
||
.foregroundStyle(.tint)
|
||
}
|
||
```
|
||
|
||
### Expanded
|
||
|
||
Shown when user long-presses the compact view.
|
||
|
||
```swift
|
||
DynamicIsland {
|
||
DynamicIslandExpandedRegion(.leading) {
|
||
Image(systemName: "timer")
|
||
.font(.title)
|
||
}
|
||
|
||
DynamicIslandExpandedRegion(.trailing) {
|
||
VStack(alignment: .trailing) {
|
||
Text("\(entry.timeRemaining)")
|
||
.font(.title2.monospacedDigit())
|
||
Text("remaining")
|
||
.font(.caption)
|
||
}
|
||
}
|
||
|
||
DynamicIslandExpandedRegion(.center) {
|
||
// Optional center content
|
||
}
|
||
|
||
DynamicIslandExpandedRegion(.bottom) {
|
||
HStack {
|
||
Button(intent: PauseIntent()) {
|
||
Label("Pause", systemImage: "pause.fill")
|
||
}
|
||
Button(intent: StopIntent()) {
|
||
Label("Stop", systemImage: "stop.fill")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## Design Principles (From WWDC 2023-10194)
|
||
|
||
### Concentric Alignment
|
||
|
||
Content should nest concentrically inside the Dynamic Island's rounded shape with even margins. Use `Circle()` or `RoundedRectangle(cornerRadius:)` — never sharp `Rectangle()` which pokes into corners.
|
||
|
||
### Biological Motion
|
||
|
||
Dynamic Island animations should feel organic and elastic. Use `.spring(response: 0.6, dampingFraction: 0.7)` or `.interpolatingSpring(stiffness: 300, damping: 25)` instead of linear animations.
|
||
|
||
---
|
||
|
||
# Part 6: Control Center Widgets (iOS 18+)
|
||
|
||
## ControlWidget Protocol
|
||
|
||
Controls appear in Control Center, Lock Screen, and Action Button (iPhone 15 Pro+).
|
||
|
||
### StaticControlConfiguration
|
||
|
||
For simple controls without configuration.
|
||
|
||
```swift
|
||
import WidgetKit
|
||
import AppIntents
|
||
|
||
struct TorchControl: ControlWidget {
|
||
var body: some ControlWidgetConfiguration {
|
||
StaticControlConfiguration(kind: "TorchControl") {
|
||
ControlWidgetButton(action: ToggleTorchIntent()) {
|
||
Label("Flashlight", systemImage: "flashlight.on.fill")
|
||
}
|
||
}
|
||
.displayName("Flashlight")
|
||
.description("Toggle flashlight")
|
||
}
|
||
}
|
||
```
|
||
|
||
### AppIntentControlConfiguration
|
||
|
||
For configurable controls.
|
||
|
||
```swift
|
||
struct TimerControl: ControlWidget {
|
||
var body: some ControlWidgetConfiguration {
|
||
AppIntentControlConfiguration(
|
||
kind: "TimerControl",
|
||
intent: ConfigureTimerIntent.self
|
||
) { configuration in
|
||
ControlWidgetButton(action: StartTimerIntent(duration: configuration.duration)) {
|
||
Label("\(configuration.duration)m Timer", systemImage: "timer")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## ControlWidgetButton
|
||
|
||
For discrete actions (one-shot operations).
|
||
|
||
```swift
|
||
ControlWidgetButton(action: PlayMusicIntent()) {
|
||
Label("Play", systemImage: "play.fill")
|
||
}
|
||
.tint(.purple)
|
||
```
|
||
|
||
## ControlWidgetToggle
|
||
|
||
For boolean state.
|
||
|
||
```swift
|
||
struct AirplaneModeControl: ControlWidget {
|
||
var body: some ControlWidgetConfiguration {
|
||
StaticControlConfiguration(kind: "AirplaneModeControl") {
|
||
ControlWidgetToggle(
|
||
isOn: AirplaneModeIntent.isEnabled,
|
||
action: AirplaneModeIntent()
|
||
) { isOn in
|
||
Label(isOn ? "On" : "Off", systemImage: "airplane")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## Value Providers (Async State)
|
||
|
||
For controls needing async state, pass a `ControlValueProvider` to `StaticControlConfiguration`:
|
||
|
||
```swift
|
||
struct ThermostatProvider: ControlValueProvider {
|
||
func currentValue() async throws -> ThermostatValue {
|
||
let temp = try await HomeManager.shared.currentTemperature()
|
||
return ThermostatValue(temperature: temp)
|
||
}
|
||
var previewValue: ThermostatValue { ThermostatValue(temperature: 72) }
|
||
}
|
||
```
|
||
|
||
The provider value is passed to your control's closure: `{ value in ControlWidgetButton(...) }`.
|
||
|
||
## Configurable Controls
|
||
|
||
Use `AppIntentControlConfiguration` with a `WidgetConfigurationIntent` (same pattern as configurable widgets). Add `.promptsForUserConfiguration()` to show configuration UI when the user adds the control.
|
||
|
||
## Control Refinements
|
||
|
||
- `.controlWidgetActionHint("Toggles flashlight")` — VoiceOver accessibility hint
|
||
- `.displayName("My Control")` / `.description("...")` — Shown in Control Center UI
|
||
|
||
---
|
||
|
||
# Part 7: iOS 18+ Updates
|
||
|
||
## Accented Rendering and Liquid Glass
|
||
|
||
Widget rendering modes span multiple iOS versions: `widgetAccentable()` (iOS 16+), `WidgetAccentedRenderingMode` (iOS 18+), and Liquid Glass effects like `glassEffect()` and `GlassEffectContainer` (iOS 26+). Detect the mode and adapt layout accordingly.
|
||
|
||
### Detecting Rendering Mode
|
||
|
||
```swift
|
||
struct MyWidgetView: View {
|
||
@Environment(\.widgetRenderingMode) var renderingMode
|
||
|
||
var body: some View {
|
||
if renderingMode == .accented {
|
||
// Simplified layout — opaque images tinted white, background replaced with glass
|
||
} else {
|
||
// Standard full-color layout
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### widgetAccentable(_:)
|
||
|
||
Marks views as part of the **accent group**. In accented mode, accent-group views are tinted separately from primary-group views, creating visual hierarchy.
|
||
|
||
```swift
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
Text("Title")
|
||
.font(.headline)
|
||
.widgetAccentable() // Accent group — tinted in accented mode
|
||
Text("Subtitle")
|
||
// Primary group by default
|
||
}
|
||
Image(systemName: "star.fill")
|
||
.widgetAccentable() // Also accent group
|
||
}
|
||
```
|
||
|
||
### WidgetAccentedRenderingMode
|
||
|
||
Controls how images render in accented mode. Apply to `Image` views:
|
||
|
||
```swift
|
||
Image("myPhoto")
|
||
.widgetAccentedRenderingMode(.accented) // Tinted with accent color
|
||
Image("myIcon")
|
||
.widgetAccentedRenderingMode(.monochrome) // Rendered as monochrome
|
||
Image("myBadge")
|
||
.widgetAccentedRenderingMode(.fullColor) // Keeps original colors (opt-out)
|
||
```
|
||
|
||
**Best practices**: Display full-color images only in `.fullColor` rendering mode. Use `.widgetAccentable()` strategically for visual hierarchy. Test with multiple accent colors and background images.
|
||
|
||
### Container Backgrounds
|
||
|
||
```swift
|
||
VStack { /* content */ }
|
||
.containerBackground(for: .widget) {
|
||
Color.blue.opacity(0.2)
|
||
}
|
||
```
|
||
|
||
In accented mode, the system removes the background and replaces it with themed glass. To prevent removal (excludes widget from iPad Lock Screen, StandBy):
|
||
|
||
```swift
|
||
.containerBackgroundRemovable(false)
|
||
```
|
||
|
||
### Liquid Glass in Custom Widget Elements
|
||
|
||
```swift
|
||
Text("Label")
|
||
.padding()
|
||
.glassEffect() // Default capsule shape
|
||
|
||
Image(systemName: "star.fill")
|
||
.frame(width: 60, height: 60)
|
||
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
||
|
||
Button("Action") { }
|
||
.buttonStyle(.glass)
|
||
```
|
||
|
||
Combine multiple glass elements with `GlassEffectContainer`:
|
||
|
||
```swift
|
||
GlassEffectContainer(spacing: 20.0) {
|
||
HStack(spacing: 20.0) {
|
||
Image(systemName: "cloud")
|
||
.frame(width: 60, height: 60)
|
||
.glassEffect()
|
||
Image(systemName: "sun")
|
||
.frame(width: 60, height: 60)
|
||
.glassEffect()
|
||
}
|
||
}
|
||
```
|
||
|
||
## Cross-Platform Support
|
||
|
||
### visionOS Widgets (visionOS 2+)
|
||
|
||
visionOS widgets are 3D objects placed in physical space — mounted on surfaces or floating. They support unique spatial features.
|
||
|
||
#### Mounting Styles
|
||
|
||
Widgets can be elevated (on top of surfaces) or recessed (embedded into vertical surfaces like walls):
|
||
|
||
```swift
|
||
.supportedMountingStyles([.elevated, .recessed]) // Default is both
|
||
// .supportedMountingStyles([.recessed]) // Wall-only widget
|
||
```
|
||
|
||
If limited to `.recessed`, users cannot place the widget on horizontal surfaces.
|
||
|
||
#### Widget Textures
|
||
|
||
Two visual textures for spatial appearance:
|
||
|
||
```swift
|
||
.widgetTexture(.glass) // Default — transparent glass-like appearance
|
||
.widgetTexture(.paper) // Poster-like look, effective with extra-large sizes
|
||
```
|
||
|
||
#### Proximity Awareness (levelOfDetail)
|
||
|
||
Widgets adapt to user distance automatically. The system animates transitions between detail levels:
|
||
|
||
```swift
|
||
@Environment(\.levelOfDetail) var levelOfDetail
|
||
|
||
var body: some View {
|
||
VStack {
|
||
Text(entry.value)
|
||
.font(levelOfDetail == .simplified ? .largeTitle : .title)
|
||
}
|
||
}
|
||
```
|
||
|
||
Values: `.default` (close viewing) and `.simplified` (distance viewing — use larger text, fewer details).
|
||
|
||
#### visionOS Widget Families
|
||
|
||
visionOS supports all system families plus extra-large sizes:
|
||
|
||
```swift
|
||
.supportedFamilies([
|
||
.systemSmall, .systemMedium, .systemLarge,
|
||
.systemExtraLarge,
|
||
.systemExtraLargePortrait // visionOS-specific portrait orientation
|
||
])
|
||
```
|
||
|
||
Extra-large families are particularly effective with `.widgetTexture(.paper)` for poster-like displays.
|
||
|
||
#### Background Detection
|
||
|
||
Detect whether the widget background is visible (removed in accented mode):
|
||
|
||
```swift
|
||
@Environment(\.showsWidgetContainerBackground) var showsBackground
|
||
```
|
||
|
||
### CarPlay (iOS 18+)
|
||
Add `.supplementalActivityFamilies([.medium])` to `ActivityConfiguration`. Uses StandBy-style full-width dashboard presentation.
|
||
|
||
### macOS Menu Bar
|
||
Live Activities from paired iPhone appear automatically in macOS Sequoia+ menu bar. No code changes required.
|
||
|
||
### watchOS Controls (11+)
|
||
`ControlWidget` works identically on watchOS — available in Control Center, Action Button, and Smart Stack. Same `StaticControlConfiguration` / `ControlWidgetButton` pattern as iOS.
|
||
|
||
## Relevance Widgets (iOS 18+)
|
||
|
||
Use `.relevanceConfiguration(for:score:attributes:)` to help the system promote widgets in Smart Stack. Attributes include `.location(CLLocation)`, `.timeOfDay(DateInterval)`, and `.activity(String)` for context-aware ranking.
|
||
|
||
## Push Notification Updates (iOS 18+)
|
||
|
||
Implement `PKPushRegistryDelegate` and handle `.widgetKit` push type to receive server-to-widget pushes. Update shared container data and call `WidgetCenter.shared.reloadAllTimelines()`. Pushes to iPhone automatically sync to Apple Watch and CarPlay.
|
||
|
||
---
|
||
|
||
# Part 8: App Groups & Data Sharing
|
||
|
||
## App Groups Entitlement
|
||
|
||
Required for sharing data between your app and extensions.
|
||
|
||
### Configuration
|
||
|
||
1. Xcode: Targets → Signing & Capabilities → Add "App Groups"
|
||
2. Identifier format: `group.com.company.appname`
|
||
3. Enable for BOTH main app target AND extension target
|
||
|
||
## Shared Containers
|
||
|
||
### Access Shared Container
|
||
|
||
```swift
|
||
let sharedContainer = FileManager.default.containerURL(
|
||
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
|
||
)!
|
||
|
||
let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json")
|
||
```
|
||
|
||
### UserDefaults with App Groups
|
||
|
||
```swift
|
||
// Main app - write data
|
||
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
|
||
shared.set("Updated value", forKey: "myKey")
|
||
|
||
// Widget extension - read data
|
||
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
|
||
let value = shared.string(forKey: "myKey")
|
||
```
|
||
|
||
### Core Data with App Groups
|
||
|
||
Point `NSPersistentStoreDescription` at the shared container URL:
|
||
|
||
```swift
|
||
let sharedStoreURL = FileManager.default.containerURL(
|
||
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
|
||
)!.appendingPathComponent("MyApp.sqlite")
|
||
|
||
let description = NSPersistentStoreDescription(url: sharedStoreURL)
|
||
container.persistentStoreDescriptions = [description]
|
||
```
|
||
|
||
## IPC Communication
|
||
|
||
- **Background URL Session** — Set `config.sharedContainerIdentifier` to your App Group ID for downloads accessible by extensions
|
||
- **Darwin Notification Center** — Use `CFNotificationCenterPostNotification` / `CFNotificationCenterAddObserver` with `CFNotificationCenterGetDarwinNotifyCenter()` for simple cross-process signals (e.g., notify widget to call `WidgetCenter.shared.reloadAllTimelines()`)
|
||
|
||
---
|
||
|
||
# Part 9: watchOS Integration
|
||
|
||
## supplementalActivityFamilies (watchOS 11+)
|
||
|
||
Add `.supplementalActivityFamilies([.small])` to `ActivityConfiguration` to show Live Activities on Apple Watch Smart Stack (same modifier used for CarPlay with `.medium`).
|
||
|
||
## activityFamily Environment
|
||
|
||
Use `@Environment(\.activityFamily)` to adapt layout — check for `.small` (watchOS) vs iPhone layout.
|
||
|
||
## Always On Display
|
||
|
||
Use `@Environment(\.isLuminanceReduced)` to simplify views for Always On Display — reduce detail, use white text, larger fonts. Combine with `@Environment(\.colorScheme)` for proper dark mode handling.
|
||
|
||
## Update Budgeting (watchOS)
|
||
|
||
watchOS updates sync automatically with iPhone via push notifications. Updates may be delayed if watch is out of Bluetooth range.
|
||
|
||
---
|
||
|
||
# Part 10: Practical Workflows
|
||
|
||
## Building Your First Widget
|
||
|
||
For a complete step-by-step tutorial with working code examples, see Apple's [Building Widgets Using WidgetKit and SwiftUI](https://developer.apple.com/documentation/widgetkit/building-widgets-using-widgetkit-and-swiftui) sample project.
|
||
|
||
**Key steps**: Add widget extension target, configure App Groups, implement TimelineProvider, design SwiftUI view, update from main app. See Expert Review Checklist below for production requirements.
|
||
|
||
---
|
||
|
||
## Expert Review Checklist
|
||
|
||
### Before Shipping Widgets
|
||
|
||
**Architecture**:
|
||
- [ ] App Groups entitlement configured in app AND extension
|
||
- [ ] Group identifier matches exactly in both targets
|
||
- [ ] Shared container used for ALL data sharing
|
||
- [ ] No `UserDefaults.standard` in widget code
|
||
|
||
**Performance**:
|
||
- [ ] Timeline generation completes in < 5 seconds
|
||
- [ ] No network requests in widget views
|
||
- [ ] Timeline has reasonable refresh intervals (≥ 15 min)
|
||
- [ ] Entry count reasonable (< 20-30 entries)
|
||
- [ ] Memory usage under limits (~30MB widgets, ~50MB activities)
|
||
- [ ] Images optimized (asset catalog or SF Symbols preferred)
|
||
|
||
**Data & State**:
|
||
- [ ] Widget handles missing/nil data gracefully
|
||
- [ ] Entry dates in chronological order
|
||
- [ ] Placeholder view looks reasonable
|
||
- [ ] Snapshot view representative of actual use
|
||
|
||
**User Experience**:
|
||
- [ ] Widget appears in widget gallery
|
||
- [ ] configurationDisplayName clear and concise
|
||
- [ ] description explains widget purpose
|
||
- [ ] All supported families tested and look correct
|
||
- [ ] Text readable on both light and dark backgrounds
|
||
- [ ] Interactive elements (buttons/toggles) work correctly
|
||
|
||
**Live Activities** (if applicable):
|
||
- [ ] ActivityAttributes under 4KB
|
||
- [ ] Authorization checked before starting
|
||
- [ ] Activity ends when event completes
|
||
- [ ] Proper dismissal policy set
|
||
- [ ] watchOS support configured if relevant (supplementalActivityFamilies)
|
||
- [ ] Dynamic Island layouts tested (compact, minimal, expanded)
|
||
|
||
**Liquid Glass** (if applicable):
|
||
- [ ] `widgetAccentable()` applied for visual hierarchy in accented mode
|
||
- [ ] `WidgetAccentedRenderingMode` set on images (`.accented`, `.monochrome`, or `.fullColor`)
|
||
- [ ] Tested with multiple accent colors and background images
|
||
- [ ] Container background configured with `.containerBackground(for: .widget)`
|
||
|
||
**visionOS** (if applicable):
|
||
- [ ] Mounting styles configured (`.elevated`, `.recessed`, or both)
|
||
- [ ] Widget texture chosen (`.glass` or `.paper`)
|
||
- [ ] `levelOfDetail` handled for proximity-aware layouts
|
||
- [ ] Extra-large families supported if appropriate (`.systemExtraLarge`, `.systemExtraLargePortrait`)
|
||
- [ ] Tested at different distances for proximity transitions
|
||
|
||
**Control Center Widgets** (if applicable):
|
||
- [ ] ControlValueProvider async and fast (< 1 second)
|
||
- [ ] previewValue provides reasonable fallback
|
||
- [ ] displayName and description set
|
||
- [ ] Tested in Control Center, Lock Screen, Action Button
|
||
|
||
**Testing**:
|
||
- [ ] Tested on actual device (not just simulator)
|
||
- [ ] Tested adding/removing widget
|
||
- [ ] Tested app data changes → widget updates
|
||
- [ ] Tested force-quit app → widget still works
|
||
- [ ] Tested low memory scenarios
|
||
- [ ] Tested all iOS versions you support
|
||
- [ ] Tested with no internet connection
|
||
|
||
---
|
||
|
||
## Testing Guidance
|
||
|
||
### Unit Testing Pattern
|
||
|
||
Test `placeholder()`, `getSnapshot()`, and `getTimeline()` methods. Save test data to shared container, call `getTimeline()` with a mock context, assert entries are non-empty and contain expected data. Use `waitForExpectations(timeout: 5.0)` for async timeline generation.
|
||
|
||
### Manual Testing Checklist
|
||
- Add widget to Home Screen, verify widget gallery, all supported sizes, data matches app
|
||
- Change data in main app, observe widget updates, force-quit app, reboot device
|
||
- Delete all app data (graceful handling), disable network (offline), Low Power Mode, multiple instances
|
||
- Monitor memory in Xcode Debug Navigator, check timeline generation time in Console, test on older devices
|
||
|
||
### Debugging Tips
|
||
- Add `print()` logging in `getTimeline()` to verify it's being called and data is loaded
|
||
- Verify App Groups: print `FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)` in both app and widget — paths must match
|
||
- After data changes in main app, call `WidgetCenter.shared.reloadAllTimelines()`
|
||
|
||
---
|
||
|
||
# Part 11: Troubleshooting
|
||
|
||
**Widget not appearing in gallery**: Check `WidgetBundle` includes it, verify `supportedFamilies()`, check extension's "Skip Install" = NO, verify deployment target matches app.
|
||
|
||
## Widget Not Refreshing
|
||
|
||
**Symptoms**: Widget shows stale data, doesn't update
|
||
|
||
**Diagnostic Steps**:
|
||
1. Check timeline policy (`.atEnd` vs `.after()` vs `.never`)
|
||
2. Verify you're not exceeding daily budget (40-70 reloads)
|
||
3. Check if `getTimeline()` is being called (add logging)
|
||
4. Ensure App Groups configured correctly for shared data
|
||
|
||
**Solution**:
|
||
```swift
|
||
// Manual reload from main app when data changes
|
||
import WidgetKit
|
||
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
// or
|
||
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
|
||
```
|
||
|
||
## Data Not Shared Between App and Widget
|
||
|
||
**Symptoms**: Widget shows default/empty data
|
||
|
||
**Diagnostic Steps**:
|
||
1. Verify App Groups entitlement in BOTH targets
|
||
2. Check group identifier matches exactly
|
||
3. Ensure using same suiteName in both targets
|
||
4. Check file path if using shared container
|
||
|
||
**Solution**:
|
||
```swift
|
||
// Both app AND extension must use:
|
||
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
|
||
|
||
// NOT:
|
||
let shared = UserDefaults.standard // ❌ Different containers
|
||
```
|
||
|
||
## Live Activity Won't Start
|
||
|
||
**Symptoms**: `Activity.request()` throws error
|
||
|
||
**Common Errors**:
|
||
|
||
**"Activity size exceeds 4KB"**:
|
||
```swift
|
||
// ❌ BAD: Large images in attributes
|
||
struct MyAttributes: ActivityAttributes {
|
||
var productImage: UIImage // Too large!
|
||
}
|
||
|
||
// ✅ GOOD: Use asset catalog names
|
||
struct MyAttributes: ActivityAttributes {
|
||
var productImageName: String // Reference to asset
|
||
}
|
||
```
|
||
|
||
**"Activities not enabled"**:
|
||
```swift
|
||
// Check authorization first
|
||
let authInfo = ActivityAuthorizationInfo()
|
||
guard authInfo.areActivitiesEnabled else {
|
||
throw ActivityError.notEnabled
|
||
}
|
||
```
|
||
|
||
## Interactive Widget Button Not Working
|
||
|
||
**Symptoms**: Tapping button does nothing
|
||
|
||
**Diagnostic Steps**:
|
||
1. Verify App Intent's `perform()` returns `IntentResult`
|
||
2. Check intent is imported in widget target
|
||
3. Ensure button uses `intent:` parameter, not `action:`
|
||
4. Check Console for intent execution errors
|
||
|
||
**Solution**:
|
||
```swift
|
||
// ✅ CORRECT: Use intent parameter
|
||
Button(intent: MyIntent()) {
|
||
Label("Action", systemImage: "star")
|
||
}
|
||
|
||
// ❌ WRONG: Don't use action closure
|
||
Button(action: { /* This won't work in widgets */ }) {
|
||
Label("Action", systemImage: "star")
|
||
}
|
||
```
|
||
|
||
**Control Center widget slow**: Use async in `ControlValueProvider.currentValue()`, never block with `Thread.sleep`. Provide fast `previewValue` fallback.
|
||
|
||
**Widget shows wrong size**: Switch on `@Environment(\.widgetFamily)` in view, adapt layout per family, avoid hardcoded sizes.
|
||
|
||
**Timeline entries out of order**: Ensure entry dates are chronological. Use incrementing offsets from `Date()`.
|
||
|
||
**watchOS Live Activity not showing**: Add `.supplementalActivityFamilies([.small])` to `ActivityConfiguration`, verify watchOS 11+, check Bluetooth/pairing.
|
||
|
||
## Performance Issues
|
||
|
||
**Symptoms**: Widget rendering slow, battery drain
|
||
|
||
**Common Causes**:
|
||
- Too many timeline entries (> 100)
|
||
- Network requests in view code
|
||
- Heavy computation in `getTimeline()`
|
||
- Refresh intervals too frequent (< 15 min)
|
||
|
||
**Solution**:
|
||
```swift
|
||
// ✅ GOOD: Strategic intervals
|
||
let entries = (0..<8).map { offset in
|
||
let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
|
||
return SimpleEntry(date: date, data: precomputedData)
|
||
}
|
||
|
||
// ❌ BAD: Too frequent, too many entries
|
||
let entries = (0..<100).map { offset in
|
||
let date = Calendar.current.date(byAdding: .minute, value: offset, to: now)!
|
||
return SimpleEntry(date: date, data: fetchFromNetwork()) // Network in timeline
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Debugging Widgets
|
||
|
||
### Simulator vs Device
|
||
|
||
- **Simulator**: Widgets refresh immediately; no budget limits apply. Useful for layout testing but misleading for refresh behavior.
|
||
- **Device**: Budget-limited (40-70 reloads/day). Test on device before shipping to verify real-world refresh timing.
|
||
- **Xcode Previews**: Work for layout but skip `getTimeline()`. Test timeline logic with unit tests or device runs.
|
||
|
||
### Common Debugging Workflow
|
||
|
||
1. Add `print()` in `getTimeline()` — verify it's called and data loads
|
||
2. Check Console.app filtered by widget extension process name
|
||
3. Use `WidgetCenter.shared.getCurrentConfigurations()` to verify registration
|
||
4. If widget shows old data after app update, verify App Groups container paths match
|
||
|
||
### Data Sharing Patterns
|
||
|
||
**SwiftData in Widgets** (iOS 17+):
|
||
- Create `ModelContainer` in widget with same schema as main app
|
||
- Use shared App Groups container: `ModelConfiguration(url: containerURL)`
|
||
- Widget reads only — never write from widget to avoid conflicts
|
||
- Main app calls `WidgetCenter.shared.reloadAllTimelines()` after writes
|
||
|
||
**GRDB/SQLite in Widgets**:
|
||
- Share database file via App Groups container
|
||
- Use `DatabasePool` (not `DatabaseQueue`) for concurrent reads
|
||
- Widget opens read-only connection: `try DatabasePool(path: dbPath, configuration: readOnlyConfig)`
|
||
- Set `configuration.readonly = true` in widget to prevent accidental writes
|
||
|
||
---
|
||
|
||
## Resources
|
||
|
||
**WWDC**: 2025-278, 2024-10157, 2024-10068, 2024-10098, 2023-10028, 2023-10194, 2022-10184, 2022-10185
|
||
|
||
**Docs**: /widgetkit, /activitykit, /appintents
|
||
|
||
**Skills**: axiom-app-intents-ref, axiom-swift-concurrency, axiom-swiftui-performance, axiom-swiftui-layout, axiom-extensions-widgets
|
||
|
||
---
|
||
|
||
**Version**: 0.9 | **Platforms**: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+
|