UIKit interop
Two decades of framework did not disappear — the Representable bridge, the Coordinator translation, and both directions of embedding.
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
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:
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
| Situation | Answer |
|---|---|
| New screen in a UIKit-rooted app | SwiftUI + UIHostingController — the standard migration move |
| WKWebView, camera/photo pickers, PDF, some MapKit | wrap with Representable |
Rich/custom text editing beyond TextEditor | wrap UITextView |
| Third-party UIKit SDK | wrap 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.
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
A legacy stepper speaks delegate; the app speaks bindings. Implement StepperCoordinator so taps flow into state, and make sync idempotent.
Route make vs update
Cement the lifecycle: given a list of Representable responsibilities, print which method owns each.
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.
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.