Modern UIKit: Sneak Peek, Part 2

Episode #282 • Jun 3, 2024 • Free Episode

We finish building a modern UIKit application with brand new state-driven tools, including a complex collection view that can navigate to two child features. And we will see that, thanks to our back-port of Swift’s observation tools, we will be able to deploy our app all the way back to iOS 13.

Previous episode
Modern UIKit: Sneak Peek, Part 2
Next episode
FreeThis episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

Stephen

Again it’s amazing to see that with the right tools you can bind a model to a UIKit view controller in a very simple manner. Bindings can be used with controls just like in SwiftUI, navigation APIs can be used just like in SwiftUI, and minimal observation can happen by just accessing fields on the model. Honestly we think it’s just hands down the best way to build UIKit features.

Brandon

Now let’s move onto the last feature without a view, and it is by far the most complex one since it deals with a collection view. This is the view that searches for existing networks and populates a collection view with all the found networks, and each cell in the collection view has various icons for the connection strength, security and more. And further, this feature can navigate to the other two features we built by either presenting a sheet or drilling down to a new screen.

Let’s see what it takes.

Wi-Fi settings view

We will again paste in some scaffolding for the controller, but this time it will be a UICollectionViewController subclass, and we will use a compositional list layout:

class WiFiSettingsViewController: UICollectionViewController
{
  @UIBindable var model: WiFiSettingsModel

  init(model: WiFiSettingsModel) {
    self.model = model
    super.init(
      collectionViewLayout:
        UICollectionViewCompositionalLayout.list(
          using: UICollectionLayoutListConfiguration(
            appearance: .insetGrouped
          )
        )
    )
  }
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

And we’ll configure the collection view in the viewDidLoad:

override func viewDidLoad() {
  super.viewDidLoad()
  navigationItem.title = "Wi-Fi"
}

There are 3 major steps to configuring a collection view in modern UIKit:

  • Defining a data source that determines the structure of the sections and items in the collection view.

  • Defining a cell register that determines what kind of view to show for each piece of data in the data source.

  • And then finally observing state changes in the model so that you can update the data source and apply the changes to the collection view, and it will even animate the changes.

However, in order to define any of these things we first need to perform a mini domain modeling exercise. This time the domain modeling is very view-specific since we are dealing with UICollectionView, and so this domain belongs in the view layer, rather than the model layer.

And then if we ever ported this feature to macOS, Windows, Linux, or any platform without the concept of a UICollectionView, we would then conduct a similar domain modeling exercise on those platforms to come up with something that makes sense for them. In essence, we are bending the view to the will of the model rather than the other way around.

The domain model exercise consists of first coming up with types that describe all of the sections and types of items that can be shown in the collection view, and then coming up with a function to transform the model’s pristine, view-independent domain into the specialized, UICollection-specific domain.

We will even embed these types inside the WiFiSettingsViewController since they belong squarely inside the target that has all of the UIKit code:

extension WiFiSettingsViewController {
  enum Section {
    case top
    case foundNetworks
  }

  enum Item: Hashable {
    case isOn
    case selectedNetwork(Network.ID)
    case foundNetwork(Network)
  }
}

These types describe that we have two different kinds of sections, the top one that holds the Wi-Fi toggle and the selected network, and then the full list of found networks.

And with that we can already define a cell register that will create a UICollectionViewListCell from an item in the data source:

let cellRegistration = UICollectionView.CellRegistration<
  UICollectionViewListCell, Item
> {
  cell, indexPath, item in
}

In here it is our responsibility to customize the cell based on the indexPath and the item handed to us. This code can get a little messy since we need to switch over the item enum and handle each of its 3 cases, and so we will extract that work out into a little helper method:

let cellRegistration = UICollectionView.CellRegistration<
  UICollectionViewListCell, Item
> {
  [weak self] cell, indexPath, item in

  guard let self else { return }
  configure(cell: cell, indexPath: indexPath, item: item)
}

…

private func configure(
  cell: UICollectionViewListCell,
  indexPath: IndexPath,
  item: Item
) {
  
}

Then in this configure method we will switch on item to figure out how to customize the cell:

switch item {
case .isOn:
  <#code#>
case let .selectedNetwork(networkID):
  <#code#>
case let .foundNetwork(network):
  <#code#>
}

In order to customize simple things on the cell, like its title, you must go through a configuration value:

var configuration = cell.defaultContentConfiguration()

You make mutations to this value, and then set it on the cell, and so we will do this in a defer:

var configuration = cell.defaultContentConfiguration()
defer { cell.contentConfiguration = configuration }

Now we can set the title on each cell:

switch item {
case .isOn:
  configuration.text = "Wi-Fi"

case let .selectedNetwork(networkID):
  guard
    let network = model.foundNetworks.first(
      where: { $0.id == networkID }
    )
  else { return }
  configuration.text = network.name
  
case let .foundNetwork(network):
  configuration.text = network.name
}

That is very basics of customizing the look of each cell. There will be a lot more to do soon, but I think it would be nice to see the changes in a preview, so let’s try to get that working.

To do so, next we can create a data source that will power the collection, and we can just call out to our cell register for providing cell views:

let dataSource = UICollectionViewDiffableDataSource<
  Section, Item
>(
  collectionView: collectionView
) { collectionView, indexPath, item in
  collectionView.dequeueConfiguredReusableCell(
    using: cellRegistration,
    for: indexPath,
    item: item
  )
}

And we are going to need access to this data source later, outside of the viewDidLoad, and so let’s add an instance property to the controller:

var dataSource: UICollectionViewDiffableDataSource<
  Section, Item
>!

And assign it in viewDidLoad:

self.dataSource = …

Next we need to transform the data in our model into a data source snapshot which can then be handed to the data source, and it will be responsible for computing the difference between the previous data and the new data to see how to animate the changes.

This transformation is very view-specific since it needs to construct the Section and Item types we defined before, and we will encapsulate that logic in a method defined on WiFiSettingsModel:

extension NSDiffableDataSourceSnapshot<
  WiFiSettingsViewController.Section,
  WiFiSettingsViewController.Item
> {
  init(model: WiFiSettingsModel) {
  }
}

We can start by creating a mutable snapshot that we can append sections and items to:

self.init()

First of all, there is always a top section because we always show the Wi-Fi switch:

appendSections([.top])
appendItems([.isOn], toSection: .top)

And then we also have a selectedNetwork row if there is a selected network:

if let selectedNetworkID = model.selectedNetworkID {
  appendItems(
    [.selectedNetwork(selectedNetworkID)],
    toSection: .top
  )
}

However, we do not want to show the selected network, or any of the networks for that matter, if Wi-Fi is turned off. So before checking the selected network we can guard that the Wi-Fi is on:

guard model.isOn 
else { return }

That’s all there is to the top section. Next we have the foundNetworks section:

appendSections([.foundNetworks])

We just need to map over all the networks to wrap them in the foundNetworks case of the Item enum:

appendItems(
  model.foundNetworks.map { .foundNetwork($0) },
  toSection: .foundNetworks
)

And that’s it!

Now that we can construct snapshots from the model we can apply them to the data source so that the collection view actually updates:

dataSource.apply(
  NSDiffableDataSourceSnapshot(model: model),
  animatingDifferences: true
)

With that we can already preview the feature. Let’s add a preview with some mock data:

import SwiftUI

#Preview {
  UIViewControllerRepresenting {
    UINavigationController(
      rootViewController: WiFiSettingsViewController(
        model: WiFiSettingsModel(foundNetworks: .mocks)
      )
    )
  }
}

extension [Network] {
  static let mocks = [
    Network(
      name: "nachos",
      isSecured: true,
      connectivity: 0.5
    ),
    Network(
      name: "nachos 5G",
      isSecured: true,
      connectivity: 0.75
    ),
    Network(
      name: "Blob Jr's LAN PARTY",
      isSecured: true,
      connectivity: 0.2
    ),
    Network(
      name: "Blob's World",
      isSecured: false,
      connectivity: 1
    ),
  ]
}

We can definitely see that we have the basics of our collection view implemented, but there’s a lot more data to cram into these cells than just the title.

Rest of collection view

So that was a quick crash course in modern collection views, and we didn’t even need to use any of our new, modern UIKit tools. We just create a cell register and a data source, and then create snapshots of a data source from our model, and finally apply those snapshots to the data source in order to get rows showing on the screen.

Stephen

But now it’s time to start using our new modern tools. We need to put a toggle in the collection view to allow the user to turn Wi-Fi off and on, and that should be bound to our model so that the UI stays in sync with our model state. And further we need to react to state changes in our model in order to update the collection.

Let’s try it out.

Let’s start with the .isOn row. We want to add a switch to the row that allows one to turn the Wi-Fi on and off:

cell.accessories = [
  .customView(
    configuration: UICellAccessory.CustomViewConfiguration(
      customView: UISwitch(),
      placement: .trailing(displayed: .always)
    )
  )
]

And immediately we see in the preview that a switch appears in the first row.

However, this switch is completely disconnected from our model. We would want it so that when the user toggles the switch the change is played back to the model, and conversely if the model changes its isOn state it should cause the switch to update visually.

This is possible thanks to the fact that we can derive bindings from our model:

cell.accessories = [
  .customView(
    configuration: UICellAccessory.CustomViewConfiguration(
      customView: UISwitch(isOn: $model.isOn),
      placement: .trailing(displayed: .always)
    )
  )
]

Here we can use the special initializer on UISwitch that takes a UIBinding. This means any changes in the switch or in the model will automatically be played back to the other.

Next, in the selectedNetwork item there are a lot of accessories that can be configured. There needs to be a blue info button that when tapped goes to the detail screen of the network:

cell.accessories = [
  .detail(displayed: .always) { [weak self] in
    guard let self else { return }
    model.infoButtonTapped(network: network)
  }
]

And just like that we see a blue info button on each row.

Then if the network is secured we want to show a lock icon:

if network.isSecured {
  let image = UIImage(systemName: "lock.fill")!
  let imageView = UIImageView(image: image)
  imageView.tintColor = .darkText
  cell.accessories.append(
    .customView(
      configuration: UICellAccessory.CustomViewConfiguration(
        customView: imageView,
        placement: .trailing(displayed: .always)
      )
    )
  )
}

And we also want to represent the strength of the Wi-Fi signal as an icon:

let image = UIImage(
  systemName: "wifi", variableValue: network.connectivity
)!
let imageView = UIImageView(image: image)
imageView.tintColor = .darkText
cell.accessories.append(
  .customView(
    configuration: UICellAccessory.CustomViewConfiguration(
      customView: imageView,
      placement: .trailing(displayed: .always)
    )
  )
)

And further, because this is the selected network, we will put in a leading checkmark accessory:

cell.accessories.append(
  .customView(
    configuration: UICellAccessory.CustomViewConfiguration(
      customView: UIImageView(
        image: UIImage(systemName: "checkmark")!
      ),
      placement: .leading(displayed: .always),
      reservedLayoutWidth: .custom(1)
    )
  )
)

Customizing the non-selected networks is very similar, and so we will extract this cell configuration to a little helper:

func configureNetwork(
  cell: UICollectionViewListCell,
  network: Network,
  indexPath: IndexPath,
  item: Item
) {
  configuration.text = network.name
  cell.accessories = [
    .detail(displayed: .always) { [weak self] in
      guard let self else { return }
      model.infoButtonTapped(network: network)
    }
  ]
  if network.isSecured {
    let image = UIImage(systemName: "lock.fill")!
    let imageView = UIImageView(image: image)
    imageView.tintColor = .darkText
    cell.accessories.append(
      .customView(
        configuration: 
          UICellAccessory.CustomViewConfiguration(
            customView: imageView,
            placement: .trailing(displayed: .always)
          )
      )
    )
  }
  let image = UIImage(
    systemName: "wifi", variableValue: network.connectivity
  )!
  let imageView = UIImageView(image: image)
  imageView.tintColor = .darkText
  cell.accessories.append(
    .customView(
      configuration:
        UICellAccessory.CustomViewConfiguration(
          customView: imageView,
          placement: .trailing(displayed: .always)
        )
    )
  )
  if network.id == model.selectedNetworkID {
    cell.accessories.append(
      .customView(
        configuration:
          UICellAccessory.CustomViewConfiguration(
            customView: UIImageView(
              image: UIImage(systemName: "checkmark")!
            ),
            placement: .leading(displayed: .always),
            reservedLayoutWidth: .custom(1)
          )
      )
    )
  }
}

And then invoke that from the selectedNetwork and foundNetwork cases:

case let .selectedNetwork(networkID):
  guard
    let network = model.foundNetworks.first(
      where: { $0.id == networkID }
    )
  else { return }
  configureNetwork(
    cell: cell,
    network: network,
    indexPath: indexPath,
    item: item
  )

case let .foundNetwork(network):
  configureNetwork(
    cell: cell,
    network: network,
    indexPath: indexPath,
    item: item
  )

Everything’s fully configured but the view is not quite right, as the selected row is not filtered from the list. We can fix that in our data source snapshot:

appendItems(
  model.foundNetworks
    .filter { $0.id != model.selectedNetworkID }
    .map { .foundNetwork($0) },
  toSection: .foundNetworks
)

And that is all it takes to configure the cell from an item when it is about to be presented. Sure it’s a lot when compared to SwiftUI, but it’s also not so bad.

But unfortunately something isn’t right. If we toggle the Wi-Fi on and off the collection view never updates.

And if new networks were inserted into the model or networks were removed, the collection view would not update for those changes either. We can see this by emulating networks being found and lost directly in our preview:

#Preview {
  let model = WiFiSettingsModel(foundNetworks: .mocks)
  return UIViewControllerRepresenting {
    UINavigationController(
      rootViewController: WiFiSettingsViewController(
        model: model
      )
    )
  }
  .task {
    while true {
      try? await Task.sleep(for: .seconds(1))
      guard Bool.random() else { continue }
      if Bool.random() {
        guard
          let randomIndex = (
            0..<model.foundNetworks.count
          )
          .randomElement()
        else { continue }
        if model.foundNetworks[randomIndex].id
          != model.selectedNetworkID
        {
          model.foundNetworks.remove(at: randomIndex)
        }
      } else {
        model.foundNetworks.append(
          Network(
            name: goofyWiFiNames.randomElement()!,
            isSecured: !(1...1_000).randomElement()!
              .isMultiple(of: 5),
            connectivity: Double((1...100).randomElement()!)
              / 100
          )
        )
      }
    }
  }
}

let goofyWiFiNames = [
  "AirSpace1",
  "Living Room",
  "Courage",
  "Nacho WiFi",
  "FBI Surveillance Van",
  "Peacock-Swagger",
  "GingerGymnist",
  "Second Floor",
  "Evergreen",
  "__hidden_in_plain__sight__",
  "MarketingDropBox",
  "HamiltonVille",
  "404NotFound",
  "SNAGVille",
  "Overland101",
  "TheRoomWiFi" ,
  "PrivateSpace",
]

When we run the preview we will see that the collection view does not update at all.

To fix this we need to compute a new snapshot and apply it to the data source every time the model changes. To do that we just need to perform the data source work above inside an observe:

observe { [weak self] in
  guard let self else { return }
  dataSource.apply(
    NSDiffableDataSourceSnapshot(model: model),
    animatingDifferences: true
  )
}

And with that small change we can see that the preview is already dynamically updating as networks are added and removed from the list. And we can toggle the Wi-Fi off and on to see the collection collapse and expand.

And the really cool thing about this is that the observe closure observes the minimal amount of state necessary to do its job. This means snapshots will only be generated and applied to the data source when any of the state accessed inside dataSourceSnapshot changes. But if other, unrelated state changes, snapshots will not be computed. And that is absolutely amazing. We don’t have to think about observation at all. We just access the state we need, and only that state will be observed.

So the behavior of the collection view is looking pretty nice, but there is more behavior to deal with, in particular navigation. The model has optional destination enum state that should drive navigation. The connect case of the enum should drive a sheet appearing and the detail cause should drive a drill-down navigation.

Let’s start with the connect case. We can present the ConnectToNetworkViewController in a sheet from that state by using the present(item:) method on UIViewController:

present(item: $model.destination.connect) { model in
  UINavigationController(
    rootViewController: ConnectToNetworkViewController(
      model: model
    )
  )
}

To get access to the .connect case of the binding, we need to reach for another tool that comes with our Swift navigation tools, and that’s case paths. We just need to annotate our destination enum:

@CasePathable
enum Destination {
  …
}

The fact that we can use a simple syntax such as $model.destination.connect to derive a binding from a specific case in an enum is all thanks to the power of our CasePaths library and its integration into our SwiftUINavigation library.

This method will observe when the destination state becomes non-nil and matches the connect case, and at that moment present the ConnectToNetworkViewController in a sheet. And further it will detect when the case of the enum switches or when the optional state becomes nil, and will automatically dismiss the sheet.

Next we have the detail case, which we can handle similarly as the sheet, but this time we will be pushing a view controller onto the underlying navigationController:

navigationController?.pushViewController(
  item: $model.destination.detail
) { model in
  NetworkDetailViewController(model: model)
}

And that is all it takes to drive navigation with state. It’s pretty incredible!

And the final thing left to do is invoke a method on the model when the cell in the collection view is tapped:

override func collectionView(
  _ collectionView: UICollectionView,
  didSelectItemAt indexPath: IndexPath
) {
  guard
    case let .foundNetwork(network) = dataSource
      .itemIdentifier(for: indexPath)
  else { return }
  model.networkTapped(networkID: network.id)
}

And that right there completes our feature! We can give it a spin in the preview or simulator to see that it works as we expect, including navigation parent/child communication. It’s truly amazing to see.

Back-port to iOS 13

So clearly this is a very fun way to build modern UIKit applications. We get to concentrate fully on domain modeling as a top priority, and then we get to implement the view by observing changes to the model. And it works with simple state observations, 2-way bindings, and navigation. And the tools that help us achieve all of this even look pretty similar to SwiftUI.

Brandon

But one of the disappointing things about what we have accomplished so far is that it appears to only work in iOS 17 and later. The real power of these tools is coming from the @Observable macro, which is restricted to iOS 17 and later, and so that’s a bummer.

Well, luckily a few months ago we released an open source project, called swift-perception, that back ports Swift’s observation tools to iOS 13 and later. This means any project can start using the tools we are demo’ing here, and can even build their own tools on top of observation.

Let’s see how this is possible.

Our first sign that we can’t use these tools on earlier platforms is that the @Observable macro is limited to iOS 17:

@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
@attached(member, …)
public macro Observable() = …

But let’s see what we can do about that. I’ll update the project settings to target iOS 16. I still want some of the modern tooling, afforded after iOS 13, like the @main app entry point, but I do want to deploy to targets earlier than 17.

As soon as we do that our code no longer compiles, because we can no longer use @Observable. Luckily, we can just replace each @Observable with @Perceptible, which is our library’s version of the observable macro:

@MainActor
@Perceptible
class NetworkDetailModel { … }

…

@MainActor
@Perceptible
class ConnectToNetworkModel { … }

…

@MainActor
@Perceptible
class WiFiSettingsModel { … }

And with just 3 lines of code, everything is compiling again. We just need to set up the app entry point to run things in an older simulator.

@main
struct WiFiSettingsApp: App {
  var body: some Scene {
    WindowGroup {
      UIViewControllerRepresenting {
        UINavigationController(
          rootViewController: WiFiSettingsViewController(
            model: WiFiSettingsModel(
              foundNetworks: .mocks
            )
          )
        )
      } 
    }
  }
}

When we do, we will see that everything works exactly as it did before, even though we are running on an iOS 16 device that does not have access to the new native observation tools.

We can also demonstrate some more deep linking. We can update our entry point to have a selected network and already be drilled down to a detail screen:

@main
struct WiFiSettingsApp: App {
  var body: some Scene {
    WindowGroup {
      UIViewControllerRepresenting {
        UINavigationController(
          rootViewController: WiFiSettingsViewController(
            model: WiFiSettingsModel(
              destination: .detail(
                NetworkDetailModel([Network].mocks[1])
              ),
              foundNetworks: .mocks,
              selectedNetworkID: [Network].mocks[1].id
            )
          )
        )
      } 
    }
  }
}

And it all just works!

Next time: building the tools

Well, this is pretty incredible.

In just a short amount of time we have been able to build a pretty complex UIKit app from scratch that features many of the things that real world apps need to deal with:

  • Driving navigation from state, and we dealt with 3 forms of navigation: alerts, sheets and drill-downs.

  • Forming 2-way bindings between our models and UI controls.

  • Observing state changes in a model for updating UI elements.

  • And dealing with complex collection views.

And we were able to accomplish all of this using precise, modern techniques, all thanks to the observation tools in Swift and our UIKitNavigation library.

Stephen

And we want to remark again how important it was for us to perform our domain modeling exercise first and in complete isolation. We built 3 observable models for 3 of our features without ever once thinking about view specific needs. And instead, when it came time to build the view, we bent the view to the will of the model to make things work.

Brandon

And that means we can reuse those models in a variety of view paradigms and platforms. We could rebuild those UIKit view controllers using SwiftUI, or we could build views for Windows, Linux, WebAssembly, or who knows what else! The possibilities are endless because we kept our domain models focused on just the business logic, and let the view flow freely from it.

Stephen

So, this has been a lot of fun, but now it’s time to dig a lot deeper. We have showed off a lot of cool tools in the past episode, but what does it take to create these seemingly magical tools? Well, with a bit of hard work we can build them from scratch, and along the way we will get some deep insights into how SwiftUI works under the hood, and even get an understanding of why SwiftUI sometimes doesn’t work the way we expect.

So let’s begin…next time!


References

Downloads

Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 64 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing