Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
- Define widget content
- Produce timeline entries
- Render each widget family
- Request relevant reloads
- Boundary 1App data
- Boundary 2Timeline provider
- Boundary 3Widget views
Connected responsibilities, not a required class hierarchy or an execution trace.
WidgetKit enables glanceable, timely content on the Home Screen, Lock Screen, and StandBy mode. Widgets use SwiftUI for their views and a timeline-based system for updates.
Widget Protocol and Configuration
import WidgetKit
import SwiftUI
// Static configuration (no user configuration needed)
struct SimpleWidget: Widget {
let kind: String = "SimpleWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SimpleProvider()) { entry in
SimpleWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Daily Summary")
.description("Shows your daily progress at a glance.")
.supportedFamilies([
.systemSmall, .systemMedium, .systemLarge,
.accessoryCircular, .accessoryRectangular, .accessoryInline
])
.contentMarginsDisabled() // iOS 17+: opt out of default content margins
}
}
// App Intent configuration (iOS 17+ user-configurable widget)
struct ConfigurableWidget: Widget {
let kind: String = "ConfigurableWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectCategoryIntent.self,
provider: ConfigurableProvider()
) { entry in
ConfigurableWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Category Widget")
.description("Shows items from a selected category.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
TimelineProvider (Placeholder, Snapshot, Timeline)
struct SimpleEntry: TimelineEntry {
let date: Date
let title: String
let value: Int
let icon: String
}
struct SimpleProvider: TimelineProvider {
// Shown while widget is loading. Must return synchronously.
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
}
// Shown in the widget gallery and transient situations.
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
if context.isPreview {
// Return sample data for the gallery preview
completion(SimpleEntry(date: .now, title: "Steps Today", value: 8432, icon: "figure.walk"))
} else {
// Fetch real data for transient display
let entry = SimpleEntry(date: .now, title: "Steps Today", value: fetchStepCount(), icon: "figure.walk")
completion(entry)
}
}
// Provides the timeline of entries that drive the widget's display.
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
var entries: [SimpleEntry] = []
let currentDate = Date()
// Create entries for the next 5 hours
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(
date: entryDate,
title: "Steps Today",
value: fetchStepCount() + (hourOffset * 500),
icon: "figure.walk"
)
entries.append(entry)
}
// Timeline reload policies:
// .atEnd - reload after the last entry's date passes
// .after(d) - reload after a specific date
// .never - only reload when the app explicitly requests it
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
private func fetchStepCount() -> Int { return 8432 }
}
// Async provider using AppIntentTimelineProvider (cleaner async/await API)
struct ConfigurableProvider: AppIntentTimelineProvider {
typealias Entry = SimpleEntry
typealias Intent = SelectCategoryIntent
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
}
func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: .now, title: configuration.category?.name ?? "All", value: 42, icon: "star")
}
func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<SimpleEntry> {
let entries = [
SimpleEntry(
date: .now,
title: configuration.category?.name ?? "All",
value: 42,
icon: "star"
)
]
return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(3600)))
}
}
TimelineEntry Design
// Rich timeline entry with multiple data points
struct DashboardEntry: TimelineEntry {
let date: Date
let tasks: [TaskItem]
let completedCount: Int
let totalCount: Int
let streakDays: Int
var completionPercentage: Double {
guard totalCount > 0 else { return 0 }
return Double(completedCount) / Double(totalCount)
}
static var preview: DashboardEntry {
DashboardEntry(
date: .now,
tasks: [
TaskItem(name: "Morning workout", isComplete: true),
TaskItem(name: "Read 30 minutes", isComplete: false),
TaskItem(name: "Meditate", isComplete: true)
],
completedCount: 5,
totalCount: 8,
streakDays: 12
)
}
}
struct TaskItem: Identifiable {
let id = UUID()
let name: String
let isComplete: Bool
}
Widget Families (systemSmall, Medium, Large, ExtraLarge)
struct SimpleWidgetView: View {
var entry: SimpleEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
smallView
case .systemMedium:
mediumView
case .systemLarge:
largeView
case .systemExtraLarge:
extraLargeView // iPad only
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
case .accessoryInline:
inlineView
@unknown default:
smallView
}
}
// Home Screen small (~169x169 pt)
var smallView: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: entry.icon)
.font(.title2)
.foregroundStyle(.blue)
Spacer()
Text(entry.title)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(entry.value)")
.font(.title.bold())
.contentTransition(.numericText())
}
.padding()
.widgetURL(URL(string: "myapp://steps"))
}
// Home Screen medium (~360x169 pt)
var mediumView: some View {
HStack {
smallView
Spacer()
VStack(alignment: .trailing) {
Text("Goal: 10,000")
.font(.caption)
ProgressView(value: Double(entry.value), total: 10000)
.tint(.blue)
Text("\(10000 - entry.value) remaining")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding()
}
}
// Home Screen large (~360x376 pt)
var largeView: some View {
VStack(alignment: .leading, spacing: 12) {
mediumView
Divider()
Text("Hourly Breakdown")
.font(.headline)
ForEach(0..<4) { hour in
HStack {
Text("\(hour + 9):00")
.font(.caption.monospacedDigit())
ProgressView(value: Double.random(in: 0.2...1.0))
.tint(.blue)
}
}
Spacer()
}
.padding()
}
var extraLargeView: some View {
largeView // Customize further for iPad extra large
}
}
Lock Screen Widgets (Accessory Families)
extension SimpleWidgetView {
// Lock Screen circular gauge
var circularView: some View {
Gauge(value: Double(entry.value), in: 0...10000) {
Image(systemName: entry.icon)
} currentValueLabel: {
Text("\(entry.value / 1000)k")
.font(.caption2)
}
.gaugeStyle(.accessoryCircular)
}
// Lock Screen rectangular
var rectangularView: some View {
VStack(alignment: .leading) {
Label("\(entry.value)", systemImage: entry.icon)
.font(.headline)
Text(entry.title)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: Double(entry.value), total: 10000)
}
}
// Lock Screen inline (single line of text beside clock)
var inlineView: some View {
Label("\(entry.value) \(entry.title)", systemImage: entry.icon)
}
}
// Rendering mode for lock screen
// Lock Screen widgets are rendered in one of three modes:
// - .vibrant: tinted semi-transparent material (iOS Lock Screen)
// - .accented: tinted with user's chosen accent color (watchOS)
// - .fullColor: standard colors (Home Screen)
// Use @Environment(\.widgetRenderingMode) to adapt
WidgetBundle for Multiple Widgets
@main
struct MyWidgets: WidgetBundle {
var body: some Widget {
SimpleWidget()
ConfigurableWidget()
DashboardWidget()
if #available(iOS 18, *) {
ControlWidget()
}
}
}
Interactive Widgets (iOS 17+ Button/Toggle)
iOS 17 introduced interactive widgets with Button and Toggle that perform AppIntents directly from the widget.
import AppIntents
// App Intent for toggling a task
struct ToggleTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Task"
@Parameter(title: "Task ID")
var taskID: String
init() {}
init(taskID: String) {
self.taskID = taskID
}
func perform() async throws -> some IntentResult {
let store = TaskStore.shared
store.toggleTask(id: taskID)
// Reload the widget timeline to reflect the change
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
return .result()
}
}
struct IncrementCountIntent: AppIntent {
static var title: LocalizedStringResource = "Increment Counter"
func perform() async throws -> some IntentResult {
CounterStore.shared.increment()
WidgetCenter.shared.reloadTimelines(ofKind: "CounterWidget")
return .result()
}
}
// Widget view with interactive elements
struct InteractiveTaskWidget: View {
let entry: DashboardEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Tasks")
.font(.headline)
ForEach(entry.tasks) { task in
HStack {
// Interactive toggle button
Button(intent: ToggleTaskIntent(taskID: task.id.uuidString)) {
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isComplete ? .green : .secondary)
}
.buttonStyle(.plain)
Text(task.name)
.strikethrough(task.isComplete)
.font(.subheadline)
}
}
Spacer()
// Interactive toggle
Toggle(isOn: entry.completedCount > 0, intent: IncrementCountIntent()) {
Text("Focus Mode")
}
.toggleStyle(.switch)
}
.padding()
}
}
Live Activities and ActivityKit
import ActivityKit
// Define the attributes for the Live Activity
struct DeliveryAttributes: ActivityAttributes {
// Dynamic content that changes over time
struct ContentState: Codable, Hashable {
var status: String
var estimatedArrival: Date
var driverName: String
var currentStep: Int // 0: preparing, 1: picked up, 2: nearby, 3: delivered
}
// Static content set at creation
let orderNumber: String
let restaurantName: String
}
class LiveActivityManager {
var currentActivity: Activity<DeliveryAttributes>?
func startDeliveryTracking(orderNumber: String, restaurant: String) throws {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
print("Live Activities not enabled")
return
}
let attributes = DeliveryAttributes(
orderNumber: orderNumber,
restaurantName: restaurant
)
let initialState = DeliveryAttributes.ContentState(
status: "Preparing your order",
estimatedArrival: .now.addingTimeInterval(1800),
driverName: "Alex",
currentStep: 0
)
let content = ActivityContent(state: initialState, staleDate: nil)
currentActivity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token // .token for server push updates, nil for local-only
)
// Observe push token for server-driven updates
if let activity = currentActivity {
Task {
for await token in activity.pushTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
print("Live Activity push token: \(tokenString)")
// Send this token to your server
}
}
}
}
// Update the Live Activity locally
func updateDelivery(status: String, step: Int, eta: Date) async {
let updatedState = DeliveryAttributes.ContentState(
status: status,
estimatedArrival: eta,
driverName: "Alex",
currentStep: step
)
let content = ActivityContent(state: updatedState, staleDate: nil)
await currentActivity?.update(content)
}
// End the Live Activity
func endDelivery() async {
let finalState = DeliveryAttributes.ContentState(
status: "Delivered!",
estimatedArrival: .now,
driverName: "Alex",
currentStep: 3
)
let content = ActivityContent(state: finalState, staleDate: nil)
// Dismissal policies:
// .default - user can dismiss manually
// .immediate - disappears right away
// .after(date) - auto-dismiss after the specified date
await currentActivity?.end(content, dismissalPolicy: .after(.now.addingTimeInterval(300)))
}
}
// Live Activity UI (defined in your Widget extension target)
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen and banner presentation
VStack(spacing: 12) {
HStack {
Text(context.attributes.restaurantName)
.font(.headline)
Spacer()
Text("Order #\(context.attributes.orderNumber)")
.font(.caption)
.foregroundStyle(.secondary)
}
ProgressView(value: Double(context.state.currentStep), total: 3)
.tint(.green)
HStack {
Label(context.state.status, systemImage: "bicycle")
.font(.subheadline)
Spacer()
Text(context.state.estimatedArrival, style: .timer)
.font(.subheadline.monospacedDigit())
}
}
.padding()
.activityBackgroundTint(.black.opacity(0.8))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "bicycle")
.font(.title2)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.estimatedArrival, style: .timer)
.font(.caption.monospacedDigit())
}
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.restaurantName)
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 8) {
Text(context.state.status)
.font(.subheadline)
ProgressView(value: Double(context.state.currentStep), total: 3)
.tint(.green)
}
}
} compactLeading: {
Image(systemName: "bicycle")
} compactTrailing: {
Text(context.state.estimatedArrival, style: .timer)
.font(.caption.monospacedDigit())
} minimal: {
Image(systemName: "bicycle")
}
}
}
}
App Intent Configuration
import AppIntents
// Define a configurable entity for widget parameters
struct CategoryEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Category")
static var defaultQuery = CategoryQuery()
var id: String
var name: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
struct CategoryQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [CategoryEntity] {
allCategories().filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [CategoryEntity] {
allCategories()
}
func defaultResult() async -> CategoryEntity? {
allCategories().first
}
private func allCategories() -> [CategoryEntity] {
[
CategoryEntity(id: "work", name: "Work"),
CategoryEntity(id: "personal", name: "Personal"),
CategoryEntity(id: "health", name: "Health")
]
}
}
// Widget configuration intent
struct SelectCategoryIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Select Category"
static var description = IntentDescription("Choose which category to display in the widget.")
@Parameter(title: "Category")
var category: CategoryEntity?
@Parameter(title: "Show Completed", default: true)
var showCompleted: Bool
}
Reloading Widgets from the Main App
import WidgetKit
class WidgetReloadManager {
// Reload a specific widget by kind
func reloadTaskWidget() {
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
}
// Reload all widgets belonging to this app
func reloadAllWidgets() {
WidgetCenter.shared.reloadAllTimelines()
}
// Get current widget configurations on the user's device
func getWidgetInfo() {
WidgetCenter.shared.getCurrentConfigurations { result in
switch result {
case .success(let widgets):
for widget in widgets {
print("Kind: \(widget.kind), Family: \(widget.family)")
}
case .failure(let error):
print("Error fetching configurations: \(error)")
}
}
}
}
// Sharing data between the app and widget extension using App Groups.
// 1. Enable App Groups capability in both the app target and widget extension target
// 2. Use the shared container:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
sharedDefaults?.set(42, forKey: "stepCount")
let sharedContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared"
)
// Write/read files in sharedContainer for larger data sets
iOS 18+ Additions
Controls API (ControlWidget)
iOS 18 introduces ControlWidget for adding interactive controls to Control Center and the Lock Screen. Controls are small, actionable widgets that can toggle states or trigger actions.
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Toggle Control (e.g., a light switch)
struct LightToggleControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "com.app.light-toggle") {
ControlWidgetToggle(
"Living Room",
isOn: LightManager.shared.isLivingRoomOn,
action: ToggleLightIntent(room: "living-room")
) { isOn in
// The view displayed in the control
Label(isOn ? "On" : "Off", systemImage: isOn ? "lightbulb.fill" : "lightbulb")
}
.tint(.yellow)
}
.displayName("Light Toggle")
.description("Toggle a light on or off.")
}
}
// App Intent that the toggle invokes
struct ToggleLightIntent: SetValueIntent {
static var title: LocalizedStringResource = "Toggle Light"
@Parameter(title: "Room")
var room: String
@Parameter(title: "Is On")
var value: Bool
init() {
self.room = "living-room"
self.value = false
}
init(room: String) {
self.room = room
self.value = false
}
func perform() async throws -> some IntentResult {
LightManager.shared.setLight(room: room, isOn: value)
return .result()
}
}
// MARK: - Button Control (e.g., trigger an action)
struct CaffeineLogControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "com.app.caffeine-log") {
ControlWidgetButton(action: LogCaffeineIntent()) {
Label("Log Coffee", systemImage: "cup.and.saucer.fill")
}
}
.displayName("Log Caffeine")
.description("Quickly log a coffee.")
}
}
struct LogCaffeineIntent: AppIntent {
static var title: LocalizedStringResource = "Log Caffeine"
func perform() async throws -> some IntentResult {
CaffeineStore.shared.logCoffee()
return .result()
}
}
// MARK: - Configurable Control with AppIntentControlConfiguration
struct ConfigurableLightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: "com.app.configurable-light",
intent: SelectRoomIntent.self
) { configuration in
ControlWidgetToggle(
configuration.room?.name ?? "Light",
isOn: LightManager.shared.isOn(room: configuration.room?.id ?? ""),
action: ToggleLightIntent(room: configuration.room?.id ?? "")
) { isOn in
Label(isOn ? "On" : "Off", systemImage: isOn ? "lightbulb.fill" : "lightbulb")
}
.tint(.orange)
}
.displayName("Room Light")
.description("Toggle a specific room's light.")
}
}
struct SelectRoomIntent: ControlConfigurationIntent {
static var title: LocalizedStringResource = "Select Room"
@Parameter(title: "Room")
var room: RoomEntity?
}
// MARK: - Registering Controls in WidgetBundle
@main
struct MyWidgets: WidgetBundle {
var body: some Widget {
SimpleWidget()
DashboardWidget()
// iOS 18+ Control Center widgets
if #available(iOS 18, *) {
LightToggleControl()
CaffeineLogControl()
ConfigurableLightControl()
}
}
}
Control Center Placement and Sizing
Controls appear in the redesigned iOS 18 Control Center. Users add them from the Control Center gallery, just like Home Screen widgets.
Key characteristics:
- Small footprint: Controls occupy a single Control Center cell (similar to the existing toggles like Wi-Fi, Bluetooth)
- Two types: ControlWidgetToggle for on/off state, ControlWidgetButton for one-shot actions
- Lock Screen access: Controls can also be placed on the Lock Screen as quick-action buttons (replacing the default flashlight/camera shortcuts)
- Tinting: Use .tint() to set the active color of a toggle control
- Static vs Configurable: Use StaticControlConfiguration for fixed controls, AppIntentControlConfiguration for user-configurable controls with parameters
- Value providers: For toggles, provide a value source that returns the current state; SwiftData or App Groups work well for shared state
Live Activity Intents (iOS 18)
iOS 18 enables Live Activities to include App Intent-driven buttons and toggles directly on the Lock Screen and Dynamic Island.
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
// Live Activity with interactive intent-driven buttons
struct OrderTrackingLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OrderAttributes.self) { context in
// Lock Screen / banner view with interactive buttons
VStack(spacing: 12) {
HStack {
Text(context.attributes.storeName)
.font(.headline)
Spacer()
Text(context.state.status)
.font(.subheadline)
.foregroundStyle(.secondary)
}
ProgressView(value: context.state.progress)
.tint(.blue)
HStack(spacing: 16) {
// Intent-driven button on the Live Activity
Button(intent: ContactDriverIntent(orderID: context.attributes.orderID)) {
Label("Contact Driver", systemImage: "phone.fill")
.font(.caption)
}
.buttonStyle(.bordered)
.tint(.green)
Button(intent: CancelOrderIntent(orderID: context.attributes.orderID)) {
Label("Cancel", systemImage: "xmark.circle")
.font(.caption)
}
.buttonStyle(.bordered)
.tint(.red)
}
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.bottom) {
Button(intent: ContactDriverIntent(orderID: context.attributes.orderID)) {
Label("Contact Driver", systemImage: "phone.fill")
}
.buttonStyle(.bordered)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.status)
}
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "bag.fill")
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.eta, style: .timer)
}
} compactLeading: {
Image(systemName: "bag.fill")
} compactTrailing: {
Text(context.state.eta, style: .timer)
.font(.caption2)
} minimal: {
Image(systemName: "bag.fill")
}
}
}
}
struct ContactDriverIntent: AppIntent {
static var title: LocalizedStringResource = "Contact Driver"
@Parameter(title: "Order ID")
var orderID: String
init() { self.orderID = "" }
init(orderID: String) { self.orderID = orderID }
func perform() async throws -> some IntentResult {
// Open the call screen or in-app chat for the driver
return .result()
}
}
struct OrderAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var status: String
var progress: Double
var eta: Date
}
let orderID: String
let storeName: String
}