Keep An iOS 18 Control Center Toggle In Sync With App State
iOS 18 adds a new kind of WidgetKit surface: controls. Instead of showing timeline content like a Home Screen widget, a control gives the system a small action the user can run from places like Control Center and the Lock Screen. On devices with an Action button, users can assign a control there too.
Controls can be buttons or toggles. A button is right for a discrete action like opening a screen or starting a capture flow. A toggle is right when the action changes boolean state and the system should show whether that state is currently on or off.
The code in this post targets iOS 18 and iPadOS 18. Put the control in your widget extension, keep shared state somewhere both the app and widget extension can read, and gate any app-side reload calls with #available(iOS 18.0, *) if your app still deploys to older systems.
Let’s build a small “Focus Mode” toggle. The app can turn focus mode on from its own settings screen, and the Control Center toggle should reflect that change without waiting for the user to tap it.
Share the state
A control runs in your widget extension process when WidgetKit asks for its current value. If the app and the widget extension need to agree on state, use a shared container, a database both targets can read, or a server-backed value.
For a tiny boolean, an App Group UserDefaults store is enough:
import Foundation
enum FocusModeControlConstants {
static let kind = "com.example.ReaderApp.focus-mode"
}
enum FocusModeStore {
private static let appGroupID = "group.com.example.ReaderApp"
private static let key = "focusModeEnabled"
private static var defaults: UserDefaults {
guard let defaults = UserDefaults(suiteName: appGroupID) else {
preconditionFailure("Missing App Group configuration")
}
return defaults
}
static var isEnabled: Bool {
defaults.bool(forKey: key)
}
static func setEnabled(_ isEnabled: Bool) {
defaults.set(isEnabled, forKey: key)
}
}
Both the app target and the widget extension target need the same App Group entitlement, and both need to read and write through the suite-name store. Avoid accidentally using .standard or an app-only @AppStorage key for the same setting. In a production app, I also like to keep the kind string in shared code. It has to match everywhere: the control definition, reload calls, and any future push configuration.
Define the toggle intent
Controls perform work through App Intents. A ControlWidgetToggle expects a SetValueIntent whose value type is Bool. WidgetKit sets the intent’s value before calling perform(), so your intent should treat that property as the state the user requested:
import AppIntents
struct SetFocusModeIntent: SetValueIntent {
static let title: LocalizedStringResource = "Focus Mode"
@Parameter(title: "Enabled")
var value: Bool
func perform() async throws -> some IntentResult {
FocusModeStore.setEnabled(value)
return .result()
}
}
Keep the intent boring. It should write the new value, finish any state updates needed by the control, and then return. When a user taps the control, the system reloads it after perform() completes, so make sure the shared store has already been updated by then.
Provide the current value
Even when your state is cheap and synchronous, I usually put the read behind a ControlValueProvider once the control needs to stay honest over time. It gives WidgetKit a fast preview value for the gallery and an async place to fetch the real value when the control is rendered:
import WidgetKit
struct FocusModeValueProvider: ControlValueProvider {
let previewValue = false
func currentValue() async throws -> Bool {
FocusModeStore.isEnabled
}
}
The preview value should be immediate and deterministic. It is used before the user adds the control, so avoid reaching into the network or doing expensive work there. currentValue() can suspend and throw, which makes it the right place for a database read or server-backed state fetch.
Build the control
Now add the control to the widget extension:
import AppIntents
import SwiftUI
import WidgetKit
struct FocusModeControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: FocusModeControlConstants.kind,
provider: FocusModeValueProvider()
) { isEnabled in
ControlWidgetToggle(
"Focus Mode",
isOn: isEnabled,
action: SetFocusModeIntent()
) { isOn in
Label(
isOn ? "On" : "Off",
systemImage: isOn ? "moon.zzz.fill" : "moon.zzz"
)
}
.tint(.indigo)
}
.displayName("Focus Mode")
.description("Turn reading focus mode on or off.")
}
}
The value-label closure receives the current isOn value. Returning a Label there lets you customize both the symbol and short value text while the title string remains the control’s main label. Keep the symbol distinctive because some placements only show the icon. On the Lock Screen and in the small Control Center size, there may not be room for your value text.
Add the control to the widget bundle the same way you add a widget:
import WidgetKit
@main
struct ReaderWidgets: WidgetBundle {
var body: some Widget {
FocusModeControl()
ReadingStatsWidget()
}
}
If you already have a bundle, just add the control beside the existing widgets.
Reload when the app changes state
The tap path is only half of the sync story. If the user changes the same setting from inside your app, WidgetKit does not know your shared boolean changed unless you tell it.
Call ControlCenter.shared.reloadControls(ofKind:) after the app writes the new state:
import WidgetKit
@MainActor
final class FocusModeSettingsModel: ObservableObject {
@Published private(set) var isEnabled = FocusModeStore.isEnabled
func setFocusModeEnabled(_ isEnabled: Bool) {
FocusModeStore.setEnabled(isEnabled)
self.isEnabled = isEnabled
if #available(iOS 18.0, *) {
ControlCenter.shared.reloadControls(
ofKind: FocusModeControlConstants.kind
)
}
}
}
Reloading is a request, not a direct redraw. The important part is the order: write the source of truth first, then ask WidgetKit to reload the matching control kind. When WidgetKit evaluates the control again, FocusModeValueProvider.currentValue() reads the fresh value.
You can also call ControlCenter.shared.reloadAllControls() when several control kinds depend on the same state, but the narrower reload is usually easier to reason about.
During development, remember that reloading is asynchronous. Verify the sync path by checking that currentValue() reads the shared store you just wrote, rather than assuming the visible control redraws immediately.
Make it configurable when one toggle is not enough
StaticControlConfiguration is right for one global toggle. If the user should be able to add several copies of the control, each pointing at a different account, project, room, or timer, move to AppIntentControlConfiguration.
The value provider becomes configuration-aware:
import AppIntents
import WidgetKit
struct SelectProjectIntent: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Project"
@Parameter(title: "Project")
var project: ProjectEntity
}
struct ProjectFocusState {
var project: ProjectEntity
var isEnabled: Bool
}
struct ProjectFocusValueProvider: AppIntentControlValueProvider {
func previewValue(configuration: SelectProjectIntent) -> ProjectFocusState {
ProjectFocusState(project: configuration.project, isEnabled: false)
}
func currentValue(
configuration: SelectProjectIntent
) async throws -> ProjectFocusState {
ProjectFocusState(
project: configuration.project,
isEnabled: try await ProjectStore.shared.isFocusModeEnabled(
for: configuration.project.id
)
)
}
}
Then the control uses the configured value when building its toggle:
struct ProjectFocusControl: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: "com.example.ReaderApp.project-focus",
provider: ProjectFocusValueProvider()
) { state in
ControlWidgetToggle(
state.project.displayString,
isOn: state.isEnabled,
action: SetProjectFocusIntent(project: state.project)
) { isOn in
Label(
isOn ? "Focused" : "Normal",
systemImage: isOn ? "checkmark.circle.fill" : "circle"
)
}
}
.promptsForUserConfiguration()
}
}
That snippet assumes ProjectEntity and SetProjectFocusIntent are already defined in your App Intents layer. The WidgetKit-specific idea is that the provider receives the user’s configuration, fetches the value for that configuration, and passes both pieces into the control template.
Keep the contract simple
The control should have one source of truth. The intent writes to it. The value provider reads from it. The app writes to the same place and asks WidgetKit to reload when the app changes it.
That small contract avoids the most common sync bug: the app toggles one copy of state while Control Center reads another. Once the shared store is clear, ControlWidgetToggle, SetValueIntent, ControlValueProvider, and ControlCenter.shared.reloadControls(ofKind:) fit together cleanly.
Further reading:
- Controls: https://developer.apple.com/documentation/widgetkit/controls-collection
- Extend your app’s controls across the system: https://developer.apple.com/videos/play/wwdc2024/10157/
- Creating controls to perform actions across the system: https://developer.apple.com/documentation/widgetkit/creating-controls-to-perform-actions-across-the-system
- Updating controls locally and remotely: https://developer.apple.com/documentation/widgetkit/updating-controls-locally-and-remotely
- ControlWidgetToggle: https://developer.apple.com/documentation/widgetkit/controlwidgettoggle
- AppIntentControlConfiguration: https://developer.apple.com/documentation/widgetkit/appintentcontrolconfiguration
- ControlValueProvider: https://developer.apple.com/documentation/widgetkit/controlvalueprovider