How to Build a Widget in SwiftUI
Add a Widget Extension target, implement AppIntentTimelineProvider to supply
TimelineEntry values, then declare your widget using
AppIntentConfiguration and a SwiftUI view. iOS schedules reloads automatically
via WidgetCenter.
import WidgetKit
import SwiftUI
struct SimpleEntry: TimelineEntry {
let date: Date
let message: String
}
struct SimpleProvider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, message: "Hello")
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
completion(SimpleEntry(date: .now, message: "Hello"))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
let entry = SimpleEntry(date: .now, message: "Live data")
completion(Timeline(entries: [entry], policy: .atEnd))
}
}
struct SimpleWidgetView: View {
var entry: SimpleEntry
var body: some View {
Text(entry.message).font(.headline).containerBackground(.fill.tertiary, for: .widget)
}
}
@main
struct SimpleWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "com.example.simple", provider: SimpleProvider()) { entry in
SimpleWidgetView(entry: entry)
}
.configurationDisplayName("My Widget")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
Full implementation
The example below uses AppIntentConfiguration — the iOS 17+ way to build
user-configurable widgets — so users can pick which stat to display right from the widget
gallery without opening the app. The TimelineEntry carries all data the view
needs, keeping the view layer purely declarative. WidgetCenter.shared.reloadTimelines
in the main app forces an immediate refresh whenever new data arrives.
// WidgetExtension/StatsWidget.swift
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - App Intent (user configuration)
struct StatsConfigIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Stats Widget"
static var description = IntentDescription("Shows your daily stats.")
@Parameter(title: "Stat Type", default: .steps)
var statType: StatType
}
enum StatType: String, AppEnum {
case steps, calories, distance
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Stat Type")
static var caseDisplayRepresentations: [StatType: DisplayRepresentation] = [
.steps: "Steps",
.calories: "Calories",
.distance: "Distance"
]
}
// MARK: - Timeline Entry
struct StatsEntry: TimelineEntry {
let date: Date
let statType: StatType
let value: Double
let unit: String
}
// MARK: - Provider
struct StatsProvider: AppIntentTimelineProvider {
typealias Entry = StatsEntry
typealias Intent = StatsConfigIntent
func placeholder(in context: Context) -> StatsEntry {
StatsEntry(date: .now, statType: .steps, value: 8_200, unit: "steps")
}
func snapshot(for configuration: StatsConfigIntent,
in context: Context) async -> StatsEntry {
await makeEntry(for: configuration)
}
func timeline(for configuration: StatsConfigIntent,
in context: Context) async -> Timeline<StatsEntry> {
let entry = await makeEntry(for: configuration)
// Refresh at midnight local time
let midnight = Calendar.current.startOfDay(for: .now).addingTimeInterval(86_400)
return Timeline(entries: [entry], policy: .after(midnight))
}
private func makeEntry(for configuration: StatsConfigIntent) async -> StatsEntry {
// Replace with real HealthKit / network fetch
let (value, unit): (Double, String) = switch configuration.statType {
case .steps: (10_432, "steps")
case .calories: (487, "kcal")
case .distance: (7.3, "km")
}
return StatsEntry(date: .now, statType: configuration.statType,
value: value, unit: unit)
}
}
// MARK: - Widget View
struct StatsWidgetView: View {
var entry: StatsEntry
@Environment(\.widgetFamily) private var family
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Label(entry.statType.rawValue.capitalized, systemImage: iconName)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text(entry.value, format: .number.precision(.fractionLength(0)))
.font(family == .systemSmall ? .title : .largeTitle)
.fontWeight(.bold)
.minimumScaleFactor(0.6)
.accessibilityLabel("\(Int(entry.value)) \(entry.unit)")
Text(entry.unit)
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding()
.containerBackground(.fill.tertiary, for: .widget)
}
private var iconName: String {
switch entry.statType {
case .steps: "figure.walk"
case .calories: "flame.fill"
case .distance: "location.fill"
}
}
}
// MARK: - Widget Declaration
@main
struct StatsWidget: Widget {
let kind = "com.example.StatsWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: StatsConfigIntent.self,
provider: StatsProvider()) { entry in
StatsWidgetView(entry: entry)
}
.configurationDisplayName("Daily Stats")
.description("Track steps, calories, or distance at a glance.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge,
.accessoryCircular, .accessoryRectangular])
}
}
// MARK: - Preview
#Preview(as: .systemSmall) {
StatsWidget()
} timeline: {
StatsEntry(date: .now, statType: .steps, value: 10_432, unit: "steps")
StatsEntry(date: .now, statType: .calories, value: 487, unit: "kcal")
}
How it works
-
StatsConfigIntent(AppIntentTimelineProvider) — Declaring aWidgetConfigurationIntentwith an@Parametertells WidgetKit to show a configuration UI in the widget gallery. The user picks "Steps," "Calories," or "Distance" without ever launching the app. -
timeline(for:in:)refresh policy — Returning.after(midnight)schedules the next reload at the next calendar day boundary. For real-time data, callWidgetCenter.shared.reloadTimelines(ofKind:)from the main app after a background fetch completes. -
containerBackground(.fill.tertiary, for: .widget)— Required on iOS 17+ so the widget renders correctly on the home screen, lock screen, and StandBy. Omitting this crashes older-style widgets on iOS 17. -
@Environment(\.widgetFamily)— Reads the current size at render time so the same view adapts its font size between.systemSmalland.systemLargefamilies without needing separate view structs. -
#Preview(as:)with atimeline:closure — The iOS 17+ widget preview macro accepts multiple timeline entries so you can flip through states directly in Xcode canvas, replicating how WidgetKit rotates entries over time.
Variants
Lock screen / StandBy (accessory families)
Accessory widgets use the same provider but need a separate view branch because they render
in grayscale and have tighter space constraints. Add .accessoryCircular and
.accessoryRectangular to supportedFamilies, then branch on
widgetFamily:
struct StatsWidgetView: View {
var entry: StatsEntry
@Environment(\.widgetFamily) private var family
var body: some View {
switch family {
case .accessoryCircular:
Gauge(value: entry.value, in: 0...15_000) {
Image(systemName: "figure.walk")
} currentValueLabel: {
Text(entry.value / 1_000, format: .number.precision(.fractionLength(1)))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.clear, for: .widget)
case .accessoryRectangular:
HStack {
Image(systemName: "figure.walk")
VStack(alignment: .leading) {
Text("Steps").font(.caption2)
Text(entry.value, format: .number).font(.headline)
}
}
.containerBackground(.clear, for: .widget)
default:
// … existing home-screen layout
Text(entry.value, format: .number).containerBackground(.fill.tertiary, for: .widget)
}
}
}
Forcing a refresh from the main app
After a network sync or user action in your main app target, invalidate the widget timeline immediately:
import WidgetKit
// Call this after saving new data (e.g., after a HealthKit background delivery)
func refreshWidget() {
WidgetCenter.shared.reloadTimelines(ofKind: "com.example.StatsWidget")
// Or reload everything:
// WidgetCenter.shared.reloadAllTimelines()
}
Common pitfalls
-
Missing
containerBackgroundon iOS 17+. Widgets that use the old.background()modifier crash or display incorrectly on iOS 17+. Always use.containerBackground(_:for:)— even if you want a transparent background, pass.clear. -
Running async code inside
getTimelineincorrectly. WithAppIntentTimelineProviderthe async/await surface is built in — usetimeline(for:in:) async. If you accidentally use the callback-basedTimelineProviderand callTask { }inside, the completion may fire before the task finishes on some iOS builds, delivering stale entries. -
Exceeding widget CPU and memory budgets. WidgetKit budgets CPU time for
timeline generation (~3 s) and caps memory around 30 MB. Move heavy work (image decoding,
Core Data fetches) to an
App Group-shared cache in the main app and read from that cache in the provider, rather than doing the work inline. -
Forgetting App Group entitlements for shared data. The widget extension is
a separate process —
UserDefaults.standardand most file paths from the main app are invisible to it. Add an App Group capability to both targets and useUserDefaults(suiteName:)or a sharedFileManagercontainer URL. -
VoiceOver labels on numeric values. Large
Textviews showing raw numbers should carry.accessibilityLabelwith the unit, e.g.,"10,432 steps today", otherwise VoiceOver reads only the digits.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a home screen and lock screen widget in SwiftUI for iOS 17+. Use WidgetKit, AppIntentTimelineProvider, AppIntentConfiguration, and containerBackground. Support systemSmall, systemMedium, accessoryCircular, and accessoryRectangular families. Share data with the main app via an App Group UserDefaults suite. Make it accessible (VoiceOver labels with units on all numeric Text views). Add a #Preview(as:) with realistic sample data for at least two timeline entries.
In Soarias's Build phase, paste this prompt into the active session after scaffolding your Widget Extension target — Claude Code will wire up the provider, shared data layer, and preview in one shot, leaving you to swap in your real data source.
Related
FAQ
Does this work on iOS 16?
Partially. AppIntentConfiguration and the async
AppIntentTimelineProvider protocol require iOS 17+. On iOS 16 you must fall
back to IntentConfiguration (backed by a Siri Intent Definition file) and the
callback-based IntentTimelineProvider. The
containerBackground modifier is also iOS 17+ — without it,
widgets built targeting iOS 16 still render correctly on iOS 16 but must handle the
modifier conditionally when running on iOS 17+. If your minimum deployment target is iOS 17,
you can use everything shown here without any #available guards.
How do I pass data from my SwiftUI app into the widget?
The widget extension runs in a separate process and cannot read from the main app's
UserDefaults.standard or its sandboxed files. The correct approach is to
enable an App Group capability on both your main app and the widget extension
targets, then write with UserDefaults(suiteName: "group.com.example.app") in the
app and read from the same suite in StatsProvider. For larger blobs use a
shared FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)
path. After writing, call
WidgetCenter.shared.reloadTimelines(ofKind:) so the provider immediately
re-fetches the fresh data.
What's the UIKit / WKWebView equivalent?
WidgetKit widgets are always SwiftUI — you cannot use UIKit views or WKWebView
inside a widget. The widget view hierarchy is rendered to an image by the system at
timeline-entry time; interactive UIKit elements, scrolling, and web content are not
supported. Interactive elements (buttons and toggles that run App Intents) became available
in iOS 17 via Button(intent:) and Toggle(isOn:intent:), but the
general view layer must remain SwiftUI.
Last reviewed: 2026-05-11 by the Soarias team.