Video #169: UIKit Navigation: Part 1
Episode: Video #169 Date: Nov 29, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep169-uikit-navigation-part-1

Description
What does all the work we’ve done with navigation in SwiftUI have to say about UIKit? Turns out a lot! Without making a single change to the view models we can rewrite the entire view layer in UIKit, and the application will work exactly as it did before, deep-linking and all!
Video
Cloudflare Stream video ID: bcec7d0682b0c1738d2c87b2c3be5779 Local file: video_169_uikit-navigation-part-1.mp4 *(download with --video 169)*
References
- Discussions
- 0169-uikit-navigation-pt1
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
Over the last 9 weeks we have built up a pretty complex application in order to dive deep into the concepts of navigation in SwiftUI . We discovered many new tools along the way that allow us to fully drive navigation off of state, including binding transformations, new overloads on existing SwiftUI Navigation APIs, and even all new SwiftUI views that aid in navigation.
— 0:25
When we first started the series of episodes we boldly stated that the ideas we discussed were just as applicable to UIKit as they were to SwiftUI, and are even applicable beyond Swift and the Apple ecosystem. And now we want to put our money where our mouth is. We are going to rebuild the application in UIKit using everything that modern UIKit APIs have to offer us, and see what new challenges it poses.
— 0:48
Even cooler, we aren’t going to build the entire thing from scratch. We are only going to rebuild the view layer. We are going to reuse all of the view models we built previously without making a single change to them. Hopefully this convinces everyone that thinking of navigation is first a domain modeling problem comes with some huge benefits, one of which is that we can be free to swap out parts of the view layer for a completely different paradigm while the business logic layer is none the wiser. The item view
— 1:15
Let’s start with the view with the least number of dependencies on other parts of the application: the item view. It’s just a basic form that shows a bunch of controls, and the only complicated thing about it is that we use an enum to represent a piece of its state. This means that each case of the enum should present completely different UI to the user, in particular the .inStock case presents a stepper and the .outOfStock case presents a toggle.
— 2:08
Let’s create a new file and get a basic ItemViewController into place: import UIKit class ItemViewController: UIViewController { }
— 2:31
We want this view controller to be powered by the ItemViewModel , so we’ll hold onto one of those, which means we need to provide an initializer. And due to complexities in implementing initializers for class hierarchies, we have to do a bit of extra work for this: class ItemViewController: UIViewController { let viewModel: ItemViewModel init(viewModel: ItemViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
— 2:55
Next we need to set up the view hierarchy for this controller. One way to do this would be to use interface builder and storyboards, but we personally like to code up our views in UIKit for maximum flexibility. We will do this in the viewDidLoad since it’s a method on the view controller that is only called a single time in its lifecycle: override func viewDidLoad() { super.viewDidLoad() }
— 3:18
There are 3 main things we need to accomplish in this view:
— 3:22
Build the view hierarchy to add to the root of the view controller.
— 3:26
Listen for changes in the view model in order to update the UI.
— 3:30
Listen for changes in the UI to send them to the view model so that it can react.
— 3:36
Those three things basically replicate how SwiftUI views works, but of course it will take us a lot more work to accomplish in UIKit.
— 3:43
We can start by creating a text field for the name of the item: let nameTextField = UITextField() nameTextField.placeholder = "Name" nameTextField.borderStyle = .roundedRect
— 3:50
Since this view consists of a bunch of views stacked on top of each other, we will put a UIStackView at the root of the view, and then put all of our views in it: let stackView = UIStackView(arrangedSubviews: [ nameTextField, ]) stackView.axis = .vertical stackView.spacing = UIStackView.spacingUseSystem stackView.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(stackView)
— 4:09
And we will pin the stack view to the controller view so that it fills the screen: NSLayoutConstraint.activate([ stackView.topAnchor.constraint( equalTo: self.view.readableContentGuide.topAnchor ), stackView.leadingAnchor.constraint( equalTo: self.view.readableContentGuide.leadingAnchor ), stackView.trailingAnchor.constraint( equalTo: self.view.readableContentGuide.trailingAnchor ), ])
— 4:16
Now we’ve already written a decent amount of UI code, but have no way of seeing the changes unless we run the application. And even then we would need to figure out a way to get the view into the application, which would require some work since everything else in the app is build in SwiftUI.
— 4:30
We’d love to use SwiftUI previews, but those only work for SwiftUI views. Well, luckily for us it’s quite easy to wrap a UIKit view controller in a SwiftUI view, using the UIViewControllerRepresentable protocol. Now, we could make a one-off representable for the ItemViewController so that we could slap it in a preview, but this is something we’re going to do a few times in this episode.
— 4:52
So instead, let’s create a reusable representable that can take any view controller. Let’s put it in the SwiftUIHelpers.swift file, and it doesn’t have to do much: struct ToSwiftUI: UIViewControllerRepresentable { let viewController: () -> UIViewController func makeUIViewController( context: Context ) -> some UIViewController { self.viewController } func updateUIViewController( _ uiViewController: UIViewControllerType, context: Context ) { } }
— 5:45
That right there is enough for us to create a preview for our controller: import SwiftUI struct ItemViewController_Previews: PreviewProvider { static var previews: some View { ToSwiftUI { ItemViewController( viewModel: .init( item: .init( name: "", color: nil, status: .inStock(quantity: 1) ), route: nil ) ) } } }
— 6:21
We can see that there’s a name text field at the top, and so that’s cool. This will be very useful as we build out more of the UI.
— 6:31
Let’s see what happens if we change the name of the item we seed the view model with: viewModel: .init( item: .init( name: "Keyboard", color: nil, status: .inStock(quantity: 1) ), route: nil )
— 6:37
Unfortunately the preview didn’t change at all. We’d hope the text field would start with the name “Keyboard” pre-populated.
— 6:44
This brings us to the second responsibility of viewDidLoad that we mentioned a moment ago. We need to listen for changes in the view model so that we can update the UI. Luckily the published fields of a view model are backed by Combine publishers, which makes it very easy for us to listen to changes. We can just .sink on the publisher and play those changes back to the UI element: self.viewModel.$item .sink { item in nameTextField.text = item.name }
— 7:15
A few of things to note about this.
— 7:18
First, since nameTextField is just a local variable in the viewDidLoad scope, and not an instance variable, there is no risk of retain cycle, and so we don’t need to capture [weak self] in the .sink , which is what we commonly do.
— 7:31
Second, we are listening to all changes to the item, but we only care about the changes to the item’s name. We could minimize the number of times this .sink closure is called by plucking out the name field from the item and removing duplicates: self.viewModel.$item .map(\.name) .removeDuplicates() .sink { nameTextField.text = $0 }
— 7:58
Third, since we are sinking on a publisher we need to keep track of it’s cancellable, so let’s add a set of cancellables to the view controller: import Combine private var cancellables: Set<AnyCancellable> = [] And store the .sink ’s return value in that set: self.viewModel.$item .map(\.name) .removeDuplicates() .sink { name in nameTextField.text = name } .store(in: &self.cancellables)
— 8:23
And with those changes we now see the text “Keyboard” appear in the text field.
— 8:27
Let’s move on to the next UI element in the item form: the color picker. In the original application we used a navigation link to drill down to a dedicated view for choosing a color, but in the interest of time we are going to simplify things for the UIKit application. We’ll have another opportunity to explore drill down navigations, but for the color picker we will leverage the UIPickerView that UIKit gives us.
— 8:49
We can create a picker view easily enough: let colorPicker = UIPickerView()
— 8:57
And we can throw it in the stack view at the root of the controller: let stackView = UIStackView(arrangedSubviews: [ nameTextField, colorPicker, ])
— 9:01
And already we have some UI showing in the preview.
— 9:06
Now populating the rows of this picker view and hooking up its functionality is quite a bit more difficult than in SwiftUI. We need to set up a delegate and data source. We are going to make the ItemViewController be the delegate and data source, but also many people like to create separate objects for these responsibilities.
— 9:23
This means we need to conform our view controller to those delegates: class ItemViewController: UIViewController, UIPickerViewDataSource, UIPickerViewDelegate {
— 9:34
And we’ll set the .delegate and .dataSource properties of the picker view: let colorPicker = UIPickerView() colorPicker.dataSource = self colorPicker.delegate = self
— 9:43
Which means we need to implement some required methods: func numberOfComponents(in pickerView: UIPickerView) -> Int { <#code#> } func pickerView( _ pickerView: UIPickerView, numberOfRowsInComponent component: Int ) -> Int { <#code#> }
— 9:50
The first is easy. A component of a picker view is a delineated section of rows to differentiate it from other rows or sections. We just want to display all of our colors in one contiguous block of rows, so we can return 1: func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
— 10:04
For the second we need to return the number of colors we have, plus 1 to represent the “none” color. Or, we can define a new array of optionals colors to represent that: extension Item.Color { static let all: [Self?] = [nil] + Self.defaults }
— 10:35
And then return that count from the data source method: func pickerView( _ pickerView: UIPickerView, numberOfRowsInComponent component: Int ) -> Int { Item.Color.all.count }
— 10:40
If we run this in the preview we see something really funny: a bunch of rows with question marks. It seems that if you specify that there are rows in the data source without providing views for the rows, UIKit defaults to question marks.
— 10:54
This highlights one of the cool things about SwiftUI. In SwiftUI the description of the data and views for a picker is smashed together in one package, so it’s not possible to accidentally provide one without the other.
— 11:09
We can provide content for the rows of the picker view by implementing a delegate method that allows us to return a string to show in a particular row: func pickerView( _ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int ) -> String? { Item.Color.all[row]?.name ?? "None" }
— 11:34
And now we’ve got some actual content showing in the picker view.
— 11:38
While we’re here, let’s go ahead and hook up the view model, so that when it changes it updates the picker. This will look similar to what we did for the name text field, but with an added twist.
— 11:49
To start, we want to listen to changes to the item’s color, which we can do with: self.viewModel.$item .map(\.color) .removeDuplicates()
— 11:55
This is a publisher that emits a color, from which we somehow need to figure out how to select a row in the picker view. Pickers have a method that allow you to select a row based on its index I the data source: colorPicker.selectRow(<#row: Int#>, inComponent: <#Int#>, animated: <#Bool#>)
— 12:10
So, we could use the color emitted by this publisher to look it up in the Item.Color.all array to find its index, and then use that with this picker view method: self.viewModel.$item .map(\.color) .removeDuplicates() .sink { color in guard let row = Item.Color.all.firstIndex(of: color) else { return } colorPicker.selectRow(row, inComponent: 0, animated: false ) } .store(in: &self.cancellables)
— 12:39
And with this change we can update the preview to force this controller to start with a particular color pre-selected: struct ItemViewController_Previews: PreviewProvider { static var previews: some View { ToSwiftUI { ItemViewController( viewModel: .init( item: .init( name: "Keyboard", color: .blue, status: .inStock(quantity: 1) ), route: nil ) ) } } }
— 12:47
Let’s now get the last bit of UI in this item form: the status controls. These are quite a bit more complicated than the text field and picker because there are two distinct controls that need to be shown and hidden depending on whether or not the item is in stock.
— 13:02
The in-stock control is a stepper for updating the quantity, paired with a label that displays the current quantity, as well as a button that allows you to mark the item as sold out. We can create all of those UI elements and add them to a stack view to represent the in stock section of the form: let quantityLabel = UILabel() let quantityStepper = UIStepper() quantityStepper.maximumValue = .infinity let quantityStackView = UIStackView(arrangedSubviews: [ quantityLabel, quantityStepper ]) let markAsSoldOutButton = UIButton(type: .system) markAsSoldOutButton.setTitle("Mark as sold out", for: .normal) let inStockStackView = UIStackView(arrangedSubviews: [ quantityStackView, markAsSoldOutButton, ]) inStockStackView.axis = .vertical
— 13:41
When the item is out-of-stock we show a toggle, as well as a button to mark the item as being back in stock: let isOnBackOrderLabel = UILabel() isOnBackOrderLabel.text = "Is on back order?" let isOnBackOrderSwitch = UISwitch() let isOnBackOrderStackView = UIStackView(arrangedSubviews: [ isOnBackOrderLabel, isOnBackOrderSwitch, ]) let isBackInStockButton = UIButton(type: .system) isBackInStockButton.setTitle("Is back in stock!", for: .normal) let outOfStockStackView = UIStackView(arrangedSubviews: [ isOnBackOrderStackView, isBackInStockButton, ]) outOfStockStackView.axis = .vertical
— 14:19
Then we can put both the in-stock and out-of-stock views into the main stack view: let stackView = UIStackView(arrangedSubviews: [ nameTextField, colorPicker, inStockStackView, outOfStockStackView, ]) statusStackView.axis = .vertical
— 14:28
If we run the preview we see some UI showing up below the picker, but of course it isn’t quite right. We need to take the extra steps to listen for changes in the view model so that we can update the UI.
— 14:34
This is a little more complicated than what we encountered previously because the state of the components is trapped in an enum, but case paths will help us out quite a bit here. For example, to get the current quantity from the status enum we can compactMap on the .inStock case path in order to extract the quantity, and then use that to update the UI: import CasePaths self.viewModel.$item .map(\.status) .compactMap(/Item.Status.inStock) .removeDuplicates() .sink { quantity in quantityLabel.text = "Quantity: \(quantity)" quantityStepper.value = Double(quantity) } .store(in: &self.cancellables)
— 15:37
Now the UI correctly shows a label of “Quantity: 1”. However, tapping on the stepper doesn’t do anything, and that brings us to the third responsibility of the viewDidLoad method. Previously we saw its responsibilities included creating the view hierarchy and binding the view model to the UI elements in the view. But now we need to bind the UI actions in the view to the view model.
— 16:04
As of iOS 14 there’s a simple way to do this. We can simply add an action to the quantity stepper that is executed whenever the value is changed, and then replay that change to the view model: quantityStepper.addAction( .init { _ in self.viewModel.item.status = .inStock( quantity: Int(quantityStepper.value) ) }, for: .valueChanged )
— 16:58
That’s all it takes, and now when we run the preview we will see that tapping on the + and - buttons of the stepper causes the quantity label to change.
— 17:03
But we also need to take care not to introduce any retain cycles in action blocks like these. Not only is the quantity stepper capturing the view controller, which holds onto the stepper in its view, the stepper is also capturing itself. We can break both of these cycles by specifying that the closure should not own either value. .init { [unowned self, unowned quantityStepper] _ in
— 17:29
Before moving on, let’s clean something up. We are now starting to see the three responsibilities of the viewDidLoad come to fruition, and it’s making the method quite long. We personally are OK with the method getting long, and we’d rather keep everything in this method than split things out into multiple methods. For one thing, splitting into multiple methods means you need to start holding onto all the UI elements as instance variables on the controller, and currently it’s kind of nice that they are only scoped to the viewDidLoad method.
— 18:01
But, even though we are OK with the method getting long, it is true that it’s getting hard to read. So, we like to put a few marks in the code to delineate between the 3 responsibilities: // MARK: View creation … // MARK: View model bindings … // MARK: UI actions … The three responsibilities
— 18:30
We are starting to see how the 3 view responsibilities translate from SwiftUI over to UIKit. In addition to setting up the view hierarchy, we also must play changes from the view model to the UI, and from the UI back to the view model.
— 18:54
But even though the responsibilities are clear, we still have more work to do to make the item view controller fully functional.
— 19:13
Let’s keep hooking up actions. When the “Mark as sold out” button is tapped we want to flip the status of the item to .outOfStock . We can do this by adding an action to the button: markAsSoldOutButton.addAction( .init { [unowned self] _ in self?.viewModel.item.status = .outOfStock( isOnBackOrder: false ) }, for: .touchUpInside )
— 20:08
And we can do the same for marking the item as back in stock: isBackInStockButton.addAction( .init { [unowned self] _ in self.viewModel.item.status = .inStock(quantity: 1) }, for: .touchUpInside )
— 20:28
If we run the preview then we will see that nothing happens when we tap these buttons, and that’s because we need to bind to the view model to know when to show and hide the in stock and out of stock views.
— 20:41
Again this is a little tricky because we are dealing with an enum, but case paths can help a great deal. We essentially want to observe changes to the item’s status, but only if it switches from in stock to out of stock, or vice versa. Case paths come with a fancy pattern matching operator that allows you to quickly determine if an enum value is in a particular case, which we can use to hide the inStockStackView when it is not in stock: self.viewModel.$item .map { /Item.Status.inStock ~= $0.status } .removeDuplicates() .sink { isInStock in inStockStackView.isHidden = !isInStock } .store(in: &self.cancellables)
— 22:25
And similarly for the outOfStockStackView : self.viewModel.$item .map { /Item.Status.outOfStock ~= $0.status } .removeDuplicates() .sink { isOutOfStock in outOfStockStackView.isHidden = !isOutOfStock } .store(in: &self.cancellables)
— 22:46
Now the preview starts with only the “in-stock” components showing, and tapping the buttons correctly flips back and forth between the two UI views.
— 23:09
We can even start the view in a specific state by just updating the view model in the preview: viewModel: .init( item: .init( name: "Keyboard", color: .blue, status: .inStock(quantity: 1_000) ), route: nil )
— 23:35
But if we try to flip to the out of stock state and on back order we will see that the toggle does not start in the on position: viewModel: .init( item: .init( name: "Keyboard", color: .blue, status: .outOfStock(isOnBackOrder: true) ), route: nil )
— 23:47
We just need to observe changes to the status’ .outOfStock case and replay those changes to the isOnBackOrderSwitch : self.viewModel.$item .map(\.status) .compactMap(/Item.Status.outOfStock) .removeDuplicates() .sink { isOnBackOrder in isOnBackOrderSwitch.isOn = isOnBackOrder } .store(in: &self.cancellables)
— 24:25
Now it works as we expect.
— 24:39
We’ve now got all the basic UI elements into place, and all of the view model bindings are set up, but there’s still a few UI actions that we haven’t yet implemented. It’s not immediately obvious right now we are missing things because the screen seems to be functional, but as soon as we try to plug this view into the rest of the application it will become apparent that the view model isn’t being updated when we perform certain actions in the view.
— 25:02
First, any changes to the name text field need to be played back to the view model: nameTextField.addAction( .init { [unowned self, unowned nameTextField] _ in self.viewModel.item.name = nameTextField.text ?? "" }, for: .editingChanged )
— 25:48
And any changes to the isOnBackOrderSwitch need to be played back to the view model: isOnBackOrderSwitch.addAction( .init { [unowned self, unowned isOnBackOrderSwitch] _ in self.viewModel.item.status = .outOfStock( isOnBackOrder: isOnBackOrderSwitch.isOn ) }, for: .valueChanged )
— 26:15
And lastly, any changes made to the picker view need to be played back to the view model, which can be done by hooking into the didSelectRow delegate method: func pickerView( _ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int ) { self.viewModel.item.color = Item.Color.all[row] }
— 26:55
This now completes the ItemViewController . It’s quite a bit more work than SwiftUI, and there’s a lot more chances for us to forget to hook up a view model binding or UI action, but it actually isn’t so bad.
— 27:09
Although it’s worth pointing out that it took about 188 lines of code in what SwiftUI accomplished in just 47 lines!
— 27:18
And most importantly we didn’t make a single change to the ItemViewModel , and that single class now powers both a UIKit view controller and a SwiftUI view. We can even take this new controller for a spin in the full application.
— 27:36
For example, in the inventory view we currently show a modal sheet when the route changes to the add case, and right now we are presenting an ItemView . Using our reusable Representable struct from before we can now swap out the ItemView for an ItemViewController : .sheet( item: self.$viewModel.route.case(/InventoryViewModel.Route.add) ) { itemToAdd in NavigationView { // ItemView(viewModel: itemToAdd) ToSwiftUI { ItemViewController(viewModel: itemToAdd) } .navigationTitle("Add") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { self.viewModel.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Save") { self.viewModel.add(item: itemToAdd.item) } } } } }
— 28:07
And just like that, our application is now a hybrid SwiftUI and UIKit application, and everything works exactly as it did before. We can tap the “Add” button, we see a sheet fly up with our ItemViewController , and even cooler the name pre-fills to be “Bluetooth Keyboard” after a moment due to that stubbed out asynchronous logic being executed in the view model. It just all works.
— 28:59
We can also update the ItemRowView so that when we drill down to edit an item it uses the new ItemViewController instead of the ItemView : NavigationLink( unwrap: self.$viewModel.route, case: /ItemRowViewModel.Route.edit, onNavigate: self.viewModel.setEditNavigation(isActive:), destination: { $itemViewModel in // ItemView(viewModel: itemViewModel) ToSwiftUI { viewController: ItemViewController(viewModel: itemViewModel) } … } ) { … }
— 29:43
We can even do the same with the popover that allows us to duplicate an existing item: .popover( item: self.$viewModel.route .case(/ItemRowViewModel.Route.duplicate) ) { itemViewModel in NavigationView { // ItemView(viewModel: itemViewModel) ToSwiftUI { ItemViewController(viewModel: itemViewModel) } … } .frame(minWidth: 300, minHeight: 500) }
— 30:05
We have now completely rid the entire app of the ItemView and it’s all being powered by the ItemViewController . It’s honestly shocking how little work it took us to get this done. The item row cell
— 30:19
But let’s keep going. Let’s go up a level in our application to attack the next screen with the fewest dependencies on the rest of the application. In the SwiftUI application that was the ItemRowView , which allowed us to extract out all the behavior for a single row of the inventory list into its own domain. It had its own view model, and it handled the logic and navigation for editing, duplicating, and deleting items.
— 30:50
This view was quite straightforward to create in SwiftUI, in fact so much so that we may have taken for granted just how much SwiftUI took care of for us. The fact that we could create a view for a row without even thinking about the larger context it was embedded in is huge. And the fact that we could even perform all navigation from the view, such as presenting alerts, popovers and pushing onto the navigation stack. In the UIKit world things are not so simple, and so we won’t get as much mileage out of a custom view for the row, but it will still be helpful.
— 31:24
When creating a custom view for the row we have to make an upfront decision that will affect the rest of our application. We have to decide from what class are we going to subclass. Ideally we would just subclass from UIView since that means the view could potentially be reused in many different places.
— 31:40
However, in practice, it’s quite difficult to do this. If we want to easily embed this view in a table view or collection view, then it works out much better to subclass UITableViewCell or UICollectionViewCell . But if you do that then it makes it more complicated to embed the view in other contexts. These are things we never have to think about with SwiftUI.
— 32:00
So, let’s take the easier route and subclass based on ease of inserting this view into a list context. We are going to use a collection view to display the inventory list. As of iOS 14, collection views can now display their cells in a style that mimics UITableView s, and it does seem like collection views are the future of UIKit lists on iOS.
— 32:21
This means we should subclass UICollectionViewCell , but even better there’s a more specific class that we can subclass: UICollectionViewListCell . This type of cell is best suited for table-like lists, so let’s create an “ItemRowCellView.swift” file and start a new class in there: import UIKit class ItemRowCellView: UICollectionViewListCell { }
— 33:08
We may be tempted to add properties to this class for all the things it needs to do its job, like say the ItemRowViewModel : class ItemRowCellView: UICollectionViewListCell { let viewModel: ItemRowViewModel }
— 33:21
But doing so would require to provide this data when the object is created. However, that’s not possible with how collection views work.
— 33:30
Collection views try to minimize the number of cells that need to be created by reusing cells that go off screen. This means that even if your collection has thousands of elements, the view only needs to provision enough cells to fill the screen, which is often times 20 or less.
— 33:44
Because of this cell reuse process we don’t actually create the cells directly ourselves, which means we can’t pass along the view model associated with the cell to its initializer. Fortunately collection views do give us other moments for customizing the cell before it is displayed on the screen, and so what we can do is expose a method for binding the view model, and then we’ll call that method from the collection view.
— 34:03
Let’s call this method bind , and it will take an ItemRowViewModel : func bind(viewModel: ItemRowViewModel) { }
— 34:14
And here we’ll do the work to observe changes to the view model to update the cell’s UI, and if there are any UI actions in the row we would pass them along to the view model.
— 34:26
Let’s start simple. We can observe changes to the item’s name so that we can update the text that is displayed in the cell: viewModel.$item .map(\.name) .removeDuplicates() .sink { name in <#???#> } .store(in: &self.cancellables) In order for this to work we need to hold onto the cancellable for the duration of the cell’s lifetime: import Combine class ItemRowCellView: UICollectionViewListCell { var cancellables: Set<AnyCancellable> = [] … }
— 35:10
And because a cell can be reused and bound to different view models as they scroll in and out of view, we should remove any existing cancellables whenever a cell is reused. We could do so at the beginning of the bind method: func bind(viewModel: ItemRowViewModel) { self.cancellables = [] … }
— 35:25
Or perhaps even better, we can hook into the cell’s prepareForReuse lifecycle method: override func prepareForReuse() { super.prepareForReuse() self.cancellables = [] }
— 35:37
So, how do we implement the body of the .sink closure?
— 35:40
The way to customize a UICollectionViewListCell is through what are known as “content configuration” values. They are structs that you set various properties on, and then you hand it off to the cell to apply the configurations, rather than mutating the cell directly. For now we will just set the text of the list cell view while making to not introduce a retain cycle: .sink { [unowned self] name in var content = self.defaultContentConfiguration() content.text = name self.contentConfiguration = content }
— 36:20
And that right there should be enough to get something displaying on the screen if we had a collection view at our disposal. It would be great if we could fire up a preview to see what it looks like, but unfortunately this view cannot be rendered easily outside the context of a collection view, and getting a collection view up and running is quite a bit of work, as we will see in a moment.
— 36:38
So, we are just going to hope that this looks ok, and will delay actually seeing it live until we start building out the inventory list collection view. However, before we get to that we can implement a few of the row’s responsibilities directly in this view. Remember that in SwiftUI the row view was responsible for showing the delete alert, the duplicate popover and the edit drill down. We can do the same in the ItemRowCellView .
— 37:08
To know when a navigation even occurs we need to listen for changes to the item’s route : viewModel.$route .removeDuplicates() .sink { route in } .store(in: &self.cancellables)
— 37:29
Then, when the route changes we can switch on it to figure out what navigation we should trigger: switch route { case .none: break case .deleteAlert: break case .duplicate: break case .edit: break }
— 38:04
The simplest of these to implement is probably the delete alert. We can create a UIAlertController , customize its title, message and actions: case .deleteAlert: let alert = UIAlertController( title: viewModel.item.name, message: "Are you sure you want to delete this item?", preferredStyle: .alert ) alert.addAction( .init(title: "Cancel", style: .cancel) { _ in viewModel.cancelButtonTapped() } ) alert.addAction( .init(title: "Delete", style: .destructive) { _ in viewModel.deleteConfirmationButtonTapped() } )
— 39:11
And then all that is left is to present the alert. Unfortunately, it is not possible to present view controllers from within a plain UIView . Only view controllers are given the APIs for performing navigation.
— 39:23
If you’ve done any amount of UIKit work you will know that there is a division between what UIKit thinks of as “view controllers” and what UIKit thinks of as “views.” There isn’t a ton of concrete documentation on the responsibilities of each object from Apple, but there are quite a few opinions in the greater iOS community.
— 39:40
Perhaps the best reference for their differences is just in what APIs they expose. View controllers have lots of methods and properties that are concerned with navigation, such as pushing or presenting child controllers, being notified of lifecycle events (will/did appear/disappear), and managing various global properties of the application such as status bar.
— 40:01
On the other hand, views have a lot of methods and properties that are concerned with how things are drawn on the screen, such as background colors, opacity, clipping, masking, sizing and more. Notably, the list of UIView APIs does not include any navigation capabilities.
— 40:17
But strangely, both UIViewController and UIView inherit from UIResponder , so there is a whole swath of functionality that both objects participate in, such as being notified of touches on the screen, motion events, remote-control events and more.
— 40:33
And even stranger, the view controller holds onto a view, which means at the end of the day it’s free to reach into the view and do whatever it wants. Can you imagine if in SwiftUI the observable object view model had access to the view? That would be really strange, and inject a lot of uncertainty into your application. If you were ever witnessing strange view behavior you would have to not only look in the view struct to see if you have some incorrect logic, but you would also have to scan every code path of the view model to see what it could possibly be doing in the view.
— 41:01
So, we think the distinction between UIViewController and UIView is extremely murky. We far more prefer the approach that SwiftUI has taken by having a single “view” concept that handles visual aspects of the view as well as navigation. Alas, we are stuck with UIKit right now, so we are going to do the simplest thing possible and force that a UIViewController context is passed to us when the view model is bound: func bind( viewModel: ItemRowViewModel, context: UIViewController ) { … }
— 41:41
And now we can use that context to present the delete alert: context.present(alert, animated: true)
— 41:49
We’d love if we could test this in a preview to make sure what we are doing is reasonable, but unfortunately we are going to have to wait a bit longer until we work on the inventory list collection view.
— 41:57
The next responsibility we can implement in the row is the duplicate popover. When the route changes to the .duplicate case we can extract out its view model associated value, create an ItemViewController , configure it, and then use the view controller context to present it as a popover: case let .duplicate(itemViewModel): let vc = ItemViewController(viewModel: itemViewModel) vc.title = "Duplicate" vc.navigationItem.leftBarButtonItem = .init( title: "Cancel", primaryAction: .init { _ in viewModel.cancelButtonTapped() } ) vc.navigationItem.rightBarButtonItem = .init( title: "Add", primaryAction: .init { _ in viewModel.duplicate(item: itemViewModel.item) } ) let nav = UINavigationController(rootViewController: vc) nav.modalPresentationStyle = .popover nav.popoverPresentationController?.sourceView = self context.present(nav, animated: true)
— 44:18
This is the first time we’ve referred to self in the sink , so let’s be sure to unown it to prevent a retain cycle. .sink { [unowned self] route in … }
— 44:30
And finally, the drill down for editing can be accomplished by constructing an ItemViewController , configuring it, and then showing it from the context view controller: case let .edit(itemViewModel): let vc = ItemViewController(viewModel: itemViewModel) vc.title = "Edit" vc.navigationItem.leftBarButtonItem = .init( title: "Cancel", primaryAction: .init { _ in viewModel.cancelButtonTapped() } ) vc.navigationItem.rightBarButtonItem = .init( title: "Save", primaryAction: .init { _ in viewModel.edit(item: itemViewModel.item) } ) context.show(vc, sender: nil)
— 45:10
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. Next time: the inventory view
— 45:18
So, let’s start by getting a basic view controller in place for the inventory list, which will be powered by an InventoryViewModel …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 0169-uikit-navigation-pt1 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 .