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: