I was looking into configurable control widgets and realised that the code mentioned in the WWDC session and documentation about it is partial.
If you have found this blog post, I have got good news for you. You can save yourself some time and examine the code for configurable control widgets created step by step.
No more guesswork, just straight-up, working code.
If you are creating the control widget from scratch, here is the first version if this series:
One method needed from the ControlWidgetConfiguration
is the ability to prompt for user configuration.
@MainActor @preconcurrency
func promptsForUserConfiguration() -> some ControlWidgetConfiguration
This method shows a configuration UI as soon as the widget is added.
App Intents
Here is where I got stuck to have two different intents: SelectTimerIntent
and ToggleTimerIntent
. Let's break them down.
SelectTimerIntent
struct SelectTimerIntent: ControlConfigurationIntent {
static var title: LocalizedStringResource = "Select Timer"
@Parameter(title: "Timer")
var timer: Timer?
init() {}
init(_ timer: Timer?) {
self.timer = timer
}
func perform() async throws -> some IntentResult {
.result()
}
}
This intent is used to choose which timer we want to work with. It is a ControlConfigurationIntent
, which means it is used to configure the widget. The timer
parameter allows the user to select a specific timer.
ToggleTimerIntent
struct ToggleTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Productivity Timer"
@Parameter(title: "Running")
var value: Bool
@Parameter(title: "Timer")
var timer: Timer
init() {}
init(timer: Timer) {
self.timer = timer
}
func perform() throws -> some IntentResult {
TimerManager.shared.setTimerRunning(value)
return .result()
}
}
This intent is used to actually start or stop the timer. It is a SetValueIntent
, which is perfect for toggling states. The value
parameter represents whether the timer should be running or not, and the timer
parameter specifies which timer we are controlling.
The SelectTimerIntent
handles configuration, while ToggleTimerIntent
handles the action.
Building the Timer Entity
Before we can create the widget, we need to define what a timer is. Here is an example of a Timer
struct:
struct Timer: Identifiable, AppEntity {
static var defaultQuery = TimerQuery()
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Timer"
let id: Int
let name: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
This struct conforms to both Identifiable
(so we can easily distinguish between timers) and AppEntity
(so we can use it with the intents).
We also need a way to query the timers for the configuration:
struct TimerQuery: EntityQuery {
func entities(for identifiers: [Timer.ID]) async throws -> [Timer] {
let allTimers = [
Timer(id: 1, name: "Work Timer"),
Timer(id: 2, name: "Break Timer"),
Timer(id: 3, name: "Exercise Timer"),
Timer(id: 4, name: "Study Timer"),
Timer(id: 5, name: "Meditation Timer")
]
return identifiers.compactMap { id in allTimers.first(where: { $0.id == id }) }
}
func suggestedEntities() async throws -> [Timer] {
try await entities(for: [1, 2, 3, 4, 5])
}
}
This query allows to fetch specific timers by ID and suggest all available timers to the user.
Managing Timer State
To keep track of whether a timer is running or not, we use a simple TimerState
struct:
struct TimerState {
let timer: Timer
let isRunning: Bool
}
And to manage the timers, we have a TimerManager
singleton:
class TimerManager {
static let shared = TimerManager()
func fetchTimerRunning(timer: Timer) async throws -> Bool {
// Logic to check if the given timer is currently running
// Returns true if the timer is running, false otherwise
false // Placeholder implementation
}
func setTimerRunning(_ isRunning: Bool) {
// Logic to start or stop the timer based on the isRunning value
}
}
In a real timer app, you would replace these placeholder implementations with actual timer logic. Yeah, I know. Fill in the blanks!
The TimerToggle
Widget
Finally, here is the TimerToggle
widget:
struct TimerToggle: ControlWidget {
static let kind: String = "com.rudrankriyam.timertoggle"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: ConfigurableProvider()
) { timerState in
ControlWidgetToggle(
timerState.timer.name,
isOn: timerState.isRunning,
action: ToggleTimerIntent(timer: timerState.timer),
valueLabel: { isOn in
Label(isOn ? "Running" : "Stopped", systemImage: "timer")
}
)
}
.displayName("Productivity Timer")
.description("Start and stop a productivity timer.")
.promptsForUserConfiguration()
}
}
We use AppIntentControlConfiguration
with the ConfigurableProvider
(we will look at this next). The configuration takes a closure that receives a TimerState
and returns a ControlWidgetToggle
.
The toggle uses the timer's name, its running state, and a ToggleTimerIntent
for its action. We provide a custom label that shows "Running" or "Stopped" along with a timer icon.
We set a display name and description for our widget and call promptsForUserConfiguration()
to ensure the user sets up the widget.
Configuring the Provider
The ConfigurableProvider
is where we handle the selection of the timer:
extension TimerToggle {
struct ConfigurableProvider: AppIntentControlValueProvider {
func previewValue(configuration: SelectTimerIntent) -> TimerState {
TimerState(timer: configuration.timer ?? .init(id: 1, name: "Work Timer"), isRunning: false)
}
func currentValue(configuration: SelectTimerIntent) async throws -> TimerState {
let timer = configuration.timer ?? .init(id: .init(), name: "Work Timer")
let isRunning = try await TimerManager.shared.fetchTimerRunning(timer: timer)
return TimerState(timer: timer, isRunning: isRunning)
}
}
}
This provider uses the SelectTimerIntent
to get the current timer and its state. It provides both a preview value (for when the widget is being configured) and the current value (for when the widget is actually in use).
Here is how it looks:
Moving Forward
Putting in the missing code from the documentation, we have created a configurable Control Widget for iOS 18. You can play around with the logic for each intent, and update the control accordingly.
Happy widgeting!