UIKit / SwiftUI Interoperability: Bridging Two UI Worlds
You have spent two years building new features in SwiftUI, but the app still has screens written in UIKit — and many
components like MKMapView, ARSCNView, or UIImagePickerController have no SwiftUI equivalent. The interop layer is
not a temporary migration shim. It is a permanent part of how iOS apps are built.
This post covers UIViewRepresentable, UIViewControllerRepresentable, the Coordinator pattern for delegation, and
bidirectional data flow between SwiftUI state and UIKit views. We will not cover hosting SwiftUI inside UIKit
(UIHostingController) — that deserves its own treatment.
Contents
- The Problem
- UIViewRepresentable: Wrapping a UIKit View
- Coordinators: Handling Delegates and Actions
- UIViewControllerRepresentable: Wrapping View Controllers
- Bidirectional Data Flow
- Advanced Usage and Edge Cases
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Suppose you are building a Pixar movie location explorer. You want an interactive map where each pin represents a
real-world location that inspired a film. SwiftUI’s Map view works for simple cases, but you need custom overlay
rendering, cluster annotations, and camera animation control that only MKMapView provides.
Dropping a UIKit view into a SwiftUI hierarchy without the representable protocol leads to a dead end:
struct MovieMapScreen: View {
var body: some View {
// This does not compile. SwiftUI has no idea
// how to host a raw UIView.
MKMapView() // Error: Cannot use UIKit view directly
}
}
SwiftUI’s rendering pipeline owns the view lifecycle. It creates, updates, and tears down views on its own schedule. To
participate in that lifecycle, a UIKit view must conform to a protocol that SwiftUI controls — and that protocol is
UIViewRepresentable.
UIViewRepresentable: Wrapping a UIKit View
UIViewRepresentable has two required methods: makeUIView(context:) to create the UIKit view once, and
updateUIView(_:context:) to push SwiftUI state changes into it.
Here is a minimal wrapper that embeds an MKMapView showing Pixar studio headquarters:
import MapKit
import SwiftUI
struct PixarMapView: UIViewRepresentable {
let region: MKCoordinateRegion
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.preferredConfiguration = MKStandardMapConfiguration()
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
// Push SwiftUI state into UIKit on every state change
mapView.setRegion(region, animated: true)
}
}
Three things to notice:
makeUIViewruns once. Treat it likeviewDidLoad. Allocate the view, set configuration that never changes, and return it.updateUIViewruns on every SwiftUI state change. This is your synchronization point. Any@State,@Binding, or@Observableproperty that changes will trigger this method.- You return a concrete UIKit type, not
some View. The associated typeUIViewTypeis inferred from your return type.
You can now use PixarMapView anywhere in your SwiftUI hierarchy:
struct ContentView: View {
@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: 37.8328,
longitude: -122.2685
), // Emeryville, CA -- Pixar HQ
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
var body: some View {
PixarMapView(region: region)
.ignoresSafeArea()
}
}
Apple Docs:
UIViewRepresentable— SwiftUI
Coordinators: Handling Delegates and Actions
UIKit relies heavily on the delegate pattern. MKMapView reports annotation selections through MKMapViewDelegate,
UITextField reports edits through UITextFieldDelegate, and so on. SwiftUI has no delegates. The bridge between these
two worlds is the Coordinator.
A Coordinator is a class you create inside your representable. SwiftUI instantiates it via makeCoordinator() and
passes it to both makeUIView and updateUIView through the context parameter.
Let us extend PixarMapView to report when the user taps a movie location pin:
struct PixarMapView: UIViewRepresentable {
let region: MKCoordinateRegion
let annotations: [MovieLocation]
var onSelectLocation: (MovieLocation) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onSelectLocation: onSelectLocation)
}
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)
// Sync the coordinator's closure in case SwiftUI rebuilds it
context.coordinator.onSelectLocation = onSelectLocation
mapView.removeAnnotations(mapView.annotations)
mapView.addAnnotations(annotations)
}
// MARK: - Coordinator
final class Coordinator: NSObject, MKMapViewDelegate {
var onSelectLocation: (MovieLocation) -> Void
init(onSelectLocation: @escaping (MovieLocation) -> Void) {
self.onSelectLocation = onSelectLocation
}
func mapView(
_ mapView: MKMapView,
didSelect annotation: any MKAnnotation
) {
guard let location = annotation as? MovieLocation else {
return
}
onSelectLocation(location)
}
}
}
The Coordinator is a reference type (class) because UIKit holds a weak or unowned reference to delegates. Making it a
final class conforming to NSObject satisfies UIKit delegate protocol requirements. The key pattern here: closures
flow down from SwiftUI, delegate callbacks flow up through the Coordinator.
Tip: Always update the coordinator’s closures inside
updateUIView. SwiftUI may create new closure instances on state changes, and the coordinator must reference the latest one.
The supporting MovieLocation model looks like this:
final class MovieLocation: NSObject, MKAnnotation {
let title: String?
let subtitle: String?
let coordinate: CLLocationCoordinate2D
let filmTitle: String
init(name: String, film: String, coordinate: CLLocationCoordinate2D) {
self.title = name
self.subtitle = film
self.filmTitle = film
self.coordinate = coordinate
super.init()
}
}
UIViewControllerRepresentable: Wrapping View Controllers
Some UIKit components are view controllers, not views. UIImagePickerController, PHPickerViewController,
MFMailComposeViewController — these cannot be wrapped with UIViewRepresentable. Use
UIViewControllerRepresentable
instead. The protocol shape is nearly identical.
Here is a wrapper for UIImagePickerController that lets users pick a photo for their favorite Pixar character profile:
struct CharacterPhotoPicker: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.dismiss) private var dismiss
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
return picker
}
func updateUIViewController(
_ uiViewController: UIImagePickerController,
context: Context
) {
// No dynamic state to push for this component
}
final class Coordinator: NSObject,
UIImagePickerControllerDelegate,
UINavigationControllerDelegate
{
let parent: CharacterPhotoPicker
init(parent: CharacterPhotoPicker) {
self.parent = parent
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info:
[UIImagePickerController.InfoKey: Any]
) {
parent.selectedImage = info[.originalImage] as? UIImage
parent.dismiss()
}
func imagePickerControllerDidCancel(
_ picker: UIImagePickerController
) {
parent.dismiss()
}
}
}
Notice the Coordinator holds a reference to parent — the representable struct itself. This gives the coordinator
access to @Binding properties and the @Environment(\.dismiss) action so it can write data back to SwiftUI and
dismiss the modal. This is the standard pattern Apple demonstrates in their own sample code.
Present it with a sheet:
struct CharacterProfileView: View {
@State private var profileImage: UIImage?
@State private var showingPicker = false
var body: some View {
VStack {
if let profileImage {
Image(uiImage: profileImage)
.resizable()
.scaledToFit()
.frame(height: 200)
}
Button("Choose Photo for Woody") {
showingPicker = true
}
}
.sheet(isPresented: $showingPicker) {
CharacterPhotoPicker(selectedImage: $profileImage)
}
}
}
Apple Docs:
UIViewControllerRepresentable— SwiftUI
Bidirectional Data Flow
The interop layer supports data flowing in both directions:
- SwiftUI to UIKit: Properties on the representable struct, read inside
updateUIView. Every time SwiftUI state changes, this method fires and you push the new values into UIKit. - UIKit to SwiftUI:
@Bindingproperties written from the Coordinator, or closures invoked by the Coordinator when a delegate callback fires.
Here is a text field wrapper that demonstrates both directions. The SwiftUI parent controls the text value, and the UIKit text field reports real-time changes back:
struct PixarSearchField: UIViewRepresentable {
@Binding var text: String
var placeholder: String = "Search Pixar films..."
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.placeholder = placeholder
textField.borderStyle = .roundedRect
textField.addTarget(
context.coordinator,
action: #selector(Coordinator.textDidChange(_:)),
for: .editingChanged
)
return textField
}
func updateUIView(_ textField: UITextField, context: Context) {
// Only update if the values diverge to avoid cursor jumps
if textField.text != text {
textField.text = text
}
}
final class Coordinator: NSObject {
var text: Binding<String>
init(text: Binding<String>) {
self.text = text
}
@objc func textDidChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
}
}
}
Warning: Always guard against unnecessary updates in
updateUIView. SettingtextField.textunconditionally on every state change resets the cursor position and breaks the editing experience. Theif textField.text != textcheck is not optional — it is required for correctness.
Understanding the Update Cycle
The flow works like a loop:
- User types in the
UITextField. - The target-action fires
textDidChange, which writes totext.wrappedValue. - The
@Bindingwrite triggers a SwiftUI state change. - SwiftUI calls
updateUIViewwith the new state. - The guard (
if textField.text != text) prevents a redundant write back into UIKit.
This cycle is the heartbeat of every representable. If you skip step 5, you risk infinite update loops or stale UI.
Advanced Usage and Edge Cases
Sizing with sizeThatFits
By default, representable views expand to fill available space. Starting with iOS 16, you can override
sizeThatFits(_:uiView:context:) to propose an intrinsic size:
@available(iOS 16.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: MKMapView,
context: Context
) -> CGSize? {
// Return nil to use SwiftUI's default layout,
// or provide a concrete size
return CGSize(width: proposal.width ?? 300, height: 250)
}
This is especially useful for self-sizing UIKit views like UILabel or custom views with intrinsic content size.
Returning nil defers to SwiftUI’s default sizing behavior.
Apple Docs:
sizeThatFits(_:uiView:context:)— SwiftUI
Handling dismantleUIView
When SwiftUI removes a representable from the hierarchy, it calls the static method dismantleUIView(_:coordinator:).
Use it to clean up observers, timers, or expensive resources:
static func dismantleUIView(
_ mapView: MKMapView,
coordinator: Coordinator
) {
mapView.removeAnnotations(mapView.annotations)
mapView.delegate = nil
}
Most representables do not need this. UIKit’s own deallocation handles cleanup in the common case. Implement it when you
have registered NotificationCenter observers or started background work tied to the view’s lifetime.
Transaction and Animation Bridging
The context parameter passed to updateUIView carries a transaction property. You can read it to detect whether
SwiftUI is animating the current state change and bridge that into a UIKit animation:
func updateUIView(_ mapView: MKMapView, context: Context) {
let animated = context.transaction.animation != nil
mapView.setRegion(region, animated: animated)
}
This preserves animation intent across the SwiftUI/UIKit boundary. When the parent view wraps a state change in
withAnimation, your UIKit view responds accordingly.
Performance Considerations
The representable protocol imposes minimal overhead. makeUIView runs once, and updateUIView runs only when SwiftUI
detects a state change. That said, there are traps:
- Avoid expensive work in
updateUIView. This method can be called frequently. Diff the incoming state against a cached value before performing costly operations like removing and re-adding all annotations. - Do not allocate views in
updateUIView. All view creation belongs inmakeUIView. Creating subviews on every update leaks memory and tanks scroll performance. - Use
@Statejudiciously. If your parent view has unrelated@Stateproperties, any change to them triggersupdateUIViewon every representable in that view body. Extract the representable into its own sub-view with only the state it needs.
Consider the annotation-syncing pattern from earlier. A naive implementation removes and re-adds every annotation on every update. A smarter approach diffs:
func updateUIView(_ mapView: MKMapView, context: Context) {
let existingSet = Set(
mapView.annotations.compactMap { $0 as? MovieLocation }
)
let newSet = Set(annotations)
let toRemove = existingSet.subtracting(newSet)
let toAdd = newSet.subtracting(existingSet)
mapView.removeAnnotations(Array(toRemove))
mapView.addAnnotations(Array(toAdd))
}
This drops annotation churn from O(n) remove-and-add to O(delta), which matters when your map shows hundreds of Pixar filming locations.
Tip: Profile representable update frequency with the SwiftUI Instruments template. The “View Body” instrument shows how often
updateUIViewfires. See WWDC 2023: Analyze SwiftUI performance for a walkthrough.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| No SwiftUI equivalent exists | Use UIViewRepresentable. |
| Presenting a UIKit view controller | Use UIViewControllerRepresentable. |
| Simple text input or label | Prefer native SwiftUI views. |
| Complex UIKit screen with navigation | Use UIHostingController instead. |
| Need delegate callbacks | Use a Coordinator. |
| Cell reuse in collection views | Use UIHostingConfiguration. |
No SwiftUI equivalent exists — components like MKMapView, ARSCNView, and AVPlayerViewController require
UIViewRepresentable or UIViewControllerRepresentable. This is the primary use case.
Presenting a UIKit view controller — UIImagePickerController and MFMailComposeViewController need
UIViewControllerRepresentable.
Simple text input or label — prefer native SwiftUI TextField or Text. The interop overhead is not worth it for
components SwiftUI handles well.
Complex UIKit screen with navigation — consider UIHostingController in the other direction instead, embedding
SwiftUI into your UIKit navigation stack.
Need delegate callbacks — use a Coordinator. Do not try to set delegates directly from the SwiftUI view body.
Cell reuse in collection views — the representable approach does not participate in UIKit’s cell reuse. For complex
lists, keep the entire list in UIKit and embed SwiftUI content per-cell with
UIHostingConfiguration (iOS 16+).
The interop layer is not a migration tool meant to be discarded. Many production apps will maintain representable wrappers indefinitely for components Apple has not ported to SwiftUI. Treat these wrappers as first-class citizens in your codebase: test them, document their state contracts, and version them alongside your SwiftUI views.
Note: WWDC 2022’s Use SwiftUI with UIKit session remains the definitive walkthrough of the representable lifecycle. WWDC 2023’s Demystify SwiftUI performance covers how representable updates interact with the SwiftUI rendering pipeline.
Summary
UIViewRepresentablewraps a UIKit view for use in SwiftUI. ImplementmakeUIViewto create it once andupdateUIViewto synchronize state.UIViewControllerRepresentabledoes the same for view controllers, essential for components like image pickers and mail composers.- The Coordinator pattern bridges UIKit delegation back to SwiftUI through closures or
@Bindingwrites. - Always guard against redundant updates in
updateUIViewto prevent cursor resets, animation glitches, and unnecessary work. - Diff state changes instead of brute-forcing full refreshes for performance-sensitive representables.
If your codebase is preparing for UIKit’s evolution in iOS 26, read
UIKit in iOS 26: Liquid Glass Variants and @Observable Integration for what changes when
UIKit gains native @Observable support. For apps still on the AppDelegate lifecycle,
UIKit Scene Lifecycle covers the migration path.