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 Toggles linked with an async binding, as well as a variant of AsyncImage that's also powered by an async binding:

preview

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 AsyncToggles 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.

Next Post Previous Post