Async Binding for SwiftUI
Reading time: 4 min
This recipe shows how to implement an async binding in SwiftUI. Just like a regular Binding, async binding creates a two-way connection between a property that stores data, and a view that displays and changes the data - except that those changes are expressed with async blocks.
This allows SwiftUI Views to render asynchronously, as well as to have their actions trigger async events. The sample image below shows to Toggle
s linked with an async binding, as well as a variant of AsyncImage
that's also powered by an async binding:
AsyncBinding
The AsyncBinding
struct wraps a single value whose getter and setter are expressed with async blocks. It also exposes a Combine Publisher
that notifies subscribing views of the current state of the binding.
The reason this can't be a property wrapper like regular @Binding
is because property wrappers don't support wrappedValue
with async get
, even though async computed properties normally are available.
First, define the AsyncState
, which captures the current state of the binding - it can be idle, performing an async operation, be ready with a value or have experienced an error while getting or setting:
public enum AsyncState<Value> {
case idle,
loading,
ready(Value),
failure(Error)
}
extension AsyncState: Equatable where Value : Equatable {
public static func == (lhs: AsyncState<Value>, rhs: AsyncState<Value>) -> Bool {
if case .idle = lhs,
case .idle = rhs {
return true
} else if case .loading = lhs,
case .loading = rhs {
return true
} else if case let .ready(lhsValue) = lhs,
case let .ready(rhsValue) = rhs,
lhsValue == rhsValue {
return true
} else {
return false
}
}
}
Next, define the AsynBinding
struct itself. It has:
- A predefined getter that gets the current value when requested, if it hasn't been set already.
- An optional setter that runs whenever a new value is set.
- The ability to asynchronously set the binding's value.
- Reset capability that allows for re-triggering of default getter, regardless of the last set value.
public typealias AsyncBindingGet<Value> = () async throws -> Value
public typealias AsyncBindingSet<Value> = (Value) async throws -> Void
public struct AsyncBinding<Value> {
private let subject: CurrentValueSubject<AsyncState<Value>, Never>
private let getter: AsyncBindingGet<Value>
private let setter: AsyncBindingSet<Value>?
public init(initialState: AsyncState<Value> = .idle,
get: @escaping AsyncBindingGet<Value>,
set: AsyncBindingSet<Value>? = nil) {
subject = CurrentValueSubject(initialState)
getter = get
setter = set
}
public var publisher: AnyPublisher<AsyncState<Value>, Never> {
subject
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
public func get() {
if case .ready(_) = subject.value {
return
}
update(getter)
}
public func set(_ newValueProvider: @escaping AsyncBindingGet<Value>) {
update {
let newValue = try await newValueProvider()
try await setter?(newValue)
return newValue
}
}
public func reset() {
subject.send(.idle)
get()
}
private func update(_ block: @escaping AsyncBindingGet<Value>) {
Task {
do {
subject.send(.loading)
let value = try await block()
subject.send(.ready(value))
} catch {
subject.send(.failure(error))
}
}
}
}
Sample use - AsyncToggle
As its name says, an AsyncToggle
's state is updated via an AsyncBinding
. This example also shows how to map a synchronous @Binding
to have its state change update the async binding.
struct AsyncToggle<Label>: View where Label : View {
let binding: AsyncBinding<Bool>
let label: () -> Label
@State private var state: AsyncState<Bool> = .idle
init(isOn binding: AsyncBinding<Bool>,
@ViewBuilder label: @escaping () -> Label) {
self.binding = binding
self.label = label
}
var body: some View {
HStack {
label()
Spacer()
ZStack {
if state == .idle || state == .loading {
ProgressView()
} else {
Toggle(isOn: .init(get: {
if case let .ready(value) = state {
return value
} else {
return false
}
}, set: { isOn in
binding.set { isOn }
})) {
idleView()
}
}
}
}
.onAppear {
binding.get()
}
.onReceive(binding.publisher) { value in
self.state = value
}
}
}
And here's some code showcasing how two AsyncToggle
s are connected via a shared AsyncBinding
. The async binding simulates an async operation by sleeping for a while before updating its value:
struct AsyncToggleTest: View {
private let isOn = AsyncBindingWithSetter<Bool> {
try await Task.sleep(nanoseconds: 3.toNanoseconds)
return false
} set: { newValue in
try await Task.sleep(nanoseconds: 2.toNanoseconds)
}
var body: some View {
VStack {
AsyncToggle(isOn: isOn) {
Text("Toggle 1")
}
AsyncToggle(isOn: isOn) {
Text("Toggle 2")
}
}
}
}
If you're missing the toNanoseconds
property, you can find it here.