DEV Community

loading...

Thoughts on SwiftUI navigation

Dariusz Rybicki
Software Engineer, Scrum Master, Mentor & iOS Developer at @elpassion
Originally published at github.com ・9 min read

...in a composable world.

Introduction

I am using SwiftUI & ComposableArchitecture for a while already. I had built several iOS & macOS apps with it, but there is one topic that I am still struggling with - NAVIGATION.

I want to share my thoughts with you, show the issues I experienced and how I tried to address them. Ultimately, I would like to develop a flexible solution that can be used to implement complex navigation flows. It should be simple to use and provides a native user experience baked by native SwiftUI APIs.

Case study

Although most of the apps will contain rather complex navigation flows, let's start with a simplified example.

Consider the following use case: We need to build a basic iOS app with three screens. A user should be able to navigate from the first screen to the second and then to the third one. There should be an option to go back from the third screen directly to the first one. We should be able to control navigation programmatically. The app should feel native. The UI and gestures should be familiar to the user.

Demo app
navigation in demo app

This could be fairly easy to implement in UIKit, with a UINavigationController controlled imperatively. You can push, pop, or even change the whole stack of view controllers on it, and all happens with a nice animation. However, things look a bit different in SwiftUI's declarative world. We have a NavigationLink that can be controlled by Binding<Bool>. We will use it, as there are no other options at the moment (besides wrapping UINavigationController using UIViewControllerRepresentable, which complicates the implementation and bounds it to the iOS platform - let's avoid this road for now).

Basic implementation

Without any further ado, here is how we can implement it using ComposableArchitecture:

  1. Define a state, actions, and reducer for each of the screen views.

    struct FirstState {
      var second: SecondState? // state of the second screen
    }
    
    enum FirstAction {
      case presentSecond(Bool) // present or dismiss second screen
      case second(SecondAction) // action of the second screen
    }
    
    let firstReducer = Reducer<FirstState, FirstAction, Void> { state, action, _ in
      switch action {
        case let .presentSecond(present):
          state.second = present ? SecondState() : nil
          return .none
    
        case .second:
          return .none
      }
    }
    
  2. Combine reducers using .optional and .pullback operators.

    let firstReducer = Reducer<FirstState, FirstAction, Void>.combine(
      secondReducer.optional().pullback(
        state: \.second,
        action: /FirstAction.second,
        environment: { _ in () }
      ),
      Reducer {
        // reducer implementation from step 1
      }
    )
    
  3. Drive NavigationLink with an optional state of the view that we will present.

    NavigationLink(
      destination: IfLetStore(
        store.scope(
          state: \.second,
          action: FirstAction.second
        ),
        then: SecondView.init(store:)
      ),
      isActive: viewStore.binding(
        get: { $0.second != nil },
        send: FirstAction.presentSecond
      ),
      label: { 
        Text("Present second screen")
      }
    )
    

So far, it doesn't look too complex, and it works. However, there are already several issues with this implementation.

Programmatically dismissed screens does not animate correctly

We can dismiss the screen by tapping on a back button or performing a swipe-from-screen-edge gesture. In this case, everything looks good. However, if we try to dismiss it programmatically by dispatching an action, things look weird. The dismissed screen disappears before pop animation ends.

This glitch appears because of the way we implemented NavigationLinks. We drive it using Binding<Bool> that gets its value from state.second != nil. When dismiss action is dispatched to the store, we immediately set the state to nil, which triggers dismiss animation. At the same time, the destination of NavigationLink changes to an empty view, thanks to IfLetStore. In this case, we can't actually present the dismissed view because we don't have its state anymore. There are several workarounds for this issue.

Solution #1

Majid Jabrayilov suggested in his blog post to wrap the whole NavigationLink in a conditional statement. With this approach, if there is no state for the presented view, we won't render NavigationLink at all. Although his approach does not use ComposableArchitecure, it could be easily adjusted to work with Store that has an optional State:

extension View {
  func navigate<State, Action, Destination: View>(
    using store: Store<State?, Action>,
    onDismiss: @escaping () -> Void,
    destination: @escaping (Store<State, Action>) -> Destination
  ) -> some View {
    background(
      IfLetStore(
        store,
        then: { destinationStore in
          NavigationLink(
            destination: destination(destinationStore),
            isActive: Binding(
              get: { true },
              set: { isActive in
                if !isActive {
                  onDismiss()
                }
              }
            ),
            label: EmptyView.init
          )
        }
      )
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

The code above looks a bit strange, especially the part in which we are hardcoding isActive binding getter to true. This works because if there is no state to present, the IfLetStore will not render the output of the then closure, so there will be no NavigationLink in the view hierarchy at all.

At first sight, this solution solves our issue, as programmatically dismissed screens animate correctly. Unfortunately, if we apply StackNavigationViewStyle to our NavigationView like this:

NavigationView {
  // ...
}
.navigationViewStyle(StackNavigationViewStyle())
Enter fullscreen mode Exit fullscreen mode

Things look even worse as push transitions do not animate anymore. Whenever we try to navigate to the next screen, it will just appear immediately without animation. I've created a gist with code that reproduces the issue and reported it to Apple. I'm not sure if it's a bug, or perhaps we should not hardcode the isActive binding as we did in this case.

Solution #2

Another solution that addresses this problem is to decouple the state that drives NavigationLink isActive binding from the state of view we are want to present:

struct FirstState {
  var isPresentingSecond: Bool // drives NavigationLink isActive binding
  var second: SecondState?
}
Enter fullscreen mode Exit fullscreen mode

To make it work, we need to add an action to the second screen, which we will dispatch when the presented view disappears.

enum SecondAction {
  // ...
  case didDisappear // dispatched when presented view disappears using `.onDisappear` SwiftUI modifier
}
Enter fullscreen mode Exit fullscreen mode

We will handle the action in the presenter's reducer. Notice that .presentSecond(false) action does not remove the presented state anymore. It just toggles isPresentingSecond flag to false. This flag drives NavigationLink and triggers dismission. When the presented screen disappears and isPresentingSecond is false, it means that the view was dismissed, and its state can be set to nil.

Reducer<FirstState, FirstAction, Void> { state, action, _ in
  switch action {
  // ...
  case .presentSecond(present):
    state.isPresentingSecond = present
    if present {
      state.second = SecondState()
    }
    return .none

  case .second(.didDisappear):
    if state.isPresentingSecond == false {
      state.second = nil
    }
    return .none
  }
}
Enter fullscreen mode Exit fullscreen mode

With the above changes, we can declare NavigationLink like this:

NavigationLink(
  destination: IfLetStore(
    store.scope(
      state: \.second,
      action: FirstAction.second
    ),
    then: SecondView.init(store:)
  ),
  isActive: viewStore.binding(
    get: \.isPresentingSecond,
    send: FirstAction.presentSecond
  ),
  label: {
    Text("Present Second")
  }
)
Enter fullscreen mode Exit fullscreen mode

I've used this approach in several apps so far. It does work as expected. Unfortunately, it adds a lot of complexity to the code. Because of decoupling the state that drives the NavigationLink (isPresentingSecond property in the above example) from the presented state (optional second property), we added a lot of boilerplate to the reducers. It's also required to add more code to the reducer if we need to handle dismissing multiple screens at once. There is actually a lot of things that we should care about, which makes the solution rather complex.

Solution #3

The main problem of the solution mentioned above is the significantly increased complexity of the code that drives navigation. With a fairly simple navigation flow, we needed to add a lot of boilerplate just to address the glitch with animations when programmatically dismissing screens. Thankfully there is a simpler way to achieve the same result.

We can create a wrapper for the NavigationLink that we are using with the ComposableArchitecture's Store. The optional state property will drive it without the need to decouple it. To make the destination view stay in the view hierarchy after the state is set to nil, we will store the last non-nil state and use it instead. This involves creating additional property in the view so we don't pollute our state structs.

struct NavigationLinkView<State, Action, Destination: View>: View {
  init(
    store: Store<State?, Action>,
    onDismiss: @escaping () -> Void,
    destination: @escaping (Store<State, Action>) -> Destination
  ) {
    self.store = store
    self.onDismiss = onDismiss
    self.destination = destination
    self.viewStore = ViewStore(
      store,
      removeDuplicates: { ($0 != nil) == ($1 != nil) }
    )
  }

  let store: Store<State?, Action>
  let onDismiss: () -> Void
  let destination: (Store<State, Action>) -> Destination
  @ObservedObject var viewStore: ViewStore<State?, Action>
  @SwiftUI.State var lastState: State?

  var body: some View {
    NavigationLink(
      destination: IfLetStore(
        store.scope(state: { $0 ?? lastState }),
        then: destination
      ),
      isActive: Binding(
        get: { viewStore.state != nil },
        set: { isActive in
          if isActive == false {
            onDismiss()
          }
        }
      ),
      label: EmptyView.init
    )
    .onReceive(viewStore.publisher) { state in
      if let state = state {
        lastState = state
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It might look complex, but it's actually rather simple. We can use it like this:

NavigationLinkView(
  store: store.scope(
    state: \.second,
    action: FirstAction.second
  ),
  onDismiss: {
    viewStore.send(.presentSecond(false))
  },
  destination: SecondView.init(store:)
)
Enter fullscreen mode Exit fullscreen mode

It does not require modifying state or reducers showcased in the "Basic implementation" section. It allows for programmatic navigation - both presenting and dismissing screens is possible by simple state mutations inside the reducer. Because we use the last non-nil state value when dismissing, there is no glitch. It also works when using StackNavigationViewStyle without an issue.

Update 10.04.2021: Thanks to the feedback I received from Thomas Visser I managed to refactor the above NavigationLink code. It's now a bit simpler and does not require storing the last non-nil value in a @State property. You can check out how does it look now in the example project's repository.

Dismissing screen and canceling its side effects

As soon as we start adding more logic to the reducers, including long-running effects, we will discover that there is no easy way to cancel them when the screen is dismissed. We just naively set the state of the presented view to nil to dismiss it. If side effects are running, an action can be dispatched when the state is already nil.

Solution #1

One way to address this problem would be to cancel all long-running effects when we dismiss screens manually. Because the navigation is state-driven and state mutations happen only inside the reducers, we are in control here.

Reducer<FirstState, FirstAction, Void> { state, action, _ in
  switch action {
  // ...
  case let .presentSecond(present):
    state.second = present ? SecondState() : nil
    if present == false {
      return .cancel(id: SecondReducerEffectId())
    }
    return .none
  // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

This should work as expected. However, it's not a very clean solution. These effects are created in the presented view's reducer, but we have to remember to cancel them in the reducer that presents the view.

Solution #2

Another solution comes from the authors of ComposableArchitecture, although it's not (yet) an official one. On a separate branch, there is an extension to the Reducer that is meant to be used to solve our problem. Instead of combining reducers with .optional and .pullback operators as proposed earlier, we can use .presents operator, like this:

let firstReducer = Reducer<FirstState, FirstAction, Void> { state, action, _ in
  // basic reducer implementation
}
.presents(
  secondReducer,
  cancelEffectsOnDismiss: true,
  state: \.second,
  action: /FirstAction.second,
  environment: { _ in () }
)
Enter fullscreen mode Exit fullscreen mode

It even looks better and is easier to read. I played a bit with this solution, and after a slight modification works well also when multiple views are dismissed at once. I found no issues so far. Here is the code:

extension Reducer {
  func presents<LocalState, LocalAction, LocalEnvironment>(
    _ localReducer: Reducer<LocalState, LocalAction, LocalEnvironment>,
    cancelEffectsOnDismiss: Bool,
    state toLocalState: WritableKeyPath<State, LocalState?>,
    action toLocalAction: CasePath<Action, LocalAction>,
    environment toLocalEnvironment: @escaping (Environment) -> LocalEnvironment
  ) -> Self {
    let id = UUID()
    return Self { state, action, environment in
      let hadLocalState = state[keyPath: toLocalState] != nil
      let localEffects: Effect<Action, Never>
      if hadLocalState {
        localEffects = localReducer
          .optional()
          .pullback(state: toLocalState, action: toLocalAction, environment: toLocalEnvironment)
          .run(&state, action, environment)
          .cancellable(id: id)
      } else {
        localEffects = .none
      }
      let globalEffects = self.run(&state, action, environment)
      let hasLocalState = state[keyPath: toLocalState] != nil
      return .merge(
        localEffects,
        globalEffects,
        cancelEffectsOnDismiss && hadLocalState && !hasLocalState ? .cancel(id: id) : .none
      )
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Actual implementation example

I've created an example project to explore how NavigationLink can be used with ComposableArchitecure and how the issues mentioned above can be addressed. I tested several approaches and solutions mentioned above. Feel free to check out commits history to follow my journey.

Demo app
navigation in demo app

Final thoughts

There is much more to explore when it comes to navigation in SwiftUI apps. I only focused on an elementary example, which can be used in real applications. It's unfortunately far away from an ultimate solution that allows implementing complex navigation flows. Due to current SwiftUI limitations, we don't have many options in this field compared to what we can do using plain, old UIKit.

NavigationLink has not only limited usage capability but also can easily cause a lot of problems. It's definitely not an equivalent of UINavigationController despite the fact that it uses it under the hood. Its declarative nature looks nice at first sight but can be a reason for headache when trying to implement a fairly simple flow, like pop-to-root, which in imperative UIKit would be a no-brainer.

The biggest pain point for me is actually the lack of a declaratively-manageable navigation stack in SwiftUI. While UIKit provides UINavigationController with setViewControllers(_:animated:) function, there is no equivalent of it in a SwiftUI world. I'm missing a declarative API that allows managing a stack of views, so we can easily update it. I would love to see it at the next WWDC.

Do you want to leave a comment?

Check out dedicated discussion.

Did you like it?

Buy Me A Coffee

Discussion (1)

Collapse
pavermakov profile image
Pavel Ermakov

SwiftUI is the worst library i have ever used. Workaround on top of another workaround and another workaround in between.