Implementing complex navigation stack in SwiftUI and The Composable Architecture

While working on several projects with SwiftUI and The Composable Architecture I approached some problems designing navigation flow. Let's assume the following scenario:

  • We have an app with a feature, that can be used only if a user provides some details in advance.
  • This feature has a separate screen in the app.
  • Before a user can access this screen, we want her/him to provide some details using another screens (if she/he didn't do it before).

For simplicity, we can assume the following conditions:

  • The app should work on iPhone only.
  • Navigation happens on a stack of screens (in UIKit app I would basically use UINavigationController to implement the coordination between the screens).
  • There is no need to support other cases of the UI (no need to use UISplitViewController etc).

To be more concrete about the screens flow in the app, I prepared some example:

  • When the app is launched, Main screen is presented
  • On the main screen user can tap on a button, that navigates to the Feature screen.
  • However, if the user didn't provide some details (for example a phone number, set security PIN etc), before we go to the Feature screen, we need to present set of other screens that allow providing those details.
  • There should be always a navigation back button visible, so the user can resign from providing the details and go back to the Main screen of the app

Normally, when working with UIKit I would implement the above requirements using UINavigationController, with the help of some coordinator that decides which screen should be presented next and calls setViewControllers([UIViewController], animated: Bool) function on the navigation controller whenever navigation should happen.

I am struggling with implementing such flow in a SwiftUI app and The Composable Architecture. I don't think the NavigationView and NavigationLink provided by SwiftUI are a good fit for my needs, so I decided to wrap a UINavigationController in UIViewControllerRepresentable . I created a navigation state, which holds an array of screen-related states, in a similar way that navigation controller holds an array of view controllers. It works, but I am not sure how to correctly design store-scoping and reducers pullbacks. As a workaround, I am creating a separate store for each of the screens, and explicitly updating the navigation state from each of the screen-reducers.

Looking forward to exchanging experience and thoughts with talented developers that are reading this forum :slight_smile:

You can find proof-of-concept implementation of the above on my GitHub: GitHub - darrarski/TCACoordinatorDemo

Feel free to give feedback, even if you don't have a concrete solution for my problem. I will appreciate all hints and ideas!

2 Likes

SwiftUI navigation unfortunately seems to still be quite limited. There was an early discussion on this forum that still appears to apply today: Deep nested navigation

The default binding behavior still seems to clash with trying to drive navigation from state (and differs across iPhone/iPad), and deep-linking doesn't seem possible without some pretty big manual workarounds. We were hoping some of these issues would be resolved in this series of betas, but so far that doesn't seem to be the case.

Folks appear to have been taking matters into their own hands in the meantime: GitHub - matteopuc/swiftui-navigation-stack: An alternative SwiftUI NavigationView implementing classic stack-based navigation giving also some more control on animations and programmatic navigation.

3 Likes

Ah to address this point I think you could take a look at how navigation is handled in the Tic-Tac-Toe demo:

  1. At the root we use state to drive the root view controller with setViewControllers: https://github.com/pointfreeco/swift-composable-architecture/blob/083a4a8cec74f4b5e1fb5b6b3ee030465d55b0fa/Examples/TicTacToe/Sources/Views-UIKit/AppViewController.swift#L38-L50
  2. And at a leaf that can push/pop view controllers we use state to drive whether or not a VC is presented: https://github.com/pointfreeco/swift-composable-architecture/blob/083a4a8cec74f4b5e1fb5b6b3ee030465d55b0fa/Examples/TicTacToe/Sources/Views-UIKit/LoginViewController.swift#L151-L165
2 Likes

Thanks for the reply @stephencelis!

I've also tried to implement fully-custom navigation in SwiftUI, but I wasn't happy with the results. I managed to build a custom navigation bar and even implemented swipe-from-edge-to-dismiss gesture that mimics native UINavigationController behaviour. The overall filling of the navigation was unfortunately far from native. My work on this topic is open-sourced on GitHub:

This time, however, I would like to achieve fully native experience by using UINavigationController under the hood.

I spent some time trying to wrap UINavigationController in UIViewRepresentable and use it along with The Composable Architecture. Here are the results of my experiments:

  • GitHub - darrarski/tca-navigation-stack-poc - master branch contains a working PoC but does not use "standard" components composition. Each view controller pushed on the stack has an independent store. To synchronize the stores with navigation stack store I am doing some workarounds. This is not an ideal solution, due to the lack of proper stores composition.
  • https://github.com/darrarski/tca-navigation-stack-poc/tree/stack-item-stores-composition - on this branch I am trying to compose the stores in a proper way. I managed to make the navigation work, but I've approached a problem with pulling back reducers that I am not sure how to solve. Perhaps you can take a look and provide some guidance? I have added // TODO comment to the code I am struggling with.

Good news. I managed to solve the problem with stack item reducers pullback I mentioned in my previous post. Fully working PoC is now available on the master branch in my repository. I chose to implement the "pullbacks" manually, without using the .optional and .pullback operators. Some refactoring would still be nice to add, but the concept is now usable and I happy to tell you that I achieved what I was looking for.

Still, I would like to know what do you think @stephencelis about my approach and the proof of concept I made :slight_smile: Perhaps it could be incorporated into The Composable Architecture with some additional work, refactoring etc.

Thanks for your help with my issue as well as for creating and maintaining the great library :beers:

3 Likes

@darrarski your solution looks interesting, I had a look on the PoC.
I am trying to figure out a solution to reuse the stack in multiple flows.
Let's take the example that you have two different features, and each one has its own navigation flow.
Ideally you would only declare the stack item actions (without the navigation stack actions), and the navigation stack reducer.

I refactored NavigationStackAction to include a generic case for domain specific stack actions and looks like it works.

enum NavigationStackAction<StackItemAction> {
  // navigation actions:
  case set([NavigationStackItemState])
  case push(NavigationStackItemState)
  case pop
  case popToRoot
  // stack item actions:
  case stackItemAction(action: StackItemAction)
}

enum CounterStackItem {
  case root(UUID, RootAction)
  case counter(UUID, CounterAction)
}

The stack can be extracted in a library and dropped in any feature.
I did not try to play with the navigation bar customization, like hiding the back button or any other changes.

You probably already used this stack in production and identified other use cases which exceed the scope of the PoC, do you have any other suggestions ?