App Intents for Widgets
When we were first introduced to App Intents, they were limited to some specific uses, such as shortcuts. They were also unable to be used with widgets, meaning we were stuck with our old intents for the time being.
This year, we've been given everything we need to swap over to the new frameworks, thanks to the brand new WidgetConfigurationIntent
and accompanying AppIntentTimelineProvider
.
Let's dive in.
Configuration Intents
To get started, you need an intent. We'll go through creating an intent in its basic form here, but I'd reccomend getting a better understanding of intents before diving into your own widgets.
If you're looking for a boost on intents, please checkout my guide from last year here
We're going to make an app that lets you track how many runs you've been on, and displays your total distance. To do this, we'll need an intent for tracking a run, and a widget to display that data. We'll start with the intent.
I'll focus on the important parts today, like the RunIntent
, but there's a whole data store backing this mini app, which you can find in the code sample at the end.
To build our intent, we'll need an AppEnum
to model some common runs, lets call that CommonRun.
enum CommonRun: Double, CaseIterable, AppEnum {
typealias RawValue = Double
case couchToKitchen = 0.05, five = 5, ten = 10, halfMarathon = 21.1, marathon = 42.2
static var typeDisplayRepresentation: TypeDisplayRepresentation = .init(stringLiteral: "Run")
static var caseDisplayRepresentations: [CommonRun : DisplayRepresentation] = [
.couchToKitchen: .init(stringLiteral: "Just nipping for another donut"),
.five: .init(stringLiteral: "5K"),
.ten: .init(stringLiteral: "10K"),
.halfMarathon: .init(stringLiteral: "Half Marathon"),
.marathon: .init(stringLiteral: "Marathon"),
]
var title: String {
switch self {
case .couchToKitchen: return "Couch to Fridge"
case .five: return "5K"
case .ten: return "10K"
case .halfMarathon: return "Half Marathon"
case .marathon: return "Marathon"
}
}
}
enum CommonRun: Double, CaseIterable, AppEnum {
typealias RawValue = Double
case couchToKitchen = 0.05, five = 5, ten = 10, halfMarathon = 21.1, marathon = 42.2
static var typeDisplayRepresentation: TypeDisplayRepresentation = .init(stringLiteral: "Run")
static var caseDisplayRepresentations: [CommonRun : DisplayRepresentation] = [
.couchToKitchen: .init(stringLiteral: "Just nipping for another donut"),
.five: .init(stringLiteral: "5K"),
.ten: .init(stringLiteral: "10K"),
.halfMarathon: .init(stringLiteral: "Half Marathon"),
.marathon: .init(stringLiteral: "Marathon"),
]
var title: String {
switch self {
case .couchToKitchen: return "Couch to Fridge"
case .five: return "5K"
case .ten: return "10K"
case .halfMarathon: return "Half Marathon"
case .marathon: return "Marathon"
}
}
}
You'll see there's a whole bunch of AppEnum
specific properties, including caseDisplayRepresentation
. These are important, as these are what the user will see in the widget. I've just used a string here, but you can actually get really fancy with this and include images/symbols, too.
Next, we need to actually use this.
Lets make our RunIntent
, and make sure it conforms to WidgetConfigurationIntent
as well as LiveActivityIntent
( so it can pull double duty as an action for a button ).
struct RunIntent: LiveActivityIntent, WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Track your run"
static var openAppWhenRun: Bool = false
@Parameter(title: "Your run distance", optionsProvider: RunOptionsProvider())
var run: CommonRun
init() {
self.run = .ten
}
init(run: CommonRun) {
self.run = run
}
func perform() async throws -> some IntentResult {
await RunStore().track(kilomereDistance: run.rawValue)
return .result(value: run.rawValue)
}
}
struct RunOptionsProvider: DynamicOptionsProvider {
func results() async throws -> [CommonRun] {
CommonRun.allCases
}
}
struct RunIntent: LiveActivityIntent, WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Track your run"
static var openAppWhenRun: Bool = false
@Parameter(title: "Your run distance", optionsProvider: RunOptionsProvider())
var run: CommonRun
init() {
self.run = .ten
}
init(run: CommonRun) {
self.run = run
}
func perform() async throws -> some IntentResult {
await RunStore().track(kilomereDistance: run.rawValue)
return .result(value: run.rawValue)
}
}
struct RunOptionsProvider: DynamicOptionsProvider {
func results() async throws -> [CommonRun] {
CommonRun.allCases
}
}
This is one of the simpler intents you can possibly write, it just has the one parameter, and the options provider just returns every possible option. The options provider is important, as this is what we'll see when we try to configure our widget.
Make sure to set openAppWhenRun
to false - we want this to run on its own.
We're done with our intents now! We can get to the widget.
AppIntentConfiguration
The part that brings all this together, is AppIntentConfiguration
. Its a new configuration, where you'd normally find StaticConfiguration
etc, and allows for an app intent to be provided.
The AppIntentConfiguration
is super simple, and just requires we provide it the widget kind, the intent type, and a timeline provider.
This is what the final widget code looks like.
struct TrackRun_Widget: Widget {
let kind: String = "TrackRun_Widget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: RunIntent.self, provider: IntentProvider()) { entry in
TrackRun_WidgetRun_WidgetEntryView(entry: entry)
}
.configurationDisplayName("Track your run")
.description("Pick a distance, tap to track.")
}
}
struct TrackRun_Widget: Widget {
let kind: String = "TrackRun_Widget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: RunIntent.self, provider: IntentProvider()) { entry in
TrackRun_WidgetRun_WidgetEntryView(entry: entry)
}
.configurationDisplayName("Track your run")
.description("Pick a distance, tap to track.")
}
}
We have allmost everything we need for this already - so lets build up our IntentProvider
, the final piece.
Your provider for an AppIntentConfiguration
must conform to AppIntentTimelineProvider
, and it's got a really similar API to the usual providers.
It has the same methods for snapshot
, placeholder
and timeline
, except now you get provided a copy of RunIntent
as the configuration.
struct IntentProvider: AppIntentTimelineProvider {
typealias Entry = TrackingDistanceEntry
typealias Intent = RunIntent
func snapshot(for configuration: RunIntent, in context: Context) async -> TrackingDistanceEntry {
return .init(date: Date(), run: .ten, distance: 37.7)
}
func placeholder(in context: Context) -> TrackingDistanceEntry {
return .init(date: Date(), run: .ten, distance: 37.7)
}
func timeline(for configuration: RunIntent, in context: Context) async -> Timeline<TrackingDistanceEntry> {
return Timeline(entries: [.init(date: Date(), run: configuration.run, distance: await RunStore().currentDistance())], policy: .never)
}
}
struct TrackingDistanceEntry: TimelineEntry {
let date: Date
let run: CommonRun
let distance: Double
}
struct IntentProvider: AppIntentTimelineProvider {
typealias Entry = TrackingDistanceEntry
typealias Intent = RunIntent
func snapshot(for configuration: RunIntent, in context: Context) async -> TrackingDistanceEntry {
return .init(date: Date(), run: .ten, distance: 37.7)
}
func placeholder(in context: Context) -> TrackingDistanceEntry {
return .init(date: Date(), run: .ten, distance: 37.7)
}
func timeline(for configuration: RunIntent, in context: Context) async -> Timeline<TrackingDistanceEntry> {
return Timeline(entries: [.init(date: Date(), run: configuration.run, distance: await RunStore().currentDistance())], policy: .never)
}
}
struct TrackingDistanceEntry: TimelineEntry {
let date: Date
let run: CommonRun
let distance: Double
}
We now have everything we need! Lets build up a widget view, and look at how we can make it interactive thanks to our intent.
struct TrackRun_WidgetRun_WidgetEntryView : View {
var entry: IntentProvider.Entry
var body: some View {
VStack(spacing: 20) {
HStack {
Text(entry.distance.formatted(.number))
.font(.largeTitle)
.fontWeight(.bold)
.foregroundStyle(Color.mint.gradient)
.monospacedDigit()
Text("km")
.font(.subheadline)
.fontWeight(.bold)
.foregroundStyle(Color.mint.gradient)
}
Button(intent: RunIntent(run: entry.run), label: {
Text("Track \(entry.run.rawValue.formatted())km")
.font(.subheadline.weight(.bold))
.fontDesign(.rounded)
})
.buttonStyle(.borderedProminent)
.tint(Color.mint)
}
.fontDesign(.rounded)
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct TrackRun_WidgetRun_WidgetEntryView : View {
var entry: IntentProvider.Entry
var body: some View {
VStack(spacing: 20) {
HStack {
Text(entry.distance.formatted(.number))
.font(.largeTitle)
.fontWeight(.bold)
.foregroundStyle(Color.mint.gradient)
.monospacedDigit()
Text("km")
.font(.subheadline)
.fontWeight(.bold)
.foregroundStyle(Color.mint.gradient)
}
Button(intent: RunIntent(run: entry.run), label: {
Text("Track \(entry.run.rawValue.formatted())km")
.font(.subheadline.weight(.bold))
.fontDesign(.rounded)
})
.buttonStyle(.borderedProminent)
.tint(Color.mint)
}
.fontDesign(.rounded)
.containerBackground(.fill.tertiary, for: .widget)
}
}
Thanks to our intent conforming to LiveActivityIntent
, we can provide it as an argument for a button, which on tap will fire off our intent.
If you add your widget to the homescreen, tap and hold, you'll be asked to select a run, which then updates the button. I'd reccomend experimenting with icons and more to make this list a little more exciting.
When you tap the button on your widget, it'll update the RunStore
, and then the widget updates with the new value.
The sample for this widget, including the full RunStore
, is available here on Github.
If you fancy reading a little more, or sharing your experiences, I’m @SwiftyAlex on twitter.