One of the things I’ve wanted to do with The Stack is to handle all of the user’s interactions with some sort of action abstraction.
It turns out that this is harder than it ought to be with the modern SwiftUI navigation mechanisms.
SwiftUI’s modern navigation uses NavigationStack instead of the older NavigationView, and it’s definitely an improvement on what came before.
The basic idea is:
- you bind the
NavigationStackview to some sort of model representing the route/path that is currently visible - ideally you use the type-erased
NavigationPathfor this purpose - whenever possible, you let SwiftUI manipulate the route for you using
NavigationLink, the default back button, and/or@Environment(\.dismiss) - this pushes items onto the route and pops them off (you can also do this explicitly yourself)
- you use
.navigationDestinationto teach SwiftUI which view to use to back each kind of item
Doing this has some nice benefits.
The view and models stay loosely coupled, and there is no one place in the code where you have to tell the NavigationStack about everything that might get pushed onto it.
The places where you do the pushing/popping can work in terms of the model or some other abstract notion of what is being presented, without needing to know anything about that actual views that will be used.
You can also put your NavigationPath instance into a router class and inject that into the environment. This lets you fish it out from anywhere and manipulate the route explicitly. For example you can implement deep links by splitting them up into items and pushing those items into the route. SwiftUI will see that the route has changed, and sort out the details of animating from where you were to where you’re going.
Actions
For The Stack (and my other apps), I want to define all the user’s interactions as abstract actions. This includes navigation actions.
I want each thing that the user can do to be represented by something that implements a Command protocol. The user will tap or click something, which will cause a Command to be performed, and the perform method of the command will do the actual mutations of the model or view-model. This will probably result in one or more updates to the UI, which the user will then interact with, and the cycle will repeat.
One reason for doing things this way is to allow the same actions to be triggered from multiple places without duplicating code. I can define a single Command for something like making a new note, and have a menu item, a button, a toolbar item, an app intent, or a siri command all invoke it.
This is especially helpful when targetting an application on multiple platforms that support different interaction mechanisms.
Another reason is that I can write common code once to take any Command and have it build the UI for me. If my command abstraction has enough information (for example a label and icon to use to represent the command, maybe an optional shortcut or tooltip), then some generic code can create a button for the command, implement the app intent for the command, insert the menu item for the command, and so on. This reduces boilerplate, and enhances consistency.
A third reason is that I can use this abstraction to support undo/redo at a level that makes sense to me. Foundation’s UndoManager has been around a long time, and is integrated into a lot of systems, including SwiftData, but it works at too granular a level for me. On this topic, I very much agree with what Drew McCormack says in this comment. I would prefer to define undo at a high level.
Finally, funnelling everything that the user does through a single mechanism allows me to support analytics and auditing.
I don’t want to know what any specific user is doing, but I would like some general analytics on usage patterns. What features to people use? What do they miss? After using feature A, do they usually choose B, or C (or rage quit in disgust!)?
Also, there is some need to monetize the applications I make! I have always been a proponent of what I would call “true micropayments”, by which I mean really charging tiny amounts for small actions, such that the user genuinely pays nothing if they don’t launch your app for weeks or months.
I’d like to try to do monetization this way. In order to do this, I need a reliable mechanism to record not what actions my users are taking, but perhaps just how many actions they’ve taken. I can then come up with a formula which ties this to real money in some way.
SwiftUI Navigation vs Actions
Unfortunately, using NavigationLink, the default back button, and/or @Environment(\.dismiss) does not lend itself particularly well to the action-based approach I’ve outlined above.
If I use NavigationLink, there is no way to ask it to invoke one of my actions when it is selected. Similarly when the user taps the default back button, or a button that calls the dismiss() method supplied by the environment.
The view-model manipulation of the route is just done for you in these cases. That means that if you want all of your navigation to be done through actions, you’re out of luck unless you make your own UI elements. This isn’t hard to do, but it is a bit annoying, and you can lose behaviour that you’d otherwise get for free. Given the fragility of SwiftUI, I prefer to give it as much semantic information as I can. Using NavigationLink tells SwiftUI more about what’s really going on, compared to just using Button. I don’t like the fact that I might be losing some clever behaviour (possibly future behaviour) if I don’t do that.
Watching The Path
A possibly solution to this problem is to allow SwiftUI to manipulate the route, but bind to the path variable and watch it for changes - for example using .onChange in my root content view.
When a change to the path happens, we can work out what it is and issue a ‘fake’ command to our action system, so that the interaction is recorded and can be audited, undone, etc. This isn’t great, but it might be better than the alternative of abandoning the recommended UI mechanisms.
Unfortunately this is pretty much impossible to achieve if you use SwiftUI’s NavigationPath to represent your route!
NavigationPath is a type-erased list of the items in your stack. The type erasure is good in that it works regardless of what you throw into it, which in turn means that you can use .navigationDestination the way that it is intended to be used, which is to declare an individual destination for each type that you place on the stack. This is important for keeping things loosely coupled - and so using NavigationPath is recommended.
Unfortunately, NavigationPath only lets you manipulate the path by adding or removing items from it, and the only information you can obtain about the current state is how many items are on it. At the point that a user taps on a NavigationLink, SwiftUI still has enough information to supply the linked value to the closure that you gave to .navigationDestination as an actual typed instance.
Sadly, it doesn’t seem to allow you retrieve a value from the path later. You can’t say, for example, “what’s the value on the top of the stack?”.
The upshot of this is that if you watch for changes to NavigationPath instance, all you can tell is that its length has changed!
If the length reduced, you know that the user popped, but if the length increased, you know that the user pushed something, but not what it was. This is not much use for generating fake navigation commands!
Not Using NavigationPath
There is an alternative approach.
NavigationStack allows you to supply a binding to a collection of anything hashable, and so you don’t strictly have to use NavigationPath.
You could, instead, define an enum with all the various kinds of things you want to push, and bind to an array of your enum type.
This works, and when you watch your array, you know exactly what kinds of things it contains, and so you can issue the relevant fake commands.
The downside of this approach is that there’s more coupling. You have a single enum that has to know about all of the things that you are going to push, and you will end up with a single .navigationDestination call for the enum type.
Imperfect Solutions
Binding to a path which is an array of enum (or some other custom type) values isn’t wrong, per-se. It is probably what I’ll need to do if I want to fully support recording commands for all of the user’s navigations.
It seems to me that it defeats much of the point of the way NavigationStack has been designed though.
In any case, personally I really wish I didn’t have to take the approach of watching mutations to the path in the first place 😢.
It would be far better if I could register a single hook with the NavigationStack. This would be called with a path manipulation to perform (push/pop/reset), and allowed to do it however it wanted to. I could use this hook to dispatch commands through my action system, and they would change the path.
This would allow me use all of the other navigation mechanisms that SwiftUI has supplied me.
Currently I’ve adopted a hybrid approach:
- I don’t watch the path.
- I use custom buttons instead of
NavigationLink, and they issue navigation commands to change the route - I use the default SwiftUI back button, and accept that I’m not currently capturing the user popping views off the stack
Tune In Next Time…
Another possible approach to this problem might be to use .onAppear/.onDisappear on the views themselves, and somehow have them generate the navigation events that way.
This would also solve another problem that I have, which is that I’ve some global UI components that I would like to always be present, but to react differently depending on what’s currently at the top of the navigation stack.
Unfortunately, relying on .onAppear/.onDisappear also has a number of problems!
Originally I’d intended to cover those in this post too, but it’s getting a bit wordy, so I think I will leave that one for another day…
Am I Holding It Wrong?
As always, this post reflects my imperfect understanding of SwiftUI. Have I missed something? If so, let me know. I’d be delighted!