How to install swiftui-uikit-interop
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-uikit-interopFull instructions (SKILL.md)
Source of truth, from dpearson2699/swift-ios-skills.
name: swiftui-uikit-interop description: "Bridges UIKit and SwiftUI with UIViewRepresentable, UIViewControllerRepresentable, UIHostingController, UIHostingConfiguration, coordinator delegates, and UIKit automatic observation tracking for shared @Observable state. Use when wrapping UIKit-only or third-party UIKit views/controllers in SwiftUI, embedding SwiftUI in UIKit, integrating mail/share/document/PDF/text-view surfaces, or migrating UIKit apps to SwiftUI incrementally."
SwiftUI-UIKit Interop
Bridge UIKit and SwiftUI in both directions. Wrap UIKit views and view controllers for use in SwiftUI, embed SwiftUI views inside UIKit screens, and synchronize state across the boundary. Targets iOS 26+ with Swift 6.3 patterns; notes backward-compatible to iOS 16 unless stated otherwise.
See references/representable-recipes.md for complete wrapping recipes and references/hosting-migration.md for UIKit-to-SwiftUI migration patterns.
Contents
- UIViewRepresentable Protocol
- UIViewControllerRepresentable Protocol
- The Coordinator Pattern
- UIHostingController
- Sizing and Layout
- State Synchronization Patterns
- UIKit Automatic Observation Tracking
- Sendable Considerations
- Common Mistakes
- Review Checklist
- References
UIViewRepresentable Protocol
Use UIViewRepresentable to wrap any UIView subclass for use in SwiftUI.
Required Methods
struct WrappedTextView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
// Called ONCE when SwiftUI inserts this view into the hierarchy.
// Create and return the UIKit view. One-time setup goes here.
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = .preferredFont(forTextStyle: .body)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
// Called on EVERY SwiftUI state change that affects this view.
// Synchronize SwiftUI state into the UIKit view.
// Guard against redundant updates to avoid loops.
if uiView.text != text {
uiView.text = text
}
}
}
Lifecycle Timing
| Method | When Called | Purpose |
|---|---|---|
makeCoordinator() | Before makeUIView. Once per representable lifetime. | Create the delegate/datasource reference type. |
makeUIView(context:) | Once, when the representable enters the view tree. | Allocate and configure the UIKit view. |
updateUIView(_:context:) | Immediately after makeUIView, then on every relevant state change. | Push SwiftUI state into the UIKit view. |
dismantleUIView(_:coordinator:) | When the representable is removed from the view tree. | Clean up observers, timers, subscriptions. |
sizeThatFits(_:uiView:context:) | During layout, when SwiftUI needs the view's ideal size. iOS 16+. | Return a custom size proposal. |
Why updateUIView is the most important method: SwiftUI calls it every time any @Binding, @State, @Environment, or @Observable property read by the representable changes. All state synchronization from SwiftUI to UIKit happens here. If you skip a property, the UIKit view will fall out of sync.
Optional: dismantleUIView
static func dismantleUIView(_ uiView: UITextView, coordinator: Coordinator) {
// Remove observers, invalidate timers, cancel subscriptions.
// The coordinator is passed in so you can access state stored on it.
coordinator.cancellables.removeAll()
}
Optional: sizeThatFits (iOS 16+)
@available(iOS 16.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: UITextView,
context: Context
) -> CGSize? {
// Return nil to fall back to UIKit's intrinsicContentSize.
// Return a CGSize to override SwiftUI's sizing for this view.
let width = proposal.width ?? UIView.layoutFittingExpandedSize.width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
UIViewControllerRepresentable Protocol
Use UIViewControllerRepresentable to wrap a UIViewController subclass -- typically for system pickers, document scanners, mail compose, or any controller that presents modally.
struct DocumentScannerView: UIViewControllerRepresentable {
@Binding var scannedImages: [UIImage]
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let scanner = VNDocumentCameraViewController()
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {
// Usually empty for modal controllers -- nothing to push from SwiftUI.
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
}
Handling Results from Presented Controllers
The coordinator captures delegate callbacks and routes results back to SwiftUI through the parent's @Binding or closures:
extension DocumentScannerView {
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
let parent: DocumentScannerView
init(_ parent: DocumentScannerView) { self.parent = parent }
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
parent.scannedImages = (0..<scan.pageCount).map { scan.imageOfPage(at: $0) }
parent.dismiss()
}
func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
parent.dismiss()
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
parent.dismiss()
}
}
}
The Coordinator Pattern
Why Coordinators Exist
UIKit delegates, data sources, and target-action patterns require a reference type (class). SwiftUI representable structs are value types and cannot serve as delegates. The Coordinator is a class instance that SwiftUI creates and manages for you -- it lives as long as the representable view.
Structure
Always nest the Coordinator inside the representable or in an extension. Store a reference to parent (the representable struct) so the coordinator can write back to @Binding properties.
struct SearchBarView: UIViewRepresentable {
@Binding var text: String
var onSearch: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UISearchBar {
let bar = UISearchBar()
bar.delegate = context.coordinator // Set delegate HERE, not in updateUIView
return bar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
context.coordinator.parent = self
if uiView.text != text {
uiView.text = text
}
}
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
parent.onSearch(parent.text)
searchBar.resignFirstResponder()
}
}
}
Key Rules
-
Set the delegate in
makeUIView/makeUIViewController, never inupdateUIView. The update method can run many times for state changes affecting the represented view -- setting the delegate there causes redundant assignment and can trigger unexpected side effects. -
Refresh copied parent state yourself. If the coordinator stores the representable in a
var parent, assigncontext.coordinator.parent = selfat the start ofupdateUIVieworupdateUIViewController. Bindings still point at their source of truth, but closures and non-binding values are copied into the coordinator. -
Use
[weak coordinator]in closures to avoid retain cycles between the coordinator and UIKit objects that capture it.
UIHostingController
Embed SwiftUI views inside UIKit view controllers using UIHostingController.
Basic Embedding
final class ProfileViewController: UIViewController {
private let hostingController = UIHostingController(rootView: ProfileView())
override func viewDidLoad() {
super.viewDidLoad()
// 1. Add as child
addChild(hostingController)
// 2. Add and constrain the view
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
// 3. Notify the child
hostingController.didMove(toParent: self)
}
}
The three-step sequence (addChild, add view, didMove) is mandatory. Skipping any step causes containment callbacks to misfire, which breaks appearance transitions and trait propagation.
Sizing Options (iOS 16+)
@available(iOS 16.0, *)
hostingController.sizingOptions = [.intrinsicContentSize]
| Option | Effect |
|---|---|
.intrinsicContentSize | The hosting controller's view reports its SwiftUI content size as intrinsicContentSize. Use in Auto Layout when the hosted view should size itself. |
.preferredContentSize | Updates preferredContentSize to match SwiftUI content. Use when presenting as a popover or form sheet. |
Updating the Root View
When data changes in UIKit, push new state into the hosted SwiftUI view:
func updateProfile(_ profile: Profile) {
hostingController.rootView = ProfileView(profile: profile)
}
For observable models, pass an @Observable object and SwiftUI tracks changes automatically -- no need to reassign rootView.
UIHostingConfiguration (iOS 16+)
Render SwiftUI content directly inside UICollectionViewCell or UITableViewCell without managing a child hosting controller:
@available(iOS 16.0, *)
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentConfiguration = UIHostingConfiguration {
ItemRow(item: items[indexPath.item])
}
return cell
}
Sizing and Layout
intrinsicContentSize Bridging
UIKit views wrapped in UIViewRepresentable communicate their natural size to SwiftUI through intrinsicContentSize. SwiftUI respects this during layout unless overridden by frame() or fixedSize().
SwiftUI-Owned Geometry
SwiftUI owns the represented view's center, bounds, frame, and transform. Do not set those properties directly on the uiView in makeUIView or updateUIView. Use sizeThatFits, intrinsic content size, SwiftUI layout modifiers, or layout code inside a custom UIKit subview for internal sublayers.
fixedSize() and frame() Interactions
| SwiftUI Modifier | Effect on Representable |
|---|---|
| No modifier | SwiftUI uses intrinsicContentSize as ideal size; the view is flexible. |
.fixedSize() | Forces the representable to its ideal (intrinsic) size in both axes. |
.fixedSize(horizontal: true, vertical: false) | Fixes width to intrinsic; height remains flexible. |
.frame(width:height:) | Overrides the proposed size; UIKit view receives this size. |
Auto Layout with UIHostingController
When embedding UIHostingController as a child, pin its view with constraints. Use .sizingOptions = [.intrinsicContentSize] so Auto Layout can query the SwiftUI content's natural size for self-sizing cells or variable-height sections.
State Synchronization Patterns
@Binding: Two-Way Sync (SwiftUI <-> UIKit)
Use @Binding when both sides read and write the same value. The coordinator writes to parent.bindingProperty in delegate callbacks; updateUIView reads the binding and pushes it into the UIKit view.
// SwiftUI -> UIKit: in updateUIView
if uiView.text != text { uiView.text = text }
// UIKit -> SwiftUI: in Coordinator delegate method
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}
Closures: One-Way Events (UIKit -> SwiftUI)
For fire-and-forget events (button tapped, search submitted, scan completed), pass a closure instead of a binding:
struct WebViewWrapper: UIViewRepresentable {
let url: URL
var onNavigationFinished: ((URL) -> Void)?
}
Environment Values
Access SwiftUI environment values inside representable methods via context.environment:
func updateUIView(_ uiView: UITextView, context: Context) {
let isEnabled = context.environment.isEnabled
uiView.isEditable = isEnabled
// Respond to color scheme changes
let colorScheme = context.environment.colorScheme
uiView.backgroundColor = colorScheme == .dark ? .systemGray6 : .white
}
Avoiding Update Loops
updateUIView is called when SwiftUI has new state for the represented view -- including changes triggered by the coordinator writing to a @Binding. Guard against redundant updates to prevent infinite loops:
func updateUIView(_ uiView: UITextView, context: Context) {
// GUARD: Only update if values actually differ
if uiView.text != text {
uiView.text = text
}
}
Without the guard, setting uiView.text may trigger the delegate's textViewDidChange, which writes to parent.text, which triggers updateUIView again.
UIKit Automatic Observation Tracking
For UIKit screens that share an @Observable model with SwiftUI, keep the screen UIKit and read observed state from UIKit's tracked update hooks:
- iOS 26+: use
updateProperties()for labels, colors, visibility, enabled state, and other non-layout UI; use layout hooks for geometry; use cell configuration update handlers for cells. - iOS 18: automatic UIKit tracking requires
UIObservationTrackingEnabledinInfo.plist. - iOS 17:
@Observableexists, but UIKit automatic observation tracking is not available. ManualwithObservationTrackingis one-shot; do not build polling loops around it. - iOS 15-16 or existing
ObservableObject: use CombineobjectWillChange, delegates, notifications, or explicit callbacks.
See references/hosting-migration.md for migration patterns.
Sendable Considerations
UIKit delegate protocols are not Sendable. When the coordinator conforms to a UIKit delegate, it inherits main-actor isolation from UIKit. Mark coordinators @MainActor or use nonisolated only for methods that truly do not touch UIKit state. In Swift 6 strict concurrency:
@MainActor
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
// Delegate methods are main-actor-isolated -- safe to access UIKit and @Binding.
}
If passing closures across isolation boundaries, ensure they are @Sendable or captured on the correct actor.
Common Mistakes
DO / DON'T
DON'T: Create the UIKit view in updateUIView.
DO: Create the view once in makeUIView; only configure/update it in updateUIView.
Why: updateUIView can run many times. Creating a new view each time destroys all UIKit state (selection, scroll position, first responder) and leaks memory.
DON'T: Set delegates in updateUIView.
DO: Set delegates in makeUIView/makeUIViewController only.
Why: Redundant delegate assignment on every update can reset internal delegate state in UIKit views like WKWebView or MKMapView.
DON'T: Mutate the represented view's frame, bounds, center, or transform.
DO: Let SwiftUI size the represented view and use sizeThatFits, intrinsic size, SwiftUI modifiers, or internal subview layout.
Why: SwiftUI controls those layout properties for the represented view; setting them directly conflicts with SwiftUI layout.
DON'T: Hold strong references to the Coordinator from closures.
DO: Use [weak coordinator] in closures.
Why: UIKit objects often store closures (completion handlers, action blocks). A strong reference to the coordinator that holds a reference to the UIKit view creates a retain cycle.
DON'T: Forget to call parent.dismiss() or completion handlers.
DO: Use the coordinator to track dismissal and invoke parent.dismiss() in all delegate exit paths.
Why: Modal controllers presented by SwiftUI (via .sheet) need their dismiss binding toggled, or the sheet state becomes inconsistent.
DON'T: Ignore dismantleUIView for views that hold observers or timers.
DO: Clean up NotificationCenter observers, Combine subscriptions, and Timer instances in dismantleUIView.
Why: Without cleanup, observers and timers continue firing after the view is removed, causing crashes or stale state updates.
DON'T: Force UIHostingController's view to fill the parent without proper constraints.
DO: Use Auto Layout constraints or sizingOptions for proper embedding.
Why: Setting frame manually breaks adaptive layout, trait propagation, and safe area handling.
DON'T: Try to use @State in the Coordinator -- it is not a View.
DO: Use regular stored properties on the Coordinator and communicate to SwiftUI via parent's @Binding properties.
Why: @State only works inside View conformances. Using it on a class has no effect.
DON'T: Poll withObservationTracking from a UIKit controller.
DO: Use UIKit automatic observation tracking hooks on iOS 18+/26+, and explicit Combine/callback invalidation for older deployment targets.
Why: Manual withObservationTracking is one-shot, and UIKit automatic tracking is not an iOS 17 feature.
DON'T: Skip the addChild/didMove(toParent:) dance when embedding UIHostingController.
DO: Always call addChild(_:), add the view to the hierarchy, then call didMove(toParent:).
Why: Skipping containment causes viewWillAppear/viewDidAppear to never fire, breaks trait collection propagation, and causes visual glitches.
Review Checklist
- View/controller created in
make*, notupdate* - Coordinator set as delegate in
make*, notupdate* -
@Bindingused for two-way state sync -
updateUIViewhandles all SwiftUI state changes with redundancy guards -
dismantleUIViewcleans up observers/timers if needed - No retain cycles between coordinator and closures (
[weak coordinator]) -
UIHostingControllerproperly added as child (addChild+didMove(toParent:)) - Sizing strategy chosen (
intrinsicContentSizevs fixedframevssizeThatFits) - Represented view geometry left to SwiftUI (
frame,bounds,center,transformnot mutated directly) - Environment values read in
updateUIViewviacontext.environmentwhere needed - UIKit
@Observablereads use automatic tracking hooks on iOS 18+/26+, not polling; iOS 17 is manual one-shot only - Coordinator marked
@MainActorfor strict concurrency - Modal controllers dismiss in all delegate exit paths (success, cancel, error)
-
UIHostingConfigurationused for collection/table view cells instead of manual hosting (iOS 16+)
References
- Wrapping recipes: references/representable-recipes.md
- Migration patterns: references/hosting-migration.md
- Apple docs: UIViewRepresentable
- Apple docs: UIViewControllerRepresentable
- Apple docs: UIHostingController
Related skills
More from dpearson2699/swift-ios-skills and the wider catalog.
swiftui-animation
Implement, review, or improve SwiftUI animations and transitions. Use when adding explicit animations with withAnimation, configuring implicit animations with .animation(_:body:) or .animation(_:value:), configuring spring animations (.smooth, .snappy, .bouncy), building phase or keyframe animations with PhaseAnimator/KeyframeAnimator, creating hero transitions with matchedGeometryEffect or matchedTransitionSource, adding SF Symbol effects (iOS 17 bounce, pulse, variableColor, scale, appear, disappear, replace; iOS 18 breathe, rotate, wiggle), implementing custom Transition or CustomAnimation types, or ensuring animations respect accessibilityReduceMotion.
ios-accessibility
Implements, reviews, or improves accessibility in iOS/macOS apps with SwiftUI, UIKit, and AppKit. Use when adding VoiceOver, Voice Control, Switch Control, or Full Keyboard Access support; when working with accessibility labels, hints, values, traits, accessibilityInputLabels, NSAccessibility, grouping, reading order, accessibility focus restoration with @AccessibilityFocusState, Dynamic Type, @ScaledMetric, custom rotors, accessibility actions, XCTest accessibility checks, App Store Accessibility Nutrition Labels, App Store Connect accessibility answers, a11y compliance audits, or system accessibility preferences.
swiftui-patterns
Builds and reviews SwiftUI views with modern MV architecture, state management, view composition, and migration/availability guidance. Covers @Observable ownership rules, @State/@Bindable/@Environment wiring, view decomposition, custom ViewModifiers, environment values, async data loading with .task, iOS 26+ handoff reminders, Writing Tools, clipboard availability caveats, and performance guidelines. Use when structuring SwiftUI app state, managing @Observable, composing view hierarchies, or correcting SwiftUI pattern guidance.
swiftui-performance
Audit and improve SwiftUI runtime performance. Use when diagnosing slow rendering, janky scrolling, high CPU, memory usage, excessive view updates, layout thrash, body evaluation cost, identity churn, view lifetime issues, lazy loading, Instruments profiling guidance, and performance audit requests.
app-store-review
Prepare for App Store review and prevent rejections. Covers App Store review guidelines, app rejection reasons, PrivacyInfo.xcprivacy privacy manifest requirements, required API reason codes, in-app purchase IAP and StoreKit rules, App Store Guidelines compliance, ATT App Tracking Transparency, EU DMA Digital Markets Act, HIG compliance checklist, app submission preparation, review preparation, metadata requirements, entitlements, widgets, and Live Activities review rules. Use when preparing for App Store submission, fixing rejection reasons, auditing privacy manifests, implementing ATT consent flow, configuring StoreKit IAP, or checking HIG compliance.
ios-networking
Build, review, or improve networking code in iOS/macOS apps using URLSession with async/await, structured concurrency, and modern Swift patterns. Use when working with REST APIs, downloading files, uploading data, WebSocket connections, pagination, retry logic, request middleware, caching, background transfers, or network reachability monitoring. Also use when handling HTTP requests, API clients, network error handling, or data fetching in Swift apps.