Reaching for UIKit from SwiftUI


Greetings, traveler!

SwiftUI is great right up until you need to touch something it doesn’t expose.

Maybe you want to tune scroll behavior, read an internal layout value, tweak a gesture recognizer, or integrate with an existing UIKit-based subsystem. In those moments, the temptation is obvious: “If SwiftUI is backed by UIKit anyway, why not just grab the underlying UIView and work with it?”

Sometimes that’s the right call. But it’s also a bet against future OS updates.

Apple does not promise that SwiftUI components keep the same UIKit backing forever. A good reminder is List: on some releases it used a UITableView, then later it moved toward UICollectionView internals. If your solution depends on specific UIKit classes being present, it can break without any deprecation cycle.

So here’s the practical stance:

  • If you need this occasionally, a focused, local extraction can be a reasonable escape hatch.
  • If you’re going to do it often, use a dedicated tool like Introspect. It packages years of platform quirks into a well-maintained API and tends to age better than one-off traversal code.
  • Either way, keep the UIKit dependency behind a small adapter so you can rip it out later.

With that out of the way, let’s look at a lightweight approach that can work when you just need a UIView instance and you’re willing to accept the trade-offs.

The idea: attach a “probe” view behind your SwiftUI view

SwiftUI lets you embed UIKit via UIViewRepresentable. The trick is to insert an invisible representable in the background, wait until it’s attached to a window (so it has access to uiView.window), then scan the window’s view hierarchy to find the UIKit view you care about.

Your code adds an extract(_:completion:) modifier:

public extension View {
    func extract<V: UIView>(
        _ type: V.Type,
        completion: @escaping (V) -> ()
    ) -> some View {
        background(ViewExtractor(completion: completion))
    }
}

This does two important things:

  1. It keeps the SwiftUI API surface clean: .extract(UIScrollView.self) { ... }.
  2. It runs as a background so it doesn’t affect layout or hit-testing.

Why the extraction is asynchronous

The representable’s updateUIView runs during SwiftUI updates, but at that point the view is not always in a window yet. Also, SwiftUI can re-run updates during layout passes where geometry changes.

That’s why the implementation defers work to the next run loop tick:

func updateUIView(_ uiView: UIView, context: Context) {
    DispatchQueue.main.async {
        if let view = extract(uiView) {
            completion(view)
        }
    }
}

This ensures:

  • layout has settled enough to compute frames,
  • uiView.window is more likely to be non-nil,
  • you’re safely on the main thread when touching UIKit.

The flip side: completion can be called multiple times. That’s not a bug—SwiftUI can re-layout and re-host views as state changes.

If you apply side effects in the closure (like adding a gesture recognizer), make them idempotent.

How the match works

The extraction is intentionally heuristic. It does not “know” which UIKit view backs your SwiftUI view. Instead it does this:

  1. Take the representable’s uiView frame and convert it into window coordinates.
  2. Walk the entire window view tree.
  3. Return the first UIView of the requested type whose frame intersects that window-space rectangle.

That logic is here:

func extract(_ uiView: UIView) -> V? {
    guard let window = uiView.window else { return nil }
    let frame = uiView.convert(uiView.bounds, to: nil)

    return firstMatch(in: window) {
        $0.convert($0.bounds, to: nil).intersects(frame)
    }
}

And the traversal is a standard depth-first search:

func firstMatch<T: UIView>(
    in view: UIView,
    where predicate: (T) -> Bool
) -> T? {
    if let match = view as? T, predicate(match) {
        return match
    }

    for subview in view.subviews {
        if let found = firstMatch(in: subview, where: predicate) {
            return found
        }
    }

    return nil
}

A concrete use case: reaching the underlying scroll view

Your example captures the most common need: “I have a ScrollView, but I want UIKit-level control.”

struct ContentView: View {
    var body: some View {
        ScrollView { }
            .extract(UIScrollView.self) { scrollView in
                scrollView.isScrollEnabled = false
            }
    }
}

Again: if you do more than a couple of these across the codebase, that’s where Introspect earns its place.

Guardrails that keep this from turning into a maintenance trap

This approach works best when you treat it as a surgical tool:

Accept that “first intersecting view” can be wrong

On complex screens, multiple views of the same type can overlap the probe’s frame. Intersection is a reasonable approximation, not a guarantee.

If you run into false positives, common refinements are:

  • restrict the search to a subtree (if you can locate a nearer container),
  • prefer the smallest intersecting area,
  • prefer views whose center is inside the probe frame.

Treat OS updates as breaking changes

Whenever you bump iOS versions, verify these hooks. If the UIKit backing changes, you’ll want the failure to be obvious and localized.

Full code:

import SwiftUI

public extension View {
    func extract<V: UIView>(
        _ type: V.Type,
        completion: @escaping (V) -> ()
    ) -> some View {
        background(ViewExtractor(completion: completion))
    }
}

private struct ViewExtractor<V: UIView>: UIViewRepresentable {
    let completion: (V) -> ()
    
    func makeUIView(context: Context) -> UIView {
        UIView()
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        DispatchQueue.main.async {
            if let view = extract(uiView) {
                completion(view)
            }
        }
    }
}

private extension ViewExtractor {
    func extract(_ uiView: UIView) -> V? {
        guard let window = uiView.window else { return nil }
        let frame = uiView.convert(uiView.bounds, to: nil)
        
        return firstMatch(in: window) {
            $0.convert($0.bounds, to: nil).intersects(frame)
        }
    }
    
    func firstMatch<T: UIView>(
        in view: UIView,
        where predicate: (T) -> Bool
    ) -> T? {
        if let match = view as? T, predicate(match) {
            return match
        }
        
        for subview in view.subviews {
            if let found = firstMatch(in: subview, where: predicate) {
                return found
            }
        }
        
        return nil
    }
}

Conclusion

Reaching into UIKit from SwiftUI is a trade: you gain control and lose guarantees.

This extractor is a clean, minimal implementation of that trade. It keeps SwiftUI code readable, stays out of layout, and gives you an escape hatch when SwiftUI doesn’t expose what you need.

Just keep it small, keep it isolated, and assume it might need revisiting after the next major iOS update.

Available on GitHub.