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+
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill provides comprehensive API reference for Apple's widget and extension ecosystem:
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.
✅ Use this skill when:
❌ Do NOT use this skill for:
❌ Real-time data that changes constantly (every few seconds)
❌ Complex interactions or multi-step workflows
❌ Large amounts of data or long lists
❌ Personalized data requiring authentication
❌ Heavy computation or network requests
| Need | Use This | Not This |
|---|---|---|
| Home Screen glanceable info | Standard Widget | Live Activity |
| Ongoing event (delivery, workout) | Live Activity | Standard Widget |
| Quick system-wide action | Control Center Widget | Standard Widget |
| Real-time score updates | Live Activity with push | Widget with frequent refresh |
| User customization | AppIntentConfiguration | Multiple static widgets |
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.
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])
}
}
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+.
For Live Activities (covered in Live Activities section).
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 |
| 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 |
| 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 |
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
])
}
}
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)
}
}
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) |
import WidgetKit
// Reload all widgets of this kind
WidgetCenter.shared.reloadAllTimelines()
// Reload specific kind
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
Daily budget: 40-70 timeline reloads per day (varies by system load and user engagement)
These do NOT count against your budget:
// ✅ 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)
}
Widget extensions have strict memory limits:
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
// ...
}
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))
}
}
}
Target: Complete getTimeline() in under 5 seconds
Strategies:
// ✅ 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
}
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:
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.
// ✅ 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
Interactive widgets use SwiftUI Button and Toggle with App Intents.
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()
}
}
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()
}
}
}
}
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.
Text("\(entry.value)")
.contentTransition(.numericText(value: Double(entry.value)))
Effect: Numbers smoothly count up or down instead of instantly changing.
VStack {
if entry.showDetail {
DetailView()
.transition(.scale.combined(with: .opacity))
}
}
.animation(.spring(response: 0.3), value: entry.showDetail)
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)")
}
}
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()
}
}
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)
}
}
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.
import ActivityKit
let authorizationInfo = ActivityAuthorizationInfo()
let areActivitiesEnabled = authorizationInfo.areActivitiesEnabled
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
)
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)")
}
}
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
}
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()
}
}
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
)
)
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
))
// 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))
let finalState = PizzaDeliveryAttributes.ContentState(
status: .delivered,
estimatedDeliveryTime: Date(),
driverName: "John"
)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .default
)
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)
}
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.)
Live Activities appear in the Dynamic Island with three size classes:
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)
}
Shown when more than two Live Activities are active (circular avatar).
DynamicIsland {
// ...
} minimal: {
Image(systemName: "timer")
.foregroundStyle(.tint)
}
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")
}
}
}
}
"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)
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)
Controls appear in Control Center, Lock Screen, and Action Button (iPhone 15 Pro+).
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")
}
}
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")
}
}
}
}
For discrete actions (one-shot operations).
ControlWidgetButton(action: PlayMusicIntent()) {
Label("Play", systemImage: "play.fill")
}
.tint(.purple)
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")
}
}
}
}
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
}
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
}
}
Accessibility hint for VoiceOver.
ControlWidgetButton(action: ToggleTorchIntent()) {
Label("Flashlight", systemImage: "flashlight.on.fill")
}
.controlWidgetActionHint("Toggles flashlight")
StaticControlConfiguration(kind: "MyControl") {
// ...
}
.displayName("My Control")
.description("Brief description shown in Control Center")
Widgets can render with accented glass effects matching system aesthetics (iOS 18+).
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
MyWidgetView(entry: entry)
.widgetAccentedRenderingMode(.accented)
}
}
}
| 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).
Widgets supported on visionOS 2+ with spatial presentation.
#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
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.
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.
Control Center widgets available on watchOS 11+ in:
struct WatchControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "WatchControl") {
ControlWidgetButton(action: StartWorkoutIntent()) {
Label("Workout", systemImage: "figure.run")
}
}
}
}
System intelligently promotes relevant widgets to Smart Stack on watchOS.
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)
]
)
}
}
enum WidgetRelevanceAttribute {
case location(CLLocation)
case timeOfDay(DateInterval)
case activity(String) // Calendar event, workout, etc.
}
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.
Required for sharing data between your app and extensions.
group.com.company.appname<key>com.apple.security.application-groups</key>
<array>
<string>group.com.mycompany.myapp</string>
</array>
let sharedContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!
let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json")
// 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")
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
}()
// Main app
let config = URLSessionConfiguration.background(withIdentifier: "com.mycompany.myapp.background")
config.sharedContainerIdentifier = "group.com.mycompany.myapp"
let session = URLSession(configuration: config)
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
)
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
}
}
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)
}
}
}
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)
}
}
}
}
@Environment(\.colorScheme) var colorScheme
var body: some View {
Text("Status")
.foregroundColor(
isLuminanceReduced
? .white // Always On: white text
: (colorScheme == .dark ? .white : .black)
)
}
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.
Required for data sharing between app and widget
In main app target:
group.com.yourcompany.yourappIn widget extension target:
In shared code (both targets):
import Foundation
struct WidgetData: Codable {
let title: String
let value: Int
let lastUpdated: Date
}
// Shared data manager
class SharedDataManager {
static let shared = SharedDataManager()
private let userDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")!
func saveData(_ data: WidgetData) {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(data) {
userDefaults.set(encoded, forKey: "widgetData")
}
}
func loadData() -> WidgetData? {
guard let data = userDefaults.data(forKey: "widgetData") else { return nil }
let decoder = JSONDecoder()
return try? decoder.decode(WidgetData.self, from: data)
}
}
In widget extension:
import WidgetKit
struct SimpleEntry: TimelineEntry {
let date: Date
let widgetData: WidgetData?
}
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
// Shown while widget loads
SimpleEntry(date: Date(), widgetData: nil)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
// Shown in widget gallery
let data = WidgetData(title: "Preview", value: 42, lastUpdated: Date())
let entry = SimpleEntry(date: Date(), widgetData: data)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// Load data from shared container
let data = SharedDataManager.shared.loadData()
let currentDate = Date()
// Create entry for now
let entry = SimpleEntry(date: currentDate, widgetData: data)
// Reload in 15 minutes
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
import SwiftUI
struct MyWidgetEntryView: View {
var entry: Provider.Entry
var body: some View {
if let data = entry.widgetData {
VStack(alignment: .leading, spacing: 8) {
Text(data.title)
.font(.headline)
Text("\(data.value)")
.font(.system(size: 36, weight: .bold))
Text("Updated: \(data.lastUpdated, style: .time)")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
} else {
Text("No data")
.foregroundColor(.secondary)
}
}
}
@main
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MyWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("My Widget")
.description("Displays your latest data")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
In your main app:
import WidgetKit
// When your data changes
func updateData(newValue: Int) {
let data = WidgetData(
title: "Current Value",
value: newValue,
lastUpdated: Date()
)
// Save to shared container
SharedDataManager.shared.saveData(data)
// Tell widgets to reload
WidgetCenter.shared.reloadAllTimelines()
}
Before shipping:
Architecture:
UserDefaults.standard in widget codePerformance:
Data & State:
User Experience:
Live Activities (if applicable):
Control Center Widgets (if applicable):
Testing:
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)
}
}
Basic Functionality:
Data Updates:
Edge Cases:
Performance:
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
Symptoms: Widget doesn't show up in the widget picker
Diagnostic Steps:
WidgetBundle includes your widgetsupportedFamilies() is setSolution:
@main
struct MyWidgetBundle: WidgetBundle {
var body: some Widget {
MyWidget()
// Add your widget here if missing
}
}
Symptoms: Widget shows stale data, doesn't update
Diagnostic Steps:
.atEnd vs .after() vs .never)getTimeline() is being called (add logging)Solution:
// Manual reload from main app when data changes
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()
// or
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
Symptoms: Widget shows default/empty data
Diagnostic Steps:
Solution:
// Both app AND extension must use:
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
// NOT:
let shared = UserDefaults.standard // ❌ Different containers
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
}
Symptoms: Tapping button does nothing
Diagnostic Steps:
perform() returns IntentResultintent: parameter, not action: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")
}
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
}
Symptoms: Widget clipped or incorrect aspect ratio
Diagnostic Steps:
entry.family in view codeSolution:
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")
}
}
}
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
]
Symptoms: Activity appears on iPhone but not Apple Watch
Diagnostic Steps:
.supplementalActivityFamilies([.small]) is setSolution:
ActivityConfiguration(for: MyAttributes.self) { context in
MyActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland { /* ... */ }
}
.supplementalActivityFamilies([.small]) // Required for watchOS
Symptoms: Widget rendering slow, battery drain
Common Causes:
getTimeline()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
}
Version: 0.9 | Platforms: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+