7.5 KiB
Working with SwiftUI bindings
Learn how to connect features written in the Composable Architecture to SwiftUI bindings.
Overview
Many APIs in SwiftUI use bindings to set up two-way communication between your application's state and a view. The Composable Architecture provides several tools for creating bindings that establish such communication with your application's store.
Ad hoc bindings
The simplest tool for creating bindings that communicate with your store is to create a dedicated action that can change a piece of state in your feature. For example, a reducer may have a domain that tracks if the user has enabled haptic feedback. First, it can define a boolean property on state:
@Reducer
struct Settings {
struct State: Equatable {
var isHapticsEnabled = true
// ...
}
// ...
}
Then, in order to allow the outside world to mutate this state, for example from a toggle, it must define a corresponding action that can be sent updates:
@Reducer
struct Settings {
struct State: Equatable { /* ... */ }
enum Action {
case isHapticsEnabledChanged(Bool)
// ...
}
// ...
}
When the reducer handles this action, it can update state accordingly:
@Reducer
struct Settings {
struct State: Equatable { /* ... */ }
enum Action { /* ... */ }
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case let .isHapticsEnabledChanged(isEnabled):
state.isHapticsEnabled = isEnabled
return .none
// ...
}
}
}
}
And finally, in the view, we can derive a binding from the domain that allows a toggle to
communicate with our Composable Architecture feature. First you must hold onto the store in a
bindable way, which can be done using the @Bindable property wrapper from SwiftUI:
struct SettingsView: View {
@Bindable var store: StoreOf<Settings>
// ...
}
Important: If you are targeting older Apple platforms (iOS 16, macOS 13, tvOS 16, watchOS 9, or less), then you must use our backport of the
@Bindableproperty wrapper:-@Bindable var store: StoreOf<Settings> +@Perception.Bindable var store: StoreOf<Settings>
Once that is done you can derive a binding to a piece of state that sends an action when the binding is mutated:
var body: some View {
Form {
Toggle(
"Haptic feedback",
isOn: $store.isHapticsEnabled.sending(\.isHapticsEnabledChanged)
)
// ...
}
}
Binding actions and reducers
Deriving ad hoc bindings requires many manual steps that can feel tedious, especially for screens with many controls driven by many bindings. Because of this, the Composable Architecture comes with tools that can be applied to a reducer's domain and logic to make this easier.
For example, a settings screen may model its state with the following struct:
@Reducer
struct Settings {
@ObservableState
struct State {
var digest = Digest.daily
var displayName = ""
var enableNotifications = false
var isLoading = false
var protectMyPosts = false
var sendEmailNotifications = false
var sendMobileNotifications = false
}
// ...
}
The majority of these fields should be editable by the view, and in the Composable Architecture this means that each field requires a corresponding action that can be sent to the store. Typically this comes in the form of an enum with a case per field:
@Reducer
struct Settings {
@ObservableState
struct State { /* ... */ }
enum Action {
case digestChanged(Digest)
case displayNameChanged(String)
case enableNotificationsChanged(Bool)
case protectMyPostsChanged(Bool)
case sendEmailNotificationsChanged(Bool)
case sendMobileNotificationsChanged(Bool)
}
// ...
}
And we're not even done yet. In the reducer we must now handle each action, which simply replaces the state at each field with a new value:
@Reducer
struct Settings {
@ObservableState
struct State { /* ... */ }
enum Action { /* ... */ }
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case let digestChanged(digest):
state.digest = digest
return .none
case let displayNameChanged(displayName):
state.displayName = displayName
return .none
case let enableNotificationsChanged(isOn):
state.enableNotifications = isOn
return .none
case let protectMyPostsChanged(isOn):
state.protectMyPosts = isOn
return .none
case let sendEmailNotificationsChanged(isOn):
state.sendEmailNotifications = isOn
return .none
case let sendMobileNotificationsChanged(isOn):
state.sendMobileNotifications = isOn
return .none
}
}
}
}
This is a lot of boilerplate for something that should be simple. Luckily, we can dramatically
eliminate this boilerplate using BindableAction and BindingReducer.
First, we can conform the action type to BindableAction by collapsing all of the individual,
field-mutating actions into a single case that holds a BindingAction that is generic over the
reducer's state:
@Reducer
struct Settings {
@ObservableState
struct State { /* ... */ }
enum Action: BindableAction {
case binding(BindingAction<State>)
}
// ...
}
And then, we can simplify the settings reducer by adding a BindingReducer that handles these
field mutations for us:
@Reducer
struct Settings {
@ObservableState
struct State { /* ... */ }
enum Action: BindableAction { /* ... */ }
var body: some Reducer<State, Action> {
BindingReducer()
}
}
Then in the view you must hold onto the store in a bindable manner, which can be done using the
@Bindable property wrapper (or the backported tool @Perception.Bindable if targeting older
Apple platforms):
struct SettingsView: View {
@Bindable var store: StoreOf<Settings>
// ...
}
Then bindings can be derived from the store using familiar $ syntax:
TextField("Display name", text: $store.displayName)
Toggle("Notifications", text: $store.enableNotifications)
// ...
Should you need to layer additional functionality over these bindings, your can pattern match the action for a given key path in the reducer:
var body: some Reducer<State, Action> {
BindingReducer()
Reduce { state, action in
switch action
case .binding(\.displayName):
// Validate display name
case .binding(\.enableNotifications):
// Return an effect to request authorization from UNUserNotificationCenter
// ...
}
}
}
Or you can apply Reducer/onChange(of:_:) to the BindingReducer to react to changes to
particular fields:
var body: some Reducer<State, Action> {
BindingReducer()
.onChange(of: \.displayName) { oldValue, newValue in
// Validate display name
}
.onChange(of: \.enableNotifications) { oldValue, newValue in
// Return an authorization request effect
}
// ...
}
Binding actions can also be tested in much the same way regular actions are tested. Rather than send
a specific action describing how a binding changed, such as .displayNameChanged("Blob"), you will
send a BindingAction action that describes which key path is being set to what value, such as
\.displayName, "Blob":
let store = TestStore(initialState: Settings.State()) {
Settings()
}
store.send(\.binding.displayName, "Blob") {
$0.displayName = "Blob"
}
store.send(\.binding.protectMyPosts, true) {
$0.protectMyPosts = true
)