Swift-ios-skills swiftui-uikit-interop

Bridges UIKit and SwiftUI by wrapping UIKit views and view controllers in SwiftUI with UIViewRepresentable and UIViewControllerRepresentable, embedding SwiftUI in UIKit with UIHostingController, and coordinating delegate callbacks. Use when integrating camera previews, map views, mail compose, document scanners, PDF renderers, text views with attributed text, or other UIKit-only or third-party UIKit SDK surfaces into a SwiftUI app, or when migrating a UIKit app to SwiftUI incrementally.

install
source · Clone the upstream repo
git clone https://github.com/dpearson2699/swift-ios-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/dpearson2699/swift-ios-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/swiftui-uikit-interop" ~/.claude/skills/dpearson2699-swift-ios-skills-swiftui-uikit-interop && rm -rf "$T"
manifest: skills/swiftui-uikit-interop/SKILL.md
source content

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

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

MethodWhen CalledPurpose
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) {
        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 runs on every state change -- setting the delegate there causes redundant assignment and can trigger unexpected side effects.

  2. The coordinator's

    parent
    property is updated automatically. SwiftUI updates the coordinator's reference to the latest representable struct value before each call to
    updateUIView
    . This means the coordinator always sees current
    @Binding
    values through
    parent
    .

  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

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]
OptionEffect
.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()
.

fixedSize() and frame() Interactions

SwiftUI ModifierEffect on Representable
No modifierSwiftUI 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 whenever SwiftUI state changes -- 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.

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
runs on every state change. 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: 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: 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*
    , 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
    )
  • Environment values read in
    updateUIView
    via
    context.environment
    where needed
  • 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