Tappable Widgets

Remember all the hacks to try and get your app to open and close super quickly to fake interactivity? well they can go!

Thanks to a surprise visit from our friends in App Intents, we can now add basic interactivity to both our widgets and live activities.

Lets look at how interactivity works.

How it works

Under the hood SwiftUI & WidgetKit cleverly reach back out to your app extension whenever a button is tapped. When it does this, it'll then let you re-generate the timeline, and quickly re-render views.

This works using App Intents to fire off an intent, that can wake your app bundle, do a thing, and then generate a new timeiline.

In order to setup some kind of interactvity you'll need at minimum a widget, and an app intent that does something ( relevant to the widget ).

We'll build both now.

Intents

The intents part of this is super simple, because the new App Intents framework gives you a perform function you can fill with any async code.

If you're looking for a boost on intents, please checkout my guide from last year here

Today we'll build a counter widget, that simply stores a number in the app, and increments / decrements it on the widget. In order to do this, we'll need an intent to increment and one to decrement.

Our intent wont look much different to usual, except it will conform to the new LiveActivityIntent.

import AppIntents

final class IncrementIntent: LiveActivityIntent {  }
import AppIntents

final class IncrementIntent: LiveActivityIntent {  }

We're required to add a title, an init, and a perform function, so add those in.

final class IncrementIntent: LiveActivityIntent { 
    static var title: LocalizedStringResource = "Increment"

    public init() { }

    public func perform() async throws -> some IntentResult { }   
}
final class IncrementIntent: LiveActivityIntent { 
    static var title: LocalizedStringResource = "Increment"

    public init() { }

    public func perform() async throws -> some IntentResult { }   
}

Next, add the perform function, which in this case just grabs a number from defaults, and increments it.

public func perform() async throws -> some IntentResult {
    let suite = UserDefaults(suiteName: Constants.suiteName)
    let counter = suite?.integer(forKey: Constants.key) ?? 0
    suite?.setValue(counter+1, forKey: Constants.key)
    return .result(value: counter)
}
public func perform() async throws -> some IntentResult {
    let suite = UserDefaults(suiteName: Constants.suiteName)
    let counter = suite?.integer(forKey: Constants.key) ?? 0
    suite?.setValue(counter+1, forKey: Constants.key)
    return .result(value: counter)
}

Our decerement intent is exactly the same, but flipped.

You could be smarter with these, but I think its good to try and keep things clear with bespoke classes for interactions like this.

final class DecrementIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Decrement"

    public init() { }

    public func perform() async throws -> some IntentResult {
        let suite = UserDefaults(suiteName: Constants.suiteName)
        let counter = suite?.integer(forKey: Constants.key) ?? 0
        suite?.setValue(counter-1, forKey: Constants.key)
        return .result(value: counter)
    }
}
final class DecrementIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Decrement"

    public init() { }

    public func perform() async throws -> some IntentResult {
        let suite = UserDefaults(suiteName: Constants.suiteName)
        let counter = suite?.integer(forKey: Constants.key) ?? 0
        suite?.setValue(counter-1, forKey: Constants.key)
        return .result(value: counter)
    }
}

Widgets

We've done the hard work - all we need to do now is add this to our widget.

Open up your widget, and add a Button using the new initialiser that instead of taking an action takes an intent.

Button(intent: ..., label: {
    ...
})
Button(intent: ..., label: {
    ...
})

All we have to do in order to connect this to the code we just wrote, is provide the intent as an argument - exactly like you would if you were using a URL button.

Button(intent: IncrementIntent(), label: {
    Image(systemName: "plus.circle.fill")
})
Button(intent: IncrementIntent(), label: {
    Image(systemName: "plus.circle.fill")
})

When you tap that button in your widget, it runs the intent, and the counter increments.

By default, you'll see a nice animation for the increment, but you can customise this using some of the nicer animations such as .contentTransition(.numericText(countsDown: false)).

Here's a full widget ( you can grab this in the sample, too. ) that demonstrates using both intents, and a nice transition.

struct WidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text(entry.counter.formatted())
                .font(.largeTitle)
                .fontWeight(.bold)
                .fontDesign(.rounded)
                .contentTransition(.numericText(countsDown: false))

            HStack {
                Button(intent: DecrementIntent(), label: {
                    Image(systemName: "minus.circle.fill")
                })

               Spacer()

               Button(intent: IncrementIntent(), label: {
                   Image(systemName: "plus.circle.fill")
               })
            }
            .padding()
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}
struct WidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text(entry.counter.formatted())
                .font(.largeTitle)
                .fontWeight(.bold)
                .fontDesign(.rounded)
                .contentTransition(.numericText(countsDown: false))

            HStack {
                Button(intent: DecrementIntent(), label: {
                    Image(systemName: "minus.circle.fill")
                })

               Spacer()

               Button(intent: IncrementIntent(), label: {
                   Image(systemName: "plus.circle.fill")
               })
            }
            .padding()
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

This code works just the same in a live activity, so you can re-use exactly what you just did.

Smarter Intents

We can get a little smarter with our intents, and flatten them into one. To do this, we'll need a new intent that has a parameter for the amount to adjust.

We'll make a new CounterIntent, and add a Parameter for the amount.

struct CounterIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Counter"

    @Parameter(title: "Amount")
    var amount: Int

    public init(amount: Int) {
        self.amount = amount
    }

    // We're required to have an empty initialiser by the system
    init() { amount = 0 }

    public func perform() async throws -> some IntentResult {
        let suite = UserDefaults(suiteName: Constants.suiteName)
        let counter = suite?.integer(forKey: Constants.key) ?? 0
        suite?.setValue(counter+amount, forKey: Constants.key)
        return .result(value: counter)
    }
}
struct CounterIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Counter"

    @Parameter(title: "Amount")
    var amount: Int

    public init(amount: Int) {
        self.amount = amount
    }

    // We're required to have an empty initialiser by the system
    init() { amount = 0 }

    public func perform() async throws -> some IntentResult {
        let suite = UserDefaults(suiteName: Constants.suiteName)
        let counter = suite?.integer(forKey: Constants.key) ?? 0
        suite?.setValue(counter+amount, forKey: Constants.key)
        return .result(value: counter)
    }
}

Next, lets change our buttons to use this.

Button(intent: CounterIntent(amount: -1), label: {
    Image(systemName: "minus.circle.fill")
})


Spacer()

Button(intent: CounterIntent(amount: 1), label: {
    Image(systemName: "plus.circle.fill")
})
Button(intent: CounterIntent(amount: -1), label: {
    Image(systemName: "minus.circle.fill")
})


Spacer()

Button(intent: CounterIntent(amount: 1), label: {
    Image(systemName: "plus.circle.fill")
})

When we run it, and tap the buttons, everything works the same as before - with just the one intent.


Are you going to add activities to your widgets? I can't wait to pull out the hacks from my existing solutions :)

Sample code is on Github <3 Sample

If you fancy reading a little more, or sharing your experiences, I’m @SwiftyAlex on twitter.