Control Widgets appear in Control Center, Siri suggestions, the lock screen, and Shortcuts. They provide quick actions without opening your app.
Control Widgets require iOS 18+ and are typically added to an existing widget target.
Getting Started
Control Widgets use the App Intents framework and should be placed in the _shared folder so they’re linked to both your main app and widget target.
Create a widget target (if needed)
Add Control Widget to _shared folder
Create targets/widget/_shared/intents.swift with your control widget code. The _shared folder ensures the code is linked to both targets, which is required for controls to work.
Register the control in your WidgetBundle
Edit targets/widget/index.swift to include your control:import WidgetKit
import SwiftUI
@main
struct exportWidgets: WidgetBundle {
var body: some Widget {
widget()
widgetControl0() // Add your control here
WidgetLiveActivity()
}
}
Run prebuild
npx expo prebuild -p ios --clean
Here’s a complete example of a control widget that launches a URL in your app:
targets/widget/_shared/intents.swift
import AppIntents
import SwiftUI
import WidgetKit
@available(iOS 18.0, *)
struct widgetControl0: ControlWidget {
// Unique ID for the control
static let kind: String = "com.bacon.clipdemo.0"
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: Self.kind) {
ControlWidgetButton(action: OpenAppIntent0()) {
// Use SF Symbols for icons
Label("App Settings", systemImage: "star")
}
}
.displayName("Launch Settings")
.description("A control that launches the app settings.")
}
}
// Must be in _shared folder when openAppWhenRun = true
@available(iOS 18.0, *)
struct OpenAppIntent0: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Launch Settings"
static let description = IntentDescription(stringLiteral: "A control that launches the app settings.")
static let isDiscoverable = true
static let openAppWhenRun: Bool = true
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
// Launch your app with a universal link
return .result(opensIntent: OpenURLIntent(URL(string: "https://pillarvalley.expo.app/settings")!))
}
}
Toggle Control Example
The default template includes a toggle control:
targets/widget/WidgetControl.swift
import AppIntents
import SwiftUI
import WidgetKit
struct widgetControl: ControlWidget {
static let kind: String = "com.developer.example.widget"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension widgetControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
widgetControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return widgetControl.Value(isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer…
return .result()
}
}
Reloading Controls
Update controls from your React Native app:
import { ExtensionStorage } from "@bacons/apple-targets";
// Reload all controls
ExtensionStorage.reloadControls();
// Reload a specific control by kind
ExtensionStorage.reloadControls("com.bacon.clipdemo.0");
Using Custom Icons
Control Widgets only support SF Symbols for icons. To use custom icons:
-
Convert your icon to an SF Symbol using Create Custom Symbols
-
Add the
.svg file to Assets.xcassets in Xcode
-
Reference it in your Label:
Label("My Action", systemImage: "my.custom.icon")
Custom SF Symbols must follow Apple’s design guidelines for Control Center icons.
Sharing Data with Your App
Control Widgets can read and write shared data using App Groups:
@available(iOS 18.0, *)
struct ToggleFeatureIntent: SetValueIntent {
@Parameter(title: "Feature Enabled")
var value: Bool
func perform() async throws -> some IntentResult {
// Write to shared storage
let defaults = UserDefaults(suiteName: "group.com.yourapp.shared")
defaults?.set(value, forKey: "featureEnabled")
defaults?.synchronize()
// Reload controls to reflect new state
ControlCenter.shared.reloadAllControls()
return .result()
}
}
Read the value in your React Native app:
import { ExtensionStorage } from "@bacons/apple-targets";
const storage = new ExtensionStorage("group.com.yourapp.shared");
const isEnabled = storage.get("featureEnabled") === "1";
See the Sharing Data guide for more details.
Apple supports several control widget types:
Single tap action:
ControlWidgetButton(action: MyIntent()) {
Label("Action", systemImage: "star")
}
Toggle
On/off state:
ControlWidgetToggle(
"Feature",
isOn: value.isEnabled,
action: ToggleIntent()
) { isOn in
Label(isOn ? "On" : "Off", systemImage: "switch.2")
}
Slider
Value adjustment:
ControlWidgetSlider(
"Volume",
value: value.volume,
in: 0...100,
action: SetVolumeIntent()
)
Customize the appearance and behavior:
struct MyControl: ControlWidget {
static let kind: String = "com.yourapp.control"
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: Self.kind) {
// Your control UI
}
.displayName("Control Name") // Name in Control Center
.description("What this control does.") // Description for users
.promptsForUserConfiguration() // Show config UI when added
}
}
Real-World Example
The Pillar Valley game uses control widgets for quick actions. The example from the README shows launching different parts of the app from Control Center.
Debugging
Test in Simulator: Control widgets appear in the Control Center simulator.
Check availability: Always wrap Control Widget code in @available(iOS 18.0, *) checks.
_shared folder is required: If openAppWhenRun = true, the intent MUST be in _shared/ to be linked to both targets. Otherwise, the app will crash when the control is tapped.
Next Steps