WWDC Quick Look 💓 By SwiftGGTeam
Extend your app's controls across the system

Extend your app's controls across the system

Watch original video

Highlight

iOS 18 adds a Control widget type to WidgetKit, supporting Button and Toggle forms in Control Center, the Lock Screen, and the iPhone 15 Pro Action button.

Core Content

WidgetKit in iOS 14 let apps display rich information like weather or calendar events. iOS 18 extends the framework with a new Control widget type. Controls focus on quick actions—turning on a flashlight or deep-linking to the Clock app.

Controls come in two types: Button for one-time actions (including launching the app), and Toggle for boolean state (e.g., toggling a feature). Like interactive widgets, Controls use App Intents to perform actions. Your app provides symbol, title, tint color, and other visual info; the system decides final appearance based on placement.

Presenter Cliff walks through building Controls with a “productivity timer” example—start on the Lock Screen, stop in Control Center, toggle via Action button—same code, three entry points. Control state management: the system auto-triggers reload after App Intent completion; apps can also request refresh via the ControlCenter API, or trigger reload via push notification for cross-device sync.

Detailed Content

Basic Control construction

First step: add the Control to your existing Widget Bundle (03:13):

@main
struct ProductivityExtensionBundle: WidgetBundle {
    
    var body: some Widget {
        ChecklistWidget()
        TaskCounterWidget()
        TimerToggle()
    }
    
}

Key points:

  • TimerToggle() is the new Control entry, sharing the Widget Bundle with existing widgets
  • WidgetKit architecture lets Controls coexist with Widgets in the same extension

Next, define the Control itself (03:29):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle"
        ) {
            ControlWidgetToggle(
                "Work Timer",
                isOn: TimerManager.shared.isRunning,
                action: ToggleTimerIntent()
            ) { _ in
                Image(systemName:
                      "hourglass.bottomhalf.filled")
            }
        }
    }
}

Key points:

  • ControlWidget is the protocol Controls conform to
  • StaticControlConfiguration is for non-configurable Controls
  • kind is the Control’s unique identifier in reverse-DNS style
  • ControlWidgetToggle defines a Toggle-type control
  • isOn binds to TimerManager’s running state
  • ToggleTimerIntent() is the App Intent executed on tap
  • The closure’s Image defines the Control’s symbol

Stateful visual design

Controls can show different symbols based on state (04:41):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle"
        ) {
            ControlWidgetToggle(
                "Work Timer",
                isOn: TimerManager.shared.isRunning,
                action: ToggleTimerIntent()
            ) { isOn in
                Image(systemName: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
        }
    }
}

Key points:

  • Closure parameter isOn lets you return different visuals per state
  • Running shows flowing hourglass; stopped shows half-filled hourglass
  • System re-evaluates closure return value on state changes

Further customize value text and tint (05:21):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle"
        ) {
            ControlWidgetToggle(
                "Work Timer",
                isOn: TimerManager.shared.isRunning,
                action: ToggleTimerIntent()
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
            .tint(.purple)
        }
    }
}

Key points:

  • Label replaces Image to show text and symbol together
  • isOn ? "Running" : "Stopped" customizes state text instead of default “on/off”
  • .tint(.purple) sets on-state color instead of default systemBlue
  • Value text is hidden on Lock Screen and small Control Center size—symbol only

App Intent implementation

Control actions run via App Intent (08:14):

struct ToggleTimerIntent: SetValueIntent, LiveActivityIntent {
    static let title: LocalizedStringResource = "Productivity Timer"
    
    @Parameter(title: "Running")
    var value: Bool  // The timer's running state
    
    func perform() throws -> some IntentResult {
        TimerManager.shared.setTimerRunning(value)
        return .result()
    }
}

Key points:

  • SetValueIntent protocol means this Intent sets a value
  • LiveActivityIntent protocol means it modifies a Live Activity
  • @Parameter value is auto-filled by the system from current state
  • perform() must complete all state updates before returning
  • System auto-reloads Control after perform() returns

Proactive Control refresh

When in-app state changes, proactively request Control refresh (08:54):

func timerManager(_ manager: TimerManager,
                  timerDidChange timer: ProductivityTimer) {
    ControlCenter.shared.reloadControls(
        ofKind: "com.apple.Productivity.TimerToggle"
    )
}

Key points:

  • ControlCenter.shared.reloadControls(ofKind:) requests refresh for a specific Control
  • Use when in-app state changes need to sync to the Control
  • Enable WidgetKit Developer Mode during development to bypass system refresh policy

Async state fetching

When state must be fetched asynchronously from server or database, use ControlValueProvider (10:03):

struct TimerValueProvider: ControlValueProvider {
    
    func currentValue() async throws -> Bool {
        try await TimerManager.shared.fetchRunningState()
    }
    
    let previewValue: Bool = false
}

Key points:

  • currentValue() is async—fetch from database or server
  • Can throw to indicate state temporarily unavailable
  • previewValue is for control preview (gallery, Lock Screen customization)—should return quickly and represent off state

Control initialization with ValueProvider (11:00):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: TimerValueProvider()
        ) { isRunning in
            ControlWidgetToggle(
                "Work Timer",
                isOn: isRunning,
                action: ToggleTimerIntent()
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
            .tint(.purple)
        }
    }
}

Key points:

  • provider parameter passes a ValueProvider instance
  • Async-fetched value passed via closure parameter isRunning
  • System runs ValueProvider first, then generates Control content from return value

Configurable Controls

For user-configurable Controls, use AppIntentControlValueProvider (13:06):

struct ConfigurableTimerValueProvider: AppIntentControlValueProvider {
    func currentValue(configuration: SelectTimerIntent) async throws -> TimerState {
        let timer = configuration.timer
        let isRunning = try await TimerManager.shared.fetchTimerRunning(timer: timer)
        return TimerState(timer: timer, isRunning: isRunning)
    }
    
    func previewValue(configuration: SelectTimerIntent) -> TimerState {
        return TimerState(timer: configuration.timer, isRunning: false)
    }
}

Key points:

  • AppIntentControlValueProvider makes values depend on Intent configuration
  • SelectTimerIntent lets users choose which timer to control
  • Returned TimerState is a custom struct with timer and running state

Corresponding Control configuration (13:40):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: ConfigurableTimerValueProvider()
        ) { timerState in
            ControlWidgetToggle(
                timerState.timer.name,
                isOn: timerState.isRunning,
                action: ToggleTimerIntent(timer: timerState.timer)
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
            .tint(.purple)
        }
    }
}

Key points:

  • AppIntentControlConfiguration is for configurable Controls
  • timerState is a custom struct with richer state info
  • Control title and action target change dynamically based on configuration

Force user configuration (14:26):

struct SomeControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            // ...
        )
        .promptsForUserConfiguration()
    }
}

Key points:

  • promptsForUserConfiguration() auto-shows configuration UI when adding the Control
  • For Controls that require configuration to work

UI fine-tuning

Customize Action Button action hint (15:42):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: ConfigurableTimerValueProvider()
        ) { timerState in
            ControlWidgetToggle(
                timerState.timer.name,
                isOn: timerState.isRunning,
                action: ToggleTimerIntent(timer: timerState.timer)
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
                .controlWidgetActionHint(isOn ?
                                         "Start" : "Stop")
            }
            .tint(.purple)
        }
    }
}

Key points:

  • controlWidgetActionHint customizes Action Button hint text
  • Hint should start with a verb; system adds “Hold to” prefix
  • isOn ? "Start" : "Stop" becomes “Hold to Start” and “Hold to Stop”

Set display name and description (16:56):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: ConfigurableTimerValueProvider()
        ) { timerState in
            ControlWidgetToggle(
                timerState.timer.name,
                isOn: timerState.isRunning,
                action: ToggleTimerIntent(timer: timerState.timer)
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
                .controlWidgetActionHint(isOn ?
                                         "Start" : "Stop")
            }
            .tint(.purple)
        }
        .displayName("Productivity Timer")
        .description("Start and stop a productivity timer.")
    }
}

Key points:

  • displayName sets Control display name instead of default app name
  • description shows in configuration UI to help users understand the Control
  • Every Control should set a specific displayName

Core Takeaways

  • Add Control entry points for high-frequency actions: Identify one or two actions users perform most (start focus, toggle whitelist, quick log) and create Toggle or Button Controls. Don’t cram entire app functionality in—Controls should be single-feature quick entry points.

  • Start with a non-configurable Toggle: Build the simplest Toggle Control with StaticControlConfiguration first to validate core functionality. Consider AppIntentControlConfiguration for multiple user-configurable instances only after the baseline works—avoid introducing too much complexity at once.

  • Design symbols for small sizes: Controls show only a circular icon on Lock Screen and symbol-only space in small Control Center. Ensure SF Symbols remain recognizable at tiny sizes; don’t rely on text. Tint colors need sufficient contrast in light and dark modes.

  • Use ValueProvider for async state: If state comes from network or database, use ControlValueProvider instead of synchronous fetch in body. previewValue should be fixed and fast—usually the Control’s off state.

  • Proactively refresh to keep state in sync: When users change state inside the app, use ControlCenter.shared.reloadControls(ofKind:) to refresh Controls. For cross-device scenarios, configure push notifications to auto-reload on state change pushes.

Comments

GitHub Issues · utterances