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:

Exploring WidgetKit: Creating Your First Control Widget in iOS 18 and SwiftUI
Discover how to create Control Widgets in iOS 18 using SwiftUI and WidgetKit. Learn to build interactive buttons for the Lock Screen and Control Center with ControlWidgetButton. Explore the ControlWidget protocol and ControlWidgetConfiguration to enhance your app’s functionality.

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:

0:00
/0:22

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!

Astro Affiliate.

Astro (Affiliate)

Find the right keywords for your app and climb the App Store rankings. Improve your app visibility and increase your revenue with Astro. The first App Store Optimization tool designed for indie developers!

Tagged in: