UIKit/AppKit and SwiftUI's Environment propagation

In previous articles of the SwiftUI environment series, we've seen how the environment propagates across views and various presentations.

As long as a view is a child of another, regardless of the connection between the two (via presentation or containment), the view will inherit the environment from their parent (plus any modification applied in-between).

This propagation happens even while many SwiftUI views are wrappers of their UIKit/AppKit counterparts (e.g., TabView and ScrollView).
This behavior is not limited to just SwiftUI wrappers: we can build our AppKit/UIKit SwiftUI wrappers, and the environment will be auto-magically propagated for us. In this short article, let's see an example of this.

In any app, it's reasonable to want to mix SwiftUI with previous UI frameworks. An example could be using SwiftUI as the content of UITableViewCells.

In order to do this we'll use Noah Gilmore's HostingCell<Content: View>: UITableViewCell definition:

final class HostingCell<Content: View>: UITableViewCell {
  private let hostingController = UIHostingController<Content?>(rootView: nil)

  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    hostingController.view.backgroundColor = .clear
  }

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

  func set(rootView: Content, parentController: UIViewController) {
    self.hostingController.rootView = rootView
    self.hostingController.view.invalidateIntrinsicContentSize()

    let requiresControllerMove = hostingController.parent != parentController
    if requiresControllerMove {
      parentController.addChild(hostingController)
    }

    if !self.contentView.subviews.contains(hostingController.view) {
      self.contentView.addSubview(hostingController.view)
      hostingController.view.translatesAutoresizingMaskIntoConstraints = false
      contentView.addConstraints([
        hostingController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
        hostingController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
        hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor),
        hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
      ])
    }

    if requiresControllerMove {
      hostingController.didMove(toParent: parentController)
    }
  }
}

Check out Noah's Self-Sizing UITableView Cells with SwiftUI article for more details on this.

With HostingCell we can now embed SwiftUI views as cells in a UITableViewController's tableView.

Let's define a simple cell:

struct CellView: View {
  var body: some View {
    HStack {
      Text("SwiftUI Text")
      Spacer()
    }
    .padding()
  }
}

Next, let's define a UITableViewController using this new cell:

final class TableViewController: UITableViewController {
  override init(style: UITableView.Style) {
    super.init(style: style)
    tableView.register(HostingCell<CellView>.self, forCellReuseIdentifier: "HostingCell<CellView>")
    tableView.separatorStyle = .none
  }

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

  override func numberOfSections(in tableView: UITableView) -> Int {
    1
  }

  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    300
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "HostingCell<CellView>", for: indexPath) as! HostingCell<CellView>
    cell.set(rootView: CellView(), parentController: self)
    return cell
  }
}

We now have a working UIKit view hosting SwiftUI views. Let's wrap this new TableViewController into a SwiftUI view, we don't need to do anything beside wrapping:

struct TableViewWrapper: UIViewControllerRepresentable {
  func makeUIViewController(context: Context) -> some UIViewController {
    TableViewController(style: .plain)
  }

  func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
  }
}

Let's recap what we have:

  • CellView, a simple SwiftUI view
  • HostingCell, a generic UITableViewCell accepting SwiftUI views
  • TableViewController, a UIViewController displaying multiple HostingCell as part of its tableView content
  • TableViewWrapper, a SwiftUI view wrapping TableViewController

Despite having UIKit views/controllers in between, CellView will inherit TableViewWrapper's environment.

To prove this, let's define a view that will set TableViewWrapper's foreground color:

struct ContentView: View {
  @State var color: Color = .black

  var body: some View {
    VStack {
      ColorPicker("Choose foreground color", selection: $color)
        .pickerStyle(.inline)
        .font(.title)
        .padding(.horizontal)

      TableViewWrapper()
        .foregroundColor(color) // 👈🏻
    }
    .ignoresSafeArea(edges: .bottom)
  }
}

Note that we didn't have to do any extra work to make this possible. We never had to reach for the environment ourselves, nor use @Environment:
CellView's Text will automatically inherit and use the foreground color set on TableViewWrapper.

This propagation works both for environment values and objects.

Conclusions

While SwiftUI might be the recommended framework for any future project, the SwiftUI team has worked hard to ensure excellent integration with previous UI frameworks. Despite having to go through multiple UIKit layers, this seamless environment propagation is yet another example of this.

Thank you for reading!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all