EvZ
EvZ

Reputation: 12179

Is ti possible to `map` an enum to Binding<Bool> in SwiftUI?

Working with MVVM in SwifUI. My aim is to have an enum state property in the ViewModel so the View could adjust it self according to the state property. States could be: idle, busy, done and error. On done I want to navigate to another screen using NavigationLink, however the problem is that it is expecting a Binding<Bool> and I could not figure out a way to map my enum state to bool.

Here is the simplified code:

struct LoginView: View {

    @ObservedObject private var viewModel: LoginViewModel

    @ViewBuilder
    var body: some View {
        ...
        // success state
        NavigationLink(destination: HomeFactory().make(), isActive: self.$viewModel.state /* <---- some sort of mapping should come here */){ EmptyView() }
        ...
    }
}

Hope that I am missing something really basic and it could be easily achieved in an elegant way.

EDIT:

Seems like it should be possible with the next method:
NavigationLink(destination: HomeFactory().make(), tag: .done, selection: self.$viewModel.viewState, label: { EmptyView() })

However I get an error and I can't figure out what is wrong: Cannot convert value of type 'Binding<ViewState>' to expected argument type 'Binding<_?>'

Here is the code:

final class LoginViewModel: ObservableObject {
  @Published var viewState: ViewState = .idle
  func begin() {
    ..
    self.viewState = .done
    ..
  }
}

struct LoginView: View {
  @ObservedObject private var viewModel: LoginViewModel

  @ViewBuilder
  var body: some View {
    ..
    NavigationLink(destination: HomeFactory().make(), tag: .done, selection: self.$viewModel.viewState, label: { EmptyView() })
    ..
  }

UPDATE:

I was very close. The ViewState in the vm should be optional:
@Published var viewState: ViewState? = .idle

Upvotes: 13

Views: 8996

Answers (3)

Cristik
Cristik

Reputation: 32806

The problem with transformations is that most of them are uni-directional, and a binding requires that its content is both readable and writable. Thus even if you would extend your enum like

enum State {
  case idle, busy, done

  var isDone: Bool { self == .done }
}

you'd still be unable to bind to $viewModel.state.isDone because the property is computed. And making it writable by adding a set is not feasible as you'd not want to change the enum value via isDone.

However, this doesn't mean that it can't be done. You can define map over Binding, and fool the system that you have a bidirectional communication:

extension Binding {
    func map<NewValue>(_ transform: @escaping (Value) -> NewValue) -> Binding<NewValue> {
        Binding<NewValue>(get: { transform(wrappedValue) }, set: { _ in })
    }
}

, which you can use it in your navigation link, or wherever you need a boolean binding from the enum one.

NavigationLink(destination: HomeFactory().make(), isActive: $viewModel.state.isDone)

One caveat, though, is the fact that the mapped Binding has a deceptive API, it says that it can also update values, when in fact it doesn't. However for most of the cases where booleans are used to configure availability, this should be fine. Careful, though to not circulate that Binding outside the context, as other consumers of that instance might not be aware of its limitation.

Upvotes: 5

luigi3
luigi3

Reputation: 33

had the same problem. Here's what I did:

    final class LoginViewModel: ObservableObject {
      @Published var viewState: ViewState = .idle
      func begin() {
        ..
        self.viewState = .done
        ..
      }
    }
    
    struct LoginView: View {
      @ObservedObject private var viewModel: LoginViewModel
    
      @ViewBuilder
      var body: some View {
        ..
        NavigationLink(destination: HomeFactory().make(), tag: .done, selection: 
Binding<Bool>(get: { viewModel.viewState.isPresentable }, set: { _ in viewModel.viewState = .idle })), label: { EmptyView() })
        ..
      }

Upvotes: 1

Vlad
Vlad

Reputation: 1041

There isn't an elegant way to map it in the view.
However, in your LoginViewModel you can have an @Published variable that gets set when the state is updated.

Here is an example:

class LoginViewModel: ObservableObject {
    @Published var shouldNavigate = false  

    var state: State = .idle {
        didSet {
            self.shouldNavigate = state == .done
        }
    }
}

Then change your NavigationLink to:

NavigationLink(destination: HomeFactory().make(), isActive: self.$viewModel.shouldNavigate){ EmptyView() }

EDIT:
You can navigate based on state, or some other enum using a NavigationLink like this:

NavigationLink(destination: HomeFactory().make(), tag: State.done, selection: self.$state){ EmptyView() }

And update your vm state definition to:
@Published var state: State = .idle

Upvotes: 6

Related Questions