On this page 7 sections
Start with the action, not the surface
App Intents is the foundation. An intent is a single action your app can perform, described in a way the system understands: “log a glass of water”, “start a timer”, “show today’s orders”. Once an action is an intent, the same code can power a Shortcut, a Siri phrase, a Spotlight result, an Action button press, an interactive widget button and a Control Centre control.
So the first planning question isn’t “should we have a widget?” It’s “what are the two or three things people do most often, and could they do them without opening the app?”
Writing an App Intent
Here is an intent for a hydration tracker:
import AppIntents
struct LogWaterIntent: AppIntent {
static let title: LocalizedStringResource = "Log Water"
static let description = IntentDescription("Adds a drink to today's total.")
@Parameter(title: "Amount (ml)", default: 250, inclusiveRange: (50, 1000))
var millilitres: Int
func perform() async throws -> some IntentResult & ProvidesDialog {
let total = try await WaterStore.shared.log(millilitres: millilitres)
return .result(dialog: "Logged \(millilitres) ml. That's \(total) ml today.")
}
}perform() runs without opening the app’s UI, so the intent must do its work through shared code, here a WaterStore, rather than through a view model that only exists when a screen is showing.
To make it available to Siri and Spotlight with no setup by the user, declare it as an App Shortcut:
struct WaterShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: LogWaterIntent(),
phrases: [
"Log water in \(.applicationName)",
"Add a drink in \(.applicationName)"
],
shortTitle: "Log Water",
systemImageName: "drop.fill"
)
}
}Every phrase must include the app’s name. App Shortcuts appear in the Shortcuts app and Spotlight as soon as the app is installed.
For actions on your app’s content, such as “open the Lisbon trip”, define an AppEntity with an EntityQuery, so the system can ask your app for matching items. Apple keeps extending what Siri can do with entities; the App Intents documentation and the current What’s new in iOS page are the places to check what the latest release adds.
Sharing data between the app and its extensions
Widgets and Live Activities run in a widget extension, a separate process from your app. To read the same data, both need to be in the same App Group (a capability you add to both targets in Xcode), and the shared store has to live in the group’s container:
import Foundation
enum SharedStorage {
static let groupID = "group.uk.leej.hydrate"
static var containerURL: URL {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)!
}
static let defaults = UserDefaults(suiteName: groupID)!
}For SwiftData, point the ModelConfiguration at the App Group with groupContainer: .identifier(SharedStorage.groupID). Keep the data the widget needs small and quick to read; widget extensions have tight memory limits.
If you read shared UserDefaults, remember to declare it in your privacy manifest with the App Group reason; Privacy manifests and App Store review explains how.
Building a widget
A widget is a SwiftUI view plus a timeline provider that tells the system what to show and when:
import WidgetKit
import SwiftUI
struct WaterEntry: TimelineEntry {
let date: Date
let totalMillilitres: Int
let goalMillilitres: Int
}
struct WaterProvider: TimelineProvider {
func placeholder(in context: Context) -> WaterEntry {
WaterEntry(date: .now, totalMillilitres: 1250, goalMillilitres: 2000)
}
func getSnapshot(in context: Context, completion: @escaping (WaterEntry) -> Void) {
completion(currentEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WaterEntry>) -> Void) {
let midnight = Calendar.current.startOfDay(for: .now.addingTimeInterval(86_400))
completion(Timeline(entries: [currentEntry()], policy: .after(midnight)))
}
private func currentEntry() -> WaterEntry {
WaterEntry(date: .now,
totalMillilitres: WaterStore.shared.todayTotal,
goalMillilitres: WaterStore.shared.dailyGoal)
}
}
struct WaterWidgetView: View {
let entry: WaterEntry
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Label("Water", systemImage: "drop.fill")
.font(.caption.bold())
.foregroundStyle(.blue)
Text("\(entry.totalMillilitres) ml")
.font(.title2.bold())
.contentTransition(.numericText())
Gauge(value: Double(entry.totalMillilitres), in: 0...Double(entry.goalMillilitres)) { EmptyView() }
.gaugeStyle(.accessoryLinearCapacity)
Button(intent: LogWaterIntent()) {
Label("Add 250 ml", systemImage: "plus")
}
.font(.caption)
}
}
}
struct WaterWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WaterWidget", provider: WaterProvider()) { entry in
WaterWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Water today")
.description("Today's total against your goal, with a quick add button.")
.supportedFamilies([.systemSmall])
}
}Three things to understand about widgets:
- They aren’t live views. The system renders your view for each timeline entry ahead of time and shows the snapshots. Code in the view runs at render time, not continuously. For a countdown, use
Text(date, style: .timer), which the system keeps updated for you. - Refreshes are budgeted. The system decides how often your timeline reloads. Ask for what you need, not more. When data changes inside the app, call
WidgetCenter.shared.reloadTimelines(ofKind: "WaterWidget")so the widget catches up straight away. - Buttons run intents.
Button(intent:)andToggle(isOn:intent:)run an App Intent in the background and then reload the widget. That’s how the “Add 250 ml” button above works without opening the app.
Apple’s WidgetKit documentation covers the families, including the small accessory widgets for the Lock Screen and Apple Watch. If your app has a watch companion, much of this code can be shared; the Apple Watch design guide covers complications and Smart Stack widgets on the wrist.
Live Activities
A Live Activity shows the progress of something happening right now, such as a delivery, a match score, a workout or a timer, on the Lock Screen and in the Dynamic Island. It lives in the same widget extension.
Define what’s fixed and what changes:
import ActivityKit
struct DeliveryAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var stage: String
var expectedArrival: Date
}
var orderNumber: String
}Describe how it looks:
import WidgetKit
import SwiftUI
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
HStack {
VStack(alignment: .leading) {
Text("Order \(context.attributes.orderNumber)").font(.headline)
Text(context.state.stage).foregroundStyle(.secondary)
}
Spacer()
Text(context.state.expectedArrival, style: .relative)
.monospacedDigit()
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(context.state.stage, systemImage: "box.truck")
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.expectedArrival, style: .relative)
}
} compactLeading: {
Image(systemName: "box.truck")
} compactTrailing: {
Text(context.state.expectedArrival, style: .relative).monospacedDigit()
} minimal: {
Image(systemName: "box.truck")
}
}
}
}Then start, update and end it from the app (and add NSSupportsLiveActivities set to YES in the app’s Info.plist):
let attributes = DeliveryAttributes(orderNumber: order.number)
let initial = DeliveryAttributes.ContentState(stage: "Preparing", expectedArrival: order.eta)
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initial, staleDate: nil),
pushType: .token // use nil if you'll only update from the app
)
// Later, when the status changes:
await activity.update(.init(state: .init(stage: "Out for delivery", expectedArrival: newETA), staleDate: nil))
// When it's done:
await activity.end(.init(state: .init(stage: "Delivered", expectedArrival: .now), staleDate: nil),
dismissalPolicy: .default)Limits worth knowing before you promise anything to stakeholders, all from Apple’s Live Activities documentation:
- A Live Activity can be active for up to eight hours, after which the system ends it. An ended activity can stay on the Lock Screen for up to four more hours.
- The static and dynamic data combined can’t exceed 4 KB.
- To update while the app isn’t running, you send ActivityKit push notifications through Apple’s push service from your server, which needs backend work as well as app work.
That last point often decides the budget. A Live Activity that only updates while the app is open is simple; one driven by a live backend is a small project of its own.
Choosing what to build
A rough way to prioritise:
| If people… | Consider |
|---|---|
| repeat the same quick action often | an App Intent, then an interactive widget button or a Control |
| check one number or status several times a day | a Home Screen or Lock Screen widget |
| wait for something that changes over minutes or hours | a Live Activity |
| ask “where is my…?” or “open my…” | App Entities so Siri and Spotlight can find content |
Most apps benefit from one or two of these done well, rather than all of them done thinly. A widget nobody adds to their Home Screen costs as much to maintain as one that everyone uses.
Key takeaways
- App Intents are the foundation: write each important action once and reuse it in Shortcuts, Siri, Spotlight, widgets and controls.
- Share data with extensions through an App Group, and keep what widgets read small.
- Widgets are pre-rendered timelines with a refresh budget; reload them when your data changes.
- Live Activities suit events that unfold over minutes or hours, within an eight-hour active limit and a 4 KB data limit.
- Choose surfaces from how people actually use the app, and build one or two well.
If you’d like to work out which of these would make the biggest difference to your app, or need one built, tell me about your app.
Written by Lee Johnson. Published 30 October 2026.