A misconception I had when first learning SwiftUI and Combine was that SwiftUI relied on Combine alone for updating data. There was a throw-away comment in one of the 2019 WWDC presentations (Data Flow through SwiftUI) relating the two, and I over-interpreted it to mean that SwiftUI solely used Combine. To be very clear – it doesn’t. SwiftUI nicely integrates with Combine, and the components you use to expose external reference models into SwiftUI (such as @ObservedObject
, @EnvironmentObject
, @StateObject
, and @Published
) use it. But quite a lot of the interaction with user interface elements, such as Text
, Toggle
, or the selection in List
operate using a different tool: Bindings
.
I think the most important distinction between the two is the direction of data flow. With bindings, the data travels in BOTH directions, and in Combine it travels in a SINGLE direction. You could describe Combine pipelines as being a “one way street for data”, and Bindings as a “two way street”.
Bindings are also not set up to manipulate the timing or transform the data; and the operate on individual values – not streams of data or values over time. That is entirely by design. A core philosophy of the design of SwiftUI is having a single source of truth. A binding exposes that single source of truth, and allows user interface elements to both reflect it and change it. All the while, it keeps the source of truth clear and easily understood. It is designed to support high-speed, low-overheard updates of single values within a view.
So when you start to think something like “Hey, I’d really like to tweak the timing on this…”, then you’re in the realm of Combine. An example might be debouncing text field updates and using the result to trigger a more expensive computation, such as a network call to get information. Unfortunately, there’s not a direct path to create or get a publisher from an existing binding, such as a @State
variable within a view.
So how do you get these things to work together? You can rearrange your code to pass in publishers, or otherwise externalize the data from your view with on of the ObservableObject
types. If you want to work on data that the view should own, such as view state, then the best option are a couple of view modifiers: onReceive(_:perform:)
and onChange(:of:perform:)
.
Publishing to a Binding
Linking a publisher to a binding, such as a view’s state variable, is in my mind the easier path. How you arrange it depends on where you create the publisher. If the publisher exists outside the view, pass it into your view on initialization, and use it with the onReceive(_:perform:)
view modifier.
The view to which its attached becomes subscriber. Remember that in Combine, the subscriber “drives all the action”, so the view is now driving the publisher, for as long as it exists. When the onReceive
modifier connects to the publisher, it requests unlimited
demand. When SwiftUI invalidates the view and recreates it, the pipeline will get set up again, and requests additional demand from the publisher. If your publisher does heavier work, such as a network request, you might find this a bit surprising.
If you do run into this, it’s a good idea to move that publisher, and the work, into an external object that isn’t owned by the view, but used by it. You can use the publisher from multiple views, and it’s a perfect place for the Combine share
operator to use a single publisher from multiple subscribers without needing to replicate the network requests for each one.
Publishing from a Binding
Prior to the SwiftUI updates in iOS 14, publishing from a binding was pretty much squashed. Local state declarations, such as a private state variable, don’t support tacking on your own custom getters and setters, where you could put in a closure to trigger a side effect.
Fortunately, with iOS 14, SwiftUI added the onChange(of:perform:)
view modifier, perfectly suited for invoking your own closure when a state variable changes. Specify the binding you to which you want to watch, and do your work within a closure you provide. To get from a closure, an imperative style of code, to a Combine publisher, a declarative style of code, means you need to work with something that crosses that boundary. The most common way is using a Combine subject, designed for imperative code to publish values. I tend to use passthroughSubject
, especially within a view, since it’s lightweight and doesn’t require references to other values. This makes it easy to use in a declaration, outside of an initializer.
Again, remember that when using Combine, the subscriber is what drives the action. So be wary of thinking of this flow as pushing a value into a publisher chain; that can lead you to making some incorrect assumptions about how things will react. What’s typically happening is something, somewhere, is already subscribed to the publisher – and it did so with an unlimited demand – meaning any values added in get propagated almost immediately.
Sample Code
To illustrate how these work, I created a simple view, with both a binding sending a value into a publisher, and another getting updated from a publisher chain. The example includes two state variables: one that is the immediate binding for a TextField
that updates as you type, the second is a holding spot for values out of a publisher.
A PassthroughSubject
provides the imperative to declarative connection by using send()
to publish values, called from within an onChange
view modifier on the TextField
. To debounce the input – in this case delay updating until you’ve stopped typing for a second – the publisher pipeline in the initializer pulls from the subject, uses the debounce
operator, and connects to the state variable delayed
using the onReceive
view modifier.
import SwiftUI
import Combine
struct PublisherBindingExampleView: View {
@State private var filterText = ""
@State private var delayed = ""
private var relay = PassthroughSubject<String,Never>()
private var debouncedPublisher: AnyPublisher<String, Never>
init() {
self.debouncedPublisher = relay
.debounce(for: 1, scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
var body: some View {
VStack {
TextField("filter", text: $filterText)
.onChange(of: filterText, perform: { value in
relay.send(value)
})
Text("Delayed result: \(delayed)")
.onReceive(debouncedPublisher, perform: { value in
delayed = value
})
}
}
}
You might be tempted to put the combine pipeline directly on the subject, but if you chained any operators, then you would be changing the type of the subject. That would effectively hide it and make it impossible to get a reference to the send()
method.
Likewise, you might want to move the publisher chain up to be a declaration, but there’s a quirk here: initialization order. Since you need to reference another local variable (relay
in this case), you need to set up that chain from inside a convenience initializer so that the other variables are already initialized and available to use. If you tried to just set it up in the declaration, the compiler would hand you an error such as:
Cannot use instance member 'relay' within property initializer; property initializers run before 'self' is available
You might say, “Hey, but what about that neat trick with lazy initialization?”. In this case, it doesn’t conveniently work. When you use a variable declared with lazy
within the onReceive
view modifier, you get the error:
Cannot use mutating getter on immutable value: 'self' is immutable
A lazy property mutates an object on its first invocation, so it’s always considered mutating, which doesn’t work with SwiftUI views, which are immutable.
As a final note, keep what you set up and process within a view’s initializer to an absolute minimum. SwiftUI views are intended to be light and ephemeral – and SwiftUI invalidates and recreates them, somethings quite often, so minimize any work you do within them. If, for example, you find yourself reaching to set up a publisher that makes a network request to get some data, seriously consider refactoring your code to externalize that request into a helper object that provides a publisher, rather than have the view repeatedly set it up each time.