Video #170: UIKit Navigation: Part 2
Episode: Video #170 Date: Dec 6, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep170-uikit-navigation-part-2

Description
We finish porting our SwiftUI application to UIKit by introducing a collection view. Along the way we will demonstrate how deep-linking works exactly as it did in SwiftUI, and we show the power of state driven navigation by seamlessly switching between the two view paradigms.
Video
Cloudflare Stream video ID: aec99b3dfef6d6fdc7fbbb2efdf4b8a6 Local file: video_170_uikit-navigation-part-2.mp4 *(download with --video 170)*
References
- Discussions
- 0170-uikit-navigation-pt2
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
This completes the ItemRowCellView , but for now we’ll just have to hope it works. We can’t actually run any of this code until we get a collection view in place. The inventory view
— 0:26
So, let’s start by getting a basic view controller in place for the inventory list, which will be powered by an InventoryViewModel . We will add this to a new InventoryViewController.swift file: import UIKit class InventoryViewController: UIViewController { let viewModel: InventoryViewModel init(viewModel: InventoryViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
— 0:57
Just like with the ItemViewController we will implement the viewDidLoad method, and in here we will be responsible for creating the view hierarchy, binding the view model to the UI, and binding the UI actions to the view model: override func viewDidLoad() { super.viewDidLoad() // MARK: View creation // MARK: View model bindings // MARK: UI actions }
— 1:15
The work we do in here will be quite a bit more involved than it was for the ItemViewController because we need to somehow get a list of rows in the UI, which means dealing with a collection view, but we’ll take it one step at a time.
— 1:27
Let’s start by just getting a title in the view along with an “Add” button in the top-right: self.title = "Inventory" self.navigationItem.rightBarButtonItem = .init(title: "Add")
— 1:54
We should already be able to see this in a preview: import SwiftUI struct InventoryViewController_Previews: PreviewProvider { static var previews: some View { NavigationView { ToSwiftUI { InventoryViewController(viewModel: .init()) } } } }
— 2:14
Unfortunately the title and button don’t appear.
— 2:20
We’re not entirely sure why. It seems like as long as we wrap our controller view in a NavigationView that we should be able to affect the navigation bar. Perhaps it’s a bug in SwiftUI or how SwiftUI and UIKit interact with one another, but the workaround is to use a UIKit UINavigationController instead of a SwiftUI NavigationView : struct InventoryViewController_Previews: PreviewProvider { static var previews: some View { ToSwiftUI { UINavigationController( rootViewController: InventoryViewController(viewModel: .init()) ) ) } }
— 3:06
Well, that’s the easiest part of the UI in this screen. Let’s try the next easiest piece of UI, which unfortunately is not so easy.
— 3:14
We will hook up the ItemViewController modal sheet that slides up when the “Add” button is tapped. We would hope we could assign the right bar button item’s primary action where we set up all the UI actions: // MARK: UI actions self.navigationItem.rightBarButtonItem? .primaryAction = .init { [unowned self] _ in self.viewModel.addButtonTapped() }
— 3:49
But unfortunately this breaks things: the “Add” button has disappeared from the preview. We’re not sure why this is, and we think it must be a bug, but we have found that we can make things work by using an initializer that takes a primary action up front instead: // MARK: View creation self.title = "Inventory" self.navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Add", primaryAction: .init { [unowned self] _ in self.viewModel.addButtonTapped() } )
— 4:27
It’s a bummer that we lose the ability to group the various view controller responsibilities as we did in the item view controller, but this is just how it has to be.
— 4:37
When the primary action is invoked it will cause the view model to do all of its internal logic to figure out what should be done, and ultimately we expect that it mutates its route enum to point to the .add case, which holds an ItemViewModel we could pass to an ItemViewController .
— 4:57
So, we can start listening for changes to the view model’s route: // MARK: View model bindings self.viewModel.$route .removeDuplicates() .sink { route in }
— 5:12
Which means we need to hold onto some cancellables in the view controller: import Combine import UIKit class InventoryViewController: UIViewController { let viewModel: InventoryViewModel private var cancellables: Set<AnyCancellable> = [] … }
— 5:28
And store the cancellables after sinking: self.viewModel.$route .removeDuplicates() .sink { route in } .store(in: &self.cancellables) This .sink closure will be invoked every time the route changes, which means it’s the perfect time for us to figure out if we are navigating to the .add route and then presenting the ItemViewController .
— 5:32
We can start by switching on the route to handle all of its different cases: switch route { case .none: break case .add: break case .row: break }
— 5:57
Right now we are most interested in the .add case. The work done in here will be quite similar to what we did in the row cell. In fact, we can copy and paste the work done to, say, show the “duplicate” popover, and make a few changes. case let .add(itemViewModel): let vc = ItemViewController(viewModel: itemViewModel) vc.title = "Add" vc.navigationItem.leftBarButtonItem = .init( title: "Cancel", primaryAction: .init { _ in self.viewModel.cancelButtonTapped() } ) vc.navigationItem.rightBarButtonItem = .init( title: "Add", primaryAction: .init { _ in self.viewModel.add(item: itemViewModel.item) } ) let nav = UINavigationController(rootViewController: vc) self.present(nav, animated: true)
— 7:34
And now that we are accessing self in a bunch of closures, we have to make sure to break any retain cycles: self.viewModel.$route .removeDuplicates() .sink { [unowned self] route in … primaryAction: .init { [unowned self] _ in … } … primaryAction: .init { [unowned self] _ in … } … } .store(in: &self.cancellables)
— 7:49
If we run the preview we see that the modal sheet does come up when we tap the “Add” button. Even the asynchronous logic to “predict” the name of the item we want to add is working.
— 8:34
But, tapping the “Cancel” or “Add” buttons in the modal don’t seem to do anything. The behavior for the “Add” button makes some sense because we haven’t even implement the list view for inventory items, so there’s nothing to add the item to. But, we would expect that at least the modal automatically dismisses when tap either button, because the route should have been flipped to nil .
— 9:01
To fix this we need to further listen for when the route switches to nil so that we can dismiss any view controller that is presented. The easiest way to do this would be to just reach into the .presentedViewController property and dismiss it: switch route { case .none: self.presentedViewController?.dismiss(animated: true) … }
— 9:22
And now things work as we expect.
— 9:28
However, this isn’t the best way to accomplish this. It would be better to be more precise in making sure that we dismiss the view controller that we know we presented. It would be bad if we accidentally dismissed someone else’s presented controller.
— 9:46
To do this we can keep a little mutable value outside the .sink closure: var presentedViewController: UIViewController?
— 9:56
And then when we present a controller we can keep track of which was presented: case let .add(itemViewModel): … presentedViewController = nav
— 10:03
And finally, when the route switches to nil we can dismiss that presented controller: case .none: presentedViewController?.dismiss(animated: true) break
— 10:14
That completes all the “easy” UI for this screen. Now all we have left is the list of inventory items, which is sadly a lot more complicated than what we dealt with in SwiftUI.
— 10:39
We will use a collection view to model this list. As of iOS 14, collection views can now display their cells in a table list format. Further, we will use diffable data sources, available since iOS 13, to power the collection view, which makes it easy to figure out how to update cells without forcing a full refresh of the list.
— 11:00
We can construct a diffable data source by specifying a number of pieces of information:
— 11:19
First, the UICollectionViewDiffableDataSource type is generic over two things:
— 11:21
The first is a type that identifies each section in the collection. We currently only have a single section, so we will use an enum with a single case: enum Section { case inventory } .
— 11:40
The second is the type of data that the data source holds, which will be given to each cell of the collection for it to customize its appearance and behavior. Just as we did in the SwiftUI version of the application, we will have ItemRowViewModel s power the data source of the list: let dataSource = UICollectionViewDiffableDataSource< Section, ItemRowViewModel >
— 12:00
Second, we specify the collection that the diffable data source connects to: let dataSource = UICollectionViewDiffableDataSource< Section, ItemRowViewModel >( collectionView: <#???#> )
— 12:11
We don’t actually have a collection view available to us right now. We will have to create one, but let’s handle that in a moment.
— 12:16
Third, we specify a “cell provider” closure that is handed the collection, index path of the cell, and data of the cell, which is an ItemRowViewModel : enum Section { case inventory } let dataSource = UICollectionViewDiffableDataSource< Section, ItemRowViewModel >( collectionView: <#???#> ) { collectionView, indexPath, itemRowViewModel in … }
— 12:44
This closure needs to return a UICollectionViewCell . Now, we don’t want to create a fresh one directly in this closure because we would be creating a new view every time a cell comes onto the screen. One of the nice things about collection views (and table views) is that you can reuse cells. So, even if your list has thousands of elements, you may only really need to have 20 views in memory since that’s as many can be shown on the screen at once.
— 13:07
The way collection views deal with this is you ask the collectionView to dequeue a reusable cell for you, and then it does the work of figuring out if it has an unused cell waiting around to hand you, or if it needs to create a fresh one: let cell = collectionView.dequeueConfiguredReusableCell( using: <#UICollectionView.CellRegistration<Cell, Item>#>, for: <#IndexPath#>, item: <#Item?#> )
— 13:28
We have to supply this method 3 arguments, 2 of which are immediately available to us. In order for it to figure out its internal reuse logic we need to tell it which index path we are at and what is the item associated with this index path: let cell = collectionView.dequeueConfiguredReusableCell( using: <#UICollectionView.CellRegistration<Cell, Item>#>, for: indexPath, item: itemRowViewModel )
— 13:50
The using argument is a CellRegistration value that we need to create from scratch, and it takes a number of pieces of information:
— 13:59
First, the type is generic over two types:
— 14:08
The first is the type of UICollectionViewCell you want to use for a particular row. This type just has to be a subclass of UICollectionViewCell , and we have already created our own subclass: it’s ItemRowCellView .
— 14:24
The second is the type of data associated with this cell, which again is just an ItemRowViewModel : let cellRegistration = UICollectionView.CellRegistration< ItemRowCellView, ItemRowViewModel >
— 14:32
Second, we provide “handler” closure that allows us to customize the provisioned cell view. It’s handed the cell view, the index path, and the data associated with the cell. Those 3 pieces of information should be enough for one to fully customize the cell however they want. Our job is simple here because the ItemRowCellView has a method that is specifically for binding the view model to the row view: let cellRegistration = UICollectionView.CellRegistration< ItemRowCellView, ItemRowViewModel > { [unowned self] cell, indexPath, itemRowViewModel in cell.bind(viewModel: itemRowViewModel, context: self) }
— 15:32
With this cell registration value we now have everything to finish creating our diffable data source: enum Section { case inventory } let dataSource = UICollectionViewDiffableDataSource< Section, ItemRowViewModel >( collectionView: <#???#> ) { collectionView, indexPath, itemRowViewModel in let cell = collectionView.dequeueConfiguredReusableCell( using: cellRegistration, for: indexPath, item: itemRowViewModel ) cell.accessories = [.disclosureIndicator()] return cell }
— 16:23
Well, except for the collection view. So, let’s see what it takes to create one of those.
— 16:32
There’s an initializer on UICollectionView that takes a frame and a collectionViewLayout . We’ll set the frame to .zero for now because we’ll let auto layout handle that for us, but we still have to figure out what to do for the collection view layout: let collectionView = UICollectionView( frame: .zero, collectionViewLayout: <#???#> ) collectionView.translatesAutoresizingMaskIntoConstraints = false
— 16:49
As of iOS 13 there is an a layout called UICollectionViewCompositionalLayout that makes it quite easy to create a variety of layouts with the concepts of sections, groups and items built in. And as of iOS 14 there is even a built-in compositional layout that is specifically tuned for lists: collectionViewLayout: UICollectionViewCompositionalLayout.list( using: <#UICollectionLayoutListConfiguration#> )
— 17:19
So, we need to figure out how to create one of these UICollectionLayoutListConfiguration s. There is an initializer that takes an appearance value, which has a number of options, but we will just take the .insetGrouped option: let layoutConfig = UICollectionLayoutListConfiguration( appearance: .insetGrouped )
— 17:43
Which we can now plug into the collection view: collectionViewLayout: UICollectionViewCompositionalLayout.list( using: layoutConfig )
— 17:46
And we finally have a collection view to hand to the data source: let dataSource = UICollectionViewDiffableDataSource< Section, ItemRowViewModel >( collectionView: collectionView ) { collectionView, indexPath, itemRowViewModel in … }
— 17:50
But doing that forces ItemRowViewModel to be Hashable . For reasons similar to why SwiftUI’s ForEach requires its data conform to Identifiable , collection view diffable data sources requires its data conform to Hashable : it uses hashability to identify individual items and efficiently compute insertions, removals, and moves.
— 18:23
We can implement a conformance inspired by the Identifiable conformance by hashing and equating based off a row’s identifier. class ItemRowViewModel: Hashable, Identifiable, ObservableObject { … func hash(into hasher: inout Hasher) { hasher.combine(self.item.id) } static func == ( lhs: ItemRowViewModel, rhs: ItemRowViewModel ) -> Bool { lhs.item.id == rhs.item.id } … }
— 19:06
Everything’s not building, but it’s worth noting that adding these kinds of conformances to reference types is always tricky. Because reference types are an amalgamation of data and behavior, it’s not always clear what these kinds of conformances should be.
— 19:33
We now have a data source to give to the collection view: collectionView.dataSource = dataSource
— 19:39
And we can add the collection view to our controller and make it fill the screen with autolayout: collectionView.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(collectionView) NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), collectionView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor), collectionView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), ])
— 19:52
We now have a collection view created, configured and added to the view hierarchy. We just have to listen for changes to the inventory in the view model and apply the changes to the data source: self.viewModel.$inventory .sink { inventory in var snapshot = NSDiffableDataSourceSnapshot<Section, ItemRowViewModel>() snapshot.appendSections([.inventory]) snapshot.appendItems(inventory.elements, toSection: .inventory) dataSource.apply(snapshot, animatingDifferences: true) } .store(in: &self.cancellables)
— 21:23
And with that we can finally see some of our inventory items in the preview. We just have to update the view model in our preview to begin with some data: struct InventoryViewController_Previews: PreviewProvider { static var previews: some View { ToSwiftUI { UINavigationController( rootViewController: InventoryViewController( viewModel: .init( inventory: [ .init( item: .init( name: "Keyboard", color: .red, status: .inStock(quantity: 100) ) ) ] ) ) ) ) } }
— 22:10
And there it is! The add item flow also works now. We can tap the “Add” button, make some changes to the form, and then hit the “Add” button and we’ll see the item is appended to the end of the list. The view models are taking care of all the real business logic. We’re just hooking up the inputs and outputs of the view model, and everything just works. Item cell actions and navigation
— 22:38
That was a lot. There is a lot of power in these UIKit APIs, and in many ways they are capable of things that are not yet possible in SwiftUI, but it can be a real slog to use them.
— 23:00
We now have the basics of the inventory list in place, but there are still a few features missing, such as the ability to delete, duplicate, and edit an item in the list. That functionality has been built into the ItemRowCellView , but we haven’t yet actually fed the UI actions to the view model so that it can update the route, and hopefully at that point everything will just work.
— 23:18
In the SwiftUI application we had buttons in the row to aid in deleting and duplicating, and then we used the whole row as a tap area for editing.
— 23:26
This latter navigation is the easiest for us to implement in our current set up, so let’s give it a shot.
— 23:44
We first need to be notified of the moment the user taps on the row, and we do this via a delegate method of the collection view. We can make the InventoryViewController the delegate of collection view by setting the .delegate property: collectionView.delegate = self Which means we need to conform to the delegate protocol: class InventoryViewController: UIViewController, UICollectionViewDelegate { … }
— 24:06
And just that gets things building again, but of course we need to override the delegate methods we are interested in. In particular, when the user “selects” a particular item at an index path: func collectionView( _ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath ) { }
— 24:16
In this method we want to notify the view model of which row was tapped.
— 24:20
In the SwiftUI application we had a view model available to us because we had an ItemRowViewModel which was provided the view model. We just needed to call the .setEditNavigation(isActive:) method when the link was activated or de-activated: NavigationLink( unwrap: self.$viewModel.route, case: /ItemRowViewModel.Route.edit, onNavigate: self.viewModel.setEditNavigation(isActive:), … )
— 24:30
In the collection view we need to find the view model that corresponds to the index path, and then invoke the .setEditNavigation(isActive:) method on it: func collectionView( _ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath ) { self.viewModel .inventory[indexPath.row] .setEditNavigation(isActive: true) }
— 25:00
That’s all it takes to feed the UI action to the view model. And we can now finally test out all of the work we performed in the row view. If we tap on a row we will drill down to an item view. This is showing that we communicating to the view model that the row was tapped, the view model is updating its route to point to the .edit case, and the row view is properly observing the change to the route and pushing a new view controller onto the navigation stack.
— 25:24
This is awesome, but if we try tapping the save or cancel buttons we will see that nothing happens. This is because we aren’t handling the case when the route flips to nil in the row view. We ran into something similar when presenting and dismissing the ItemViewController as a modal sheet from the InventoryViewController .
— 25:40
It was an easy fix for the modal sheet. We just need to reach into the presentedViewController property, if it existed, and dismiss that controller. It’s not so simple to dismiss a view controller that is on the navigation stack. The only APIs exposed to us by navigation controllers is to pop the controller that is highest on the stack, or to completely reset the stack of view controllers.
— 26:05
Although it would be easy, it’s probably not a good idea to simply pop the last controller from the stack. We could have more things pushed onto the stack, like the color picker, and so when the row’s route nil s out it would only pop the color picker, not the item view controller.
— 26:19
We need to be more precise in how we reset the navigation stack. We need to pop everything off the top of the stack up to the current context view controller. We don’t know of a super simple way to do this. Seems you just need to search the navigation controller’s children for the currently presented controller, and then reset the navigation controller’s children to include everything up to, but not including, that presented controller.
— 27:01
To keep track of that view controller in the first place, we’ll introduce another optional variable, like we did in the inventory view controller, and assign it when we navigate: var presentedViewController: UIViewController? viewModel.$route .removeDuplicates() .sink { [unowned self] route in switch route { … case let .edit(itemViewModel): … context.show(vc, sender: nil) presentedViewController = vc } }
— 27:24
And now when the route goes nil , we can attempt to pop to this particular view controller before popping again. guard let vc = presentedViewController else { return nil } context.navigationController?.popToViewController(vc, animated: true) context.navigationController?.popViewController(animated: true) presentedViewController = nil
— 27:57
Now when we run the application we will see that the cancel and save buttons are working correctly.
— 28:10
It is a little strange to be popping twice to achieve this behavior, and perhaps would be more correct to explicitly call setViewControllers on the navigation controller, but this appears to be working for now, so let’s just roll with it.
— 28:26
The InventoryViewController is now nearly feature complete, but there’s still two features we need to implement. We are handling the delete and duplicate routes in the ItemRowCellView , but we still don’t have any UI for allowing the user to delete or duplicate a particular item in the list.
— 28:44
In the SwiftUI version of the application we had buttons in each row for these actions. Adding those buttons to the ItemRowCellView is possible, but it’s a more difficult to do in UIKit than in SwiftUI. There is a shortcut we can take by adding buttons to the row that are exposed when you swipe on it. This is done by setting the trailingSwipeActionsConfigurationProvider property on the layout config we created earlier: var layoutConfig = UICollectionLayoutListConfiguration( appearance: .insetGrouped ) layoutConfig .trailingSwipeActionsConfigurationProvider = { indexPath in }
— 29:20
In this closure we need to return a UISwipeActionsConfiguration value, which describes the buttons that are exposed when you swipe on a row from right to left. We can start by returning an empty list of actions: layoutConfig .trailingSwipeActionsConfigurationProvider = { indexPath in return UISwipeActionsConfiguration(actions: []) }
— 29:36
To specify these buttons one first creates UIContextualAction s, then bundles them into a UISwipeActionsConfiguration and returns that from the closure. A UIContextualAction can be constructed by specifying its title and a closure to execute when it is tapped. UIContextualAction( style: <#UIContextualAction.Style#>, title: <#String?#>, handler: <#UIContextualAction.Handler#> )
— 29:50
We want to implement separate actions for duplicating and deleting items: let duplicate = UIContextualAction( style: .normal, title: "Duplicate" ) { _, _, completion in } let delete = UIContextualAction( style: .destructive, title: "Delete" ) { _, _, completion in } return UISwipeActionsConfiguration(actions: [delete, duplicate])
— 30:39
We can look at our preview and the swipe actions already appear, but we need to use each action’s handler block to notify the view model when either is tapped.
— 30:50
In order to get the view model associated with a particular index path, we can use the itemIdentifier(for:) method on the data source: guard let viewModel = dataSource.itemIdentifier(for: indexPath) else { return nil }
— 31:17
But now we have a bit of a chicken-and-egg problem where in order to define the data source we need to have the data source available. Well accessing the data source in this closure is a late binding, so we can work around by introduce an implicitly unwrapped data source before the layout: var dataSource: UICollectionViewDiffableDataSource< Section, ItemRowViewModel >!
— 31:39
And then assigning the data source later: dataSource = UICollectionViewDiffableDataSource< Section, ItemRowViewModel >(…)
— 31:42
And now we are free to use the data source inside the trailingSwipeActionsConfigurationProvider closure: let duplicate = UIContextualAction( style: .normal, title: "Duplicate" ) { _, _, completion in viewModel.duplicateButtonTapped() completion(true) } let delete = UIContextualAction( style: .destructive, title: "Delete" ) { _, _, completion in viewModel.deleteButtonTapped() completion(true) }
— 32:06
Now when we run the preview we will see that we can swipe right-to-left on a row to reveal some actions. And tapping on one of these actions acts as we expect.
— 32:19
If we open up a duplicate modal, however, we will see that it never dismisses itself, even though the route has been nil ‘d out. This means that our view model’s state and UIKit’s state has gotten out of sync, and that is going to lead to problems. SwiftUI never has to worry about this because state and views are always kept in sync, but in UIKit we have to do a bit of extra work.
— 32:47
We need to track the duplicate view controller when it’s presented, and we need to dismiss this view controller when the route goes nil : viewModel.$route .removeDuplicates() .sink { route in switch route { case .none: guard let vc = presentedViewController else { return } vc.dismiss(animated: true) context.navigationController?.popToViewController( vc, animated: true ) context.navigationController?.popViewController( animated: true ) presentedViewController = nil case .deleteAlert: … presentedViewController = alert case let .duplicate(itemViewModel): … presentedViewController = nav } } .store(in: &self.cancellables)
— 33:19
And now if we run the app and open a duplicate model we will see that it properly dismisses.
— 33:30
If we swipe to delete, and tap the alert’s confirm button, we get a crash. This is because we are calling popToViewController when the route goes nil , but the alert of course is not in the navigation stack. We need to do a little bit of extra work to ensure that the navigation controller’s stack contains the presented controller before popping, and if it’s not in the stack, we can dismiss it instead. case .none: guard let vc = presentedViewController else { return } if context.navigationController?.viewControllers.contains(vc) == true { context.navigationController?.popToViewController( vc, animated: true ) context.navigationController?.popViewController(animated: true) } else { vc.dismiss(animated: true) } presentedViewController = nil
— 34:02
And now when we run the preview, we can successfully delete a row and it animates away!
— 34:13
So, it looks like the InventoryViewController is now feature complete. Let’s try plugging it into the main application by updating the content view to use the view controller instead of the InventoryView : ToSwiftUI { NavigationViewController( rootViewController: InventoryViewController( viewModel: self.viewModel.inventoryViewModel ) } } .tabItem { Text("Inventory") } .tag(Tab.inventory)
— 34:47
When we run the application we will see that the root tab view, and its first and third tabs, are all still powered by SwiftUI, but the middle tab is now entirely powered by UIKit. Everything works basically as it did in the SwiftUI version. The root content view
— 35:08
We are now down to our last SwiftUI view that hasn’t been converted to a UIKit view controller, and that’s the content view. Luckily this is the easiest one to convert because it’s just a simple tab view and mostly delegates all of its responsibilities to each tab.
— 35:26
We can start by creating a ContentViewController.swift file with a ContentViewController class, and we’ll even have it subclass from UITabBarController which sets up some basic infrastructure for us.
— 35:33
This view controller will depend on the AppViewModel , which is our root-level view model that powers the entire application: class ContentViewController: UITabBarController { let viewModel: AppViewModel init(viewModel: AppViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
— 36:00
Then, just as with all our other view controllers, we will use the viewDidLoad method as the entry point to set up all views, bind data from the view model to the views, and feed actions from the view into the view model.
— 36:12
Setting up the views is a matter of creating all the view controllers for each tab and assigning it to the tab controller’s array of view controllers: override func viewDidLoad() { super.viewDidLoad() let oneLabel = UILabel() oneLabel.text = "One" oneLabel.sizeToFit() let one = UIViewController() one.tabBarItem.title = "One" one.view.addSubview(oneLabel) oneLabel.center = one.view.center let inventory = UINavigationController( rootViewController: InventoryViewController( viewModel: self.viewModel.inventoryViewModel ) ) inventory.tabBarItem.title = "Inventory" let threeLabel = UILabel() threeLabel.text = "Three" threeLabel.sizeToFit() let three = UIViewController() three.tabBarItem.title = "Three" three.view.addSubview(threeLabel) threeLabel.center = three.view.center self.setViewControllers([one, inventory, three], animated: false) }
— 36:46
And the view model only has a single piece of data that this controller cares about, which is the tab that is selected. We can .sink on the view model’s selectedTab publisher, and play that information back to the controller: self.viewModel.$selectedTab .sink { [unowned self] tab in switch tab { case .one: self.selectedIndex = 0 case .inventory: self.selectedIndex = 1 case .three: self.selectedIndex = 2 } } .store(in: &self.cancellables)
— 37:25
But we’ll need to make sure to hold onto the cancellables: import Combine import SwiftUI class ContentViewController: UITabBarController, UITabBarControllerDelegate { let viewModel: AppViewModel private var cancellables: Set<AnyCancellable> = [] … }
— 37:43
We also need to listen for changes to the selected tab, such as when the user manually changes the tab, and play that back to the view model. To do that we can override a method on the tab controller that notifies us when the user changes the selected tab: override func tabBar( _ tabBar: UITabBar, didSelect item: UITabBarItem ) { }
— 38:06
And in here we need to figure out which tab was selected and update the view model accordingly: override func tabBar( _ tabBar: UITabBar, didSelect item: UITabBarItem ) { guard let index = tabBar.items.firstIndex(of: item) else { return } switch index { case 0: self.viewModel.selectedTab = .one case 1: self.viewModel.selectedTab = .inventory case 2: self.viewModel.selectedTab = .three default: break } }
— 38:48
And because we’re overriding this method, it might be prudent to call super . super.tabBar(tabBar, didSelect: item)
— 38:58
And that’s all it takes to build the ContentViewController . We can even swap out our ContentView for this view controller in the main entry point to the application: return WindowGroup { ToSwiftUI { ContentViewController( viewModel: .init( inventoryViewModel: .init( inventory: [ .init(item: keyboard), .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) ) ), ], route: nil ), selectedTab: .one ) ) } }
— 39:40
And now our entire application is driven off of UIKit. Everything basically works the same, all of our view models power the entire application without a single change to them, but we are now using UIKit. That is pretty incredible.
— 39:59
Even better, because we made no changes to the view models, and because we’ve made sure to have all UIKit navigation driven off the state of the view models, it also means that deep linking works in our new application just as well as it did in the old.
— 40:12
We can construct a route in the AppViewModel that describes exactly where we want to navigate to, and the views and view controllers will take care of the rest. For example, if we update the route to point to a particular row, and the edit case of that row, then the application will immediately open to that exact screen: route: .row( id: keyboard.id, route: .edit( .init( item: keyboard, route: nil ) ) ) ), selectedTab: .inventory
— 40:51
When we launch our application we are instantly deep-linked into the inventory tab and are drilled down into keyboard’s edit screen.
— 41:08
Even the URL-based deep linking works. We just need to hook up the onOpenURL logic again, which is currently only done in the SwiftUI version of our application. let viewModel = AppViewModel( inventoryViewModel: .init( inventory: [ .init(item: keyboard), .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) ) ), ], route: nil ), selectedTab: .one ) return WindowGroup { ToSwiftUI { ContentViewController( viewModel: viewModel ) } .onOpenURL { url in viewModel.open(url: url) } }
— 42:03
If we take the UUID that is printed out for the second row, hop over to Safari, and enter this into the URL bar: nav:///inventory/1D96C990-85DA-40B0-8BCB-5D7671192AB6/edit
— 42:30
The application will open and navigate to the edit charger screen, and we can make edits that will be instantly reflected in the inventory screen. UIKit/SwiftUI live-switching
— 43:00
Now that we’ve essentially built the view layer of the application in two completely different styles, once in the fancy new SwiftUI declarative style, and again in the older, tried-and-true UIKit style, let’s have some fun with it.
— 43:14
We are going to make it possible to run both versions of the application, with a button at the top that lets you dynamically flip between the SwiftUI and UIKit powered views. The fact that this is possible, and not only possible but even easy, will show definitely just how powerful it is to have all of state fully driven by navigation.
— 43:36
To accomplish this we are going to create a root-level view that holds onto a local @State boolean that determines if we are currently using the SwiftUI view or not. Then, based on that value we can either show the ContentView or the ContentViewController , and if we did a good job of driving everything from state it should all just work: struct RootView: View { @State var isSwiftUI = true let viewModel: AppViewModel var body: some View { ZStack(alignment: .top) { Group { if self.isSwiftUI { ContentView(viewModel: viewModel) } else { ToSwiftUI { ContentViewController(viewModel: self.viewModel) } .onOpenURL { url in self.viewModel.open(url: url) } } } .padding(.top, 44) Button(self.isSwiftUI ? "Use UIKit" : "Use SwiftUI") { self.isSwiftUI.toggle() } } } }
— 45:23
We can use the RootView in the entry point of the application: return WindowGroup { RootView(viewModel: viewModel) }
— 45:26
Before we run the application, we need to undo the work we did to render UIKit inside the existing SwiftUI application, so let’s delete all of the ToSwiftUI s we added earlier, and comment the original SwiftUI views back in.
— 46:20
And now when we run the application we will see a button at the top of the screen. Tapping the button allows us to flip back and forth between the two versions of the app. Even cooler, we can navigate to a specific spot, like say the edit screen of an item, and switching between view styles works here too. Conclusion
— 48:26
We now have an application that can flip between two completely different view paradigms, SwiftUI and UIKit, all being powered by the same view models, and it all just works. When we flip from one view paradigm to another, the state of the application is fully restored just from the state held in the view model. We are recreating the view controller from scratch each time we flip to SwiftUI and back to UIKit. This proves that the view model holds 100% of the state necessary for the view model to do its job. It’s pretty amazing to see that all of the navigation ideas we explored in SwiftUI apply equally as well to UIKit!
— 49:16
That’s it for today’s episode. Till next time! References Collection: Navigation Brandon Williams & Stephen Celis • Sep 20, 2021 Note Navigation is a really, really complex topic, and it’s going to take us many episodes go deep into it. We will show at its heart, navigation is really a domain modeling problem, which means we need to discover tools that allow us to transform one domain into another. Once this is accomplished we will see that many seemingly disparate forms of navigation can be unified in a really amazing way. https://www.pointfree.co/collections/swiftui/navigation Downloads Sample code 0170-uikit-navigation-pt2 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 .