Teabyte

Mobile app development for iOS and Swift

AppIntents for Widgets

2023-06-10

During WWDC 2023 Apple announced that the AppIntents framework can now also be used to create Widget configurations. Since last year's announcement of AppIntents I was looking forward to also configuring Widgets with AppIntents. Now it's here and I want to share how to create an AppIntent based widget. I will assume some basic knowledge of how widgets work in general throughout the article. I can highly recommend checking out the official documentation about Widgets in that case.

Create an AppIntentConfiguration

Previously we used StaticConfiguration or IntentConfiguration to configure widgets. Starting from iOS 17 it's now possible to use AppIntentConfiguration to use AppIntents for configuration.

struct FavouriteIceCream: Widget {
    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: "com.test.icecream-favourite",
            intent: SelectFavouriteIceCream.self,
            provider: IceCreamProvider(),
        ) { entry in
            IceCreamView(entry: entry)
        }
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}

The additions to previous configuration options are that intent can now be of any type conforming to AppIntent && WidgetConfigurationIntent and provider needs to conform to AppIntentTimelineProvider.

Create AppIntent

An AppIntent type is essentially a way to make app content and functionality available to the system. With the addition of WidgetConfigurationIntent the type can now also be used to create configurations for widgets. Let's create a bare minimum app intent for our widget.

 
@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct SelectFavouriteIceCream: AppIntent, WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Select favourite ice cream"
    static var description = IntentDescription("Selects the user favourite ice cream")
 
    @Parameter(title: "IceCream", optionsProvider: IceCreamOptionsProvider())
    var iceCream: String
 
    @Parameter(title: "Site", optionsProvider: ToppingOptionsProvider())
    var topping: String
}

The configuration lets users select an ice cream as well as a topping. It uses the Parameter property wrapper to make the properties modifiable in the widget configuration UI.

For both options, we can declare a DynamicOptionsProvider which is responsible for giving the user options to choose from. For the sake of simplicity, we only allow String types here. It is also possible to declare custom types and custom enums as parameters. For this, I recommend reading the official documentation about custom data types.

extension SelectFavouriteIceCream {
    struct IceCreamOptionsProvider: DynamicOptionsProvider {
        func results() async throws -> [String] {
            ["Vanilla", "Strawberry", "Lemon"]
        }
    }
 
    struct ToppingOptionsProvider: DynamicOptionsProvider {
        func results() async throws -> [String] {
            ["Chocolate Sirup", "Sprinkles", "Peanut Butter", "Toasted Coconut Flakes"]
        }
    }
} 

Create AppIntentTimelineProvider

Last but not least we need to create the IceCreamProvider conforming to the new AppIntentTimelineProvider. These new types of protocol requirements are very similar to IntentTimelineProvider. The only differences I found are that an AppIntent is used for configuration and the methods for snapshot and timeline are using async/await instead of completions. As with the previous code, the provider is rather straightforward for our example:

struct IceCreamProvider: AppIntentTimelineProvider {
    typealias Entry = IceCreamEntry
    typealias Intent = SelectFavouriteIceCream
 
    func placeholder(in _: Context) -> IceCreamEntry {
        .sampleData
    }
 
    func snapshot(for configuration: SelectFavouriteIceCream, in context: Context) async -> IceCreamEntry {
        return .init(iceCream: configuration.iceCream, topping: configuration.topping)
    }
 
    func timeline(for configuration: SelectFavouriteIceCream, in context: Context) async -> Timeline<IceCreamEntry> {
        let nextUpdate: Date = .now.addingTimeInterval(60 * 15) // 15 minutes
        let entry = IceCreamEntry(iceCream: configuration.iceCream, topping: configuration.topping)
        return Timeline(entries: [entry], policy: .after(nextUpdate))
    }
}

With the provider being in place we have successfully created an AppIntent based widget. Great! In the next two chapters, I want to dive a little bit into more advanced techniques for widgets with app intents.

Create dependencies between @Parameters

In the past, it was possible to create dependencies between parameters of your intent definition files used to configure widgets. With AppIntents this is possible as well. For this we are going to use IntentParameterDependency. It's a property wrapper we can use within our DynamicOptionsProvider to create a dependency to a parameter of an app intent declaration.

In the context of the ice cream example let's say different toppings are only available for certain ice cream types. We declare a dependency to the SelectFavouriteIceCream intents .iceCream property. The type of the intent property will be treated as Optional<SelectFavouriteIceCream> from which we then can read the respective declared dependencies. In this case the .iceCream

extension SelectFavouriteIceCream {
    struct ToppingOptionsProvider: DynamicOptionsProvider {
        @IntentParameterDependency<SelectFavouriteIceCream>(
            \.$iceCream
        )
        var intent
 
        func results() async throws -> [String] {
            switch intent?.iceCream {
            case "Vanilla":
                return ["Chocolate Sirup"]
            case "Strawberry":
                return ["Sprinkles"]
            case "Lemon":
                return ["Toasted Coconut Flakes"]
            default: 
                return  ["Chocolate Sirup", "Sprinkles", "Peanut Butter", "Toasted Coconut Flakes"]
            }
        }
    }
} 

The IntentParameterDependency can also be used when querying for custom types as described in official documentation about custom data types.

Share AppIntents across Frameworks

Another valuable addition brought to AppIntents framework is the AppIntentsPackage protocol.

With this protocol, you can instruct the compiler to find app intents that are used in frameworks. Previously, app intents needed to be declared either in-app or extension targets directly to be correctly recognized. Now we can also move them to specific frameworks and have them correctly picked up.

This feature is especially helpful for modularized applications where intents might be declared in different features which themselves are frameworks.

To make app intents available from frameworks two things are necessary:

  1. Frameworks need to declare a type conforming to AppIntentsPackage
  2. In the top-level extension or app target you need to pick up the types and combine them.

It is like bubbling up the information of app intents in your dependency tree. Let us examine this in the following setup. The WidgetExtension depends on IceCreamUI which itself depends on IceCreamData. The latter one contains the app intent definitions.

Target: WidgetExtension -> Target: IceCreamUI -> Target: IceCreamIntents

In order to make the app intents available in the WidgetExtensions the following chain needs to be implemented:

// Target: IceCreamIntents
public struct IceCreamAppIntents: AppIntentsPackage { }
 // Empty delcaration tells compiler to search for all intents in this target
// Target: IceCreamUI
import IceCreamIntents
 
public struct IceCreamUIAppIntents: AppIntentsPackage {
    static var includedPackages: [AppIntentsPackage.Type] = [
        IceCreamAppIntents.self // Make child intents available here
    ]
}
// Target: WidgetExtension
import IceCreamUI
 
@main
struct WidgetsBundle: WidgetBundle {
    @WidgetBundleBuilder var body: some Widget {
       ...
    }
}
 
@available(iOSApplicationExtension 17.0, *)
extension WidgetsBundle: AppIntentsPackage {
    static var includedPackages: [AppIntentsPackage.Type] = [
        IceCreamUIAppIntents.self // Make child intents available here
    ]
}

With this chain, we could potentially build a tree from the bottom up to collect all app intents from different frameworks until we reach the root at the app or extension target.

In my opinion, this is a very helpful addition to the AppIntents framework. Especially if you want to share app intent definitions across multiple frameworks.

Conclusion

Thanks a lot for reading this far! This year's additions to AppIntents to let us allow them also to configure widgets brings us one more step closer to code-only projects. It seems to be clear that Apple favours code as the source of truth for the upcoming years. Maybe to even ditch the Xcode project files 🤔? Let's see.

I hope you found this article interesting and can use some of the information provided in your daily development work.

If you have any questions or found some issues, please don't hesitate to reach out!

See you next time 👋