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.