One-way bindings in SwiftUI
Communication between views in SwiftUI can be tricky. As explained in a previous story about SwiftUI State monitoring, SwiftUI PropertyWrappers offer us a lot by hiding some complexity of managing the source of truth for our views. However, they can also bring confusion regarding state management and how to communicate between views.
Background
SwiftUI views are not referenced; they are values. We cannot access them, and we cannot store information in them. Therefore, we need a source of truth that would survive the view for view updates.
This is where SwiftUI tools kick in:
@State
: To store and monitor a value type or anyObservable
class.@StateObject
: To store and monitor anObservableObject
class.@ObservedObject
: To monitor without storing anObservableObject
class.
Outside of those values that represent the source of truth on how to build our interface, views will need to communicate with each other.
The more obvious pattern is the parameter. A parent view gives a child view a copy of a value as a parameter. This brings natural Parent-to-Child one-way communication, and it is at the core of SwiftUI:
List(items) { item in
// Passing item as a parameter to ListRow
ListRow(item: item)
}
Passing an item as a parameter of ListRow, and having the item be a constant in ListRow, does not mean that the item cannot mutate. It means that ListRow has no control over the mutation of the item. It just takes it and uses it to construct a View.
This is what I would call a one-way binding from parent to child.
Another pattern that is very well known in SwiftUI is the Binding. Passing a Binding to a view allows the child view to access, and mutate, a value it does not own. This allows sharing a state across multiple views, where the owner is one of the parents, and every child owning the binding is allowed to mutate it.
VStack {
// Passing name as a binding to TextField
TextField("First name", text: $name)
Text("Hello, \(name)")
}
By passing this text as a binding, we can still mutate it, and the TextField will update accordingly. At the same time, the TextField can mutate the value (because the user types) and we’ll get the new value.
This is a two-way binding between parent and child.
But what about Child-to-Parent communication?
Seems uncommon, but it’s far more common than it seems:
- Buttons trigger an action when they’re pressed. It’s a child communicating its internal state (button was pressed).
- TextFields have an onSubmit modifier when the TextField is submitted.
Turns out we can see some kind of pattern in existing examples: child to parent often are Swift callbacks, with or without an associated value.
But there is also another pattern, less obvious:
ScrollView {
ScrollViewReader { proxy in
// The child can interact with the parent ScrollView
Child(proxy: proxy)
}
}
We can access a ScrollView behavior from the content view using the ScrollViewProxy, provided by a ScrollViewReader.
Using a callback with a value in this case is less practical. Imagine getting the proxy in a modifier callback: we’d have to store it in some sort of special state before using it!
The advantage of this other pattern is that the proxy can be used in other callbacks, like a Button action, and is not restricted to only being used within a single callback. The following approach would have been way less practical:
ScrollView {
Child(proxy: proxy)
.withScrollViewReader { proxy in
// Store the proxy in the State or something ?!?
}
}
Whatever the kind of pattern that would be practical for our use case, we can build our own!
The mandatory callback
Probably the easiest to set up, and the most common: the mandatory callback.
To build one, provide a callback in the initializer and store it for later use!
This is the approach I use in ButtonKit’s implementation of AsyncButton:
public struct AsyncButton<S: View>: View {
private let role: ButtonRole?
private let action: () async -> Void
private let label: S
public var body: some View {
Button(role: role) {
Task {
await action()
}
} label: {
label
}
}
public init(
role: ButtonRole? = nil,
action: @escaping () async -> Void,
@ViewBuilder label: @escaping () -> S
) {
self.role = role
self.action = action
self.label = label()
}
}
The optional callback modifier
Now, take this very same AsyncButton implementation, and let’s say we’d like to access the underlying Task value, maybe for custom cancellation logic.
We could provide that task with another callback that would be nullable:
public init(
role: ButtonRole? = nil,
action: @escaping () async -> Void,
taskChanged: ((Task<Void, Never>?) -> Void)? = nil,
@ViewBuilder label: @escaping () -> S
) {
...
}
But this leads to a less readable call site:
AsyncButton {
await myVeryLongAction()
} taskChanged: { task in
// Do something with the nullable task
} label: {
Text("Press me!")
}
Instead, I find the modifier callback approach more consistent with the SwiftUI API and more readable:
AsyncButton {
await myVeryLongAction()
} label: {
Text("Press me!")
}
.asyncButtonTaskChanged { task in
// Do something with the nullable task
}
To build this, we need to access the inner task of the button, which is an inner state. We can store the task as a @State in the button. Therefore, it would be nonsense to make a Binding since parent views should not write the task of the button, just access it.
Instead, we can expose the task using SwiftUI Preferences.
public struct AsyncButton<S: View>: View {
...
@State private var task: Task<Void, Never>?
public var body: some View {
Button(role: role) {
task = Task {
await action()
task = nil
}
} label: {
label
}
// This will send the value task to the parents!
.preference(key: AsyncButtonTaskPreferenceKey.self, value: task)
}
}
And since we don’t want parent views to access the preference key directly, but only in read-only with a callback, we can make the key private and hide everything behind a modifier:
// Typealias to have a more readable handler
public typealias AsyncButtonTaskChangedHandler = @MainActor @Sendable (Task<Void, Never>?) -> Void
extension View {
// This is the modifier we expose with our callback
public func asyncButtonTaskChanged(_ handler: @escaping AsyncButtonTaskChangedHandler) -> some View {
// We cannot use onPreferenceChange directly because of a
// non-isolation issue with Swift Concurrency. But we can
// proxy through a modifier instead.
modifier(OnAsyncButtonTaskChangeModifier { task in
handler(task)
})
}
}
// The preference key is private, to prevent writes from outside
struct AsyncButtonTaskPreferenceKey: PreferenceKey {
static var defaultValue: Task<Void, Never>?
static func reduce(value: inout Task<Void, Never>?, nextValue: () -> Task<Void, Never>?) {
// Always send the latest value we get.
// That way, the callback is called for every
// new task even from multiple AsyncButton views
value = nextValue()
}
}
// Also, no need to expose our modifier, that is internal implementation
struct OnAsyncButtonTaskChangeModifier: ViewModifier {
let handler: AsyncButtonTaskChangedHandler
init(handler: @escaping AsyncButtonTaskChangedHandler) {
self.handler = handler
}
func body(content: Content) -> some View {
content
// Each time the preference change, call the callback
.onPreferenceChange(AsyncButtonTaskPreferenceKey.self) { task in
self.handler(task)
}
}
}
ButtonKit implementation also comes with asyncButtonTaskStarted
and asyncButtonTaskEnded
modified, with similar implementation, and all sharing the same preference key.
The proxy provider
Even though the following is not implemented in ButtonKit because I don’t think providing this kind of Proxy to build the Label is providing more value than the current AsyncButtonStyle protocol pattern, we could add this kind of proxy:
public struct AsyncButtonProxy {
public let task: Task<Void, Never>?
public var isLoading: Bool {
task != nil
}
init(task: Task<Void, Never>? = nil) {
self.task = task
}
public func cancel() {
task?.cancel()
}
}
That would then be consumed by the AsyncButton label:
AsyncButton {
await myVeryLongAction()
} label: {
AsyncButtonReader { proxy in
Text(proxy.isLoading ? "Loading…" : "Press me!")
}
}
.asyncButtonStyle(.none)
Here, the flow is more indirect and doesn’t require Preferences since the proxy is provided only downward to the child for it to consume.
As we don’t want the proxy to become a parameter for the Button label, we can provide it downward using SwiftUI Environment values.
private struct AsyncButtonProxyEnvironmentKey: EnvironmentKey {
static var defaultValue = AsyncButtonProxy()
}
extension EnvironmentValues {
var asyncButtonProxy: AsyncButtonProxy {
get {
self[AsyncButtonProxyEnvironmentKey.self]
}
set {
self[AsyncButtonProxyEnvironmentKey.self] = newValue
}
}
}
public struct AsyncButton<S: View>: View {
...
public var body: some View {
Button(role: role) {
...
} label: {
label
.environment(\.asyncButtonProxy, .init(task: task))
}
}
...
}
Now that the proxy value we need is passed to the label in the environment values, we can catch it and use it in the Reader implementation:
public struct AsyncButtonReader<C: View>: View {
@Environment(\.asyncButtonProxy)
private var proxy
private let content: (AsyncButtonProxy) -> C
public var body: some View {
content(proxy)
}
public init(@ViewBuilder _ content: @escaping (AsyncButtonProxy) -> C) {
self.content = content
}
}
And the magic unfolds!
Final words
I really like working with SwiftUI, and I often find myself wondering how I could or should bring better reusability to my views.
But trying to reproduce some of its internal magic for my own views helps me achieve just that: a clean call site, high customization, and straight-to-the-point components for my apps!
What better reward could I imagine?
SharePal
Lighting speed sharing!
Don’t miss a thing!
Don't miss any of my indie dev stories, app updates, or upcoming creations!
Stay in the loop and be the first to experience my apps, betas and stories of my indie journey.