Recursive navigation

Could anyone advise how to implement a navigation stack where screens are referencing each other?

For example something like an App Store that has the following screens & transitions:

  1. A list of apps allows navigating to an app view.
  2. The app view allows navigating to a list of similar apps.

As we have a requirement of UI state to be represented in data, there is a problem there. States referencing each other, as a result, the app state structure has an undefined size.

The situation becomes even more problematic if we would try to modularize the app by moving each screen into its own library and try to isolate screens from each other to enable parallel compilation.

3 Likes

@Nikita_Leonov I think the recursive higher-order reducer demo may have what you're looking for. It's a simplistic example but demonstrates a recursive navigation tree.

3 Likes

:+1: I should pull the project more often, have not seen this one. Indeed this is in line with a problem we have. I still not quite sure the modularization will be solved with this. The higher-order example has a self-referenced recursive state and do not bring any additional states and do not have modularization constraint at all. Meanwhile in the original provide example has two screens referencing each other. I will need to dig more into the example and try to re-purpose it for the scenario provided. Thanks!

If the state and actions need to be nested recursively, I think you should be able to introduce generic "holes" in a feature that embeds a feature from another module:

struct ModuleState<ExternalState> {
  ...
  var externalState: ExternalState
}

enum ModuleAction<ExternalAction> {
  ...
  case externalAction(ExternalAction)
}

struct ModuleEnvironment<ExternalEnvironment> {
  ...
  var externalEnvironment: ExternalEnvironment
}

You'll have to decide on the trade-off between abstracting over features in this way or merely combining things into a single module.

If the state and actions of features are completely independent, you can pull them back to another module that coordinates everything in a parent state, action, environment, and view.

2 Likes

Another way of doing this is just using explicit types for holding navigation state of particular screen:

struct AState {
  indirect enum Route {
    case b(BState)
    // Other possible routes from screen A
  }
  // ....
  var route: Route?
}

struct BState {
  indirect enum Route {
    case a(AState)
    // Other possible routes from screen B
  }
  // ....
  var route: Route?
}

The same idea applies to Actions as well.

These Routes can be generalized a bit :slightly_smiling_face:

Some great info in this thread about recursive higher-order reducers and modularization!

I'm still struggling to figure out how to properly implement the original ask. I've successfully done recursive navigation (ScreenA to ScreenA) in other parts of the app thanks to the link by @stephencelis. Is the recurse function applicable to the use case of ScreenA that can go to ScreenB that can go to a new ScreenA? If so, I wasn't sure how to implement that. In the meantime, I'm getting a crash on launch of app. Any thoughts?

@nikitaame Recurse needs to go through the full layer of recursion. In the demo example, it goes through children[index] via forEach:

case .node:
  return self.forEach(
    state: \.children,
    action: /NestedAction.node(id:action:),
    environment: { $0 }
  )
  .run(&state, action, environment)

In your case it could go through screenB.screenA via pullback.optional. I think something like:

case let .screenB(.screenA)):
  return self.pullback(
    state: \.screenB.screenA,
    action: (/ScreenAAction.screenB)
      .appending(path: /ScreenBAction.screenA),
    environment: { ... }
  )
  .optional()
  .run(&state, action, environment)
1 Like

Hey, @stephencelis thanks for the answer!

After taking a look at it and implementing something along those lines, I'm not sure it is what I'm looking for.

If we have case let .screenB(.screenA)): on ScreenA, we're taking care of the actions happening two levels deep. I'm looking to take care of the actions happening just one level in on ScreenB. Normally would be taken care of by combining the reducers but seeing the crash happen on init.

@stephencelis I've gone ahead a put together a sample project to better illustrate what is happening.

When putting together the project, it actually doesn't compile (unlike the project I'm working on) due to a circular reference. This of course makes sense and is the reason it is crashing in the app that compiles.

Any thoughts on how to work around this?

Ahh ok, with some help from someone else, got it working!

        case .screenA:
            return self
                .optional()
                .pullback(
                    state: \.screenA,
                    action: /ScreenAAction.screenA,
                    environment: { $0 }
                )
                .run(&state, action, environment)

Thank you for the help!

@stephencelis @nikitaame
I have a question,
I understand, From ScreenA to ScreenA , ScreenA to ScreenB to ScreenB,
How about ScreenA to [ScreenB] to ScreenA?

@yosuke I believe @nikitaame got it working by explicitly declaring the type on screenAReducer and then nesting the screenBReducer inside .recurse { ... } instead of using .combine(...).

So the screenAReducer from the example would look something like this:

let screenAReducer: Reducer<ScreenAState, ScreenAAction, ScreenAEnvironment> =
  .recurse { `self`, state, action, environment in
    switch action {
    case .screenA:
      return self.optional().pullback(...)
        .run(&state, action, environment)
    case .screenB:
      return screenBReducer.optional().pullback(...)
        .run(&state, action, environment)
    ...
    }
  }

@iampatbrown
Thank you for your reply,

I still don't understand, in case screenA has multiple screenB.
About your showed code, It looks like only screenA to screenB?

Check out the code from this post earlier in the thread: Recursive navigation - #8 by stephencelis

If you're still having trouble, though, feel free to share the code that you're trying to get to work :smile:

2 Likes

Thank you,
This is my code of screenA has multiple screenB,
In my code, ProfileView has multiple TimelineLogCell and, TimelineLogCell has multiple ProfileView.

This is what I want.

Could you please advise?

@yosuke When you invoke the recurse reducer you must describe to the self reducer exactly how to pull back through all of its domain layers before re-invoking the reducer. It's a bit of a mind-trip, but I believe you can change these lines:

To the following:

return self
    // 1. Optionalize the profile domain for the timeline log cell
    .optional()
    // 2. Pull the optional profile back to the timeline log cell domain
    .pullback(
        state: \TimelineLogCellState.profileState,
        action: /TimelineLogCellAction.profileAction,
        environment: { _ in ProfileEnvironment() }
    )
    // 3. Pull each timeline log cell back to the profile domain to
    //    complete the recursion
    .forEach(
        state: \ProfileState.timelineLogCellStateList,
        action: /ProfileAction.timelineLogCellAction(index:action:),
        environment: { _ in TimelineLogCellEnvironment() }
    )
    .run(&state, action, env)
1 Like

@stephencelis Thank you for the advice!!
also, @iampatbrown Thanks!

I completed my sample app as you advised,
but it received action but the state did not change.

TimelineAction.timelineLogCellAction
(No state changes)

It seems it does not connect correctly...

I would be very grateful if you could advise. Thank you.

RecursiveSample