EP 164 · SwiftUI Navigation · Oct 18, 2021 ·Members

Video #164: SwiftUI Navigation: Sheets & Popovers, Part 3

smart_display

Loading stream…

Video #164: SwiftUI Navigation: Sheets & Popovers, Part 3

Episode: Video #164 Date: Oct 18, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep164-swiftui-navigation-sheets-popovers-part-3

Episode thumbnail

Description

Now that we’ve built up the tools needed to bind application state to navigation, let’s exercise them. We’ll quickly add two more features to our application, beef up our navigation tools, and even write unit tests that assert against navigation and deep-linking.

Video

Cloudflare Stream video ID: fe3a307298f07f67d1be98c4509c76d9 Local file: video_164_swiftui-navigation-sheets-popovers-part-3.mp4 *(download with --video 164)*

References

Transcript

0:05

We have finished our refactor, and though there were a few bumps along the way, we got through it, and it is already greatly simplifying our inventory list domain. The inventory list view and view model get to handle fewer responsibilities, and we push more domain specific responsibilities to the row view and row view model.

0:27

While it’s true that creating child view models is a little gnarly, the benefits from doing so are tremendous. If we wanted to, we could completely split the inventory list domain from the item row domain, putting them in completely separate modules and making it easier to build, test, and run them in isolation.

0:59

Don’t forget, but the whole reason we did this refactor was to add new features for editing and duplicating items to the row domain, so now let’s flex these muscles by adding more functionality to the row and show that it does not needlessly bloat the parent domain. We are going to add two more buttons to the row: one for editing the item, and one for duplicating the item. And just to make things interesting we are going to show the edit screen in a modal sheet and the duplicate screen in a popover. Modeling item row navigation

1:37

Let start with some domain modeling. The edit feature will be driven by a modal sheet, which means we either need a boolean or an optional value to drive its presentation and dismissal. We will further make the decision that changes made in the edit screen are not instantly reflected in the item of this view model, but rather we will require you tap “Save” to commit the changes. This means an optional piece of state is most appropriate: class ItemRowViewModel: Identifiable, ObservableObject { @Published var deleteItemAlertIsPresented = false @Published var item: Item @Published var itemToEdit: Item? … }

2:09

Similarly, the duplicate feature will be driven off of a popover, which we haven’t explored its API yet, but it is very similar to that of modal sheets. This means we’ll need yet another piece of optional state to represent the presentation and dismissal of the popover: class ItemRowViewModel: Identifiable, ObservableObject { @Published var deleteItemAlertIsPresented = false @Published var item: Item @Published var itemToDuplicate: Item? @Published var itemToEdit: Item? … }

2:32

Before going any further we should stop and ask whether or not this domain modeling seems right. We are holding two optional values and a boolean, a total of possible 8 states, for which there are only 4 valid states: either the edit screen is presented, or the duplicate screen is presented, or the alert is presented, or none of them are presented.

2:54

So half the possible states are completely invalid, and that is going to leak complexity all throughout our domain. We are never going to know for certain that when one of these values is non- nil that it means the corresponding screen is presented. It could just be leftover stale data, and in reality some other screen could be presented. We will have to litter our code with defensive programming in order to make sure these invalid states cannot happen, but we will never know for sure.

3:18

A far better way to model this data would be to use an enum so that we can mark each of these states as mutually exclusive so that no two can happen at the same time. We will call this enum Route because it represents different places we can navigate to: class ItemRowViewModel: Identifiable, ObservableObject { // @Published var deleteItemAlertIsPresented = false @Published var item: Item // @Published var itemToDuplicate: Item? // @Published var itemToEdit: Item? @Published var route: Route? enum Route { case deleteAlert case duplicate(Item) case edit(Item) } … }

4:11

By using a proper route enum we will have the compiler checking us every step of the way so that we can’t do something invalid.

4:17

Now, properly modeling our domain has led to some compiler errors, so let’s see what it takes to fix them and what we need to do to add the new sheet and popover interactions. First we can fix the initializer to take a single Route value rather than a few optionals and a boolean: init( item: Item, route: Route? = nil ) { self.item = item self.route = route }

4:34

Next, when tapping the delete button we can now set the route field rather than flipping a boolean to true : func deleteButtonTapped() { route = .deleteAlert }

4:41

The only other error we have in this file is in the view where we specify how the alert should be displayed: isPresented: $viewModel.deleteItemAlertIsPresented,

4:46

This field on the view model no longer exists, and is instead now represented by a case that sits inside the Route enum. We can construct a boolean binding from scratch that under the hood checks if the route value is set to the .deleteAlert case, and when dismissing will write nil to the route : isPresented: Binding( get: { if case .some(.deleteAlert) = viewModel.route { return true } else { return false } }, set: { isPresented in if !isPresented { viewModel.route = nil } } ),

5:37

This looks really gnarly, and it is, but luckily we can write a helper on Binding that hides away all of this messiness. At its core we have a binding of an optional value, the Route enum, and we want to try to match a particular case in the route enum, and in doing so should get a binding of a boolean.

6:01

Let’s try to theorize what that might look like if we could hide all that work away. // isPresented: Binding( // get: { // if case .some(.deleteAlert) = viewModel.route { // return true // } else { // return false // } // }, // set: { isPresented in // if !isPresented { // viewModel.route = nil // } // } // ), isPresented: $viewModel.route.<#???#>

6:01

This helper says that given a binding of an optional route we can get a binding of a bool. In fact, we already wrote a helper like that, called isPresent , extension Binding { func isPresent<Wrapped>() -> Binding<Bool> where Value == Wrapped? { .init( get: { self.wrappedValue != nil }, set: { isPresented in if !isPresented { self.wrappedValue = nil } } ) } }

6:33

So perhaps we can make use of that here: isPresented: $viewModel.route.isPresent(),

6:36

And it actually compiles! But it’s not quite what we want. This will present an alert whenever the route goes non- nil , which includes the edit and duplicate routes. We only want an alert to present when we’re in the deleteAlert route, which means we need to provide additional information to our helper. What could we provide to let the binding know which enum case to zero in on? We could maybe use a tool that is highly tuned for working with enums, and that’s case paths. We could use supply a case path to check if we’re presenting a specific case of our route enum: isPresented: $viewModel.route.isPresent( /ItemRowViewModel.Route.deleteAlert ),

7:19

Let’s get a signature for this method in place, and let’s put it in “SwiftUIHelpers.swift”. extension Binding { func isPresent<Enum, Case>( _ casePath: CasePath<Enum, Case> ) -> Binding<Bool> where Value == Enum? { … } }

7:50

Inside the body of this method we can basically do exactly what we did in the ad-hoc binding. The get endpoint can use the case path to try to match the case inside the binding’s value, and if we are writing false to the new binding then we can simply nil out the original binding: extension Binding { func isPresent<Enum, Case>( _ casePath: CasePath<Enum, Case> ) -> Binding<Bool> where Value == Enum? { .init( get: { if let wrappedValue = self.wrappedValue, casePath.extract(from: wrappedValue) != nil { return true } else { return false } }, set: { isPresented in if !isPresented { self.wrappedValue = nil } } ) } }

8:35

And now the code that we wrote earlier is building, and looking much more succinct. .alert( viewModel.item.name, isPresented: $viewModel.route.isPresent( /ItemRowViewModel.Route.deleteAlert ), actions: { Button("Delete", role: .destructive) { viewModel.delete() } }, message: { Text("Are you sure you want to delete this item?") } )

8:37

This says very clearly that the alert is presented when the route enum matches the .deleteAlert case. It’s really nice that we get to model our domain in the nicest way possible while still making it easy for us to use SwiftUI’s APIs, and it’s all thanks to case paths, which allow us to properly abstract over the shape of enums like key paths allow us to do for structs.

9:06

Let’s move onto the edit functionality, which we want to show in a sheet. We already have the .sheet(unwrap:) helper defined earlier which allows us to transform a binding of an optional into a binding of an honest value: .sheet(unwrap: $viewModel.route) { $route in }

9:20

In this new view builder scope we have a binding of an honest Route , and since it’s an enum the only thing we can really do is switch on it: .sheet(unwrap: $viewModel.route) { $route in switch route { case .deleteAlert: // ??? case let .edit(item): // TODO: show item view case let .duplicate(item): // ??? } }

9:38

There’s two things wrong with this. First of all, .sheet(unwrap:) requires that the binding be of an Identifiable value, so we would have to make Route identifiable. But worse, the sheet is going to show anytime the route becomes non- nil . So even if we are trying to show an alert, or if we want to show the duplicate popover, this sheet is going to trigger at the same time.

10:07

The problem is that we don’t want the sheet to show anytime route is non- nil , but rather only when it becomes non- nil and it’s in the .edit case. We can accomplish this by constructing yet another ad-hoc binding for performing that matching logic: .sheet( unwrap: Binding( get: { guard case let .some(.edit(item)) = viewModel.route else { return nil } return item }, set: { item in if let item = item { viewModel.route = .edit(item) } } ) ) { $item in ItemView(item: $item) }

11:15

That is very intense, but it gets the job done. To convince ourselves of that let’s hook up the logic necessary to show this sheet. We’ll add a button to the row for editing: Button(action: { viewModel.editButtonTapped() }) { Image(systemName: "pencil") } .padding(.leading)

11:38

And we’ll implement the editButtonTapped in the view model by just setting the route field: func editButtonTapped() { route = .edit(item) }

11:47

And before we test this functionality out, let’s remember to wrap the ItemView in a navigation view when presented as a modal, and attach toolbar buttons for cancel and save: .sheet( unwrap: Binding( get: { guard case let .some(.edit(item)) = viewModel.route else { return nil } return item }, set: { viewModel.route = $0.map { .edit($0) } } ) ) { $item in NavigationView { ItemView(item: $item) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewModel.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Save") { viewModel.edit(item: item) } } } .navigationBarTitle("Edit") } }

12:11

And implementing those new view model endpoints is straightforward: func edit(item: Item) { item = item route = nil } func cancelButtonTapped() { route = nil }

12:35

Now we can bring up the edit modal, make changes, hit save, and the changes are instantly reflected in the list of items.

12:44

So, this is looking good, but of course we would never want to construct bindings like this from scratch. Just as we did with the isPresent method, we can cook up another method that hides away all this messiness from us.

12:59

The crux of the problem is that we have a binding of an optional value, in this case a Route enum, and we want to extract out the associated value for a particular case of the enum, ultimately resulting in a binding of an optional. It’s that final binding of an optional that we can hand off to the .sheet(unwrap:) method to determine when to present or dismiss the edit modal.

13:21

Let’s once again theorize what such a helper may look like in practice. This helper should allow us to take a binding of a route and produce a binding of a particular case. .sheet( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit) // Binding( // get: { // guard case let .some(.edit(item)) = viewModel.route // else { return nil } // return item // }, // set: { // viewModel.route = $0.map { .edit($0) } // } // ) ) { $item in … }

13:56

Let’s get the signature down for this helper method. It will be similar to the isPresent signature, except it will return a binding of an optional: extension Binding { func case<Enum, Case>( _ casePath: CasePath<Enum, Case> ) -> Binding<Case?> where Value == Enum? { } }

14:45

And then we can paste in the more ad hoc work we were doing before, but use the case path instead. extension Binding { func case<Enum, Case>( _ casePath: CasePath<Enum, Case> ) -> Binding<Case?> where Value == Enum? { Binding<Case?>( get: { guard let wrappedValue = self.wrappedValue, let case = casePath.extract(from: wrappedValue) else { return nil } return case }, set: { case in if let case = case { self.wrappedValue = casePath.embed(case) } else { self.wrappedValue = nil } } ) } }

16:05

Now we get to replace 16 messy lines of code for describing our sheet’s presentation with just a single line: .sheet( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit) ) { $item in

16:18

And just to ensure everything works, we can run the application one more time, edit an inventory item, and saving and cancellation all works as we’d expect. Duplication through a popover

16:31

We just hooked up the edit screen and it is looking really great, all thanks to even more binding transformations that we discovered along the way. And hopefully we’ll get a lot of leverage out of them.

16:41

What we’ve done with this route enum is describe all the different places we can navigate to from the item row. We used an enum to ensure that every route is mutually exclusive. Then we discovered the binding transformations necessary to pluck out the data from an associated case to hand the appropriate binding for SwiftUI to carry out the navigation. We have built a huge toolbox to handle various binding transformations so that we can model our domain in the most correct way possible while still using Apple’s SwiftUI APIs.

17:20

Now let’s tackle the popover. Popovers have a near identical API as sheets.

17:35

There are two overloads, one taking a binding of a boolean and the other taking a binding of an optional. The main difference is that the methods also take two optional arguments for describing the anchor and arrow of the popover: .popover( isPresented: <#Binding<Bool>#>, attachmentAnchor: <#PopoverAttachmentAnchor#>, arrowEdge: <#Edge#>, content: <#() -> View#> ) .popover( item: <#Binding<Identifiable?>#>, attachmentAnchor: <#PopoverAttachmentAnchor#>, arrowEdge: <#Edge#>, content: <#(Identifiable) -> View#> )

18:02

Just as we saw with sheets, it is far more useful to have an API that takes a binding of an optional and transforms it into a binding of an honest value so that you can pass that binding along to the view hosted in the popover.

18:19

For that reason we will proactively create a version of the popover that does just like that. If we copy and paste our sheet(unwrap:) helper, rename it to popover and invoke SwiftUI’s popover method under the hood, we get something that already compiles: extension View { func popover<Value, Content>( unwrap item: Binding<Value?>, @ViewBuilder content: @escaping (Binding<Value>) -> Content ) -> some View where Value: Identifiable, Content: View { popover(item: item) { _ in if let item = Binding(unwrap: item) { content(item) } } } }

18:44

We should probably add those other arguments as well, like attachmentAnchor and arrowEdge , but we won’t be using them in our episodes, so that will remain an exercise for the viewer.

18:57

With that overload of .popover defined we can now easily express the idea of presenting a popover when the route field becomes non- nil and is in the .duplicate case of the enum: .popover( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.duplicate) ) { $item in } That will automatically deriving a binding of an honest Item , hand it to the view builder, and then we can hand it to an ItemView . So let’s copy and paste the edit endpoint and make a few small changes, notably, we will update the title to “Duplicate” and we’ll have the add button call a new duplicate endpoint on the view model: .popover( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.duplicate) ) { $item in NavigationView { ItemView(item: $item) .navigationBarTitle("Duplicate") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewModel.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Add") { viewModel.duplicate(item: item) } } } } }

19:48

In order to get this popover to actually show we need to hook up some logic in the view and view model. We’ll add a button to the row view that calls out to a duplicateButtonTapped method on the view model: Button(action: { viewModel.duplicateButtonTapped() }) { Image(systemName: "square.fill.on.square.fill") } .padding(.leading)

20:11

Then we can implement this new endpoint by simply setting the route field to the .duplicate case of the Route enum: func duplicateButtonTapped() { route = .duplicate(item.duplicate()) }

20:42

In order to do that we need a way to create a duplicate an existing Item , where all the fields are equal, but a new id has been assigned: extension Item { func duplicate() -> Self { .init(name: name, color: color, status: status) } }

21:07

And we can stub out the new view model endpoint for committing the duplication action, but it’s not yet clear how to implement it: func duplicate(item: Item) { }

21:20

We need to somehow communicate to the parent view model that we want to insert a new item into the inventory. But before we handle that, let’s see how things are looking. If we run the app on the iPhone simulator, tapping the edit button brings a sheet over the screen with that row’s details pre-filled.

21:49

One thing you may notice is that this “popover” looks more like a modal sheet. It turns out that iPhones do not support popovers, and so they naturally degrade to just plain sheets. However, if we launch an iPad simulator we should see something more popover-like.

22:12

Well, the popover is really tiny. We’re not sure why this is happening. It may be a bug in SwiftUI, or maybe you’re supposed to give the popover view an explicit frame: .popover( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.duplicate) ) { $item in NavigationView { … } .frame(minWidth: 300, minHeight: 500) }

22:31

Once we do that it looks more like how we expect.

22:43

However, this feature is not quite finished yet. If we tap the “add” button, nothing happens, and that’s because we need a way to communicate back to the parent inventory view that we want an item to be duplicated. We can do this in the same way that we handle deletions. We will add a new callback to the ItemRowViewModel that is invoked when we are ready to commit the item duplication: var onDuplicate: (Item) -> Void = { _ in }

23:14

And now we are able to implement the .duplicate(item:) method in the row view model: func duplicate(item: Item) { onDuplicate(item) route = nil }

23:24

In order to layer on the actual duplication logic in the inventory view model we just have to override this callback when we bind the view model: private func bind(itemRowViewModel: ItemRowViewModel) { … itemRowViewModel.onDuplicate = { [weak self] item in withAnimation { self?.add(item: item) } } … }

23:50

Now when we run the app we see we have full duplication capabilities. We can tap the duplicate button on “Keyboard”, rename it to “Bluetooth Keyboard”, hit save, and we instantly have a new item in our inventory.

24:42

So, this application has become quite complex. We have an inventory list with the capabilities of adding new items to the inventory. Further, each row of the list is an entire domain unto itself that is capable of showing an alert to confirm deleting the item, it can also show a modal for editing the item, as well as show a popover for duplicating the item.

25:07

But all the work we’ve been in really starts to pay dividends once we take a look at what kind of deep-linking capabilities we have. If you want to launch into the app in a state so that the inventory tab is selected, and an alert is displayed asking if you want to delete the first item from the inventory, it’s as simple as constructing a view model that describes that exact state: var body: some Scene { let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100)) WindowGroup { ContentView( viewModel: .init( inventoryViewModel: .init( inventory: [ .init(item: keyboard, route: .deleteAlert), .init(item: Item( name: "Charger", color: .yellow, status: .inStock(quantity: 20)) ), .init(item: Item( name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true)) ), .init(item: Item( name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false)) ), ] ), selectedTab: .inventory ) ) } }

25:45

Or if you want to launch into the state where the edit screen is presented: .init(item: keyboard, route: .edit(keyboard),

26:04

Or if you want to launch into the application in a state where you are duplicating: .init(item: keyboard, route: .duplicate(keyboard),

26:15

We can even present the edit and duplicate routes with an item that already has some edits staged: var editedKeyboard = keyboard editedKeyboard.name = "Bluetooth Keyboard" editedKeyboard.status = .inStock(quantity: 1000) … .init(item: keyboard, route: .duplicate(editedKeyboard),

26:34

And we’re instantly launched into a popover with those edits applied.

26:42

All of these states were extremely easy to deep link into. You just have to construct the view model in a very precise way, and the compiler even helps you do that by revealing step-by-step what fields you have to fill in, and then SwiftUI takes care of the rest.

27:08

Now we are really seeing the power of modeling as much of our application’s state as possible in a single source of truth. It instantly unlocks deep linking into any part of our application. We would not be able to do this if we littered @State throughout our code because we have no way of instantiating those values outside of those views. Testing navigation and deep-linking

27:48

But there’s another benefit to writing view models in this way. We instantly get the ability to write very detail tests for our application’s logic. Let’s try it out.

28:05

Let’s try writing a test for the flow of a user tapping the add button, asserting that the modal sheet appears, and then tapping add again and asserting that the item was added to the inventory collection.

28:21

We can start with some scaffolding to get the test in place and create an InventoryViewModel : import XCTest @testable import SwiftUINavigation class SwiftUINavigationTests: XCTestCase { func testAddItem() throws { let viewModel = InventoryViewModel() } }

28:38

Then we can start invoking methods on the view model and assert on how the view model changed afterwards. For example, we could invoke the addButtonTapped method and confirm that the itemToAdd field flipped to something non- nil , signifying that the modal sheet was presented. let viewModel = InventoryViewModel() viewModel.addButtonTapped()

29:03

And already we have a passing test.

29:11

Next, we will test when the add method is invoked with an item, by first unwrapping the value to get access to it: let itemToAdd = try XCTUnwrap(viewModel.itemToAdd)

29:39

Next we are simulating the user confirming that they want to add the item to the inventory list, and we can assert that itemToAdd went back to nil , signifying that the sheet was dismissed, and that the item was added to the inventory array: XCTAssertNil(viewModel.itemToAdd) XCTAssertEqual(viewModel.inventory.count, 1) XCTAssertEqual(viewModel.inventory[0].item, itemToAdd)

30:25

If we run tests everything passes!

30:32

Let’s try another scenario. Let’s test what happens when we delete an item from the inventory. This time we can start the test in the state of already having an item in the collection: func testDelete() throws { let viewModel = InventoryViewModel( inventory: [ .init( item: Item( name: "Keyboard", color: .red, status: .inStock(quantity: 1) ) ) ] ) }

31:14

And then we can simulate tapping on the delete button, which should cause the route to flip to .deleteAlert , which signifies that the delete alert is presented: viewModel.inventory[0].deleteButtonTapped() XCTAssertEqual(viewModel.inventory[0].route, .deleteAlert)

31:42

We just need to make the route equatable, which is easy enough to do, since all of its associated values are equatable extension ItemRowViewModel.Route: Equatable {}

31:54

And finally we can confirm the deletion, which should cause the inventory collection to be emptied: viewModel.inventory[0].deleteConfirmationButtonTapped() XCTAssertEqual(viewModel.inventory.count, 0)

32:25

Our test passes! This means we are testing that the parent-child communication we set up is working, even for items that start in the collection. If we were to go back to the code we had where we failed to bind item row view models in the initializer: inventory = inventory // [] // for itemRowViewModel in inventory { // bind(itemRowViewModel: itemRowViewModel) // } XCTAssertEqual failed: (“1”) is not equal to (“0”)

32:53

We get a failing test.

33:09

Let’s try something a little more complicated. Let’s test what happens when someone duplicates an item. We can start with a view model that already has an item in its inventory and invoke its duplicate button: func testDuplicateItem() throws { let viewModel = InventoryViewModel( inventory: [ .init( item: Item( name: "Keyboard", color: .red, status: .inStock(quantity: 1) ) ) ] ) viewModel.inventory[0].duplicateButtonTapped() }

33:59

Next we can assert that the route changed accordingly, which signifies the popover being presented. Unfortunately because we have not controlled the

UUID 34:29

We haven’t controlled this dependency, but we can at least check if it’s in the proper case: XCTAssertNotNil( (/ItemRowViewModel.Route.duplicate) .extract(from: try XCTUnwrap(viewModel.inventory[0].route)) )

UUID 35:25

We could strengthen this by asserting against certain fields, but we won’t for now. It’s also possible to hide some of this messiness behind a little helper function, but we’ll leave that as an exercise for the viewer.

UUID 35:37

Finally, we’ll emulate what happens when the user confirms the duplication by asserting that an additional item was added to the inventory collection: let dupe = item.duplicate() viewModel.inventory[0].duplicate(item: dupe) XCTAssertEqual(viewModel.inventory.count, 2) XCTAssertEqual(viewModel.inventory[0].item, item) XCTAssertEqual(viewModel.inventory[1].item, dupe) XCTAssertNil(viewModel.inventory[0].route)

UUID 26:42

And just like that we have another passing test.

UUID 36:51

These tests were incredibly easy to write and they exercise how multiple domains of the application interact with each other. There’s some pretty complex logic being hooked up in the view models, such as when an item is added to the inventory. For example, if we were to comment out the logic that hooks up the duplication dismissal logic we would instantly get a test failure: func duplicate(item: Item) { onDuplicate(item) // route = nil } XCTAssertNil failed

UUID 37:22

This was only possible due to our desire to run the entire application off of a single source of truth. So, there are a ton of benefits to writing SwiftUI applications in this style, two being that you get instant deep linking capabilities and can write deep, comprehensive tests.

UUID 37:48

While it is amazing to have test coverage on deep linking and routing in our application, we did have to be on top of our game when it comes to writing the actual assertions. Because we’re dealing with reference types we don’t get the ability to make them equatable and instead need to do remember which fields to assert against, and in fact, our tests as written could be improved a lot. Next time: navigation links

UUID 38:18

So, we have now gone really deep into exploring the concepts of navigation when it comes to tabs, alerts, confirmation dialogs, modal sheets and popovers. We are really starting to see what it means to model navigation as state, and how that state starts to take the shape of a tree-like structure, where each next screen is represented by a piece of optional state becoming non- nil , and those optionals can nest deeper and deeper.

UUID 39:07

Further, we are seeing that in order to properly model navigation state in a tree structure we need to create more and more tools for transforming bindings. This often takes the shape of transforming bindings of optionals, or more generally bindings of enum, and we’ve already discovered multiple of these tools.

UUID 39:25

But, there is still one extremely important form of navigation that we haven’t yet talked about it, and it is both the most prototypical form of navigation and often thought of as the most complicated: and that’s navigation links. Well, SwiftUI calls them navigation links, but back in the UIKit days you may have known it by “push” and “pop” navigation.

UUID 39:44

This form of navigation allows you to push a new view onto the screen, with a right-to-left animation, and that new screen will automatically show a back button in the top-left, which allows you to pop the view to the previous one with a left-to-right animation. Further, the view that is presented with navigation links are typically dynamic and have behavior themselves, so just as with modal sheets and popovers we need a way of spawning a new view model to hand to the next screen.

UUID 40:12

So, it seems like navigation links and modal sheets are quite similar. And honestly we think they are basically two names for the same fundamental thing, but for whatever reason their APIs are very different. Further, we feel like many of the complexities that crop up with navigation links is most often due to incorrectly modeled state, rather than any inherent complexity in navigation links itself. In fact, the tools we built in the previous episodes for transforming and destructuring bindings is going to be extremely useful for navigation links, and will make it far easier to use links than it is with just the tools that Apple gives us.

UUID 40:48

So, let’s dig in…next time! References SwiftUI Navigation Brandon Williams & Stephen Celis • Nov 16, 2021 After 9 episodes exploring SwiftUI navigation from the ground up, we open sourced a library with all new tools for making SwiftUI navigation simpler, more ergonomic and more precise. https://github.com/pointfreeco/swiftui-navigation WWDC 2021: Demystifying SwiftUI Matt Ricketson, Luca Bernardi & Raj Ramamurthy • Jun 9, 2021 An in-depth explaining on view identity, lifetime, and more, and crucial to understanding how @State works. https://developer.apple.com/videos/play/wwdc2021/10022/ Collection: Derived Behavior Brandon Williams & Stephen Celis • May 17, 2021 Note The ability to break down applications into small domains that are understandable in isolation is a universal problem, and yet there is no default story for doing so in SwiftUI. We explore the problem space and solutions, in both vanilla SwiftUI and the Composable Architecture. https://www.pointfree.co/collections/case-studies/derived-behavior Downloads Sample code 0164-navigation-pt5 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .