5.9 — SwiftUI ↔ UIKit interop
Opening scenario
You’re building a SwiftUI map screen. SwiftUI’s Map view (iOS 17+) covers most cases — but you need to drop custom annotation views, handle camera animation programmatically, and read the underlying gesture recognizer to detect long-press-and-drag. SwiftUI’s Map doesn’t expose those hooks. Time to wrap MKMapView in a UIViewRepresentable.
Or: you have a legacy UIKit app and your team wants to start writing new screens in SwiftUI. Each new SwiftUI screen needs to push from existing UINavigationControllers. Time for UIHostingController.
Interop goes both ways. In 2026, almost every shipping iOS app is a mixed codebase. Knowing how to bridge cleanly — and where the pitfalls are — is non-negotiable.
| Direction | Use |
|---|---|
UIKit UIView → SwiftUI | UIViewRepresentable |
UIKit UIViewController → SwiftUI | UIViewControllerRepresentable |
| SwiftUI → UIKit (as a UIView) | UIHostingConfiguration (cells), wrap UIHostingController.view |
| SwiftUI → UIKit (as a VC) | UIHostingController |
AppKit NSView → SwiftUI | NSViewRepresentable (covered in chapter 5.11) |
Concept → Why → How → Code
UIViewRepresentable — wrap a UIKit view
The minimal protocol:
struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
WKWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
uiView.load(URLRequest(url: url))
}
}
// Usage
WebView(url: URL(string: "https://example.com")!)
.frame(height: 400)
makeUIView(context:)is called once to create the viewupdateUIView(_:context:)is called whenever SwiftUI re-evaluates with new statecontextprovides access to coordinator and environment
The tricky part is updateUIView: you must reconcile the existing view to match the current SwiftUI state. Idempotent, cheap, and handles all properties.
Coordinator — UIKit delegate callbacks
UIKit delegates need an object. SwiftUI views are structs. The bridge:
struct MapView: UIViewRepresentable {
@Binding var region: MKCoordinateRegion
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
map.setRegion(region, animated: false)
return map
}
func updateUIView(_ map: MKMapView, context: Context) {
// Only update if changed externally to avoid feedback loops
if !context.coordinator.isUserDriven, map.region != region {
map.setRegion(region, animated: true)
}
}
final class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
var isUserDriven = false
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
isUserDriven = true
parent.region = mapView.region
DispatchQueue.main.async { self.isUserDriven = false }
}
}
}
Coordinator holds the delegate. The parent struct is passed by value (latest copy) so the coordinator always has the current bindings.
The feedback loop problem
When SwiftUI state changes → updateUIView runs → sets UIKit state → UIKit delegate fires → updates SwiftUI state → updateUIView runs again → loop.
Solutions:
- Compare before applying:
if uiView.value != newValue { uiView.value = newValue } - Flag user-driven changes as above (
isUserDriven) - Coalesce on next runloop with
DispatchQueue.main.async
Every wrapper needs to think about this. Bugs caused by feedback loops manifest as jitter, infinite re-renders, or “the view fights back”.
UIViewControllerRepresentable — wrap a UIViewController
Same shape, but for VCs:
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Environment(\.dismiss) var dismiss
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
return picker
}
func updateUIViewController(_ vc: UIImagePickerController, context: Context) {
// typically nothing
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let img = info[.originalImage] as? UIImage {
parent.image = img
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}
// Usage
.sheet(isPresented: $showPicker) {
ImagePicker(image: $selectedImage)
}
Useful for VCs SwiftUI hasn’t natively replaced: MFMailComposeViewController, custom camera UIs, PKAddPaymentPassViewController, etc.
Passing changes both ways — Bindings
The pattern: SwiftUI state → wrapper struct → updateUIView propagates to UIKit. UIKit changes → coordinator delegate → mutates the binding → SwiftUI re-renders → updateUIView (debounced via the isUserDriven flag).
Avoid two-way bindings that update on every frame (e.g., scroll position) without throttling — you’ll cause re-render storms.
Sizing
By default, UIKit views report their intrinsicContentSize. SwiftUI uses that for layout. If the wrapped view doesn’t have one (a UIScrollView, a MKMapView), wrap with .frame(...):
WebView(url: url).frame(height: 400)
MapView(region: $region).frame(height: 300)
For self-sizing in lists, set the intrinsic size explicitly in the UIKit view, or override sizeThatFits(_:) in a UIView subclass.
UIHostingController — embed SwiftUI in UIKit
let host = UIHostingController(rootView: ProfileView(user: user))
navigationController?.pushViewController(host, animated: true)
UIHostingControllerIS aUIViewControllerhosting a SwiftUI hierarchy- Push, present, embed in tab bars, child of other VCs
- Pass observable state via environment as usual:
let host = UIHostingController(
rootView: ProfileView().environment(authService)
)
For inline embedding (SwiftUI view inside a UIKit view):
class MyVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let host = UIHostingController(rootView: HeaderView())
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.topAnchor.constraint(equalTo: view.topAnchor),
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
host.didMove(toParent: self)
}
}
The full UIKit child-VC dance — add, constrain, didMove.
UIHostingConfiguration — SwiftUI in cells (iOS 16+)
class FeedVC: UIViewController, UICollectionViewDataSource {
var collectionView: UICollectionView!
func collectionView(_ cv: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = cv.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
let item = items[indexPath.item]
cell.contentConfiguration = UIHostingConfiguration {
FeedCardView(item: item)
}
return cell
}
}
UIHostingConfiguration was Apple’s response to “we want SwiftUI cells but UICollectionView is faster than LazyVGrid”. Native interop. No coordinator. Reuse handled correctly. The right answer when you have a UIKit list with SwiftUI rows.
Navigation across the boundary
SwiftUI pushes via NavigationStack. UIKit pushes via UINavigationController.pushViewController. When a SwiftUI view is embedded in a UIKit nav controller (or vice versa), you can use either:
// Inside SwiftUI hosted in UIKit nav:
struct HostedView: View {
@Environment(\.uikitNavigationController) var nav // custom env key
var body: some View {
Button("Push UIKit") {
nav?.pushViewController(SomeUIKitVC(), animated: true)
}
}
}
// Inject the nav controller via env in the hosting controller setup
A common pattern: each push-able screen is a UIHostingController containing a SwiftUI view. The SwiftUI view requests navigation via a closure or callback that the host responds to by pushing.
Sharing state across the boundary
@Observable instances work across boundaries — pass them via environment:
// UIKit side
let auth = AuthService()
let host = UIHostingController(rootView: ProfileView().environment(auth))
// And the UIKit VC can hold the same `auth` reference, mutate it, and SwiftUI views update.
For old ObservableObject, same idea with .environmentObject(auth).
For one-way data flow (push state from UIKit to SwiftUI), pass via the rootView’s properties and update the rootView:
host.rootView = ProfileView(user: newUser)
This re-evaluates the root with new props.
When NOT to interop
- Don’t wrap simple UIKit primitives that SwiftUI already has.
UILabel→ useText.UIButton→ useButton. - Don’t wrap UIKit views for “performance” without evidence. SwiftUI’s
Text,List,LazyVStackare already fast. - Don’t push UIKit into a SwiftUI screen to avoid learning SwiftUI patterns. Tech debt.
Interop is a tool for specific gaps:
- UIKit-only APIs (PassKit, ReplayKit, AVKit, MapKit’s full surface, custom camera UIs)
- Specialized 3rd-party UIKit libraries with no SwiftUI equivalent
- Performance-critical custom drawing (sometimes
CALayerwork) - Gradual migration from UIKit codebases
Threading
UIViewRepresentablemethods run on main thread (it’s@MainActor-ish)updateUIViewmay be called many times; keep it cheap and idempotent- Don’t dispatch UIKit mutations to background; you’ll crash
@MainActor and Swift 6
In Swift 6 strict concurrency, UIView and UIViewController subclasses are @MainActor-isolated. The UIViewRepresentable methods are also main-isolated. Things mostly Just Work, but be careful:
- Coordinator methods called from UIKit delegates are on main (since UIKit is main-actor)
- If you spawn a
Task { ... }in a delegate method that updates SwiftUI bindings, mark it@MainActoror be sure the binding mutation happens on main
In the wild
- Robinhood wraps a charting library (originally OpenGL-based) in
UIViewRepresentablefor SwiftUI screens; the new candles render in SwiftUI but the chart canvas remains UIKit. - Apollo mixed SwiftUI heavily but kept the comments thread as a
UICollectionViewfor performance reasons, embedded viaUIHostingConfiguration. - Uber has SwiftUI driver-side screens that embed
MKMapViewviaUIViewRepresentablefor full camera/annotation control. - Apple Wallet’s “Add to Wallet” flow uses
PKAddPassesViewController(UIKit) presented from SwiftUI viaUIViewControllerRepresentable. - Most production apps in 2026 have a
Bridging/folder with 5–20 representable wrappers for things SwiftUI doesn’t cover yet.
Common misconceptions
- “Wrapping UIKit always means losing SwiftUI animations.” Not necessarily —
UIViewanimations can be coordinated with SwiftUI state viaupdateUIViewandUIView.animate. But it’s manual. - “
updateUIViewis called once.” It’s called many times — on every state change observed by the wrapping SwiftUI view. Must be idempotent and cheap. - “Coordinator is for state.” It’s primarily for delegates (the UIKit object holding callbacks). It can hold state, but that state is per-coordinator instance and rebuilt across some scenarios.
- “
UIHostingControlleris heavy.” Not particularly. Embedding a SwiftUI view as a single cell is fine. Embedding 1,000 hosting controllers as cells is slow — useUIHostingConfigurationinstead. - “
@Bindingto a UIKit-driven value is enough.” Without debounce/coalesce logic, you’ll create feedback loops. Always think about who writes to the binding and when.
Seasoned engineer’s take
Treat representables as a bounded interface. Each one has:
- A clear, narrow purpose (wrap this one UIKit thing)
- A coordinator handling delegate callbacks
- Explicit feedback-loop prevention
- Documented sizing assumptions (does it need
.frame(...)?) - A
#Previewshowing it in isolation
Keep these in a dedicated Bridging/ folder. Treat them like third-party code: review carefully, add tests for the bridge behavior, and isolate from app logic.
For new code, start in SwiftUI. Drop to UIKit only when you hit a specific gap. Resist the urge to “just use the UIKit version because it’s more flexible” — you trade flexibility for the entire SwiftUI ecosystem (animations, accessibility, layout, multi-platform).
For old codebases, embed SwiftUI feature-by-feature in UIHostingController. Each new screen is SwiftUI; integration is via well-defined boundaries (push, pop, environment-shared state). Over time, the SwiftUI portion grows.
TIP: When debugging “the UIKit view isn’t updating”, check that
updateUIViewactually runs (
WARNING: Never capture
selfstrongly from a closure stored on a UIKit delegate inside a Representable’s Coordinator. Standard memory-leak pattern. Useweakor pass values explicitly.
Interview corner
Junior-level: “How do you embed a UILabel in a SwiftUI view?”
Trick question — use Text, not UILabel. SwiftUI has a native equivalent. Wrapping basic UIKit primitives is wasted effort. UIViewRepresentable is for things SwiftUI doesn’t cover (MapKit, custom drawing, third-party UIKit widgets).
Mid-level: “Walk through building a UIViewRepresentable wrapper for MKMapView with two-way region binding.”
Implement makeUIView to create and configure MKMapView; set delegate to context.coordinator. Implement updateUIView to apply state from the SwiftUI side — guarded against feedback loops (skip if change came from the user via the coordinator). Implement makeCoordinator returning a class that conforms to MKMapViewDelegate. In mapView(_:regionDidChangeAnimated:), mark isUserDriven = true, update parent.region (the binding), and reset the flag on next runloop. Without that guard, the SwiftUI side writes the region back to the map, triggering another delegate call, ad infinitum.
Senior-level: “Your app is 80% UIKit, and the team wants to start writing new features in SwiftUI. Outline the migration architecture, the boundary conventions, and how you handle shared state.”
Boundary architecture:
- Each new SwiftUI screen wrapped in
UIHostingController - Existing
UINavigationControllers push hosting controllers seamlessly (pushViewController(host, animated: true)) - Existing tab-bar controller adds SwiftUI tabs by wrapping them in hosting controllers
- For existing screens that need partial SwiftUI (e.g., a SwiftUI banner inside a UIKit list), use
UIHostingConfigurationfor cells,UIHostingControlleras a child VC for sections
Shared state:
- Migrate to an
@Observable(orObservableObject) layer for cross-feature state — auth, user, feature flags - UIKit screens hold a reference and observe via
withObservationTracking { ... }(iOS 17+) or Combine (older) and update UI manually - SwiftUI screens consume via
@Environment(Type.self)injected at hosting controller creation
Navigation:
- New screens use SwiftUI
NavigationStackonly within their own SwiftUI subgraphs - Cross-screen navigation goes through the existing UIKit nav controller (predictable, testable)
- Per-screen, the hosting controller receives a callback closure for “navigate to X”; the closure pushes the next hosting controller
Conventions:
- All bridging code in a
Bridging/module, reviewed carefully - Each
Representablehas a#Preview - Each
UIHostingControllersetup has a factory function (Screens.makeProfile()) so the construction is testable - Migration tracked in a doc — N screens UIKit, M screens SwiftUI, target % per quarter
Pitfalls handled:
- Navigation bar visibility differs between UIKit and SwiftUI — set
navigationBarHiddenper-screen, document the convention - iOS 16+
NavigationStackkeyboard-avoidance differs from UIKit — test both paths - Sheets presented from UIKit show fine in a SwiftUI hosting child but inherit the UIKit presentation style; specify modally
Red flag in candidates: Saying “we should rewrite everything in SwiftUI before adding features.” Indicates poor judgment for incremental migration.
Lab preview
The Phase 5 labs are pure SwiftUI, but Lab 5.3 (Multiplatform Notes) optionally uses NSViewRepresentable for macOS-specific behaviors (chapter 5.11 covers AppKit interop in depth).