Cross-Platform Swift: View Paradigms

Episode #290 • Aug 12, 2024 • Free Episode

It’s time to go cross-platform! We will take a feature written in Swift and use it in vastly different situations, including not only SwiftUI and UIKit, but beyond Apple’s frameworks and ecosystems. We will start with a baby step and introduce our feature to a third party view paradigm, Airbnb’s Epoxy.

Previous episode
Cross-Platform Swift: View Paradigms
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

Brandon

We have spent the past many weeks going deep into what we think “modern” UIKit looks like. Along the way we rebuilt many of SwiftUI’s state management tools in order to tune them specifically for UIKit. This includes:

  • A UIBinding type that mimics SwiftUI’s Binding type and it allows two features to read from and write to a piece of shared state, and observe any state changes to the state. And we can even extend UIControls to be powered by bindings just as they are in SwiftUI, including their focus.

Stephen
  • Presentation methods defined on all UIViewControllers that allows one to handle sheets, popovers, alerts, drill-downs and navigation stacks in a state-driven API that mimics SwiftUI.

Brandon

A general purpose observe method that allows one to simply and minimally observe changes inside an observable model so that one can update UI elements in the view.

Stephen

And we took things further by making all of these tools work with Swift’s powerful domain modeling features, such as enums, and we even back ported all the tools to work on iOS 16 and earlier so that you can use the tools today. No need to wait until you can support only the latest and greatest of iOS.

We accomplished all of this, and more, in just about 400 lines of library code! It’s pretty amazing to see, and then we even released the tools in an official library called Swift Navigation, which provides tools for all sorts of navigation patterns in Swift applications, including SwiftUI, UIKit, and AppKit.

Brandon

However, even though we personally feel the things we showed off in that series are quite amazing, there are quite a few people out there that are just not interested in UIKit, at all. Many were surprised that we were even covering UIKit at all, and told us that it was a waste of time.

But UIKit is only the tip of the iceberg. Many times during our “Modern UIKit” series we hinted at the fact that the ideas we were exploring go far beyond UIKit. At a surface level we at the very least see that these techniques apply to both UIKit and SwiftUI equally. And so if you are building an app just for Apple’s platforms then you can build your features’ core domain without a care for view-related concerns, and then decide whether it is appropriate to use SwiftUI or UIKit once you get around to actually making the view.

Stephen

But at a deeper level, these techniques also apply if you want to start building cross platform, such as for Windows, Linux, Wasm or something else. Many of the tools we built in the last series were even made in pure Swift, such as the observe function and UIBinding. That means they already compile for any platform that Swift is available on.

But going cross platform puts a lot more responsibility on you to build your features in a way that separates view-specific concerns from your business concerns. You shouldn’t mix view concepts into your domain because the view for iOS is going to be vastly different from the view for Windows or the web.

Brandon

And now it is finally time for us to put our money where our mouth is! 🤑

We are going to show how it is possible to use the same core Swift feature in vastly different situations. We hope that this proves to everyone that domain modeling is by far the most important task to undertake when designing your applications, and should be done before ever thinking about the view. And if done well, you will be part of the way towards porting your application to platforms that you previously could have never even dreamed of!

Stephen

Now, we do want to prepare you that we are not here to provide the full story when it comes to building cross platform apps. At the end of the day there is a minefield of build tool problems to figure out to actually build a Windows or Wasm app in Swift, and we do not have a solution for that. Instead we are going to make use of the work of others who are experts in those fields, and that will free us up to concentrate on the area we excel at, which is architecting apps for solving real business problems.

Brandon

And so let’s begin. We are going to ease ourselves into this process by first considering a use case that is still on Apple’s platforms, but using a different view paradigm. There are a number of libraries out there that aim to make it easier to build UIKit view hierarchies. One of the more popular ones, and made by a large tech company, is Airbnb’s “Epoxy” library.

This is now a third style of building views for iOS applications, alongside vanilla UIKit and SwiftUI. So clearly if we are able to make Epoxy work with the tools we previously built, which, by the way, were built without ever even considering that something like Epoxy exists, then I think we can all start to see the power of the ideas we have been hammering on for weeks.

So let’s dig in…

Cross-paradigm development: observation

The “Epoxy” library from Airbnb bills itself as a “suite of declarative UI APIs for building UIKit applications in Swift”. Right in the README we can see an example of what this means by seeing their example of a counter feature that is powered by a UICollectionView:

class CounterViewController: CollectionViewController {
  init() {
    let layout = UICollectionViewCompositionalLayout
      .list(using: .init(appearance: .plain))
    super.init(layout: layout)
    setItems(items, animated: false)
  }

  enum DataID {
    case row
  }

  var count = 0 {
    didSet {
      setItems(items, animated: true)
    }
  }

  @ItemModelBuilder
  var items: [ItemModeling] {
    TextRow.itemModel(
      dataID: DataID.row,
      content: .init(
        title: "Count \(count)",
        body: "Tap to increment"),
      style: .large)
      .didSelect { [weak self] _ in
        self?.count += 1
      }
  }
}

At a high level this is doing a few things:

  • It uses a collection view to power the view. The Epoxy library pretty much uses collection views exclusively to power views, which makes sense because most views in iOS apps are scrolling lists of views.

  • It’s storing mutable state directly in the view controller via the count store property.

  • Whenever that state is mutated it invokes a library method called setItems, which will do the work of refreshing the collection view that powers this controller.

  • The items that are set is constructed from a computed property that creates an array of what are known as ItemModeling values. And these values package up all the information for a cell in the collection view, including its content and what happens when the cell is tapped.

This is yet another view paradigm that is a bit different from both UIKit and SwiftUI. It’s kind of like a blend between the two. And if we are able to seamlessly use our existing models in this new view paradigm, then I think it would help drive home even more how important domain modeling is.

I’ve got a project open already for us to explore this. It’s essentially the project we were working in during the “Modern UIKit” series. We’ve got our CounterModel, as well as views that are powered by the model in both SwiftUI and UIKit.

The model is pretty straightforward, but let’s remind ourselves of everything it does. It holds onto some state, such as the current count, an optional fact about the number being displayed (and that fact is displayed in an alert when non-nil), a boolean that determines if the fact is loading, and we also through in some text and a focus boolean just to make things a little spicier:

var count = 0 {
  didSet {
    isTextFocused = !count.isMultiple(of: 3)
  }
}
var fact: Fact?
var factIsLoading = false
var isTextFocused = false
var text = ""

Then there are a few methods on the object that are invoked from the view for executing logic in the feature, such as incrementing or decrementing the count, or fetching a new fact.

We also cleaned the project up a little bit. Most importantly we have deleted all of the navigation tools we built from scratch during that series, and instead we are now depending on our Swift Navigation library which includes all of those tools.

Let’s start by adding a dependency on Airbnb’s Epoxy library.

And we will create a new file, CounterFeatureEpoxy, that will recreate the view for the counter feature using Epoxy. And most importantly we will not make a single change to the counter model.

Let’s now build the view that will display all of this information to the user. The view will be a UIViewController subclass, but there is a special view controller provided by epoxy that is tuned specifically for describing the cells of a collection view in a declarative manner, it’s called CollectionViewController:

import Epoxy

class EpoxyCounterViewController: CollectionViewController {
}

We will want this controller powered by our CounterModel, and so let’s add a stored property and an initializer:

import Epoxy
import SwiftNavigation
import UIKit

class EpoxyCounterViewController: CollectionViewController {
  @UIBindable var model: CounterModel

  init(model: CounterModel) {
    self.model = model
    super.init(
      layout: UICollectionViewCompositionalLayout.list(
        using: UICollectionLayoutListConfiguration(
          appearance: .grouped
        )
      )
    )
  }
}

Next we will introduce a viewDidLoad where we can start observing changes to our model in order to populate the collection view:

override func viewDidLoad() {
  super.viewDidLoad()

  observe { [weak self] in
    guard let self else { return }

  }
}

And then what we want to do in this observe trailing closure is access the various fields in our model in order to build the data source for the collection view.

The way one does this in epoxy is to invoke the setItems method and pass it an array of values conforming to the ItemModeling protocol, which describes all the various qualities of the cell being displayed in the collection:

setItems(
  <#[ItemModeling]#>, 
  animated: false
)

The most direct way to construct a value of type ItemModeling is to use the concrete ItemModel type:

ItemModel(
  dataID: <#AnyHashable#>,
  params: <#Hashable#>,
  content: <#Equatable#>,
  makeView: <#(Hashable) -> UIView#>,
  setContent: <#(ItemModel<UIView>.CallbackContext, Equatable) -> Void#>
)

It takes a number of arguments in its initializer, but we don’t care about some of these right now.

The first argument is a unique identifier used for the cell in the collection view. This is similar to how SwiftUI’s ForEach requires its items to be identifiable.

From looking at Epoxy’s example code it seems the way they suggest handling this is to define an enum that describes all of the various cells in the collection view. Right now we will model the count label and the increment/decrement buttons:

private enum DataID {
  case count
  case decrementButton
  case incrementButton
}

And then we can use these values for the dataID argument:

ItemModel(
  dataID: DataID.count,
  params: <#Hashable#>,
  content: <#Equatable#>,
  makeView: <#(Hashable) -> UIView#>,
  setContent: <#(ItemModel<UIView>.CallbackContext, Equatable) -> Void#>
)

Next we have the params argument, which is data that can be used to customize the creation of the UIView that is put into the cell of the collection view. We don’t have a need for this, so we will just stuff some hashable data into this argument in order to appease the compiler:

params: false,

And the content argument represents data that can be used to update the view dynamically after the view has been created. This can be the count from the model, because that is the state we want to ultimately put into the label:

content: model.count,

Note that this step is key because it means we are accessing an observable field directly inside observe, and that way the trailing closure will be invoked again if that count changes. This means we get automatic re-computation of this data source for free, and does so in the most minimal way possible.

And now we have two trailing closures left: one to create the view and the other to customize the view. In these closures we can just create a UILabel and then set the text of the label:

ItemModel(
  dataID: DataID.count,
  params: false,
  content: model.count,
  makeView: { _ in
    UILabel()
  },
  setContent: { context, count in
    context.view.text = "Count: \(count)"
  }
),

And just like that we can already show something in a preview:

import SwiftUI
import UIKitNavigation

struct EpoxyCounterViewControllerPreviews: PreviewProvider {
  static var previews: some View {
    UIViewControllerRepresenting {
      EpoxyCounterViewController(model: CounterModel())
    }
  }
}

Nothing too impressive yet, but let’s keep going.

Next we can add an ItemModel to represent the decrement button. We aren’t going to spell out all of the details and instead just paste it in:

ItemModel(
  dataID: DataID.decrementButton,
  params: false,
  content: false,
  makeView: { _ in
    let button = UIButton(
      type: .system,
      primaryAction: UIAction { [weak self] _ in
        self?.model.decrementButtonTapped()
      }
    )
    button.setTitle("Decrement", for: .normal)
    return button
  },
  setContent: { _, _ in }
)

As well as the increment button:

ItemModel(
  dataID: DataID.incrementButton,
  params: false,
  content: false,
  makeView: { _ in
    let button = UIButton(
      type: .system,
      primaryAction: UIAction { [weak self] _ in
        self?.model.incrementButtonTapped()
      }
    )
    button.setTitle("Increment", for: .normal)
    return button
  },
  setContent: { _, _ in }
)

And we now have a very basic counter feature powered by the same model that was used in SwiftUI and UIKit, but it is now being displayed using Airbnb’s Epoxy library.

Now of course all of this ItemModel stuff is very messy right now. In a more real world use of this Epoxy library you wouldn’t create these ItemModels directly, but instead create what are known as EpoxyableViews, which allow you to hide away a lot of these details.

Unfortunately Epoxy doesn’t come with some simple EpoxyableViews out of the box, such as for labels and buttons, but their sample code has a lot of examples. I am going to copy-and-paste some views I found for handling labels and buttons:

final class Label: UILabel, EpoxyableView {

  // MARK: Lifecycle

  init(style: Style) {
    super.init(frame: .zero)
    translatesAutoresizingMaskIntoConstraints = false
    font = style.font
    numberOfLines = style.numberOfLines
    if style.showLabelBackground {
      backgroundColor = .secondarySystemBackground
    }
  }

  required init?(coder _: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  struct Style: Hashable {
    let font: UIFont
    let showLabelBackground: Bool
    var numberOfLines = 0
  }

  typealias Content = String

  func setContent(_ content: String, animated _: Bool) {
    text = content
  }
}

extension Label.Style {
  static func style(
    with textStyle: UIFont.TextStyle,
    showBackground: Bool = false
  ) -> Label.Style {
    .init(
      font: UIFont.preferredFont(forTextStyle: textStyle),
      showLabelBackground: showBackground
    )
  }
}

final class ButtonRow: UIView, EpoxyableView {

  // MARK: Lifecycle

  init() {
    super.init(frame: .zero)
    setUp()
  }

  required init?(coder _: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  // MARK: Internal

  struct Behaviors {
    var didTap: (() -> Void)?
  }

  struct Content: Equatable {
    var text: String?
  }

  func setContent(_ content: Content, animated _: Bool) {
    text = content.text
  }

  func setBehaviors(_ behaviors: Behaviors?) {
    didTap = behaviors?.didTap
  }

  // MARK: Private

  private let button = UIButton(type: .system)
  private var didTap: (() -> Void)?

  private var text: String? {
    get { button.title(for: .normal) }
    set { button.setTitle(newValue, for: .normal) }
  }

  private func setUp() {
    translatesAutoresizingMaskIntoConstraints = false
    layoutMargins = UIEdgeInsets(
      top: 20, left: 24, bottom: 20, right: 24
    )
    backgroundColor = .quaternarySystemFill

    button.tintColor = .systemBlue
    button.titleLabel?.font = .preferredFont(forTextStyle: .title3)
    button.translatesAutoresizingMaskIntoConstraints = false

    addSubview(button)
    NSLayoutConstraint.activate([
      button.leadingAnchor
        .constraint(equalTo: layoutMarginsGuide.leadingAnchor),
      button.topAnchor
        .constraint(equalTo: layoutMarginsGuide.topAnchor),
      button.trailingAnchor
        .constraint(equalTo: layoutMarginsGuide.trailingAnchor),
      button.bottomAnchor
        .constraint(equalTo: layoutMarginsGuide.bottomAnchor),
    ])

    button.addTarget(
      self, action: #selector(handleTap), for: .touchUpInside
    )
  }

  @objc
  private func handleTap() {
    didTap?()
  }
}

And now the collection of ItemModeling values becomes a little simpler:

[
  Label.itemModel(
    dataID: DataID.count,
    content: "Count: \(model.count)",
    style: .style(with: .title)
  ),

  ButtonRow.itemModel(
    dataID: DataID.decrementButton,
    content: ButtonRow.Content(text: "Decrement"),
    behaviors: ButtonRow.Behaviors(didTap: { [weak self] in
      self?.model.decrementButtonTapped()
    })
  ),

  ButtonRow.itemModel(
    dataID: DataID.incrementButton,
    content: ButtonRow.Content(text: "Increment"),
    behaviors: ButtonRow.Behaviors(didTap: { [weak self] in
      self?.model.incrementButtonTapped()
    })
  )
]

Refinements and navigation

This all looks pretty nice so far. We now have a very simple data description of the rows in a collection view, and then the mere act of constructing the data source inside our observe tool makes it so that the view automatically subscribes to any fields accessed in the model. This means as soon as the model changes, the data source will be reloaded, and then we see the freshest state in the view.

Stephen

But, our view is still showing only the most basic kind of functionality, that of incrementing and decrementing. There is more functionality to think about, such as loading a fact for the number, which involves showing a progress indicator, and we want to also be able to perform navigation, such as showing an alert.

Let’s take a look at that.

Let’s start building out the functionality of the fact loading. We will start by adding a button that when tapped fetches a fact, and that requires adding a new data ID:

private enum DataID {
  case count
  case decrementButton
  case factButton
  case incrementButton
}

…and then adding the button to the collection of items:

ButtonRow.itemModel(
  dataID: DataID.factButton,
  content: ButtonRow.Content(text: "Get fact"),
  behaviors: ButtonRow.Behaviors(didTap: { [weak self] in
    Task { await self?.model.factButtonTapped() }
  })
)

And then we want a row for the fact, so that’s another ID:

private enum DataID {
  case count
  case decrement
  case fact
  case factButton
  case increment
}

…and then we want to conditionally show the fact if it is present:

if let fact = model.fact {
  Label.itemModel(
    dataID: DataID.fact,
    content: fact.value,
    style: .style(with: .body)
  )
}

But the problem here is that we can’t put an if let conditional right here because we are in the middle of a big array literal.

However, Epoxy comes with a custom builder that allows you to build up collections of item models in a syntax that allows of if let conditionals like this. To get into a builder context we need to define a function or computed property, and we will go with a computed property:

@ItemModelBuilder
var items: [ItemModeling] {

}

Now we can cut-and-paste our items from the observe closure to this computed property, but we can now also get rid of the commas:

@ItemModelBuilder
var items: [ItemModeling] {
  Label.itemModel(
    dataID: DataID.count,
    content: "Count: \(model.count)",
    style: .style(with: .body)
  )

  ButtonRow.itemModel(
    dataID: DataID.decrement,
    content: ButtonRow.Content(text: "Decrement"),
    behaviors: ButtonRow.Behaviors(didTap: { [weak self] in
      self?.model.decrementButtonTapped()
    })
  )

  ButtonRow.itemModel(
    dataID: DataID.increment,
    content: ButtonRow.Content(text: "Increment"),
    behaviors: ButtonRow.Behaviors(didTap: { [weak self] in
      self?.model.incrementButtonTapped()
    })
  )

  ButtonRow.itemModel(
    dataID: DataID.factButton,
    content: ButtonRow.Content(text: "Get fact"),
    behaviors: ButtonRow.Behaviors(didTap: { [weak self] in
      Task { await self?.model.factButtonTapped() }
    })
  )

  if let fact = model.fact {
    Label.itemModel(
      dataID: DataID.fact,
      content: fact.value,
      style: .style(with: .body)
    )
  }
}

This compiles, and we can use this collection of item models in the observe closure:

setItems(items, animated: false)

And just like that we have a somewhat functional app! We can run the preview and see that we can count up, count down, and even fetch a fact.

We aren’t yet showing an activity indicator though, and that would be very helpful. epoxy doesn’t come with a component out of the box for activity indicators, but I will paste in a very simple one:

final class ActivityIndicatorView:
  UIActivityIndicatorView, EpoxyableView
{
  func setContent(_ content: Bool, animated: Bool) {
    if content {
      startAnimating()
    } else {
      stopAnimating()
    }
  }
}

And then we will add a new ID to our enum:

private enum DataID {
  case activity
  case count
  case decrement
  case fact
  case factButton
  case increment
}

…and in the else branch of the fact if let we can show the activity indicator:

if let fact = model.fact {
  Label.itemModel(
    dataID: DataID.fact,
    content: fact.value,
    style: .style(with: .body)
  )
} else {
  ActivityIndicatorView.itemModel(
    dataID: DataID.activity,
    content: model.factIsLoading,
    style: .large
  )
}

And now this is look a lot more similar to the versions of this feature we have built in the past in both UIKit and SwiftUI.

There is one last thing missing from our demo that we did have in both the UIKit and SwiftUI versions of this feature, and that is navigation. Epoxy comes with a whole suite of tools for handling navigation. For example, in the README we will find an example that shows how one can inherit from their NavigationController to get access to a setStack method for setting the stack of the controller.

And the stack is defined by a value type description of an element of the stack called NavigationModel, and there’s even a result builder syntax one can use to describe the stack:

class FormNavigationController: NavigationController {
  init() {
    super.init()
    setStack(stack, animated: false)
  }

  enum DataID {
    case step1, step2
  }

  var showStep2 = false {
    didSet {
      setStack(stack, animated: true)
    }
  }

  @NavigationModelBuilder
  var stack: [NavigationModel] {
    .root(dataID: DataID.step1) { [weak self] in
      Step1ViewController(didTapNext: {
        self?.showStep2 = true
      })
    }

    if showStep2 {
      NavigationModel(
        dataID: DataID.step2,
        makeViewController: {
          Step2ViewController(didTapNext: {
            // Navigate away from this step.
          })
        },
        remove: { [weak self] in
          self?.showStep2 = false
        })
    }
  }
}

And there’s even helpers for performing presentations, like sheets and popovers, called setPresentation, and it too supports a result builder syntax for describing presentations:

class PresentationViewController: UIViewController {
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    setPresentation(presentation, animated: true)
  }

  enum DataID {
    case detail
  }

  var showDetail = true {
    didSet {
      setPresentation(presentation, animated: true)
    }
  }

  @PresentationModelBuilder
  var presentation: PresentationModel? {
    if showDetail {
      PresentationModel(
        dataID: DataID.detail,
        presentation: .system,
        makeViewController: { [weak self] in
          DetailViewController(didTapDismiss: {
            self?.showDetail = false
          })
        },
        dismiss: { [weak self] in
          self?.showDetail = false
        })
    }
  }
}

And this is cool and all, but also it’s pretty unique to Epoxy. These kinds of navigation APIs don’t really look like what one does in UIKit or SwiftUI. But we actually don’t need to use these APIs thanks to the state-drive APIs that come with our UIKitNavigation library.

To see this let’s comment out the fact label being shown in the view:

// if let fact = model.fact {
//   Label.itemModel(
//     dataID: DataID.fact,
//     content: "fact.value",
//     style: .style(with: .body)
//   )
// } else {
…
// }

And instead we will use the present(item:) API from our library to drive the presentation of a controller from the optional fact state:

present(item: $model.fact) { fact in

}

We can show any kind of controller we want here, but to keep things simple we will just do an alert:

present(item: $model.fact) { fact in
  let alert = UIAlertController(
    title: "Fact",
    message: fact.value,
    preferredStyle: .alert
  )
  alert.addAction(UIAlertAction(title: "OK", style: .default))
  return alert
}

And just like that we have navigation happening in our app. So really we feel that it’s probably not necessary to use the navigation tools of Epoxy, and instead rely on just the declarative view building tools.

Cross-platform development

So this is all pretty cool. We have now shown a 3rd kind of view paradigm for which Swift’s amazing observation tools and our powerful navigation tools can be used to greatly simplify development.

We hope this helps our viewers see that the concepts that we have been talking about for the past many weeks was not solely about UIKit. No, the ideas we have been describing have a far deeper meaning with regards to how one builds apps and models their domains. With a little bit of upfront work we are able to build the core logic of our features in isolation, and then decide how we want to interface with the model in the view. Whether that be SwiftUI, UIKit, a 3rd party framework like Epoxy, or even some combination of view paradigms.

Brandon

But now it’s time to really drive home the point we are trying to make. We are now going to embark on making a cross platform version of our humble counter feature. We are going to make a version that runs in the browser, and still powered by the exact same model that we have been using in SwiftUI, UIKit and Epoxy.

We are going to do this by using something called WebAssembly, or Wasm for short. It is a binary format that can be embedded directly in a web browser, and other platforms, that allows one to run any language in the browser, not just JavaScript. Many languages support Wasm, including Python, Ruby, Rust, and C++, and even Swift has experimental support for Wasm.

This means we should be able to run our previously built model right in the browser, ideally without any changes whatsoever, and then the real work will be connecting our model to the browser’s DOM. This will be reminiscent of how we built our UIKit views, where we needed to explicitly create labels, text fields and buttons, hook those things up to the model, and observe changes in the model to update the UI. This is all possible even in a web app.

Sounds too good to be true, so let’s get started.


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