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

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:

  1. makeUIView runs once. Treat it like viewDidLoad. Allocate the view, set configuration that never changes, and return it.
  2. updateUIView runs on every SwiftUI state change. This is your synchronization point. Any @State, @Binding, or @Observable property that changes will trigger this method.
  3. You return a concrete UIKit type, not some View. The associated type UIViewType is 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: @Binding properties 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. Setting textField.text unconditionally on every state change resets the cursor position and breaks the editing experience. The if textField.text != text check is not optional — it is required for correctness.

Understanding the Update Cycle

The flow works like a loop:

  1. User types in the UITextField.
  2. The target-action fires textDidChange, which writes to text.wrappedValue.
  3. The @Binding write triggers a SwiftUI state change.
  4. SwiftUI calls updateUIView with the new state.
  5. 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 in makeUIView. Creating subviews on every update leaks memory and tanks scroll performance.
  • Use @State judiciously. If your parent view has unrelated @State properties, any change to them triggers updateUIView on 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 updateUIView fires. See WWDC 2023: Analyze SwiftUI performance for a walkthrough.

When to Use (and When Not To)

ScenarioRecommendation
No SwiftUI equivalent existsUse UIViewRepresentable.
Presenting a UIKit view controllerUse UIViewControllerRepresentable.
Simple text input or labelPrefer native SwiftUI views.
Complex UIKit screen with navigationUse UIHostingController instead.
Need delegate callbacksUse a Coordinator.
Cell reuse in collection viewsUse 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 controllerUIImagePickerController 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

  • UIViewRepresentable wraps a UIKit view for use in SwiftUI. Implement makeUIView to create it once and updateUIView to synchronize state.
  • UIViewControllerRepresentable does 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 @Binding writes.
  • Always guard against redundant updates in updateUIView to 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.