iOS widget tutorial with WidgetKit and SwiftUI, built on Windows
Widgets let people see the one thing they care about most without opening your app, such as today’s step count, the next bus or the number of to-dos. In this tutorial you add a water tracker widget to a SwiftUI project called MyApp. Each tap in the app logs a glass, and the widget on the Home Screen updates to match. Everything is done on Windows.
The project in this tutorial already builds with KXBuild and installs on an iPhone. If you haven’t got that far yet, start with your first SwiftUI app.
When a widget makes sense
A widget is a separate piece of SwiftUI UI that lives on the Home Screen, the Lock Screen or in Today View. It can’t scroll, can’t take text input and isn’t running all the time. The system shows content from a timeline you provide in advance and switches to the next entry when its time comes.
Widgets suit information you take in at a glance, such as counts, progress, countdowns or the latest message. Content that needs frequent interaction or real-time updates isn’t a good fit. For live progress on the Lock Screen (a food delivery, a game score), use a Live Activity.
The widget and the app are two separate programs, each with its own bundle ID, and they share data through an App Group. The sections below go through adding the widget, writing the code, sharing data, signing, installing and releasing, in that order.
Add the widget extension
Use either of the following.
- VS Code extension. In the KXBuild view, right-click the MyApp target and choose
KXBuild: Add Target…, or run it from the Command Palette. Choose Widget as the type, keep the default name MyAppWidget, and entergroup.com.example.myappas the App Group. When it’s done, a notification in the bottom-right corner lists the App ID you need to register in the developer portal. - Command line. Run the following command in the project folder.
1kxbuild project add-target -P .\MyApp.xcodeproj --type widget --name MyAppWidget --app-group group.com.example.myapp
An App Group is usually group. followed by the app’s bundle ID. Since the widget needs to read the app’s data, fill it in when you add the widget, and the App Group is added to both the app and the widget.
The widget’s bundle ID is the app’s bundle ID plus the name, in this case com.example.myapp.myappwidget. The widget requires iOS 17 or later. Your app can still support older versions, the widget just won’t be available on them. For the other extension types and options, see Add a target and the kxbuild command reference.
Generated files
A new MyAppWidget folder appears in the project folder, and it builds as is.
| File | Purpose |
|---|---|
| MyAppWidgetBundle.swift | Entry point of the widget extension, lists every widget the extension provides |
| MyAppWidget.swift | The widget itself, with its timeline, entries, UI and configuration |
| Info.plist | Declares this as a WidgetKit extension, usually no changes needed |
| Assets.xcassets | The widget’s accent color and background color |
| MyAppWidget.entitlements | The widget’s App Group |
On the app side, MyApp.entitlements also gets the same App Group. If the project didn’t have that file, it’s created. The widget is already set up to be built, packaged and installed with the app, so you don’t need to change any project settings.
Open MyAppWidget.swift. Here’s what each type in it does.
- SimpleEntry. One piece of data to display.
dateis when it starts showing. - Provider. Supplies data to the system.
placeholderis the content shown before loading,getSnapshotis used for the preview when someone adds the widget, andgetTimelinereturns the entries to show over the coming period and when to ask for more. - MyAppWidgetEntryView. Draws one entry as UI.
- MyAppWidget. The widget’s configuration, including its name, description and supported sizes.
MyAppWidget_Previews at the end of the file is for the Xcode canvas on a Mac. You can keep it or delete it.
Write the widget
Replace MyAppWidget.swift with the following. It reads the number of glasses the app logged today and supports three sizes, small and medium on the Home Screen and circular on the Lock Screen.
1import WidgetKit
2import SwiftUI
3
4let appGroup = "group.com.example.myapp"
5
6var sharedDefaults: UserDefaults {
7 UserDefaults(suiteName: appGroup) ?? .standard
8}
9
10/// Glasses logged today, 0 if the stored date isn't today
11func todayCups() -> Int {
12 let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
13 guard sharedDefaults.double(forKey: "cupsDay") == today else { return 0 }
14 return sharedDefaults.integer(forKey: "cups")
15}
16
17struct CupsEntry: TimelineEntry {
18 let date: Date
19 let cups: Int
20}
21
22struct Provider: TimelineProvider {
23 func placeholder(in context: Context) -> CupsEntry {
24 CupsEntry(date: Date(), cups: 3)
25 }
26
27 func getSnapshot(in context: Context, completion: @escaping (CupsEntry) -> Void) {
28 completion(CupsEntry(date: Date(), cups: todayCups()))
29 }
30
31 func getTimeline(in context: Context, completion: @escaping (Timeline<CupsEntry>) -> Void) {
32 let now = Date()
33 let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now))!
34 // Show the current count now, reset to zero at midnight, then ask for a new timeline
35 let entries = [
36 CupsEntry(date: now, cups: todayCups()),
37 CupsEntry(date: midnight, cups: 0),
38 ]
39 completion(Timeline(entries: entries, policy: .atEnd))
40 }
41}
42
43struct MyAppWidgetEntryView: View {
44 @Environment(\.widgetFamily) var family
45 var entry: Provider.Entry
46 let goal = 8
47
48 var body: some View {
49 switch family {
50 case .accessoryCircular:
51 Gauge(value: Double(min(entry.cups, goal)), in: 0...Double(goal)) {
52 Image(systemName: "drop.fill")
53 } currentValueLabel: {
54 Text("\(entry.cups)")
55 }
56 .gaugeStyle(.accessoryCircularCapacity)
57 case .systemMedium:
58 HStack(spacing: 16) {
59 VStack(alignment: .leading, spacing: 4) {
60 Label("Water today", systemImage: "drop.fill")
61 .font(.caption)
62 .foregroundStyle(.blue)
63 Text("\(entry.cups) / \(goal) glasses")
64 .font(.title.bold())
65 }
66 ProgressView(value: Double(min(entry.cups, goal)), total: Double(goal))
67 .tint(.blue)
68 }
69 default:
70 VStack(alignment: .leading, spacing: 6) {
71 Label("Water", systemImage: "drop.fill")
72 .font(.caption)
73 .foregroundStyle(.blue)
74 Text("\(entry.cups)")
75 .font(.system(size: 44, weight: .bold, design: .rounded))
76 Text("Goal \(goal) glasses")
77 .font(.caption)
78 .foregroundStyle(.secondary)
79 }
80 .frame(maxWidth: .infinity, alignment: .leading)
81 }
82 }
83}
84
85struct MyAppWidget: Widget {
86 let kind: String = "MyAppWidget"
87
88 var body: some WidgetConfiguration {
89 StaticConfiguration(kind: kind, provider: Provider()) { entry in
90 MyAppWidgetEntryView(entry: entry)
91 .containerBackground(.fill.tertiary, for: .widget)
92 }
93 .configurationDisplayName("Water Tracker")
94 .description("Shows how many glasses of water you've had today.")
95 .supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular])
96 }
97}
A few key points.
- A timeline holds several entries at once. The system doesn’t ask the widget for data every second. It takes the whole timeline and switches entries by each one’s
date. The midnight reset entry is included up front, so the widget doesn’t have to be woken at midnight. - Refresh policy.
.atEndasks again after the last entry is shown,.after(date)asks again at the given time, and.nevermeans no more updates unless the app tells the system to reload. - Sizes.
supportedFamiliesdecides which sizes people can pick, and the UI useswidgetFamilyto lay out each one. Lock Screen sizes start withaccessory, and the system renders them in a single color. - Background. Since iOS 17, widgets must set their background with
containerBackground. Without it, the widget shows a message instead of your content.
You don’t need to change MyAppWidgetBundle.swift. To add another widget later, write a new Widget struct and add it to the bundle’s body.
Share data between the app and the widget
The app and the widget each have their own storage, so they can’t see each other’s UserDefaults.standard. When both read and write through UserDefaults(suiteName: "group.com.example.myapp"), the data lives in the App Group’s shared container.
Replace the app’s ContentView.swift with the following. Tapping the button logs a glass and then tells the widget to refresh.
1import SwiftUI
2import WidgetKit
3
4struct ContentView: View {
5 private let defaults = UserDefaults(suiteName: "group.com.example.myapp") ?? .standard
6 @State private var cups = 0
7
8 var body: some View {
9 VStack(spacing: 24) {
10 Image(systemName: "drop.fill")
11 .font(.system(size: 56))
12 .foregroundStyle(.blue)
13 Text("\(cups) glasses of water today")
14 .font(.title.bold())
15 Button("Log a glass") {
16 addCup()
17 }
18 .buttonStyle(.borderedProminent)
19 }
20 .padding()
21 .onAppear { cups = todayCups() }
22 }
23
24 private func todayCups() -> Int {
25 let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
26 guard defaults.double(forKey: "cupsDay") == today else { return 0 }
27 return defaults.integer(forKey: "cups")
28 }
29
30 private func addCup() {
31 cups = todayCups() + 1
32 defaults.set(cups, forKey: "cups")
33 defaults.set(Calendar.current.startOfDay(for: Date()).timeIntervalSince1970, forKey: "cupsDay")
34 WidgetCenter.shared.reloadAllTimelines()
35 }
36}
WidgetCenter.shared.reloadAllTimelines() makes the system call the widget’s getTimeline again right away. To refresh just one widget, use reloadTimelines(ofKind: "MyAppWidget"), where the argument is the kind from the widget’s configuration.
The App Group name must match exactly in the app, the widget code and the entitlements. In a larger project you can put the read and write code in a Swift file both targets use. Here it’s written in both places to keep things easy to follow.
Set up App IDs and provisioning profiles in the developer portal
The widget is a separate program and is signed with its own provisioning profile. Sign in to developer.apple.com, go to Certificates, Identifiers & Profiles and follow these steps in order.
- Create the App Group. On the Identifiers page, click the plus button, choose App Groups and enter
group.com.example.myappas the identifier. - Enable App Groups for the app’s App ID. Open
com.example.myapp, check App Groups under Capabilities, click Configure next to it, select the App Group you just created and save. - Register an App ID for the widget. Click the plus button and choose App IDs > App. Choose Explicit for the bundle ID and enter
com.example.myapp.myappwidget. Check App Groups and select the same App Group. - Generate the provisioning profiles. On the Profiles page, create a Development profile for the widget’s App ID and select your certificate and test devices. The app’s existing profile was generated before App Groups was enabled, so open it, click Edit, then Save without changes, and download it again.
Put both provisioning profiles in the KXBuild keychain folder and delete the app’s old profile. At build time, the app and the widget each get a matching profile by bundle ID automatically, so you don’t need to set one in the project.
A wildcard App ID (com.example.*) can’t have App Groups. If you’re only building the UI and don’t need to share data yet, leave the App Group empty when adding the widget, and a single wildcard development profile can sign both the app and the widget. For full steps on App IDs, provisioning profiles and the certificate folder, see Get an iOS certificate, p12 and provisioning profile on Windows.
See it on your iPhone
Select your iPhone in the Status Bar and press F5. KXBuild builds, signs and installs the app and the widget together, then launches the app. Tap “Log a glass” a few times.
Go back to the Home Screen, touch and hold an empty area to enter editing mode, tap “Edit” or the plus button in the top-left corner and choose “Add Widget”. Find MyApp in the list, pick a size and add it. For the Lock Screen size, touch and hold the Lock Screen, choose “Customize” and add it there. Tap the button in the app again and the number on the widget changes too.
Keep a few things in mind while debugging.
- F5 debugs the app. The widget runs in its own process, so breakpoints in the app don’t reach it. Keep the widget’s logic simple. Do the calculations in the app, write the results to shared storage and let the widget only display them.
- Refreshes are limited. The system caps how many times each widget can refresh per day, so a very short timeline interval won’t actually refresh that often. Calling
reloadAllTimelines()while the app is in the foreground doesn’t count toward that limit, and having the app notify the widget when data changes is the most reliable approach. - Code changes don’t show up. After you reinstall with F5, widgets on the Home Screen usually switch to the new code automatically. If nothing changes, remove the widget and add it again.
Build the .ipa and submit to the App Store
For App Store release, both the app and the widget need App Store provisioning profiles. On the Profiles page, generate an App Store profile for each of the two App IDs and put them in the keychain folder. Then use either of the following.
- VS Code extension. Switch the Build configuration node in the KXBuild view to Release and run
KXBuild: Buildfrom the Command Palette. - Command line. Run the following command in the project folder.
1kxbuild build .\MyApp.xcodeproj -c Release
The resulting build\MyApp.ipa already includes the widget. The App Store requires extensions to have the same version number as the app, so when you change the version later, change it for both the MyApp and MyAppWidget targets in the Project Settings Editor. For uploading and submitting for review, see Publish to the App Store on Windows.
FAQ
The widget isn’t in the widget gallery
First make sure the iPhone runs iOS 17 or later. Then open the app once and go back to the widget gallery. If it’s still missing, restart the iPhone and check again.
The widget is blank or stays a gray placeholder
The gray blocks are what placeholder looks like, and they normally switch to real content quickly. If they stay, getTimeline most likely never calls completion, or the widget code crashed, so check for force unwraps and out-of-range array access. If the widget shows only a single line of text, containerBackground isn’t set.
Data changes in the app but the widget doesn’t update
- The App Group names in the app and the widget don’t match, or one side still uses
UserDefaults.standard. - The app doesn’t call
WidgetCenter.shared.reloadAllTimelines()after writing the data. - The system is limiting refreshes. Refresh requests sent while the app isn’t in the foreground can be delayed, so open the app and try again.
Widget signing error during the build
- If the error says no matching provisioning profile was found, the
keychainfolder has no profile forcom.example.myapp.myappwidget, or that profile doesn’t include your test device. - If the error says the provisioning profile doesn’t include App Groups, the profile wasn’t regenerated after App Groups was enabled for the App ID, or you’re using a wildcard profile.
- For more causes and fixes of signing and install errors, see 16 common iOS code signing and .ipa install errors, and how to fix them.