Issue #1043
iOS 18 introduced interactive controls that live directly in Control Center and on the Lock Screen. Unlike widgets that only display information, control widgets respond to taps, letting users trigger actions without opening the app. A ControlWidgetToggle is the simplest form: it represents a boolean state and fires an AppIntent when the user taps it.
This article walks through building a Focus Session toggle. Tapping it starts or ends a focus block, and the control reflects the current state each time the user opens Control Center.
Setting up the widget extension
Control widgets live in a Widget Extension target, the same target type used for home screen widgets. In Xcode, add a new target via File > New > Target and choose Widget Extension. Make sure the extension and the main app share an App Group so they can read and write the same UserDefaults suite.
The widget extension’s entry point lists which widgets to expose:
@main
struct FocusWidgetBundle: WidgetBundle {
var body: some Widget {
FocusSessionWidget()
}
}
Sharing state with the main app
The toggle needs to persist its state somewhere both the app and the extension can reach. An App Group gives both targets access to the same UserDefaults suite:
struct FocusSession {
private static let suite = UserDefaults(suiteName: "group.com.example.focusapp")
static var isActive: Bool {
get { suite?.bool(forKey: "focusActive") ?? false }
set { suite?.set(newValue, forKey: "focusActive") }
}
}
This keeps the storage detail in one place. Both the intent and the provider read through this computed property.
The action intent
When the user taps the toggle, WidgetKit calls an intent. For a boolean control, that intent conforms to SetValueIntent, which provides a typed value parameter automatically wired to the toggle state:
struct ToggleFocusIntent: SetValueIntent {
static let title: LocalizedStringResource = "Toggle Focus Session"
@Parameter(title: "Active")
var value: Bool
func perform() async throws -> some IntentResult {
FocusSession.isActive = value
return .result()
}
}
SetValueIntent is distinct from a plain AppIntent. WidgetKit passes the new boolean directly into value before calling perform(), so there is no need to read the previous state and invert it manually.
The value provider
The provider gives WidgetKit the current state of the toggle. For a widget that requires no user configuration, conform to StaticControlValueProvider:
struct FocusSessionProvider: StaticControlValueProvider {
var previewValue: Bool { false }
func currentValue() async throws -> Bool {
FocusSession.isActive
}
}
previewValue appears in the widget gallery before the user adds the control. currentValue() is called each time WidgetKit needs a fresh reading, such as after the intent runs or when the user opens Control Center.
Building the widget
With the intent and provider in place, the widget itself is straightforward:
struct FocusSessionWidget: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.example.focusapp.FocusSession",
provider: FocusSessionProvider()
) { isActive in
ControlWidgetToggle(
"Focus Session",
isOn: isActive,
action: ToggleFocusIntent()
) { on in
Label(
on ? "Session active" : "Start session",
systemImage: on ? "moon.fill" : "moon"
)
}
}
}
}
The label closure receives the current boolean and returns a Label. WidgetKit uses the label’s text and image in the control’s compact and expanded presentations. Choosing different SF Symbols for the on and off states gives the user an immediate visual cue without looking at the title.
Availability
ControlWidget and ControlWidgetToggle require iOS 18. If the widget extension target supports older OS versions, guard the declaration:
@available(iOS 18, *)
struct FocusSessionWidget: ControlWidget { ... }
When the extension’s deployment target is set to iOS 18 or later, the annotation is not needed.
Once the widget is built and installed, it appears under the app’s section in the Controls gallery. The user can add it to Control Center or to the Lock Screen, and each tap updates the shared UserDefaults value through the intent.
Start the conversation