Thoughts on SwiftUI navigation
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 |
---|
![]() |
This could be fairly easy to implement in UIKit, with a 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
that can be controlled by
. We will use it, as there are no other options at the moment (besides wrapping
using
, 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:
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 } }
Combine reducers using
and
operators.
let firstReducer = Reducer<FirstState, FirstAction, Void>.combine( secondReducer.optional().pullback( state: \.second, action: /FirstAction.second, environment: { _ in () } ), Reducer { // reducer implementation from step 1 } )
Drive
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 s. We drive it using
that gets its value from
. When dismiss action is dispatched to the store, we immediately set the state to
, which triggers dismiss animation. At the same time, the destination of
changes to an empty view, thanks to
. 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 in a conditional statement. With this approach, if there is no state for the presented view, we won’t render
at all. Although his approach does not use ComposableArchitecure, it could be easily adjusted to work with
that has an optional
:
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
)
}
)
)
}
}
The code above looks a bit strange, especially the part in which we are hardcoding binding getter to
. This works because if there is no state to present, the
will not render the output of the
closure, so there will be no
in the view hierarchy at all.
At first sight, this solution solves our issue, as programmatically dismissed screens animate correctly. Unfortunately, if we apply to our
like this:
NavigationView {
// ...
}
.navigationViewStyle(StackNavigationViewStyle())
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 binding as we did in this case.
Solution #2
Another solution that addresses this problem is to decouple the state that drives
binding from the state of view we are want to present:
struct FirstState {
var isPresentingSecond: Bool // drives NavigationLink isActive binding
var second: SecondState?
}
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
}
We will handle the action in the presenter’s reducer. Notice that action does not remove the presented state anymore. It just toggles
flag to
. This flag drives
and triggers dismission. When the presented screen disappears and
is
, it means that the view was dismissed, and its state can be set to
.
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
}
}
With the above changes, we can declare 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")
}
)
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 (
property in the above example) from the presented state (optional
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 that we are using with the ComposableArchitecture’s
. 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
, we will store the last non-
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
}
}
}
}
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:)
)
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- state value when dismissing, there is no glitch. It also works when using
without an issue.
Update 2021-04-10: Thanks to the feedback I received from Thomas Visser I managed to refactor the above code. It’s now a bit simpler and does not require storing the last non-
value in a
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 to dismiss it. If side effects are running, an action can be dispatched when the state is already
.
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
// ...
}
}
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 that is meant to be used to solve our problem. Instead of combining reducers with
and
operators as proposed earlier, we can use
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 () }
)
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
)
}
}
}
Actual implementation example
I’ve created an example project to explore how 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 |
---|
![]() |
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.
has not only limited usage capability but also can easily cause a lot of problems. It’s definitely not an equivalent of
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 with
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.