← All topics/UIKit & SwiftUI internals

Question 8

UIKit and SwiftUI interop

How do you embed SwiftUI in UIKit or wrap UIKit for SwiftUI, and what usually goes wrong at the boundary?

Follow-ups

  • When would you use UIHostingController vs UIHostingConfiguration?
  • What is a Coordinator for in UIViewRepresentable?

Answer outline

UIHostingController embeds a SwiftUI hierarchy inside UIKit. Treat it like any child view controller: addChild, add its view, constrain it, then call didMove(toParent:). For SwiftUI content inside table and collection cells, UIHostingConfiguration (iOS 16 and later) is the better fit, because it avoids a full hosting controller per cell.

UIViewRepresentable and UIViewControllerRepresentable wrap UIKit for SwiftUI. makeUIView creates the UIKit object once for that representable's identity, and updateUIView pushes the latest SwiftUI state into it.

A Coordinator is the bridge for delegates, data sources, targets, and callbacks. It lets UIKit report changes back into SwiftUI bindings or models without stuffing delegate logic into the view struct.

The usual bugs sit right at the boundary:

  1. 1.Missing containment: addChild without didMove(toParent:), or a hosted view added with no constraints, so appearance callbacks and sizing go wrong.
  2. 2.Retain cycles: closures in the coordinator or callbacks that capture the hosting controller or the wrapped view strongly.
  3. 3.Update loops: writing to SwiftUI state from updateUIView, which triggers another update, which writes again.
  4. 4.Mismatched lifecycles: UIKit code that assumes it owns the view's lifetime, when SwiftUI decides when a representable is created, updated, and torn down.

Principles

  • Use UIHostingController at screen and view controller boundaries, and UIHostingConfiguration for cell content.
  • makeUIView creates and updateUIView synchronizes, so make updates idempotent: running one twice must change nothing.
  • Route delegate-style callbacks through a Coordinator, and guard binding writes so they can't feed back into updateUIView.

Standard child containment, with the hosted view pinned to the parent's edges:

SwiftUI inside a UIKit child view controller
let host = UIHostingController(rootView: ProfileView(model: model))
addChild(host)
view.addSubview(host.view)
host.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    host.view.topAnchor.constraint(equalTo: view.topAnchor),
    host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
host.didMove(toParent: self)

A UITextField bridged into a SwiftUI binding through a Coordinator, with an equality check so updateUIView doesn't loop:

UIKit wrapped for SwiftUI
struct TextFieldWrapper: UIViewRepresentable {
    @Binding var text: String

    final class Coordinator: NSObject, UITextFieldDelegate {
        var text: Binding<String>

        init(text: Binding<String>) {
            self.text = text
        }

        func textFieldDidChangeSelection(_ textField: UITextField) {
            text.wrappedValue = textField.text ?? ""
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text)
    }

    func makeUIView(context: Context) -> UITextField {
        let field = UITextField()
        field.delegate = context.coordinator
        return field
    }

    func updateUIView(_ field: UITextField, context: Context) {
        if field.text != text {
            field.text = text
        }
    }
}

Follow-up angles

  • UIHostingConfiguration works well for SwiftUI row content, but reuse, stable identity, and cheap row bodies still apply.
  • Embedding SwiftUI doesn't unify UINavigationController state with NavigationStack, so choose one owner for navigation decisions.