WidgetKit and Live Activities
Comprehensive guide to WidgetKit for Home Screen widgets, Live Activities, Dynamic Island, and Control Center integration in iOS 26.
Prerequisites
-
iOS 17+ for interactive widgets (iOS 26 recommended)
-
Xcode 26+
-
Widget Extension target
Widget Extension Setup
Creating Widget Target
-
File → New → Target
-
Select "Widget Extension"
-
Name your widget
-
Enable "Include Live Activity" if needed
-
Enable "Include Configuration App Intent" for configurable widgets
Basic Widget Structure
import WidgetKit import SwiftUI
struct SimpleWidget: Widget { let kind: String = "SimpleWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
SimpleWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("My Widget")
.description("Shows important information")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
Timeline Provider
struct Provider: TimelineProvider { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), message: "Loading...") }
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
let entry = SimpleEntry(date: Date(), message: "Snapshot")
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
var entries: [SimpleEntry] = []
let currentDate = Date()
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, message: "Hour \(hourOffset)")
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
struct SimpleEntry: TimelineEntry { let date: Date let message: String }
Widget View
struct SimpleWidgetEntryView: View { var entry: Provider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .systemLarge:
LargeWidgetView(entry: entry)
default:
Text(entry.message)
}
}
}
struct SmallWidgetView: View { let entry: SimpleEntry
var body: some View {
VStack {
Text(entry.message)
.font(.headline)
Text(entry.date, style: .time)
.font(.caption)
}
}
}
iOS 26 Glass Presentation
Accented Rendering
Widgets on iOS 26 use the new glass presentation system:
struct GlassWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "GlassWidget", provider: Provider()) { entry in GlassWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .supportedFamilies([.systemSmall, .systemMedium]) } }
struct GlassWidgetView: View { let entry: SimpleEntry
var body: some View {
VStack {
Image(systemName: "star.fill")
.font(.largeTitle)
// Accented rendering for glass background
.widgetAccentedRenderingMode(.accentedDesaturated)
Text(entry.message)
.font(.headline)
}
}
}
Widget Accented Rendering Modes
// For images in widgets Image("CustomIcon") .widgetAccentedRenderingMode(.desaturated) // Blend with home screen .widgetAccentedRenderingMode(.accentedDesaturated) // Blend with accent .widgetAccentedRenderingMode(.fullColor) // Full color (media only)
Interactive Widgets
Button Actions
import AppIntents
struct InteractiveWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "Interactive", provider: Provider()) { entry in InteractiveWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .supportedFamilies([.systemSmall, .systemMedium]) } }
struct InteractiveWidgetView: View { let entry: TaskEntry
var body: some View {
VStack(alignment: .leading) {
Text(entry.task.title)
.font(.headline)
Button(intent: ToggleTaskIntent(taskId: entry.task.id)) {
Label(
entry.task.isComplete ? "Completed" : "Mark Done",
systemImage: entry.task.isComplete ? "checkmark.circle.fill" : "circle"
)
}
.buttonStyle(.bordered)
}
}
}
struct ToggleTaskIntent: AppIntent { static var title: LocalizedStringResource = "Toggle Task"
@Parameter(title: "Task ID")
var taskId: String
func perform() async throws -> some IntentResult {
await TaskManager.shared.toggle(taskId)
return .result()
}
}
Toggle Actions
struct ToggleWidgetView: View { let entry: SettingEntry
var body: some View {
Toggle(isOn: entry.isEnabled, intent: ToggleSettingIntent(settingId: entry.id)) {
Label("Enable Feature", systemImage: "gear")
}
.toggleStyle(.button)
}
}
Configurable Widgets
App Intent Configuration
import AppIntents
struct ConfigurableWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( kind: "ConfigurableWidget", intent: ConfigureWidgetIntent.self, provider: ConfigurableProvider() ) { entry in ConfigurableWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Custom Widget") .description("Configure which data to show") .supportedFamilies([.systemSmall, .systemMedium]) } }
struct ConfigureWidgetIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Configure Widget" static var description = IntentDescription("Choose what to display")
@Parameter(title: "Category")
var category: WidgetCategory?
@Parameter(title: "Show Count")
var showCount: Bool
}
enum WidgetCategory: String, AppEnum { case recent, favorites, all
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Category")
static var caseDisplayRepresentations: [WidgetCategory: DisplayRepresentation] = [
.recent: "Recent",
.favorites: "Favorites",
.all: "All"
]
}
Configurable Provider
struct ConfigurableProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> ConfigurableEntry { ConfigurableEntry(date: Date(), configuration: ConfigureWidgetIntent()) }
func snapshot(for configuration: ConfigureWidgetIntent, in context: Context) async -> ConfigurableEntry {
ConfigurableEntry(date: Date(), configuration: configuration)
}
func timeline(for configuration: ConfigureWidgetIntent, in context: Context) async -> Timeline<ConfigurableEntry> {
let entry = ConfigurableEntry(date: Date(), configuration: configuration)
return Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(3600)))
}
}
struct ConfigurableEntry: TimelineEntry { let date: Date let configuration: ConfigureWidgetIntent }
Live Activities
Activity Attributes
import ActivityKit
struct DeliveryAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { var status: DeliveryStatus var estimatedArrival: Date var driverName: String }
var orderNumber: String
var restaurantName: String
}
enum DeliveryStatus: String, Codable { case preparing case onTheWay case arriving case delivered }
Live Activity View
struct DeliveryActivityView: View { let context: ActivityViewContext<DeliveryAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(context.attributes.restaurantName)
.font(.headline)
Spacer()
Text(context.state.status.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
ProgressView(value: context.state.status.progress)
HStack {
Label(context.state.driverName, systemImage: "person.circle")
Spacer()
Text(context.state.estimatedArrival, style: .timer)
}
.font(.caption)
}
.padding()
}
}
Dynamic Island
struct DeliveryLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in // Lock screen presentation DeliveryActivityView(context: context) } dynamicIsland: { context in DynamicIsland { // Expanded regions DynamicIslandExpandedRegion(.leading) { Image(systemName: "bag.fill") } DynamicIslandExpandedRegion(.trailing) { Text(context.state.estimatedArrival, style: .timer) } DynamicIslandExpandedRegion(.center) { Text(context.attributes.restaurantName) .font(.headline) } DynamicIslandExpandedRegion(.bottom) { ProgressView(value: context.state.status.progress) } } compactLeading: { Image(systemName: "bag.fill") } compactTrailing: { Text(context.state.estimatedArrival, style: .timer) } minimal: { Image(systemName: "bag.fill") } } } }
Starting a Live Activity
func startDeliveryActivity(order: Order) async throws { guard ActivityAuthorizationInfo().areActivitiesEnabled else { throw ActivityError.notAuthorized }
let attributes = DeliveryAttributes(
orderNumber: order.id,
restaurantName: order.restaurant
)
let initialState = DeliveryAttributes.ContentState(
status: .preparing,
estimatedArrival: order.estimatedArrival,
driverName: "Assigning..."
)
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil),
pushType: .token // Enable push updates
)
// Store activity ID for later updates
UserDefaults.standard.set(activity.id, forKey: "currentDeliveryActivity")
// Get push token for server updates
for await token in activity.pushTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
await sendTokenToServer(tokenString)
}
}
Updating Live Activity
func updateDeliveryStatus(to status: DeliveryStatus, driver: String? = nil) async { guard let activityId = UserDefaults.standard.string(forKey: "currentDeliveryActivity"), let activity = Activity<DeliveryAttributes>.activities.first(where: { $0.id == activityId }) else { return }
var newState = activity.content.state
newState.status = status
if let driver {
newState.driverName = driver
}
await activity.update(
ActivityContent(state: newState, staleDate: nil)
)
}
Ending Live Activity
func endDeliveryActivity() async { guard let activityId = UserDefaults.standard.string(forKey: "currentDeliveryActivity"), let activity = Activity<DeliveryAttributes>.activities.first(where: { $0.id == activityId }) else { return }
let finalState = DeliveryAttributes.ContentState(
status: .delivered,
estimatedArrival: Date(),
driverName: activity.content.state.driverName
)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .after(.now + 3600) // Dismiss after 1 hour
)
UserDefaults.standard.removeObject(forKey: "currentDeliveryActivity")
}
iOS 26 Live Activity Updates
CarPlay Support
Live Activities now appear on CarPlay in iOS 26:
struct CarPlayDeliveryActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in // Lock screen DeliveryActivityView(context: context) } dynamicIsland: { context in // Dynamic Island config... } .supplementalActivityFamilies([.small]) // CarPlay support } }
macOS Support
Live Activities now work on macOS Tahoe:
#if os(macOS) struct MacDeliveryActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in MacDeliveryView(context: context) } } } #endif
Control Center Widgets
Control Widget
import WidgetKit import AppIntents
struct QuickToggleControl: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration(kind: "QuickToggle") { ControlWidgetToggle( "Dark Mode", isOn: DarkModeBinding(), action: ToggleDarkModeIntent() ) { isOn in Label(isOn ? "On" : "Off", systemImage: isOn ? "moon.fill" : "sun.max") } } .displayName("Dark Mode") .description("Toggle dark mode") } }
struct DarkModeBinding: ControlValueProvider { var previewValue: Bool { false }
func currentValue() async throws -> Bool {
await SettingsManager.shared.isDarkMode
}
}
struct ToggleDarkModeIntent: SetValueIntent { static var title: LocalizedStringResource = "Toggle Dark Mode"
@Parameter(title: "Dark Mode")
var value: Bool
func perform() async throws -> some IntentResult {
await SettingsManager.shared.setDarkMode(value)
return .result()
}
}
Control Widget Button
struct QuickActionControl: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration(kind: "QuickAction") { ControlWidgetButton(action: QuickNoteIntent()) { Label("Quick Note", systemImage: "note.text.badge.plus") } } .displayName("Quick Note") .description("Create a quick note") } }
Widget Relevance (watchOS 26)
Relevance Configuration
struct RelevantWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "Relevant", provider: RelevantProvider()) { entry in RelevantWidgetView(entry: entry) } .supportedFamilies([.accessoryRectangular]) } }
struct RelevantProvider: TimelineProvider { func relevances() async -> WidgetRelevances<Void> { // Define when widget is most relevant return WidgetRelevances( // Show during workout RelevantContext.workout: .defaultLarge, // Show in morning RelevantContext.date(from: morning, to: noon): .defaultMedium ) }
// Other provider methods...
}
Widget Data Sharing
App Groups
// In both app and widget extension let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared") sharedDefaults?.set(data, forKey: "widgetData")
// In widget let data = UserDefaults(suiteName: "group.com.yourapp.shared")?.data(forKey: "widgetData")
Shared Container
let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared" )
Triggering Widget Refresh
import WidgetKit
// Refresh specific widget WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
// Refresh all widgets WidgetCenter.shared.reloadAllTimelines()
Best Practices
- Efficient Timeline Updates
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) { // Generate only necessary entries let entries = generateRelevantEntries()
// Use appropriate refresh policy
let policy: TimelineReloadPolicy
if hasUpcomingEvents {
policy = .after(nextEventDate)
} else {
policy = .atEnd
}
completion(Timeline(entries: entries, policy: policy))
}
- Handle Widget Families
struct AdaptiveWidgetView: View { @Environment(.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
CompactView()
case .systemMedium:
MediumView()
case .systemLarge, .systemExtraLarge:
DetailedView()
case .accessoryCircular:
CircularView()
case .accessoryRectangular:
RectangularView()
default:
DefaultView()
}
}
}
- Test with Widget Development Mode
// In scheme arguments -widgetKitDevMode YES
- Memory Efficiency
// Load images efficiently Image("icon") .resizable() .aspectRatio(contentMode: .fit)
// Avoid heavy computations in view body // Pre-calculate in provider
Official Resources
-
WidgetKit Documentation
-
ActivityKit Documentation
-
WWDC23: Bring widgets to new places
-
WWDC25: What's new in widgets
-
Adding interactivity to widgets