swiftui-uikit-interop

$npx mdskill add dpearson2699/swift-ios-skills/swiftui-uikit-interop

Bridges UIKit and SwiftUI for bidirectional view embedding and state sync.

  • Wraps UIKit-only views or controllers for use in SwiftUI.
  • Uses UIViewRepresentable, UIViewControllerRepresentable, and UIHostingController.
  • Selects wrapping pattern based on UIKit component type and lifecycle needs.
  • Generates SwiftUI-compatible wrappers with coordinator delegates and observation tracking.

SKILL.md

.github/skills/swiftui-uikit-interopView on GitHub ↗
---
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 controllers, embed SwiftUI in UIKit screens, and synchronize state without duplicating lifecycle ownership.

See [references/representable-recipes.md](references/representable-recipes.md) for complete wrapping recipes and [references/hosting-migration.md](references/hosting-migration.md) for UIKit-to-SwiftUI migration patterns.

## Contents

- [UIViewRepresentable Protocol](#uiviewrepresentable-protocol)
- [UIViewControllerRepresentable Protocol](#uiviewcontrollerrepresentable-protocol)
- [The Coordinator Pattern](#the-coordinator-pattern)
- [UIHostingController](#uihostingcontroller)
- [Sizing and Layout](#sizing-and-layout)
- [State Synchronization Patterns](#state-synchronization-patterns)
- [UIKit Automatic Observation Tracking](#uikit-automatic-observation-tracking)
- [Sendable Considerations](#sendable-considerations)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
- [References](#references)

## UIViewRepresentable Protocol

Use `UIViewRepresentable` to wrap any `UIView` subclass for use in SwiftUI.

### Required Methods

```swift
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

```swift
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+)

```swift
@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.

```swift
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:

```swift
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.

```swift
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

1. **Set the delegate in `makeUIView`/`makeUIViewController`, never in `updateUIView`.** 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.

2. **Refresh copied parent state yourself.** If the coordinator stores the representable in a `var parent`, assign `context.coordinator.parent = self` at the start of `updateUIView` or `updateUIViewController`. Bindings still point at their source of truth, but closures and non-binding values are copied into the coordinator.

3. **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

```swift
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+)

```swift
@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:

```swift
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:

```swift
@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.

```swift
// 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:

```swift
struct WebViewWrapper: UIViewRepresentable {
    let url: URL
    var onNavigationFinished: ((URL) -> Void)?
}
```

### Environment Values

Access SwiftUI environment values inside representable methods via `context.environment`:

```swift
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:

```swift
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 `UIObservationTrackingEnabled` in `Info.plist`.
- iOS 17: `@Observable` exists, but UIKit automatic observation tracking is not available. Manual `withObservationTracking` is one-shot; do not build polling loops around it.
- iOS 15-16 or existing `ObservableObject`: use Combine `objectWillChange`, delegates, notifications, or explicit callbacks.

See [references/hosting-migration.md](references/hosting-migration.md#automatic-observation-tracking-in-uikit) 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:

```swift
@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

| Mistake | Fix |
|---|---|
| UIKit object or delegate recreated in `update*` | Create once in `make*`; update only changed state. |
| Represented frame/bounds/transform mutated directly | Let SwiftUI size it; use intrinsic size, `sizeThatFits`, modifiers, or internal layout. |
| Coordinator retained by UIKit closures | Capture it weakly and keep one lifecycle owner. |
| Picker/controller exit path misses dismissal or completion | Route every delegate exit through one coordinator cleanup path. |
| Observers, timers, or subscriptions outlive the view | Remove/cancel them in `dismantle*`. |
| Hosting controller uses manual frame without containment | Use Auto Layout and `addChild`/`didMove(toParent:)`. |
| `@State` used inside a coordinator | Use stored properties and communicate through bindings/callbacks. |
| `withObservationTracking` is polled manually | Use UIKit automatic observation hooks where available or explicit invalidation on older targets. |

## Review Checklist

- [ ] View/controller created in `make*`, not `update*`
- [ ] Coordinator set as delegate in `make*`, not `update*`
- [ ] `@Binding` used for two-way state sync
- [ ] `updateUIView` handles all SwiftUI state changes with redundancy guards
- [ ] `dismantleUIView` cleans up observers/timers if needed
- [ ] No retain cycles between coordinator and closures (`[weak coordinator]`)
- [ ] `UIHostingController` properly added as child (`addChild` + `didMove(toParent:)`)
- [ ] Sizing strategy chosen (`intrinsicContentSize` vs fixed `frame` vs `sizeThatFits`)
- [ ] Represented view geometry left to SwiftUI (`frame`, `bounds`, `center`, `transform` not mutated directly)
- [ ] Environment values read in `updateUIView` via `context.environment` where needed
- [ ] UIKit `@Observable` reads use automatic tracking hooks on iOS 18+/26+, not polling; iOS 17 is manual one-shot only
- [ ] Coordinator marked `@MainActor` for strict concurrency
- [ ] Modal controllers dismiss in all delegate exit paths (success, cancel, error)
- [ ] `UIHostingConfiguration` used for collection/table view cells instead of manual hosting (iOS 16+)

## References

- Wrapping recipes: [references/representable-recipes.md](references/representable-recipes.md)
- Migration patterns: [references/hosting-migration.md](references/hosting-migration.md)
- Apple docs: [UIViewRepresentable](https://sosumi.ai/documentation/swiftui/UIViewRepresentable)
- Apple docs: [UIViewControllerRepresentable](https://sosumi.ai/documentation/swiftui/UIViewControllerRepresentable)
- Apple docs: [UIHostingController](https://sosumi.ai/documentation/swiftui/UIHostingController)

More from dpearson2699/swift-ios-skills

SkillDescription
accessorysetupkitDiscover and configure Bluetooth and Wi-Fi accessories using AccessorySetupKit. Use when presenting a privacy-preserving accessory picker, defining discovery descriptors for BLE or Wi-Fi devices, handling accessory session events, migrating from CoreBluetooth permission-based scanning, or setting up accessories without requiring broad Bluetooth permissions.
activitykitImplement, review, or improve Live Activities and Dynamic Island experiences in iOS apps using ActivityKit. Use when building real-time updating widgets for the Lock Screen and Dynamic Island — delivery tracking, sports scores, ride-sharing status, workout timers, media playback, or any time-sensitive information that updates in real time. Also use when working with ActivityKit, ActivityAttributes, Activity lifecycle (request/update/end), Dynamic Island layouts (compact/minimal/expanded), push-to-update Live Activities, or Lock Screen live widgets.
adattributionkitMeasure ad effectiveness with privacy-preserving attribution using AdAttributionKit. Use when registering ad impressions, handling attribution postbacks, updating conversion values, implementing re-engagement attribution, configuring publisher or advertiser apps, or replacing SKAdNetwork with AdAttributionKit for ad measurement.
alarmkitImplement AlarmKit alarms and countdown timers for iOS and iPadOS with Lock Screen, Dynamic Island, StandBy, and paired Apple Watch system UI. Covers AlarmManager scheduling, AlarmAttributes and AlarmPresentation, system Stop and AlarmButton secondary actions, authorization, state observation, countdown widget-extension handoff, and Live Activity integration. Use when building wake-up alarms, countdown timers, or alarm-style alerts that need Apple's system alarm experience.
app-clipsBuild iOS App Clips with invocation URLs, App Clip Codes, NFC, QR codes, Safari banners, Maps, Messages, target setup, App Store Connect experiences, size/capability constraints, NSUserActivity routing, SKOverlay promotion, App Group/keychain handoff, ephemeral notifications, location confirmation, and full-app migration. Use when creating App Clips or wiring App Clip invocation, experience configuration, or full-app handoff.
app-intentsImplement App Intents for Siri, Shortcuts, Spotlight, widgets, Control Center, and Apple Intelligence on iOS. Covers AppIntent actions, AppEntity and EntityQuery models, AppShortcutsProvider phrases, IndexedEntity Spotlight indexing, WidgetConfigurationIntent, SnippetIntent, and assistant schemas. Use when exposing app actions or entities to system surfaces.
app-store-reviewAudits App Store submission readiness and rejection risk across current review guidelines, PrivacyInfo.xcprivacy and required-reason APIs, privacy labels, ATT, StoreKit payments, metadata, entitlements, widgets, and Live Activities. Use when preparing a submission, responding to rejection, reconciling privacy evidence, or separating upload blockers from cleanup.
apple-on-device-aiBuild private, on-device AI features on iPhone, iPad, and Mac with Foundation Models, Core ML, MLX Swift, or llama.cpp. Use when choosing an Apple-local model runtime, building an Apple Intelligence chatbot or tool-calling feature, running an LLM on Apple Silicon, converting or compressing a Python model for Core ML, or comparing on-device inference backends. For Swift Core ML loading and prediction code, use the coreml skill.
appmigrationkitTransfer app data to or from other platforms using AppMigrationKit. Use when implementing system-orchestrated one-time migration between iOS and Android or another platform, building an AppMigrationExtension, packaging transportable resources with ResourcesArchiver, importing resources on the destination device, reporting import progress, handling migration errors and app group cleanup, checking MigrationStatus, or testing migration code with AppMigrationTester.
audioaccessorykitSupport automatic audio switching for paired third-party Bluetooth headphones or earbuds with AudioAccessoryKit. Use when a companion app registers an audio accessory, an app extension reports worn/removed placement or connected source-device changes, or AccessoryControlDevice capabilities and errors need handling. Do not use for general AVAudioSession routing, Bluetooth transport, or initial accessory pairing.