Adding Support for Apple Watch Ultra Action Button

Apple recently announced the Apple Watch Ultra, which adds an additional button to the Apple Watch.

One of the primary uses for this button is to control (start / pause / resume) workouts.

We've recently added this functionality into Streaks Workout, so this article shares what we've learned in doing so.

Firstly, your app must have the Workout Processing Background Mode (WKBackgroundModes must contain workout-processing).

Secondly, your app must provide an implementation of StartWorkoutIntent.

Define the Workout Style Value

The first thing you need to do is create a workout style, which must either be an AppEntity or AppEnum. In Streaks Workout, we are using the user-defined custom workouts, so we use AppEntity. If you have a fixed list of workout types then using AppEnum would be simpler.

import AppIntents

struct MyWorkoutAppEntity: AppEntity {
    var id: UUID
    var title: String

    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Workout Type"

    var displayRepresentation: DisplayRepresentation {
        .init(stringLiteral: title)
    }

    static var defaultQuery = MyWorkoutAppEntityQuery()
}

Since AppEntity requires a query type, you must also implement this.

struct MyWorkoutAppEntityQuery: EntityQuery {
    func entities(for identifiers: [UUID]) async throws -> [MyWorkoutAppEntity] {
        let workouts = try await suggestedEntities()
        return workouts.filter { identifiers.contains($0.id) }
    }

    func suggestedEntities() async throws -> [MyWorkoutAppEntity] {
        // Return your list of workout types here
        [
            WorkoutStyle(id: .runningIdentifier, title: "Running")
        ]
    }
}

Create the Start Workout Intent

This works just like other iOS 16 App Intents, in that you do the hard lifting inside the perform() method.

In Streaks Workout, we also want the app to run in the foreground, so we set openAppWhenRun to true.

We're not using an App Intents extension in Streaks Workout so this is code is all added to the main Apple Watch extension.

import AppIntents

struct MyStartWorkoutIntent: StartWorkoutIntent {
    typealias WorkoutStyle = MyWorkoutAppEntity

    @Parameter(title: "Workout")
    var workoutStyle: WorkoutStyle

    init() {
        workoutStyle = .init(id: .defaultIdentifier, title: "Defaut Workout")
    }

    static var openAppWhenRun: Bool { true }

    static var suggestedWorkouts: [MyStartWorkoutIntent] {
        // Return configured workout intents here
        
        [
            MyStartWorkoutIntent(
                style: WorkoutStyle(id: .runningIdentifier, title: "Running")
            )
        ]
    }

    var displayRepresentation: DisplayRepresentation {
        workoutStyle.displayRepresentation
    }

    static var title: LocalizedStringResource = "Start a Workout"

    static var parameterSummary: some ParameterSummary {
        Summary("Start a \($\.workout) workout")
    }

    func perform() async throws -> some IntentResult {

        // Start your workout here

        return .result()
    }
}

A few things to note in the above code:

  • For the code to compile, you must define a default workout style in the init() method.
  • For the Action Button configuration screen to display your workouts, you must define the var workoutStyle as a @Parameter. This is good practice anyway, since it allows you to then use this App Intent in the Shortcuts app.
  • The suggestedWorkouts variable is not asynchronous, so you can't simply just call MyWorkoutAppIntentQuery.suggestedEntities() here. You need to maintain a non-asynchronous list somewhere.

Implementing Pause and Resume

These are a bit simpler to implement, since no parameters or custom entities are required:

struct MyPauseWorkoutIntent: PauseWorkoutIntent {
    static var title: LocalizedStringResource = "Pause Workout"

    func perform() async throws -> some IntentResult {
        // Pause the workout
        return .result()
    }
}

struct MyResumeWorkoutIntent: ResumeWorkoutIntent {
    static var title: LocalizedStringResource = "Resume Workout"

    func perform() async throws -> some IntentResult {
        // Resume the workout
        return .result()
    }
}

Migrating from Previous INIntent

Before Apple Watch Ultra and StartWorkoutIntent was announced, we already had a Shortcut for starting a workout in Streaks Workout, called WorkoutStartIntent.

We want to keep this for users running older version of iOS and Apple Watch, but we don't want both the old and this new one to appear in the Shortcuts app.

To solve this, we changed our definition to implement CustomIntentMigratedAppIntent:

struct MyStartWorkoutIntent: StartWorkoutIntent, CustomIntentMigratedAppIntent {

    // This is the name in the .intentsdefinition file
    static var intentClassName: String = "WorkoutStartIntent"

    // ...
}

Configuring as a Shortcut

Part of the new App Intents system is to create an AppShortcutsProvider. There is plenty of other detailed material about doing this, but to round out our implementation we also included our intents here.

struct MyAppShortcuts: AppShortcutsProvider {
    static var shortcutTileColor: ShortcutTileColor {
        .orange
    }

    @AppShortcutsBuilder
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: MyStartWorkoutIntent(),
            phrases: [
                "Start \(\.$workoutStyle) in \(.applicationName)"
            ]
        )

        AppShortcut(
            intent: MyPauseWorkoutIntent(),
            phrases: [
                "Pause \(.applicationName)"
            ]
        )

        AppShortcut(
            intent: MyResumeWorkoutIntent(),
            phrases: [
                "Resume \(.applicationName)"
            ]
        )
    }
}

That's all there is to it! Now you can go to the Apple Watch Settings app, then go to Action Button to configure the button for your app.

Further Reading