Async Navigation Overview
Overview for the AsyncNavigation package.
This post is part of the series about navigation. Earlier posts:
Async Navigation
My first blog post about navigation with async functions used this example:
flowchart LR
A[Cart] --> B[Shipping Address]
B --> C[Add Coupon]
C --> H[Order]
C --> D[Payment Method]
D --> F[Billing Address]
F --> E[Card Number]
D --> G[Apple Pay]
E --> H[Order]
G --> H[Order]
The post described an API that would capture this flow in the most intuitive and flexible way. In this post, we’ll look at the code from a full working example that implements this flow. The implementation uses AsyncNavigation.
Navigation Nodes
The diagram above is a graph where each node is a screen, and the edges describe the transitions to the next screen. When the user gets what he needs from the screen, he navigates to the next one. For example, in the first one, the user gets the updated cart; in the next one, he gets the shipping address, and so on. Here is the code for the first few nodes:
1
2
3
4
5
6
7
8
9
10
11
func adjustCart() -> RootNavigationNode<CartContainer> {
.init(CartContainer.ViewModel(cart: cart))
}
func getShippingAddress() -> NavigationNode<AddressForm> {
.init(AddressForm.ViewModel(title: "Shipping Address"), proxy)
}
func addCoupon(cart: Cart) -> NavigationNode<AddCoupon> {
.init(AddCoupon.ViewModel(cart: cart, availableCoupons: availableCoupons), proxy)
}
Each navigation node has a template parameter, which is the namespace for the screen. The namespace defines the screen view model, the content view, and the type of value that the user gets as a result of interacting with the screen. A namespace makes it possible to refer to each of these components by their standard names (ViewModel and ContentView). The view model contract is described by the BasicViewModel protocol. The most important part of that protocol is the publish method that gets called when the user is done with the screen. The value that the user gets out of interacting with the screen is the argument to publish.
Each navigation node (except the root) is constructed with a navigation proxy (NavigationProxy) that does the actual navigation via calls to push and pop (the actual protocol has more APIs). Each node also has then functions that provide the connection to the next node. Each then overload has a callback describing what happens after the user is done with this node. The callback takes the published value as one of the arguments. Together, this allows code like this:
1
2
3
4
5
await getShippingAddress().then { shippingAddress, _ in
await addCoupon(cart: cart).then { coupon, _ in
...
}
}
A little counterintuitively, the actual navigation happens inside then. In this example, the code first creates the node to get the shipping address and calls then on that node. The implementation for then calls the navigation proxy to navigate to the UI to get the shipping address. After that then awaits the result of user interaction with this UI (which gets published by the model when the user is done with the screen). Finally, then calls the callback with this value.
These implementation details may be unexpected, but the API has ergonomics that feel intuitive for following the navigation flow. We can really think of it as first getting the shipping address, then adding a coupon, etc. We can also set breakpoints for each await to get the values for each node in the flow. If we want to find out what happens inside one of the nodes, we can set breakpoints in the content view or the view model for the node.
The last .then is special:
1
2
3
.then { _, _ in
proxy.popToRoot()
}
because it describes what happens after the last node, and (as we know from the implementation details) in doing so triggers the last screen to show up. Typically, the user goes back to root (and restarts the flow) or ends the flow completely.
The first node is also special, but in a different way. The native navigation UI always requires the starting screen, so any abstraction we define over navigation should use this first node as an argument. Additionally, our abstraction must know the type for the first screen. If we used a regular node (NavigationNode), we would have to explicitly specify the first screen type for the flow, and the code would look like this:
1
NavigationFlow<CartContainer>(adjustCart())
but using a different type for the first node (RootNavigationNode) allows the compiler to infer the type, so we can omit <CartContainer>:
1
NavigationFlow(adjustCart())
where adjustCart() is
1
2
3
func adjustCart() -> RootNavigationNode<CartContainer> {
.init(CartContainer.ViewModel(cart: cart))
}
Navigation Proxy
There are different native APIs that we can use for navigation. On iOS, we have NavigationStack (SwiftUI) and UINavigationController (UIKit). Previously SwiftUI had NavigationView, and we might need to transition to something else in the future. The NavigationProxy protocol from AsyncNavigation allows you to write navigation code that doesn’t depend on a specific implementation. AsyncNavigation provides 3 implementations:
NavigationFlow, backed byNavigationStackUIKitNavigationFlow, backed byUINavigationControllerCustomNavigationFlow, custom implementation for macOS
Usually, NavigationFlow is the ideal choice because it uses the latest SwiftUI APIs. However, it has some limitations. For example, if you have a split view where each pane has its own navigation, NavigationStack won’t work there, but UINavigationController works fine. I came across other limitations where UINavigationController based navigation works better. The only difference in the code using AsyncNavigation is how you start the flow. Instead of writing NavigationFlow, you just write UIKitNavigationFlow (or CustomNavigationFlow), and the rest of the code stays exactly the same.
The constructor for all of these implementations takes a closure with 2 arguments. The first one describes the result of user interaction with the first screen, and the second one is the navigation proxy (NavigationProxy). You use the navigation proxy when constructing regular navigation nodes (NavigationNode) and when you want to navigate to a specific screen (the code examples above show both node creation and navigation back to root).
So far, we put a placeholder for the second argument in then closures for regular nodes. That argument is for the navigation index that should be used to go back to a previous node and is usually not needed. If the user decides to go back, he just taps the back button. However, occasionally, it’s necessary to navigate back to a specific screen programmatically. In those cases, that index provides a flexible way to describe where to go. For example:
1
2
3
4
5
6
7
8
9
await screenA().then { valueA, indexA in
await screenB().then { valueB, indexB in
await screenC().then { valueC, indexC in
await screenD().then { valueD, indexD in
proxy.pop(to: indexB)
}
}
}
}
Even if we later change the navigation, as long as we capture indexB in the same way, we can easily navigate back to it without having to count how many screens back we’d have to go.
You might wonder what happens when the user navigates back by tapping the back button. In that case, the UI for the screen that the user left gets destroyed. As part of that, the view model gets cancelled, the async function that gets the value from the screen gets completed, and the flow of execution goes to the async function for the previous screen. Calling proxy.pop(to:) results in similar changes but possibly for more than one screen.
Scalability
While it is possible to define a navigation flow where the entire flow is one async function, this cannot work for large apps, and even for smaller flows it typically doesn’t make sense. Instead, just like with regular functions, we can break down navigation into smaller functions and compose the complete navigation from them. For example, we can break the order flow into 2 subflows: one where the user has to pay and one where the order becomes free after a discount:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func finishRegularOrder(cart: Cart, coupon: Coupon?, shippingAddress: Address) async {
await pickPaymentMethod().then { paymentMethod, _ in
switch paymentMethod {
case .creditCard:
await getBillingAddress().then { billingAddress, _ in
await getCreditCardDetails().then { cardDetails, _ in
await showOrderSummary(
cart: cart,
coupon: coupon,
shippingAddress: shippingAddress,
paymentMethod: .creditCard(cardDetails, billingAddress)
)
.then { _, _ in
proxy.popToRoot()
}
}
}
case .applePay:
await showOrderSummary(
cart: cart,
coupon: coupon,
shippingAddress: shippingAddress,
paymentMethod: .applePay
)
.then { _, _ in
proxy.popToRoot()
}
}
}
}
func finishFreeOrder(cart: Cart, coupon: Coupon, shippingAddress: Address) async {
await showOrderSummary(
cart: cart,
coupon: coupon,
shippingAddress: shippingAddress,
paymentMethod: nil
)
.then { _, _ in
proxy.popToRoot()
}
}
and then we can just call these functions from the main flow:
1
2
3
4
5
6
7
8
9
10
await getShippingAddress().then { shippingAddress, _ in
await addCoupon(cart: cart).then { coupon, _ in
if let coupon, cart.total(coupon: coupon) == 0 {
await finishFreeOrder(cart: cart, coupon: coupon, shippingAddress: shippingAddress)
}
else {
await finishRegularOrder(cart: cart, coupon: coupon, shippingAddress: shippingAddress)
}
}
}
Common Patterns
To use AsyncNavigation, your screens must follow some conventions:
- A namespace for the screen
- The view model must be called
ViewModelwithin that namespace - The content view must be called
ContentViewwithin that namespace - The view model must have a type corresponding to a value for the screen
- The view model must call
publishwhen the user is done with the screen
All of these requirements are captured in protocols (ViewModelUINamespace, BasicViewModel, ViewModelContentView). AsyncNavigation also provides BaseViewModel<T> to avoid unnecessary boilerplate code.
I used AsyncNavigation in a few commercial apps, which led to some patterns that are also very useful (although not compiler enforced):
- The start of a flow is the flow container that describes the first screen and your choice for the navigation abstraction. For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct OrderFlowContainer: View {
let cart: Cart
let availableCoupons: [Coupon]
func adjustCart() -> RootNavigationNode<CartContainer> {
.init(CartContainer.ViewModel(cart: cart))
}
var body: some View {
NavigationFlow(adjustCart()) { cart, proxy in
await OrderFlow(cart: cart, availableCoupons: availableCoupons, proxy: proxy).run()
}
}
}
- The rest of the flow is captured by the flow struct (the example above has
OrderFlow), which holds the proxy and is called from the flow container using therunmethod. - The flow struct first describes every node. For example:
1 2 3 4 5 6 7 8 9 10 11 12 13
struct OrderFlow { ... let proxy: NavigationProxy func getShippingAddress() -> NavigationNode<AddressForm> { .init(AddressForm.ViewModel(title: "Shipping Address"), proxy) } func addCoupon(cart: Cart) -> NavigationNode<AddCoupon> { .init(AddCoupon.ViewModel(cart: cart, availableCoupons: availableCoupons), proxy) } ...
Since
proxyis part of the struct, it doesn’t need to be passed as an argument for each node, which makes the code more compact. - Each node function describes the screen by the action that the user takes on that screen. For example, specifying a shipping address, selecting a coupon, etc. This makes the navigation code look like a series of steps, which matches how we think about navigation.
- The run method uses the nodes to describe the actual flow.
- If the flow is complex, logical parts of the flow that combine multiple screens are extracted into separate functions that are called in the
runmethod. For example,1 2 3 4 5 6 7 8 9 10 11 12
func run() async { await getShippingAddress().then { shippingAddress, _ in await addCoupon(cart: cart).then { coupon, _ in if let coupon, cart.total(coupon: coupon) == 0 { await finishFreeOrder(cart: cart, coupon: coupon, shippingAddress: shippingAddress) } else { await finishRegularOrder(cart: cart, coupon: coupon, shippingAddress: shippingAddress) } } } }
Together, these patterns make navigation much easier to write and maintain.
The next post will cover testing of AsyncNavigation flows.