Combining TCA and the PointFree Navigation extensions

I've been trying to work out if I can make this work.

I'd like to use the Route modelling that is introduced in the most recent series with TCA.

I have something like...

struct PersonListState {
  enum Route {
     case addPerson(AddPersonState)
  }

  @BindableState var route: Route = nil 
  var people: [Person] = []
}

I can make this work and I can add an "Add Person" button that will send an action and the environment will set the route and create the AddPersonState inside it.

But... I still need to create a store scoping the state from the enum... so I'm currently having to use the binding of the item to get the state and then still just have to scope my current store to the new state so I have the double unwrapping thing that was the problem solved by some of the navigation apis.

Hmm... rubber duck effect... I think I've solved it.

I can create my own TCA extension of the navigation extensions that allows me to scope a store to the state struct that is stored in the route enum I think.

I'll give it a go and see what happens.

1 Like

Right, well this is far from ideal so far... but it works :sweat_smile:

I wrote this...

extension View {
    func sheet<Enum, Case, Content, State, LocalState, Action, LocalAction>(
        unwrapping enum: Binding<Enum?>,
        case casePath: CasePath<Enum, Case>,
        store: Store<State, Action>,
        state: @escaping (State) -> LocalState?,
        action: @escaping (LocalAction) -> Action,
        onDismiss: (() -> Void)? = nil,
        @ViewBuilder content: @escaping (Store<LocalState, LocalAction>) -> Content
    ) -> some View where Content: View {
        self.sheet(unwrapping: `enum`.case(casePath), onDismiss: onDismiss) { _ in 
            SwitchStore(store) { 
                CaseLet.init(state: state, action: action, then: content)
            }
        }
    }
}

Something I tried to do is remove the state parameter as it is essentially the same as the casePath.

But this works so far.

So now I have a route like...

enum Route {
  case addPerson(AddPersonState)
}

And state...

struct PersonListState {
  @BindableState var route: Route? = nil
}

This uses the binding action and reducer.

Then in my reducer I have...

case .addPersonTapped:
  state.route = .addPerson(AddPersonState(.....))
  return .none

And then in my view...

.sheet(
  unwrapping: viewStore.binding(\.$route),
  case: /PersonStateRoute.addPerson,
  store: store.scope(state: \.route), // I have to scope this to get to the enum
  state: /PersonStateRoute.addPerson,
  action: PersonAction.addPersonAction,
  content: AddPersonView.init(store:)
)

Which is a bit meh... from an interface point of view. But it does allow me to drive the navigation from state within TCA and then scope through the reducer at the same time.

And as you can see... it seems to work so far... Beginnings of an app written on my iPad! - YouTube

This is what I've been using. Haven't stress tested it, but it's been working fine for what I need so far.

extension View {
  public func sheet<State, Action, Content>(
    _ store: Store<State?, Action>,
    onDismiss: (() -> Void)? = nil,
    @ViewBuilder content: @escaping (Store<State, Action>) -> Content
  ) -> some View
    where Content: View
  {
    WithViewStore(store.scope(state: { $0 != nil })) { isPresent in
      self.sheet(
        isPresented: Binding(get: { isPresent.state }, set: { _ in }),
        onDismiss: onDismiss,
        content: { IfLetStore(store, then: content) }
      )
    }
  }
}

So your example could look something like.

.sheet(
  store.scope(state: \.addPersonState, action: PersonListAction.addPersonAction),
  onDismiss: { viewStore.send(.sheetDismissed) },
  content: AddPersonView.init(store:)
)

I extracted the enum as a computed property for this example but this can be done multiple ways, such as using OptionalPaths like in isowords. You can see it used in the HomeFeature.

2 Likes
Terms of Service

Privacy Policy

Cookie Policy