Modern SwiftUI · advanced

UIKit interop

Two decades of framework did not disappear — the Representable bridge, the Coordinator translation, and both directions of embedding.

12 min read12 min practice0/2 exercises4 recall cards

By the end you will be able to

  • Wrap a UIKit view with UIViewRepresentable and keep it in sync with SwiftUI state
  • Explain what a Coordinator is and implement the delegate-to-binding translation
  • Embed SwiftUI into UIKit with UIHostingController, and know when each direction applies
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

It is 2026, SwiftUI is a decade old — why does a professional iOS engineer still need UIKit interop?

Direction 1: UIKit inside SwiftUI

UIViewRepresentable adapts a UIKit view into the SwiftUI world. Two required methods, with sharply different jobs:

struct WebView: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        WKWebView()                                   // ONCE: create and configure
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        webView.load(URLRequest(url: url))            // EVERY state change: sync
    }
}

// Used like any view:
WebView(url: articleURL)
    .frame(height: 400)

The mental model that prevents the classic bugs: the struct is a lightweight description (recreated constantly, like any view), while the UIKit view it makes is a long-lived object. makeUIView runs once per appearance; updateUIView runs on every relevant SwiftUI update and must reconcile the object with the current description — the same "state → UI" discipline as body, applied imperatively.

The Coordinator: two languages, one translator

The deep difference between the frameworks is direction of communication. SwiftUI speaks bindings (state flows down, edits flow up through $). UIKit speaks delegates and targets (objects call back methods on other objects). The Coordinator is the adapter standing between them:

struct SearchBar: UIViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text)                       // created before makeUIView
    }

    func makeUIView(context: Context) -> UISearchBar {
        let bar = UISearchBar()
        bar.delegate = context.coordinator             // UIKit talks to the coordinator
        return bar
    }

    func updateUIView(_ bar: UISearchBar, context: Context) {
        if bar.text != text { bar.text = text }        // loop guard
    }

    final class Coordinator: NSObject, UISearchBarDelegate {
        let text: Binding<String>
        init(text: Binding<String>) { self.text = text }

        func searchBar(_ bar: UISearchBar, textDidChange searchText: String) {
            text.wrappedValue = searchText             // delegate call → binding write
        }
    }
}

Read the round trip: the user types → UIKit fires the delegate → the Coordinator writes the binding → SwiftUI state changes → updateUIView confirms (and the guard stops the echo). The Coordinator is a class because UIKit delegates require reference semantics (NSObject, usually), and it lives as long as the UIKit view does — SwiftUI keeps it alive across the struct's recreations.

That translation — callback in, binding write out — is the entire idea, and it runs here:

The Coordinator pattern with the UIKit parts simulated — the translation is the real thing.

Note the second sync: the guard recognised the value it had just reported and skipped — the echo that, unguarded, becomes the infinite update loop.

Direction 2: SwiftUI inside UIKit

The other bridge is one class:

let hosting = UIHostingController(rootView: StatsView(model: model))
navigationController.pushViewController(hosting, animated: true)

UIHostingController is a real view controller whose content is a SwiftUI hierarchy — push it, present it, embed it as a child. This direction is how existing apps adopt SwiftUI at all: new screens are built in SwiftUI and hosted into the UIKit shell, one at a time, while navigation, DI containers and the app skeleton stay put. Data flows in through the rootView's initializer — the same explicit injection the architecture lesson taught, which is why well-factored models cross the boundary untouched.

The practical wrinkle worth knowing: sizing. A hosted SwiftUI view inside self-sizing contexts (table cells, popovers) historically needed nudging — sizingOptions = [.intrinsicContentSize] — and cell embedding is now first-class via UIHostingConfiguration, which is the modern way to put SwiftUI rows in a UICollectionView you are not ready to replace.

When to reach across, honestly

SituationAnswer
New screen in a UIKit-rooted appSwiftUI + UIHostingController — the standard migration move
WKWebView, camera/photo pickers, PDF, some MapKitwrap with Representable
Rich/custom text editing beyond TextEditorwrap UITextView
Third-party UIKit SDKwrap it; keep the wrapper thin
"SwiftUI list feels slow"profile first — modern List is rarely the problem; rewriting in UIKit is the last resort, not the reflex

The seam discipline from the architecture track applies at the framework boundary too: keep Representables thin and dumb — configuration and translation only, no business logic. A wrapper with rules in it is untestable from both sides.

Check yourself

Your wrapped UITextView re-scrolls to the top on every keystroke. The Representable sets textView.text = text in updateUIView. What is happening?

Write the translator

Write the code

A legacy stepper speaks delegate; the app speaks bindings. Implement StepperCoordinator so taps flow into state, and make sync idempotent.

Solution unlocks after 3 attempts

Route make vs update

Write the code

Cement the lifecycle: given a list of Representable responsibilities, print which method owns each.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

You have joined a seven-year-old UIKit app; leadership wants SwiftUI adoption without a rewrite. Draft the interop policy one-pager: which screens go SwiftUI first (and why those), what gets hosted vs wrapped, the Representable rules (thin, idempotent update, guards), and the one metric you would track to prove the migration is not degrading the app.

Checkpoint

You can now:

  • Wrap UIKit views with the make/update lifecycle and idempotent reconciliation
  • Implement the Coordinator's delegate-to-binding translation
  • Break the update feedback loop on sight
  • Host SwiftUI in UIKit shells and argue an incremental migration plan

Next up: modules, access control and Swift Package Manager — drawing the boundaries all these layers live behind.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.