UIViewRepresentable and UIViewControllerRepresentable: SwiftUI + UIKit Interop in iOS 26
A production-ready guide to bridging SwiftUI and UIKit in iOS 26: UIViewRepresentable, UIViewControllerRepresentable, coordinators, UIHostingController, sizing, and the pitfalls that trip up mixed codebases.
UIViewRepresentable is the SwiftUI protocol that lets you wrap a UIKit UIView subclass so it can be used inside a SwiftUI view hierarchy; its sibling UIViewControllerRepresentable does the same for a UIViewController. In iOS 26 with Swift 6, both protocols still handle the hard parts (lifecycle, layout negotiation, state synchronization) through makeUIView/updateUIView or their controller equivalents, plus an optional Coordinator that owns delegate callbacks and target-action selectors. I've shipped four apps that lean heavily on this bridge, and the patterns below are the ones that actually survive Xcode 26's stricter concurrency checks.
Use UIViewRepresentable to wrap a single UIView (like MKMapView or PDFView); use UIViewControllerRepresentable when you need a full controller lifecycle, navigation, or view containment.
The Coordinator is a plain reference-type object created by makeCoordinator(). It survives view updates and is where every delegate, target-action, and gesture callback belongs.
Never mutate SwiftUI @State or a @Binding directly from updateUIView. Route UIKit-originated changes back through the coordinator to avoid the "Modifying state during view update" runtime warning.
UIHostingController is the reverse bridge. It hosts a SwiftUI View inside a UIKit view hierarchy and is the recommended way to add SwiftUI screens to an existing UIKit app.
Override sizeThatFits(_:uiView:context:) in iOS 16+ to give SwiftUI's layout system an accurate intrinsic size instead of relying on Auto Layout guessing.
Under Swift 6 strict concurrency, mark representable methods as @MainActor-safe and treat the coordinator as main-actor bound (it always is in practice).
What is UIViewRepresentable in SwiftUI?
UIViewRepresentable is a SwiftUI protocol, defined in the SwiftUI framework and refined in iOS 16, that gives you a formal contract for embedding a single UIView subclass into the SwiftUI layout tree. When SwiftUI encounters a representable value, it does three things: it calls makeUIView(context:) once to create the underlying view, calls updateUIView(_:context:) whenever the surrounding SwiftUI state changes, and eventually calls dismantleUIView(_:coordinator:) when the view leaves the hierarchy. That lifecycle is what makes the bridge safe: SwiftUI owns the view's placement, but you own its behavior.
In iOS 26 the protocol still requires only two methods, but Apple added a few refinements worth calling out. First, the associated UIViewType is inferred automatically from your makeUIView return type, so the explicit typealias is optional. Second, the Context struct now carries a strongly typed environment that respects new @Entry keys (see the SwiftUI @Entry macro guide for how to expose your own values). Finally, the layout system now prefers sizeThatFits(_:uiView:context:) over Auto Layout's intrinsicContentSize, which fixes the classic "my wrapped view collapses to zero" bug.
Honestly, the mental model is simpler than the API suggests. Treat your representable as a stateless function of SwiftUI state that returns a UIKit view. Any state that must live between updates (a delegate, a cancellable, a gesture recognizer target) goes on the coordinator, not the struct.
UIViewRepresentable vs UIViewControllerRepresentable
Both protocols exist because UIKit itself has two distinct hierarchies. A raw UIView handles drawing and touch, while a UIViewController owns lifecycle, presentation, containment, and navigation. Picking the wrong one produces subtle bugs: a controller wrapped as a view loses its viewWillAppear callbacks; a view forced into a controller adds an unnecessary layer and a phantom safe-area inset.
Dimension
UIViewRepresentable
UIViewControllerRepresentable
Wraps
A single UIView subclass
A UIViewController subclass
Lifecycle callbacks
None (just make/update/dismantle)
Full viewWillAppear, viewDidDisappear, etc.
Best for
Custom drawing, text views, maps, camera preview layers
The rule of thumb I use: if UIKit's own API returns the object from a UIViewController subclass, wrap it with UIViewControllerRepresentable. That covers every system picker and share sheet you'll ever need. Anything you build yourself, like a custom drawing canvas, a hardware-accelerated video preview, or an MTKView, should be a UIView and use UIViewRepresentable. When in doubt, prefer the view flavor: it's cheaper and interacts better with the SwiftUI layout system covered in the custom Layout protocol guide.
How the Coordinator pattern works
Struct-based SwiftUI values are recreated on every render, so they can't serve as delegates, targets, or observer objects. UIKit expects a stable reference-type instance. The Coordinator is SwiftUI's answer. You implement makeCoordinator(), return an instance of a nested class, and SwiftUI stores it in the Context for the lifetime of the underlying view. That single instance is where every UIKit callback that needs to talk back to SwiftUI lives.
The canonical shape looks like this:
import SwiftUI
import UIKit
struct SearchField: UIViewRepresentable {
@Binding var text: String
var onSubmit: (String) -> Void = { _ in }
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIView(context: Context) -> UISearchBar {
let searchBar = UISearchBar()
searchBar.delegate = context.coordinator
searchBar.searchBarStyle = .minimal
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
// Keep the coordinator in sync with the latest struct instance,
// otherwise closures capture a stale copy.
context.coordinator.parent = self
if uiView.text != text {
uiView.text = text
}
}
@MainActor
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchField
init(parent: SearchField) {
self.parent = parent
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
parent.onSubmit(searchBar.text ?? "")
searchBar.resignFirstResponder()
}
}
}
Three details matter here. First, the coordinator stores a mutable reference to the parent struct, and updateUIView re-assigns it every time SwiftUI renders. That's the trick that keeps @Binding writes and closure captures fresh. Second, the coordinator is marked @MainActor because every UIKit delegate call arrives on the main thread anyway; being explicit satisfies Swift 6's strict concurrency checks without adding runtime overhead. Third, notice that updateUIView compares before assigning. Writing an already-current value back to UISearchBar would fight the user's cursor position and cause visible flicker (I hit this exact bug shipping a search UI in 2024).
Wrapping a UIKit view: a real PDFView example
PDFKit's PDFView has no SwiftUI equivalent, and it's the example I hit most often in real work (internal tools, reader apps, invoicing screens). Here's a complete, runnable wrapper that supports document loading, page navigation, and two-way sync with a SwiftUI binding.
import SwiftUI
import PDFKit
struct PDFReader: UIViewRepresentable {
let document: PDFDocument
@Binding var currentPageIndex: Int
var displayMode: PDFDisplayMode = .singlePageContinuous
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.document = document
view.displayMode = displayMode
view.autoScales = true
view.usePageViewController(false)
NotificationCenter.default.addObserver(
context.coordinator,
selector: #selector(Coordinator.pageChanged(_:)),
name: .PDFViewPageChanged,
object: view
)
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
context.coordinator.parent = self
if uiView.document !== document {
uiView.document = document
}
if uiView.displayMode != displayMode {
uiView.displayMode = displayMode
}
// Sync SwiftUI -> UIKit page index without triggering the observer loop.
if let target = document.page(at: currentPageIndex),
uiView.currentPage != target {
uiView.go(to: target)
}
}
static func dismantleUIView(_ uiView: PDFView, coordinator: Coordinator) {
NotificationCenter.default.removeObserver(coordinator)
}
@MainActor
final class Coordinator: NSObject {
var parent: PDFReader
init(parent: PDFReader) {
self.parent = parent
}
@objc func pageChanged(_ notification: Notification) {
guard let view = notification.object as? PDFView,
let page = view.currentPage,
let index = view.document?.index(for: page),
index != parent.currentPageIndex else { return }
parent.currentPageIndex = index
}
}
}
The dismantleUIView hook is critical here. Without it, the notification observer would leak past the view's lifetime and trigger callbacks against a torn-down coordinator. Every representable that uses NotificationCenter, KVO, or gesture recognizer targets should implement this static method. See the official UIViewRepresentable documentation for the full lifecycle diagram.
How do you pass data from SwiftUI to UIKit?
Data flows in two directions, and each direction has one correct channel. SwiftUI-to-UIKit runs through the representable's stored properties: put whatever you want the UIKit view to reflect on the struct as a regular let, a @Binding, or a callback closure, then read it inside updateUIView. Because SwiftUI diffs the struct on every render, any property change automatically triggers an update pass without you writing observers.
UIKit-to-SwiftUI is the reverse: the coordinator receives the delegate or notification callback, then writes back through a @Binding or invokes a stored closure. The write happens on the coordinator's main-actor context, which SwiftUI schedules for the next runloop tick. This asymmetry is intentional; it prevents mutation loops.
For long-lived, cross-view state you should still prefer the modern @Observable approach documented in the SwiftUI @Observable macro guide, and let the representable read from the environment. Concretely: mark your store as @Observable, inject it once at the app root, then grab it inside the representable's Context.environment. That keeps the UIKit layer out of the observation graph while still reacting to updates.
Using UIHostingController to embed SwiftUI in UIKit
UIHostingController is the inverse of UIViewControllerRepresentable. Given a SwiftUI View, it produces a UIViewController you can push, present, or add as a child in any UIKit navigation flow. This is the recommended path for teams migrating a UIKit app one screen at a time.
import SwiftUI
import UIKit
final class LegacyContainer: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let profile = ProfileScreen(userId: currentUserId)
let host = UIHostingController(rootView: profile)
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
host.didMove(toParent: self)
// iOS 16+: opt into SwiftUI-driven safe-area behavior.
host.safeAreaRegions = []
// iOS 26: opt into SwiftUI background color propagation.
host.sizingOptions = [.preferredContentSize, .intrinsicContentSize]
}
}
Three iOS 26 refinements matter. sizingOptions tells UIKit which sizes the SwiftUI content should broadcast up the responder chain. Use .preferredContentSize for popovers and half-sheet presentations, and .intrinsicContentSize when the hosting controller lives inside a UIStackView. safeAreaRegions lets you cede safe-area handling entirely to SwiftUI, which prevents the double-inset bug that plagued iOS 14 through 15. And in iOS 26 you can pass any View that uses new modifiers like glassEffect (see the Liquid Glass tutorial) and they work correctly inside the hosted layer.
Sizing, layout, and sizeThatFits in iOS 26
The single most common complaint about representable views is "it collapses to zero" or "it takes the whole screen." Both stem from the same cause: SwiftUI doesn't know your UIKit view's intrinsic size. Before iOS 16 you had to fight this with setContentHuggingPriority and layout constraints. Since iOS 16, the fix is a single optional method:
Returning nil tells SwiftUI to fall back to Auto Layout. That's the right call for views that already have a well-defined intrinsicContentSize, like UISwitch or UISegmentedControl. Return an explicit CGSize for views like UILabel, UITextView, or MKMapView that either have no intrinsic size or size differently depending on the proposal. The ProposedViewSize struct optionally carries nil width or height, which signals "I have no constraint in that axis" (treat it as infinity for wrapping calculations).
Swift 6 concurrency, MainActor, and iOS 26 gotchas
Swift 6's strict concurrency mode, covered end-to-end in the Swift 6.2 approachable concurrency guide, has real consequences for representable code. All three protocol methods are already isolated to @MainActor, so you can call any UIKit API from them without annotation. The coordinator, however, is a class you define, and Swift 6 won't assume it's main-actor-bound unless you say so. Two options work: annotate the coordinator class with @MainActor, or make it inherit from NSObject and let the delegate protocols do the isolation for you.
The subtle gotcha is capturing self in an async closure inside the coordinator. If you spawn a Task from a delegate method, that task inherits the main actor, which is what you want. But if you hand a completion handler to a background API and then update SwiftUI state, you must jump back to @MainActor explicitly with await MainActor.run, or cleaner, mark the coordinator method itself @MainActor. The compiler will tell you when you get it wrong.
One iOS 26 change worth flagging: the Context.transaction now surfaces the animation curve set by the enclosing SwiftUI view. If you want your wrapped UIKit view to participate in the same animation, read context.transaction.animation in updateUIView and pass its duration to UIView.animate. See Apple's UIViewControllerRepresentable reference for the analogous methods on the controller side.
Common pitfalls and best practices
After enough hours in mixed codebases, the same seven mistakes keep coming up:
Recreating the wrapped view in updateUIView. The whole point of makeUIView being separate is that it runs once. Any let view = SomeUIKitView() inside updateUIView is a bug.
Setting the coordinator's parent in makeUIView but not updateUIView. The struct is reconstructed on every render; if you cache the first one, closures fire with stale bindings.
Forgetting dismantleUIView for observers. Notification and KVO subscriptions outlive the view otherwise, and the coordinator gets messaged after deallocation.
Writing bindings without comparing. An unconditional parent.text = newValue in a text change callback fights the user's typing on slow devices; guard with !=.
Using UIViewControllerRepresentable for a plain UIView. That wraps your view in a controller purely to satisfy the API. It doubles the layer count and confuses layout.
Not returning sizeThatFits. If your view collapses to zero, this is almost always the reason.
Storing the coordinator on the struct. The coordinator lives in the Context. Trying to hold a reference in the struct produces two coordinators and unpredictable delegate routing.
Beyond avoiding those, the habits that scale are: keep representables as thin as possible, treat them as UIKit-to-SwiftUI adapters rather than logic containers, and put all business behavior in an @Observable store the parent SwiftUI view drives. Combined with the NavigationStack routing guide, this pattern lets you migrate a UIKit app one screen at a time without the SwiftUI portion ever depending on UIKit imports. For the underlying protocol declarations, Apple's UIHostingController documentation is the authoritative reference to keep pinned in a browser tab.
Frequently Asked Questions
Can you use SwiftUI in UIKit?
Yes. Wrap any SwiftUI View in a UIHostingController and add it as a child controller, push it onto a UINavigationController, or present it modally. This is the officially recommended path for teams incrementally migrating a UIKit app to SwiftUI; you can do it one screen at a time without rewriting the surrounding navigation stack.
What is the difference between UIViewRepresentable and UIViewControllerRepresentable?
UIViewRepresentable wraps a single UIView subclass and gives you make/update/dismantle hooks. UIViewControllerRepresentable wraps a UIViewController and additionally exposes the full controller lifecycle, including viewWillAppear, viewDidDisappear, presentation, and child-controller containment. Use the view variant for drawing and single-widget components; use the controller variant for anything that manages navigation, presentation, or system pickers.
Does UIViewRepresentable work with Swift 6 strict concurrency?
Yes. Both protocols are already isolated to @MainActor in iOS 26, so your makeUIView and updateUIView implementations can call UIKit APIs freely. You do, however, need to annotate your Coordinator class with @MainActor (or make it an NSObject conforming to a UIKit delegate protocol) so the compiler knows callbacks arrive on the main actor.
Why does my UIViewRepresentable collapse to zero size?
The wrapped view has no intrinsic content size and no explicit sizing hook, so SwiftUI has no way to know how large it wants to be. Implement sizeThatFits(_:uiView:context:) and return a CGSize based on the proposal, or apply .frame(width:height:) in SwiftUI. Returning nil falls back to Auto Layout; use that only if the wrapped view has a working intrinsicContentSize.
When should I use UIHostingController instead of UIViewControllerRepresentable?
Use UIHostingController when the outer container is UIKit and you want to embed SwiftUI, for example, adding a SwiftUI screen to a UINavigationController in a legacy app. Use UIViewControllerRepresentable for the reverse: the outer container is SwiftUI and you need to embed an existing UIKit controller like PHPickerViewController. They're complementary, not interchangeable.
SwiftUI @FocusState binds view focus to a value. Learn iOS 26 patterns for keyboard toolbars, focus chains, sheet dismissals, OTP inputs, and hardware keyboard support with working code.
The SwiftUI @Entry macro replaces the classic EnvironmentKey boilerplate with a single line. Here's how to use it for EnvironmentValues, FocusedValues, Transaction, and ContainerValues in iOS 26 apps.
How to use SwiftUI PhotosPicker in iOS 26 for multi-select, video filtering, and file-based loading. Covers race conditions, iCloud progress, and VoiceOver.