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:

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.

ParentParameterChild

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.

ParentBindingChild

But what about Child-to-Parent communication?

Seems uncommon, but it’s far more common than it seems:

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.

ParentCallbackChild

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!

ParentMagicProxyInvokeChild

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.

ParenitnitializeCaplrloxmyetRheoaddeorpnropvriodxeyproxyChild

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 app icon

SharePal

Lighting speed sharing!

Learn more

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.

Thank you for registering!
You’ll retrieve all of my latest news!

Your email is sadly invalid.
Can you try again?

An error occurred while registering.
Please try again