How do you mix UIKit and SwiftUI safely?
Most production apps mix both today. The safety concerns are specific: state ownership across the bridge, lifecycle mismatches, and performance pitfalls at the boundary.
1. Embedding SwiftUI inside UIKit — UIHostingController
let swiftUIView = ProfileView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: swiftUIView)
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.frame = view.bounds
hostingController.didMove(toParent: self)
Safety concerns: size negotiation with Auto Layout (use sizingOptions/preferredContentSize for dynamic-height content); repeated UIHostingController instantiation in cells is expensive — reuse one instance and update rootView on reuse; safe area behavior can vary by embedding context.
2. Embedding UIKit inside SwiftUI — UIViewRepresentable/UIViewControllerRepresentable
struct LegacyMapView: UIViewRepresentable {
@Binding var region: MKCoordinateRegion
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
mapView.setRegion(region, animated: true)
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
final class Coordinator: NSObject, MKMapViewDelegate {
let parent: LegacyMapView
init(_ parent: LegacyMapView) { self.parent = parent }
}
}
Safety concerns: updateUIView is called far more often than expected — guard against redundant work; the Coordinator is the correct place for delegate/target-action bridging; there’s no direct SwiftUI-native equivalent to viewWillAppear/viewDidDisappear for wrapped content, so bridge appear/disappear through .onAppear/.onDisappear on the representable.
3. State ownership across the boundary — the most common real bug source
struct ContentView: View {
@State private var region = MKCoordinateRegion(...)
var body: some View {
LegacyMapView(region: $region) // UIKit reads/writes via binding, SwiftUI is source of truth
}
}
Decide clearly which side owns the source of truth. If both a UIKit delegate callback and SwiftUI @State can independently mutate the “same” data, you get hard-to-reproduce state-consistency bugs.
4. @MainActor and threading consistency across the boundary
final class Coordinator: NSObject, SomeDelegate {
func didReceiveUpdate(_ data: Data) {
Task { @MainActor in
parent.viewModel.update(data)
}
}
}
Not every UIKit delegate callback is guaranteed to run on the main thread — worth checking rather than assuming.
5. Navigation — pick one system as the source of truth, don’t run both simultaneously
SwiftUI’s NavigationStack/NavigationPath and UIKit’s UINavigationController push/pop shouldn’t both try to manage the same navigation stack independently. Most mixed apps pick one system as authoritative per flow.
6. Animation and transition coordination
SwiftUI’s animation system and UIKit’s UIView.animate/UIViewPropertyAnimator don’t automatically coordinate — anything beyond simple crossfades at the boundary often needs deliberate custom transition code.
7. Testing the boundary specifically
Keep the representable/wrapper as thin as possible — just the bridging logic — and push actual business logic into a plain, independently-testable ViewModel/service that both sides depend on.
Interview-ready summary
| Concern | Mechanism/discipline |
|---|---|
| SwiftUI inside UIKit | UIHostingController, proper child VC containment, watch cell-reuse cost |
| UIKit inside SwiftUI | UIViewRepresentable/UIViewControllerRepresentable, Coordinator for delegate bridging |
| State ownership | One clear source of truth per piece of state, not independent mutation from both sides |
| Threading | Explicit @MainActor hops for delegate callbacks not guaranteed to be on main thread |
| Navigation | Pick one navigation system as authoritative per flow, don’t run both simultaneously |
| Animation | Custom transition coordination for anything crossing the boundary beyond simple cases |
| Testability | Keep the bridging code thin; push logic into a shared, independently-testable layer |
One-liner if pressed: “I’d bridge SwiftUI into UIKit via UIHostingController with proper child-view-controller containment, and UIKit into SwiftUI via UIViewRepresentable/UIViewControllerRepresentable with a Coordinator handling delegate callbacks — the real safety discipline is picking one clear owner for any state that crosses the boundary rather than letting both frameworks mutate it independently, being deliberate about which navigation system is authoritative for a given flow, and explicitly hopping to @MainActor in delegate callbacks that aren’t guaranteed to already be on the main thread.”