Using Error protocol in SwiftUI @Binding (does any or some fix this?)

Writing some SwiftUI code to handle user login with an asynchronous API, I came up with a technique to initiate the network task, and handle possible errors thrown by it.

My first attempt was to build an AsyncButton is a view built on Button that calls the supplied action inside a Task

struct AsyncButton<Label: View>: View {
	@Binding		public	var error		:	Error?
					public	var	action		:	() async throws -> Void
	@ViewBuilder	public	var label		:	() -> Label
}

The button calls action inside a Task, and if an error is thrown, it sets error to the thrown error.

This is then used by a view like this:

struct UserLoginForm: View {
	@State	var	login		:	String			=	""
	@State	var	password	:	String		=	""
	@State	var	error		:	AsyncError?
	
	var body: some View {
		NavigationStack {
			VStack {
				TextField("Login", text: self.$login)
				SecureField("Password", text: self.$password)
				
				if let error = self.error, case API.Errors.invalidCredentials = error {
					Text("Check your credentials and try again")
				}
				
				AsyncButton(error: self.$error) {
					try await self.actions.login(with: self.login, password: self.password)
					self.presentationMode.wrappedValue.dismiss()
				} label: {
					Text("Login")
				}
			}
		}
		.onChange(of: self.error) { inError in
			print("Error is \(String(describing: inError))")
		}
	}
	
	@EnvironmentObject					var	actions				:	Actions
	@Environment(\.presentationMode)	var	presentationMode
}

Unfortunately, this doesn't work, because .onChange(of: self.error) fails to compile, because onChange(of:) requires that Error conform to Equatable, and it can’t.

I worked around this issue by instead using a struct for the binding:

struct AsyncErrorWrapper {
    let error: Error
}

But I couldn't make that equatable until I added an id: UUID property, and use that for the Equatable conformance.

All of that strikes me as cumbersome, at best. I don’t have a good understanding of the recent work on existentials. I'm not sure changing the declaration would automagically fix the need for Equatable conformance, but it seems to me it should be possible for SwiftUI to figure out if someone assigned something to error.

In any case, is there more elegant way to handle this?

Do you need to do anything with the error inside the view itself?

The quick an easy solution:

  • change the State that wraps your (any Error)? into a StateObject
  • add an ObservableObject which has your error wrapped by Published
  • the view can pick the error up without an onChange operator
  • you can react to the change inside the object if needed
  • propagate the error via projection: $model.error which will create a Binding like you wanted
  • no equality operation required

Make sure to publish the error on MainActor. ;)

@MainActor
class ErrorModel: ObservableObject {
  @Published
  var error: (any Error)? = nil {
    didSet {
      print("Error is \(String(describing: error))")
    }
  }

  init() {}
}

struct UserLoginForm: View {
  ...
  @StateObject
  var model: ErrorModel = ErrorModel()

  var body: some View {
    NavigationStack {
      VStack {
        ...
        AsyncButton(error: $model.error) { ... } label: { ... }
      }
    }
  }
}

I think I was too vague. With the wrapped error struct, it does work. The error gets set and the view updates:

if let error = self.error, case API.Errors.invalidCredentials = error {
					Text("Check your credentials and try again")
				}

And onChange is called. What I was hoping for was a way to avoid wrapping the Error at all. And I want the technique to be usable both ways, as well as in something like .sheet(item: self.$error)….

Thanks for the note about setting the error on the main actor. I’ll make sure it does that.

I don't undestand that. If you properly cast the error to a concrete and Equatable error then this is trivial, but that would require to wrapping onChange inside such scope.

Not sure what you meant here either. If it's your custom sheet overload, then sure why not. ;)

If all you wanted from onChange was to print the error then you could do this:

@State
var error: (any Error)? = nil

var errorBinding: Binding<(any Error)?> {
  Binding(
    get: { error },
    set: { 
      error = newValue
      print(...)
    }
  )
}

Note that the following example seems like it "could" work, but it won't.

@State
var error: (any Error)? = nil {
  didSet {
    print(...)
  }
}

The reason is that this didSet will trigger only via the computed error.set accessor and the Binding generated by State does not pass the value through that. It does only work if the view sets the property directly (e.g. error = someError), not through a binding.

A 3rd option would be to do this:

@State
var error: (any Error)? = nil {
  didSet {
    // if you use `errorBinding` instead `$error`, 
    // this observer will trigger
  }
}

var errorBinding: Binding<(any Error)?> {
  Binding(
    get: { error },
    set: { error = newValue }
  )
}

Instead of creating such wrapper properties manually you can have a View extension for this:

extension View {
  func binding<T>(_ keyPath: ReferenceWritableKeyPath<Self, T>) -> Binding<T> {
    Binding(
      get: {
        self[keyPath: keyPath]
      },
      set: { newValue in
        self[keyPath: keyPath] = newValue
      }
    )
  }
}

struct MyView: View {
  @State
  var error: (any Error)? = nil {
    didSet {
      print(...)
    }
  }

  var body: some View {
    AsyncButton(error: self.binding(\.error)) { ... } label: { ... }
  }
}
1 Like