In iOS 17 (announced in June 2023), it became possible to configure widgets using the new "App Intents" system, instead of the legacy "INIntents" system.
The legacy .intentsdefinition
file had become extremely unwieldy to use, so this is a great improvement (especially when supporting as many languages as Streaks does).
However, there was one particular feature we're using which has made this conversion difficult: parent parameters.
For example, take the Streaks stats widget. This is a single widget that allows the user to choose different charts to show:
Once configured, it would shows the associated chart:
Certain charts also included a "time of day" parameter, but using the parent parameter option, the time of day selection would only show when relevant. This is represented in the .intentsdefinition
file as follows:
In order to make this function in the same way when using App Intents, we used the parameterSummary
property:
struct StatsWidgetConfigurationAppIntent: AppIntent, WidgetConfigurationIntent {
// ...
// @Parameter var type, timePeriod, taskType, etc..
// ...
static var parameterSummary: some ParameterSummary {
When(\StatsWidgetConfigurationAppIntent.$type, .oneOf, [.timeOfDay, .daysOfWeek, .completionSuccess]) {
Summary("\(\.$type)") {
\.$timePeriod
\.$taskType
\.$showTitle
\.$theme
}
} otherwise : {
Summary("\(\.$type)") {
\.$taskType
\.$showTitle
\.$theme
}
}
}
}
This reads as follows:
If the user selects "time of day", "day of week", or "completion success", show list of options including the "time period" option. For all other chart types, don't show "time period".
This all works great!
The problem occurred with the other conditional parameter above: taskType
, which was presented as follows:
If the user chose "Specific", then an option to choose a specific task is shown. If they choose "Next of Page", then an option with a list of pages is shown, and so on.
In order to model this in an App Entity using parameterSummary
, we would then need a different summary for every task type ("automatic", "specific", "next on page", "next in category"), as well as accounting for each chart type. This is 8 combinations.
Now consider another of our widgets: the "Tasks" widget. This lets you choose up to 4 different tasks, and has the same options:
In this case, there would be 16 combinations, which really doesn't scale well. It's extremely hard to maintain and is inflexible if future changes are needed.
To solve this, I introduced a new type called TaskTypeAppEntity
, which encapsulates the four different types (automatic, specific, next on page, next in category) in a single entity:
struct TaskTypeAppEntity: AppEntity, Identifiable {
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Task Type"
let id: TaskTypeAppEntityIdentifier
var title: String
var displayRepresentation: DisplayRepresentation {
.init(title: .init(stringLiteral: title))
}
static let defaultQuery = TaskTypeAppEntityQuery()
}
enum TaskTypeAppEntityIdentifier {
case automatic
case task(TaskID)
case category(CategoryID)
case page(PageID)
}
When building the defaultQuery
, it's just a case of including all of the options in that query. You can even group it into separate sections:
This was achieved with an EntityQuery
as follows:
struct TaskTypeAppEntityQuery: EntityQuery {
typealias Entity = TaskTypeAppEntity
func suggestedEntities() async throws -> IntentItemCollection<Entity> {
// The "Automatic Option"
let automaticItem: IntentItem<Entity> = .init(
.init(
id: .automatic,
title: "Automatic"
)
)
// Build the task items
let taskItems: [IntentItem<Entity>] = [
.init(
Entity(
id: .task(/* some ID */),
title: "Walk the Dog"
)
)
]
// Build the category items
let categoryItems: [IntentItem<Entity>] = [
.init(
Entity(
id: .category(/* some ID */),
title: "Health & Fitness"
)
)
]
// Create the task section, with a title
let tasksSection: IntentItemSection<Entity> = .init(
"Task",
items: taskItems
)
// Create the category section, with a title
let categoriesSection: IntentItemSection<Entity> = .init(
"Category",
items: categoryItems
)
// Combine all of the sections
let sections: [IntentItemSection<Entity>] = [
.init(items: [ automaticItem ]),
tasksSection,
categoriesSection
]
return .init(sections: sections)
}
// Make the automatic option the default option
func defaultResult() async -> TaskTypeAppEntity? {
.init(id: .automatic, title: "Automatic")
}
func entities(for identifiers: [Entity.ID]) async throws -> [Entity] {
// ...
}
}
Note: The defaultResult()
value is very useful here, since it will ensure the correct option is chosen when the widget is first added. It's not possible to specify a default for non-enum @Property directly in the AppIntent, so it must be done here.
Referring back to the "Tasks" widget configuration above, instead of needing 16 combinations to present the widget options nicely, now there is only one. Because of this, I've omitted the parameterSummary
completely and let the system generate it.
The four different task types can now be modelled as follows:
struct TasksWidgetConfigurationAppIntent: WidgetConfigurationIntent {
// other parameters
@Parameter(title: "Task 1")
var task1Type: TaskTypeAppEntity
@Parameter(title: "Task 2")
var task2Type: TaskTypeAppEntity
@Parameter(title: "Task 3")
var task3Type: TaskTypeAppEntity
@Parameter(title: "Task 4")
var task4Type: TaskTypeAppEntity
// other parameters
}
Since the complexity of TaskTypeAppEntity
is grouped nicely within its own reusable struct and EntityQuery
, the actual widget configuration is far simpler now. Additionally, I'm able to use TaskTypeAppEntity
everywhere across Streaks where it makes sense to decide how a task is chosen for display.