Jan 05, 2021

Building Functional Stack Navigation

This post deals with the internals of my newly-released navigation library for SwiftUI, Stapel.

Today's mobile applications offer a lot of different features, but in most cases, they're built on a simple principle: You're viewing a certain view and by tapping buttons, links, or other displayed controls, you can navigate to a different screen.

Sometimes, this navigation also happens completely without active user action, for example when you open a universal link that activates the application and immediately navigates to the link destination.

But navigating forward isn't everything: Users can go to the previous view by swiping right or via the back button in the navigation bar. Once you've "popped" the active view, you can't swipe again to get back, you'll have to repeat the actions that led to the view being "pushed".

Pushing and popping views onto a virtual stack is how most navigation implementations work. While not the default, SwiftUI also offers a stack navigation mode for navigation views, which I'd heavily recommend. This makes views behave as you'd expect, appear when they are rendered, disappear only when they're no longer in the view tree.

Without explicit stack navigation, nested views would make the former view disappear until it comes to the foreground again.

StackNavigationStyle is a requirement to achieve the multi-layer navigation you come to expect from most applications. While there might be edge cases, this will allow users to navigate forward and backward using NavigationLinks.

🗺 Navigating with SwiftUI

To add navigation features to your application, you'll have to render a NavigationView first. This is the container that keeps track of the navigation state. Once a navigation view is rendered in some layer of the view hierarchy, subviews can utilize navigation - you could even nest multiple NavigationViews, depending on what you're trying to achieve.

With the NavigationView in place, you can now focus on guiding users through your application with NavigationLinks. These views can either be interactive or reactive/programmatic. The former option will simply render a button that pushes the destination view onto the stack once tapped.

NavigationLink(
  destination: Text("Pushed view")
) {
  Text("Push a new view!")
}

This option is really helpful when you want your users to control what should be pushed when. If you render a list of items that should lead to a more detailed view for each of those when clicked, that would be perfect.

List {
  NavigationLink(
    destination: DetailView(name: "Item 1")
  ) {
    Text("Item 1")
  }
}

For a lot of cases, this will come in handy. But there are additional situations. Let's say you're fetching some data from your backend and want to push a new view once that's done: This requires a feature called programmatic navigation, which is a complicated way to say that not the user but the application should be in control of pushing or popping views.

In SwiftUI, you have some workarounds that enable programmatic navigation: In addition to rendering a simple link, NavigationLink can also be initialized with an isActive boolean binding that will push the view when it's true, or set it to false once the user pops the view.

Those two properties are the foundation of why Stapel works as it does, which I'll explain later. For now, know that you can add programmatic navigation to a degree by using isActive and toggling it, whenever you want to push a view.

struct ContentView: View {
    @State var isActive: Bool = false
    var body: some View {
        NavigationView {
            VStack {
                Text("Some view")
                Button(action: {
                    // load some data
                    isActive.toggle()
                }, label: {
                    Text("Load data")
                })
                NavigationLink(
                    destination: Text("Pushed"),
                    isActive: $isActive,
                    label: { EmptyView() }
                )
            }
        }
    }
}

Note that I didn't render a label this time, instead, I added an EmptyView. This is useful when you want to get fully programmatic navigation. If you want the user to be able to navigate themselves, you can use a label here again of course.

This time, we added a managed state variable isActive that is passed as a binding to the NavigationLink. When you tap the button, we'll toggle the variable and it will push a view onto the stack, without any further user action.

This boolean binding is nice for "binary" pushes when you either want a view pushed or want a notification when it's popped. SwiftUI offers another initializer, though: Similar to how tab views work, you can add a tag and selection string binding. When you want to display a specific view, you set the state variable to the tag of its respective NavigationLink. Once a user taps the back button, the selection will be set to nil.

struct ContentView: View {
    @State var shown: String? = nil
    var body: some View {
        NavigationView {
            VStack {
                Text("No view shown")

                HStack {
                    Button(action: {
                        shown = "1"
                    }, label: {
                        Text("First")
                    })

                    Button(action: {
                        shown = "2"
                    }, label: {
                        Text("Second")
                    })

                    Button(action: {
                        shown = "3"
                    }, label: {
                        Text("Third")
                    })

                }

                NavigationLink(destination: Text("1"), tag: "1", selection: $shown, label: {
                    EmptyView()
                })

                NavigationLink(destination: Text("2"), tag: "2", selection: $shown, label: {
                    EmptyView()
                })

                NavigationLink(destination: Text("3"), tag: "3", selection: $shown, label: {
                    EmptyView()
                })

            }
        }
    }
}

Okay, that's pretty useful. But all of those solutions only help you on one layer, and you need to know what should be pushed beforehand. The latter can be solved by rendering the destination view dynamically, by adding it as a variable instead.

Both of those issues are solved easily when using a navigation library like Stapel.

📚 Enhance your navigation with Stapel

Now that we know the features SwiftUI exposes for navigating, let's think of what we want. We need a virtual stack of views that can be pushed to and popped from, either through user action (by rendering a button or link that can be tapped) or programmatically by exposing an interface.

We also want to control how navigation bars are rendered, we want the back button to work as expected, especially being able to long-press the back button to get a context menu with a list of views on the stack which you can use to pop to root with one tap instead of going back multiple times.

But it doesn't end here: We want to make context-aware decisions about when to push views and handle edge cases, for example when the current and destination views are equal. In that case, it might not make sense to push the same view again but to re-render the current view with some update.

All of those requirements resulted in me building Stapel. It exposes programmatic and user-based stack navigation, built completely on top of native features like NavigationViews and NavigationLinks. Let's see some examples first and then learn a bit more about how it works under the hood.

Let's consider the first example again, where we simply rendered a NavigationLink that pushes a new view. Once you've set up Stapel in your view hierarchy, all you need to do is to render

StackNavigationLink(
    label: {
        Text("Push a new view!")
    }
) {
    Text("Pushed view")
}

It looks quite similar, doesn't it? And the outcome is completely identical. A label is rendered, and the view gets pushed once you tap it.

Let's make it a bit more sophisticated, think of the data loading example. We want to push a view once the request has finished.

struct ContentView: View {
    @EnvironmentObject var stack: Stack

    var body: some View {
        Button(
            action: {
                // load some data
                stack.push(view: AnyView(Text("Pushed")))
            },
            label: {
                Text("Load data")
            }
        )
    }
}

This example gets a lot more simple. Instead of managing state and declaring a navigation link, we simply push the destination onto our stack once we've loaded everything.

And it gets even more interesting: You're not bound to the current view to push a new view onto the stack. At any place where you can access the stack (so pretty much anywhere in the view hierarchy), you can use the exposed stack APIs. A parent view receiving data could push a view onto the stack even though it's not the top-most view.

So how exactly does all of this work? How can we push views from anywhere, without rendering explicit NavigationLinks declaring state and destinations?

Everything in Stapel revolves around the stack, a virtual representation of the view layers of your application. The first element could be the initial screen rendering a list. Once you tap on a list item to view the details of a specific item, we'll push a detail view onto the stack, another item in the dictionary.

Wait, a dictionary? Wouldn't we rather render an array of views in the order they've been added? Yes and no. Pushing views isn't everything Stapel manages. Somehow, those views have to be rendered still, just pushing onto a virtual data structure doesn't make it work.

This is where we introduce the concept of pushers. Remember the layers we talked about earlier? In most cases, your app will have one layer for every view that can be pushed. You could also think of it as a "push opportunity". Wherever we declare this layer, we can push another view. If we haven't declared a layer in the current view and perform a push anyway, we'll push it onto the previously declared layer, wherever that is. This can result in replacing the current view instead of pushing as we'd expect.

Let's get back to the layers, though. When using Stapel, you declare those layers by rendering a pusher.

struct ContentView: View {
    var body: some View {
        WithPusher {
            Text("This view can now be pushed onto")
        }
    }
}

With no further configuration necessary, WithPusher renders a pusher view and your content as a VStack. The pusher view registers itself with the stack, which now knows about the layered structure of our views.

Whenever you push onto the stack, we'll search the stack for the pusher which is currently active, usually, this is the pusher registered in the current view, and assign it the view to be rendered. This assignment is reflected in the view hierarchy as well, remember that we have to render something to push the view.

As we learned earlier, this is a NavigationLink. With your pushed view set as the destination and a managed isActive binding set to true, SwiftUI takes care of pushing the view as you'd expect. I never wanted to build any custom logic around animations and transitions for the push process, so I figured that making use of the built-in navigation would be a perfect fit here.

And this integration also helps us to know when the user navigates back: isActive will then be updated to false, which we'll notice and clean up the stack to match the rendered views.

Note that this only works because SwiftUI handles pushing and popping of views in a stacked fashion. We also depend on isActive switching to false once the view is popped, otherwise, we'd have no way of knowing what's happening.

Concluding this section, we learned that Stapel manages an internal dictionary of registered pushers with their assigned views (or empty slots to be filled), as well as rendering NavigationLinks once a view is pushed.

Internally we perform a couple of additional steps, but the outcome stays the same. To set up Stapel, we require users to add the following to their view hierarchy.

struct ContentView: View {
    @StateObject var stack = Stack()
    var body: some View {
        WithStapel {
            Text("Root view")
        }
        .environmentObject(stack)
    }
}

This lightweight wrapper will create a NavigationView in stack mode, this is just a safeguard so we don't forget to set this, as the whole system depends on it.

We ask users to manage the stack themselves, though, as they might want to use it at top-level or pass it somewhere else to perform programmatic navigation.


This was the first part of diving into the internals of Stapel, my recently-published stack navigation library for SwiftUI.