Jan 10, 2021

Deliver context-aware navigation with Push Evaluation

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

In my previous post, we took a deep dive into how mobile application navigation usually works. We saw that views are built in layers, pushed onto a virtual stack once a user taps a specific button, or when a certain event triggers programmatic navigation.

While this is great already, we can go even further than this. Applications are designed in layers of views. Each layer or view has its own context. Think of a chat application: You might have an initial view that displays a list of chats a user participates in.

When tapping a specific chat, a detailed view containing a list of messages and other controls will be pushed onto the stack. This is covered by regular navigation, let's make it a tad more interesting.

Our application continuously synchronizes with our backend infrastructure and receives incoming messages or other updates via push notifications and other channels. In case of an incoming message, we should distinguish between multiple cases

  • Case A: The incoming message is sent in a chat the user is viewing at the moment. In this case, we do not want to push the same view again, as that would lead to a bad user experience
  • Case B: The incoming message was sent in a different chat, or the user isn't viewing any chat at all, right now. In this case, we'd like to show a small bubble view that informs the user about the new message and pushes the chat to the stack once clicked

Designing our application in this way allows us to make context-based decisions, resulting in a polished experience that handles multiple use cases. The active view is able to evaluate whether a push should occur, or whether it should just perform an internal update instead.

When building a SwiftUI application using Stapel, all of the aforementioned functionality is included out of the box with push evaluation, let's check out an example

struct ContentView: View {
    @StateObject var stack = Stack()

    var body: some View {
        WithStapel({ (context) -> Bool in
            guard let hasExpected = context["expected"] else {
                return false
            }
            guard let isString = hasExpected as? String else {
                return false
            }
            return isString == "value"
        }) {
            Text("Root view")
            Button(action: {
                stack.push(view: AnyView(Text("No-op")))
            }, label: {
                Text("Push falsy")
            })
            Button(action: {
                stack.push(
									view: AnyView(Text("Pushed with evaluation")),
									context: ["expected" : "value"]
								)
            }, label: {
                Text("Push truthy")
            })
        }
        .environmentObject(stack)
    }
}

The code above will display the root view and register an evaluation function on the root-level pusher. It's important to know that an evaluation function only works for one level. If you're pushing a view and it is assigned to the root-level pusher (the pusher rendered for the root view), its evaluation function will be invoked.

If you push a view to a subsequent layer, though, we'll either invoke the corresponding evaluation function if supplied, otherwise we'll push it to the stack without further evaluation.

For push evaluation to work, we have two requirements: An evaluation function registered in case we want to limit pushes to specific layers (in our chat example, we'd register an evaluation function in the chat view pusher), as well as having to supply the context that is passed to the evaluation function, which can use it to decide how to proceed.

In the code example, our evaluation function expects the context dictionary to contain an expected key and value string value. If our context matches this shape, views for this pusher will be pushed, otherwise, nothing will happen.

For our chat application, we could check whether the context contains an identifier that matches the current chat, and if not, allow us to push the view. If the identifiers match, we could trigger a view update, for example by modifying a state variable.

Once you get the hang of it, push evaluation is an incredibly straightforward mechanism, but it enables delightful app experiences that you'd come to expect from modern software.

By allocating the decision power to the place where the outcome would occur, and by supplying the context necessary to make an informed decision, you can push views from anywhere and get the expected outcome, without having to pass data between layers manually or perform some other magic tricks. It just works.

If you haven't given Stapel a shot yet, please do! I'm currently integrating it with a couple of projects I'm working on, and the difference is noticeable already. We're not losing any of the customization options SwiftUI offers for navigation while getting regular and programmatic navigation, as well as push evaluation for free.