| name | 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+ |
| skill_type | reference |
| version | 1.0.0 |
| apple_platforms | iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+ |
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
- App Extensions — Shared data, lifecycle, entitlements
What are widgets?: Widgets are SwiftUI views that display timely, relevant information from your app. Unlike live app views, widgets are archived snapshots rendered on a timeline and displayed by the system.
What are extensions?: App extensions are separate executables bundled with your app that run in sandboxed environments with limited resources and capabilities.
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
- Supporting watchOS or visionOS widgets
❌ 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 — A series of entries that define when and what content your widget displays. The system automatically shows the appropriate entry at each specified time.
TimelineProvider — Protocol you implement to supply timeline entries to the system. Includes methods for placeholder, snapshot, and actual timeline generation.
TimelineEntry — A struct containing your widget's data and the date when it should be displayed. Each entry is like a "snapshot" of your widget at a specific time.
Timeline Budget — The daily limit (40-70) of how many times the system will request new timelines for your widget. Helps conserve battery.
Budget-Exempt — Timeline reloads that don't count against your daily budget (user-initiated, app foregrounding, system-initiated).
Widget Family — The size/shape of a widget (systemSmall, systemMedium, accessoryCircular, etc.). Your view adapts based on the family.
App Groups — An entitlement that allows your app and extensions to share data through a common container. Required for widgets to access app data.
ActivityAttributes — Defines both static data (set once when Live Activity starts) and dynamic ContentState (updated throughout activity lifecycle).
ContentState — The part of ActivityAttributes that changes during a Live Activity's lifetime. Must be under 4KB total.
Dynamic Island — iPhone 14 Pro+ feature where Live Activities appear around the TrueDepth camera. Has three sizes: compact, minimal, and expanded.
ControlWidget — iOS 18+ feature allowing widgets to appear in Control Center, Lock Screen, and Action Button for quick actions.
Concentric Alignment — Design principle for Dynamic Island content where visual mass (centroid) nestles inside the Island's rounded walls with even margins.
Visual Mass (Centroid) — The perceived "weight" center of your content. In Dynamic Island, this should align with the Island's shape for proper fit.
Supplemental Activity Families — Enables Live Activities to appear on Apple Watch or CarPlay in addition to iPhone.
Part 1: Standard Widgets (iOS 14+)
Widget Configuration Types
StaticConfiguration
For widgets that don't require user configuration.
@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.
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
Decision Tree:
Does your widget need user configuration?
├─ NO → Use StaticConfiguration
│ └─ Example: Weather widget for current location
│
└─ YES → Need configuration
├─ Simple static options (no dynamic data)?
│ └─ Use AppIntentConfiguration with WidgetConfigurationIntent
│ └─ Example: Timer with preset durations (5, 10, 15 minutes)
│
└─ Dynamic options (projects, contacts, playlists)?
└─ Use AppIntentConfiguration + EntityQuery
└─ Example: Project status widget showing user's projects
Configuration Type Comparison:
| Configuration | Use When | Example |
|---|---|---|
| StaticConfiguration | No user customization needed | Weather for current location, battery status |
| AppIntentConfiguration (simple) | Fixed list of options | Timer presets, theme selection |
| AppIntentConfiguration (EntityQuery) | Dynamic list from app data | Project picker, contact picker, playlist selector |
| ActivityConfiguration | Live ongoing events | Delivery tracking, workout progress, sports scores |
Widget Families
System Families (Home Screen)
| Family | Size (points) | iOS Version | Use Case |
|---|---|---|---|
systemSmall |
~170×170 | 14+ | Single piece of info, icon |
systemMedium |
~360×170 | 14+ | Multiple data points, chart |
systemLarge |
~360×380 | 14+ | Detailed view, list |
systemExtraLarge |
~720×380 | 15+ (iPad only) | Rich layouts, multiple views |
Accessory Families (Lock Screen, iOS 16+)
| Family | Location | Size | Content |
|---|---|---|---|
accessoryCircular |
Circular complication | ~48×48pt | Icon or gauge |
accessoryRectangular |
Above clock | ~160×72pt | Text + icon |
accessoryInline |
Above date | Single line | Text only |
Example: Supporting Multiple Families
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.
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.
| Policy | Behavior |
|---|---|
.atEnd |
Reload after last entry |
.after(date) |
Reload at specific date |
.never |
No automatic reload (manual only) |
Manual Reload
import WidgetKit
// Reload all widgets of this kind
WidgetCenter.shared.reloadAllTimelines()
// Reload specific kind
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
Refresh Budgets
Daily budget: 40-70 timeline reloads per day (varies by system load and user engagement)
Budget-Exempt Scenarios
These do NOT count against your budget:
- User explicitly reloads (pull-to-refresh on Home Screen)
- App is foregrounded
- User adds widget to Home Screen
- System-initiated reloads (e.g., after reboot)
Best Practices
// ✅ 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)
}
// ❌ BAD: Too frequent (1 min) - will exhaust budget
let entries = (0..<60).map { offset in
let date = Calendar.current.date(byAdding: .minute, value: offset, to: now)!
return SimpleEntry(date: date, data: data)
}
Performance Implications
Memory Limits
Widget extensions have strict memory limits:
- ~30MB for standard widgets
- ~50MB for Live Activities
- System terminates extension if exceeded
Best practices:
// ✅ GOOD: Load only what you need
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let data = loadRecentItems(limit: 10) // Limited dataset
let entries = generateEntries(from: data)
completion(Timeline(entries: entries, policy: .atEnd))
}
// ❌ BAD: Loading entire database
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let allData = database.loadAllItems() // Thousands of items = memory spike
// ...
}
Network Requests
Never make network requests in widget views - they won't complete before rendering.
// ❌ CRITICAL ERROR: Network in view
struct MyWidgetView: View {
var body: some View {
VStack {
Text("Weather")
}
.onAppear {
Task {
// This will NOT work - view is already rendered
let weather = try? await fetchWeather()
}
}
}
}
// ✅ CORRECT: Network in timeline provider
struct Provider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
// Fetch data here, before rendering
let weather = try await fetchWeather()
let entry = SimpleEntry(date: Date(), weather: weather)
completion(Timeline(entries: [entry], policy: .atEnd))
}
}
}
Timeline Generation Performance
Target: Complete getTimeline() in under 5 seconds
Strategies:
- Cache in main app - Precompute expensive operations
- Async/await - Don't block completion handler
- Limit entries - 10-20 entries maximum
- Minimal computation - Simple transformations only
// ✅ GOOD: Fast timeline generation
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// Read pre-computed data from shared container
let shared = UserDefaults(suiteName: "group.com.myapp")!
let cachedData = shared.data(forKey: "widgetData")
let entries = generateQuickEntries(from: cachedData)
completion(Timeline(entries: entries, policy: .after(Date().addingTimeInterval(3600))))
}
// ❌ BAD: Expensive operations in timeline
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// Parsing large JSON, complex algorithms
let json = parseHugeJSON() // 10+ seconds
let analyzed = runMLModel(on: json) // 5+ seconds
// Widget will timeout and show placeholder
}
Battery Impact
Widget refresh = battery drain
| Refresh Strategy | Daily Budget Used | Battery Impact |
|---|---|---|
| Strategic (4x/hour) | ~48 reloads | Low |
| Aggressive (12x/hour) | Budget exhausted by 6 PM | High |
| On-demand only | 5-10 reloads | Minimal |
When to reload:
- ✅ Significant data change (order status update)
- ✅ User opens app (free reload)
- ✅ Time-based (hourly weather)
- ❌ Speculative updates (might change)
- ❌ Cosmetic changes (color theme)
View Rendering Performance
Widgets render frequently (every time user views Home Screen/Lock Screen)
// ✅ GOOD: Simple, efficient views
struct MyWidgetView: View {
var entry: Provider.Entry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(entry.title)
.font(.headline)
Text(entry.subtitle)
.font(.caption)
}
.padding()
}
}
// ❌ BAD: Heavy view operations
struct MyWidgetView: View {
var entry: Provider.Entry
var body: some View {
VStack {
// Avoid expensive operations in view body
Text(entry.title)
// Don't compute in body - precompute in entry
ForEach(complexCalculation(entry.data)) { item in
Text(item.name)
}
}
}
func complexCalculation(_ data: [Item]) -> [ProcessedItem] {
// This runs on EVERY render
return data.map { /* expensive transform */ }
}
}
Rule: Precompute everything in TimelineEntry, keep views simple.
Image Performance
// ✅ GOOD: Asset catalog images (fast)
Image("icon-weather")
// ✅ GOOD: SF Symbols (fast)
Image(systemName: "cloud.rain.fill")
// ⚠️ ACCEPTABLE: Small images from shared container
if let imageData = Data(/* from shared container */),
let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
}
// ❌ BAD: Remote images (won't load)
AsyncImage(url: URL(string: "https://...")) // Doesn't work in widgets
// ❌ BAD: Large images (memory spike)
Image(/* 4K resolution image */) // Will cause termination
Part 2: Interactive Widgets (iOS 17+)
Button and Toggle
Interactive widgets use SwiftUI Button and Toggle with App Intents.
Button with App Intent
struct MyWidgetView: View {
var entry: Provider.Entry
var body: some View {
VStack {
Text(entry.count)
Button(intent: IncrementIntent()) {
Label("Increment", systemImage: "plus.circle")
}
}
}
}
struct IncrementIntent: AppIntent {
static var title: LocalizedStringResource = "Increment Counter"
func perform() async throws -> some IntentResult {
// Update shared data using App Groups
let shared = UserDefaults(suiteName: "group.com.myapp")!
let count = shared.integer(forKey: "count")
shared.set(count + 1, forKey: "count")
return .result()
}
}
Toggle with App Intent
struct ToggleFeatureIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Feature"
@Parameter(title: "Enabled")
var enabled: Bool
func perform() async throws -> some IntentResult {
// Update shared data using App Groups
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set(enabled, forKey: "featureEnabled")
return .result()
}
}
struct MyWidgetView: View {
@State private var isEnabled: Bool = false
var body: some View {
Toggle(isOn: $isEnabled) {
Text("Feature")
}
.onChange(of: isEnabled) { newValue in
Task {
try? await ToggleFeatureIntent(enabled: newValue).perform()
}
}
}
}
invalidatableContent Modifier
Provides visual feedback during App Intent execution.
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
Text("\(entry.value)")
.contentTransition(.numericText(value: Double(entry.value)))
Effect: Numbers smoothly count up or down instead of instantly changing.
View Transitions
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.
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.
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
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.
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
import ActivityKit
let authorizationInfo = ActivityAuthorizationInfo()
let areActivitiesEnabled = authorizationInfo.areActivitiesEnabled
Start an Activity
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
import ActivityKit
func startDeliveryActivity(order: Order) {
// Check authorization first
let authInfo = ActivityAuthorizationInfo()
guard authInfo.areActivitiesEnabled else {
print("Live Activities not enabled by user")
return
}
let attributes = PizzaDeliveryAttributes(
orderNumber: order.id,
pizzaType: order.pizzaType
)
let initialState = PizzaDeliveryAttributes.ContentState(
status: .preparing,
estimatedDeliveryTime: order.estimatedTime
)
do {
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: initialState, staleDate: nil),
pushType: .token
)
// Store activity ID for later updates
UserDefaults.shared.set(activity.id, forKey: "currentDeliveryActivityID")
} catch let error as ActivityAuthorizationError {
// User denied Live Activities permission
print("Authorization error: \(error.localizedDescription)")
} catch let error as ActivityError {
switch error {
case .dataTooLarge:
// ActivityAttributes exceeds 4KB
print("Activity data too large - reduce attribute size")
case .tooManyActivities:
// System limit reached (typically 2-3 simultaneous)
print("Too many active Live Activities")
default:
print("Activity error: \(error.localizedDescription)")
}
} catch {
print("Unexpected error: \(error)")
}
}
Safely Updating Activities
func updateActivity(newStatus: DeliveryStatus) async {
// Find active activity
guard let activityID = UserDefaults.shared.string(forKey: "currentDeliveryActivityID"),
let activity = Activity<PizzaDeliveryAttributes>.activities.first(where: { $0.id == activityID })
else {
print("No active delivery activity found")
return
}
let updatedState = PizzaDeliveryAttributes.ContentState(
status: newStatus,
estimatedDeliveryTime: Date().addingTimeInterval(10 * 60),
driverName: "John"
)
// Await the update result
let updateTask = Task {
await activity.update(
ActivityContent(state: updatedState, staleDate: nil)
)
}
await updateTask.value
}
Handling Activity Lifecycle
class DeliveryManager {
private var activityTask: Task<Void, Never>?
func monitorActivity(_ activity: Activity<PizzaDeliveryAttributes>) {
// Cancel previous monitoring
activityTask?.cancel()
// Monitor activity state
activityTask = Task {
for await state in activity.activityStateUpdates {
switch state {
case .active:
print("Activity is active")
case .ended:
print("Activity ended by system")
// Clean up
UserDefaults.shared.removeObject(forKey: "currentDeliveryActivityID")
case .dismissed:
print("Activity dismissed by user")
// Clean up
UserDefaults.shared.removeObject(forKey: "currentDeliveryActivityID")
case .stale:
print("Activity marked stale")
@unknown default:
break
}
}
}
}
deinit {
activityTask?.cancel()
}
}
Updating Activities
Update with New Content
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
let updatedContent = ActivityContent(
state: updatedState,
staleDate: nil
)
await activity.update(updatedContent, alertConfiguration: AlertConfiguration(
title: "Pizza is here!",
body: "Your \(attributes.pizzaType) pizza has arrived",
sound: .default
))
Ending Activities
Dismissal Policies
// Immediate - removes instantly
await activity.end(nil, dismissalPolicy: .immediate)
// Default - stays for ~4 hours on Lock Screen
await activity.end(nil, dismissalPolicy: .default)
// After date - removes at specific time
let dismissTime = Date().addingTimeInterval(60 * 60) // 1 hour
await activity.end(nil, dismissalPolicy: .after(dismissTime))
Final Content
let finalState = PizzaDeliveryAttributes.ContentState(
status: .delivered,
estimatedDeliveryTime: Date(),
driverName: "John"
)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .default
)
Push Notifications for Live Activities
Request Push Token
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+)
For scenarios requiring more frequent updates than standard push limits:
let activity = try Activity.request(
attributes: attributes,
content: initialContent,
pushType: .token
)
// App needs "com.apple.developer.activity-push-notification-frequent-updates" entitlement
Standard push limit: ~10-12 per hour Frequent push entitlement: Significantly higher limit for live events (sports, stocks, etc.)
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.
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).
DynamicIsland {
// ...
} minimal: {
Image(systemName: "timer")
.foregroundStyle(.tint)
}
Expanded
Shown when user long-presses the compact view.
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
"A key aspect to making things fit nicely inside the Dynamic Island is for them to be concentric with its shape. This is when rounded shapes nest inside of each other with even margins all the way around."
Visual mass (centroid) should nestle inside the Dynamic Island walls:
// ✅ GOOD: Concentric circular shape
Circle()
.fill(.blue)
.frame(width: 44, height: 44)
// ❌ BAD: Square poking into corners
Rectangle()
.fill(.blue)
.frame(width: 44, height: 44)
// ✅ BETTER: Rounded rectangle
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
.frame(width: 44, height: 44)
Biological Motion
Dynamic Island animations should feel organic and elastic, not mechanical:
// Elastic spring animation
.animation(.spring(response: 0.6, dampingFraction: 0.7), value: isExpanded)
// Biological curve
.animation(.interpolatingSpring(stiffness: 300, damping: 25), value: content)
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.
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.
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).
ControlWidgetButton(action: PlayMusicIntent()) {
Label("Play", systemImage: "play.fill")
}
.tint(.purple)
ControlWidgetToggle
For boolean state.
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 that need to fetch current state asynchronously.
struct TemperatureControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "ThermostatControl", provider: ThermostatProvider()) { value in
ControlWidgetButton(action: AdjustTemperatureIntent()) {
Label("\(value.temperature)°", systemImage: "thermometer")
}
}
}
}
struct ThermostatProvider: ControlValueProvider {
func currentValue() async throws -> ThermostatValue {
// Fetch current temperature from HomeKit/server
let temp = try await HomeManager.shared.currentTemperature()
return ThermostatValue(temperature: temp)
}
var previewValue: ThermostatValue {
ThermostatValue(temperature: 72) // Fallback for preview
}
}
struct ThermostatValue: ControlValueProviderValue {
var temperature: Int
}
Configurable Controls
Allow users to customize the control before adding.
struct ConfigureTimerIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Configure Timer"
@Parameter(title: "Duration (minutes)", default: 5)
var duration: Int
}
struct TimerControl: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: "TimerControl",
intent: ConfigureTimerIntent.self
) { config in
ControlWidgetButton(action: StartTimerIntent(duration: config.duration)) {
Label("\(config.duration)m", systemImage: "timer")
}
}
.promptsForUserConfiguration() // Show configuration UI when adding
}
}
Control Refinements
controlWidgetActionHint
Accessibility hint for VoiceOver.
ControlWidgetButton(action: ToggleTorchIntent()) {
Label("Flashlight", systemImage: "flashlight.on.fill")
}
.controlWidgetActionHint("Toggles flashlight")
displayName and description
StaticControlConfiguration(kind: "MyControl") {
// ...
}
.displayName("My Control")
.description("Brief description shown in Control Center")
Part 7: iOS 18+ Updates
Liquid Glass / Accented Rendering
Widgets can render with accented glass effects matching system aesthetics (iOS 18+).
widgetAccentedRenderingMode
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
MyWidgetView(entry: entry)
.widgetAccentedRenderingMode(.accented)
}
}
}
Rendering Modes
| Mode | Effect |
|---|---|
.accented |
System applies glass effect, respects vibrancy |
.fullColor |
Full color rendering (default) |
Design consideration: When .accented, your widget's colors blend with system glass. Test in multiple contexts (Home Screen, StandBy, Lock Screen).
visionOS Support
Widgets supported on visionOS 2+ with spatial presentation.
Mounting Styles
#if os(visionOS)
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
MyWidgetView(entry: entry)
}
.supportedFamilies([.systemSmall, .systemMedium])
.ornamentLevel(.default) // Spatial ornament positioning
}
}
#endif
CarPlay Widgets (iOS 18+)
Live Activities appear on CarPlay displays in supported vehicles.
struct MyLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: NavigationAttributes.self) { context in
NavigationView(context: context)
} dynamicIsland: { context in
DynamicIsland {
// Dynamic Island presentation
}
}
.supplementalActivityFamilies([
.small, // watchOS
.medium // CarPlay
])
}
}
CarPlay rendering: Uses StandBy-style full-width presentation on the dashboard.
macOS Menu Bar
Live Activities from paired iPhone appear in macOS menu bar automatically (no code changes required, macOS Sequoia+).
Presentation: Compact view appears in menu bar; clicking expands to show full content.
watchOS Controls
Control Center widgets available on watchOS 11+ in:
- Control Center
- Action Button
- Smart Stack (automatic suggestions)
struct WatchControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "WatchControl") {
ControlWidgetButton(action: StartWorkoutIntent()) {
Label("Workout", systemImage: "figure.run")
}
}
}
}
Relevance Widgets (iOS 18+)
System intelligently promotes relevant widgets to Smart Stack on watchOS.
RelevanceConfiguration
struct RelevantWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "RelevantWidget", provider: Provider()) { entry in
RelevantWidgetView(entry: entry)
}
.relevanceConfiguration(
for: entry,
score: entry.relevanceScore,
attributes: [
.location(entry.userLocation),
.timeOfDay(entry.relevantTimeRange)
]
)
}
}
WidgetRelevanceAttribute
enum WidgetRelevanceAttribute {
case location(CLLocation)
case timeOfDay(DateInterval)
case activity(String) // Calendar event, workout, etc.
}
Push Notification Updates (iOS 18+)
WidgetPushHandler
Server-to-widget push notifications with cross-device sync.
class WidgetPushHandler: NSObject, PKPushRegistryDelegate {
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) {
if type == .widgetKit {
// Update widget data in shared container
let shared = UserDefaults(suiteName: "group.com.myapp")!
if let data = payload.dictionaryPayload["widgetData"] as? [String: Any] {
shared.set(data, forKey: "widgetData")
}
// Reload widgets
WidgetCenter.shared.reloadAllTimelines()
}
}
}
Cross-device sync: Push to iPhone automatically syncs to Apple Watch and CarPlay Live Activities.
Part 8: App Groups & Data Sharing
App Groups Entitlement
Required for sharing data between your app and extensions.
Configuration
- Xcode: Targets → Signing & Capabilities → Add "App Groups"
- Identifier format:
group.com.company.appname - Enable for both: Main app target AND extension target
Example Entitlement File
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.mycompany.myapp</string>
</array>
Shared Containers
Access Shared Container
let sharedContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!
let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json")
UserDefaults with App Groups
// 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
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
let sharedStoreURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!.appendingPathComponent("MyApp.sqlite")
let description = NSPersistentStoreDescription(url: sharedStoreURL)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
// Handle errors
}
return container
}()
IPC Communication
Background URL Session (For Downloads)
// Main app
let config = URLSessionConfiguration.background(withIdentifier: "com.mycompany.myapp.background")
config.sharedContainerIdentifier = "group.com.mycompany.myapp"
let session = URLSession(configuration: config)
Darwin Notification Center (Simple Signals)
import Foundation
// Post notification
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName("com.mycompany.myapp.dataUpdated" as CFString),
nil, nil, true
)
// Observe notification (in widget)
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
{ (center, observer, name, object, userInfo) in
// Reload widget
WidgetCenter.shared.reloadAllTimelines()
},
"com.mycompany.myapp.dataUpdated" as CFString,
nil, .deliverImmediately
)
Part 9: watchOS Integration
supplementalActivityFamilies (watchOS 11+)
Live Activities from iPhone automatically appear on Apple Watch Smart Stack.
struct MyLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// iPhone presentation
DeliveryView(context: context)
} dynamicIsland: { context in
// Dynamic Island (iPhone only)
DynamicIsland { /* ... */ }
}
.supplementalActivityFamilies([.small]) // Enable watchOS
}
}
activityFamily Environment
Adapt layout for Apple Watch.
struct DeliveryView: View {
@Environment(\.activityFamily) var activityFamily
var context: ActivityViewContext<DeliveryAttributes>
var body: some View {
if activityFamily == .small {
// watchOS-optimized layout
WatchDeliveryView(context: context)
} else {
// iPhone layout
iPhoneDeliveryView(context: context)
}
}
}
Always On Display Adaptation
isLuminanceReduced
struct WatchWidgetView: View {
@Environment(\.isLuminanceReduced) var isLuminanceReduced
var body: some View {
if isLuminanceReduced {
// Simplified view for Always On Display
Text(timeString)
.font(.system(.title, design: .rounded))
} else {
// Full color, detailed view
VStack {
Text(timeString).font(.title)
Text(statusString).font(.caption)
}
}
}
}
Color Scheme Adaptation
@Environment(\.colorScheme) var colorScheme
var body: some View {
Text("Status")
.foregroundColor(
isLuminanceReduced
? .white // Always On: white text
: (colorScheme == .dark ? .white : .black)
)
}
Update Budgeting (watchOS)
Synchronization: watchOS Live Activity updates are synchronized with iPhone. When iPhone receives an update via push notification, watchOS automatically refreshes.
Connectivity: Updates may be delayed if Apple Watch is out of range or Bluetooth is disconnected.
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 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.standardin 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)
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 Timeline Providers
import XCTest
import WidgetKit
@testable import MyWidgetExtension
class TimelineProviderTests: XCTestCase {
var provider: Provider!
override func setUp() {
super.setUp()
provider = Provider()
}
func testPlaceholderReturnsValidEntry() {
let context = MockContext()
let entry = provider.placeholder(in: context)
XCTAssertNotNil(entry)
// Placeholder should have default/safe values
}
func testTimelineGenerationWithValidData() {
// Setup: Save test data to shared container
let testData = WidgetData(title: "Test", value: 100, lastUpdated: Date())
SharedDataManager.shared.saveData(testData)
let expectation = expectation(description: "Timeline generated")
let context = MockContext()
provider.getTimeline(in: context) { timeline in
XCTAssertFalse(timeline.entries.isEmpty)
XCTAssertEqual(timeline.entries.first?.widgetData?.title, "Test")
expectation.fulfill()
}
waitForExpectations(timeout: 5.0)
}
}
Manual Testing Checklist
Basic Functionality:
- Add widget to Home Screen
- Verify it shows in widget gallery
- Check all supported sizes display correctly
- Confirm data matches app data
Data Updates:
- Change data in main app
- Observe widget updates (may take seconds)
- Force-quit app, verify widget still shows data
- Reboot device, verify widget persists
Edge Cases:
- Delete all app data, verify widget handles gracefully
- Disable network, verify widget works offline
- Enable Low Power Mode, verify widget respects limits
- Add multiple instances of same widget
Performance:
- Monitor memory usage in Xcode (Debug Navigator)
- Check timeline generation time in Console logs
- Verify no crashes in crash logs
- Test on older devices (not just latest iPhone)
Debugging Tips
Widget not updating?
// Add logging to getTimeline()
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
print("⏰ Widget timeline requested at \(Date())")
let data = SharedDataManager.shared.loadData()
print("📊 Loaded data: \(String(describing: data))")
// ...
}
// In main app after data change
print("🔄 Reloading widget timelines")
WidgetCenter.shared.reloadAllTimelines()
Check Console logs:
Widget: ⏰ Widget timeline requested at 2024-01-15 10:30:00
Widget: 📊 Loaded data: Optional(WidgetData(title: "Test", value: 42))
Verify App Groups:
// In both app and widget, verify same path
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.yourcompany.yourapp"
)
print("📁 Container path: \(container?.path ?? "nil")")
// Both should print SAME path
Part 11: Troubleshooting
Widget Not Appearing in Gallery
Symptoms: Widget doesn't show up in the widget picker
Diagnostic Steps:
- Check
WidgetBundleincludes your widget - Verify
supportedFamilies()is set - Check extension target's "Skip Install" is NO
- Verify extension's deployment target matches app
Solution:
@main
struct MyWidgetBundle: WidgetBundle {
var body: some Widget {
MyWidget()
// Add your widget here if missing
}
}
Widget Not Refreshing
Symptoms: Widget shows stale data, doesn't update
Diagnostic Steps:
- Check timeline policy (
.atEndvs.after()vs.never) - Verify you're not exceeding daily budget (40-70 reloads)
- Check if
getTimeline()is being called (add logging) - Ensure App Groups configured correctly for shared data
Solution:
// 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:
- Verify App Groups entitlement in BOTH targets
- Check group identifier matches exactly
- Ensure using same suiteName in both targets
- Check file path if using shared container
Solution:
// 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":
// ❌ 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":
// 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:
- Verify App Intent's
perform()returnsIntentResult - Check intent is imported in widget target
- Ensure button uses
intent:parameter, notaction: - Check Console for intent execution errors
Solution:
// ✅ 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/Unresponsive
Symptoms: Control takes seconds to respond, appears frozen
Cause: Synchronous work in ControlValueProvider or intent perform()
Solution:
struct MyValueProvider: ControlValueProvider {
func currentValue() async throws -> MyValue {
// ✅ GOOD: Async fetch
let value = try await fetchCurrentValue()
return MyValue(data: value)
}
var previewValue: MyValue {
// ✅ GOOD: Fast fallback
MyValue(data: "Loading...")
}
}
// ❌ BAD: Don't block main thread
func currentValue() async throws -> MyValue {
Thread.sleep(forTimeInterval: 2.0) // Blocks UI
}
Widget Shows Wrong Size/Layout
Symptoms: Widget clipped or incorrect aspect ratio
Diagnostic Steps:
- Check
entry.familyin view code - Verify view adapts to family size
- Test all supported families
- Check for hardcoded sizes
Solution:
struct MyWidgetView: View {
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
var body: some View {
switch family {
case .systemSmall:
SmallLayout(entry: entry)
case .systemMedium:
MediumLayout(entry: entry)
default:
Text("Unsupported")
}
}
}
Timeline Entries Not Appearing in Order
Symptoms: Widget jumps between entries randomly
Cause: Entry dates not in chronological order
Solution:
// ✅ GOOD: Chronological dates
let now = Date()
let entries = (0..<5).map { offset in
let date = Calendar.current.date(byAdding: .hour, value: offset, to: now)!
return SimpleEntry(date: date, data: "Entry \(offset)")
}
// ❌ BAD: Out of order dates
let entries = [
SimpleEntry(date: Date().addingTimeInterval(3600), data: "2"),
SimpleEntry(date: Date(), data: "1"), // Out of order
]
watchOS Live Activity Not Showing
Symptoms: Activity appears on iPhone but not Apple Watch
Diagnostic Steps:
- Check
.supplementalActivityFamilies([.small])is set - Verify Apple Watch is paired and nearby
- Check watchOS version (11+)
- Ensure Bluetooth enabled
Solution:
ActivityConfiguration(for: MyAttributes.self) { context in
MyActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland { /* ... */ }
}
.supplementalActivityFamilies([.small]) // Required for watchOS
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:
// ✅ 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
}
Part 12: Resources
Based on: WWDC 2025-278, 2024-10157, 2024-10068, 2024-10098, 2023-10028, 2023-10194, 2022-10184, 2022-10185.
For detailed WWDC session descriptions, Apple documentation links, sample code, and HIG references, see REFERENCES.md.
Version: 0.9 | Platforms: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+