|

SwiftUI Introspect: Finding Views Without Private APIs

A native view hierarchy with two markers isolating the view to inspect

A SwiftUI ScrollView does not promise to expose its underlying UIScrollView. That is usually a good thing. You describe the interface in SwiftUI, and the framework decides which native views to create.

But sometimes a native control has a setting that SwiftUI does not expose. For example, an iOS app may want to adjust the scroll view’s deceleration rate. The UIKit property is simple: UIScrollView.decelerationRate. The difficult part is getting the right UIScrollView, especially when the screen contains nested scroll views or runs on several iOS versions.

SwiftUI Introspect solves that lookup problem without calling private SwiftUI APIs. Its interesting idea is not a way to cast a SwiftUI view into UIKit. It is a small runtime search, placed next to the SwiftUI view, with limits on where the search is allowed to look.

The view you wrote is not the view you can inspect

Here is what the public API looks like for an iOS scroll view:

import SwiftUI
import SwiftUIIntrospect

struct ResultsView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0..<40, id: \.self) { index in
                    Text("Result \(index)")
                }
            }
        }
        .introspect(.scrollView, on: .iOS(.v17, .v18, .v26, .v27)) {
            scrollView in
            scrollView.decelerationRate = .fast
        }
    }
}

The closure receives a UIScrollView, not a ScrollView. The list of iOS versions is intentional: the library needs to know which native class is expected on the running OS. If no version predicate matches, the modifier leaves the SwiftUI content alone.

There is no direct reference from ResultsView to the native object. To see where that object comes from, we have to follow IntrospectModifier in Sources/Introspect.swift.

Two invisible views give the search an address

When a version matches, IntrospectModifier.body places an IntrospectionAnchorView in a background and an IntrospectionView in an overlay. Both are zero-sized and hidden from accessibility. They use the same generated ID.

The two markers become native view controllers through SwiftUI’s platform representable system. An internal store pairs them by ID, keeping weak references to the controllers. This gives the search a starting point and a second marker that tells it where to stop.

The shape is roughly:

SwiftUI ScrollView
  ├─ background: anchor (ID: A)
  └─ overlay: inspection view (ID: A)
                |
                +-- find matching native view
                    between the paired markers

This is a model of the lookup, not a claim that SwiftUI builds exactly this native tree. The real tree belongs to SwiftUI and can change.

The implementation in Sources/Introspect.swift makes the search more precise than “walk up until you find any scroll view.” For receiver introspection, it finds the nearest common ancestor of the two marker entities, then walks descendants between the anchor and inspection marker. It returns the first matching native type, while skipping the library’s own marker entities.

The traversal is lazy. The tests in PlatformEntityTraversalTests.swift check that lookup stops after a match and does not continue into later branches. The ScrollViewTests.swift cases also check nested scroll views: an outer scroll view and an inner one must resolve to different native objects.

That last detail matters. A global search for the first UIScrollView would be easy to write and wrong as soon as a screen had more than one.

Receiver and ancestor are different questions

The default .introspect call asks about the view that receives the modifier. In the source, IntrospectionSelector.from builds two lookup functions: one for a receiver and one for an ancestor. IntrospectionScope chooses which one is used.

If the modifier sits inside the ScrollView, the receiver is the inner Text, not the scroll view. In that position, the caller can explicitly request an ancestor:

ScrollView {
    Text("Document")
        .introspect(
            .scrollView,
            on: .iOS(.v17, .v18, .v26, .v27),
            scope: .ancestor
        ) { scrollView in
            scrollView.decelerationRate = .fast
        }
}

For ancestor lookup, the selector walks upward and takes the first native object of the requested type. For receiver lookup, it searches the bounded region between the markers. The distinction is part of the API because the location of a SwiftUI modifier changes what it can reasonably mean.

It also avoids a misleading promise. SwiftUI Introspect cannot guarantee that every SwiftUI value has a matching UIKit or AppKit class. A Text is not necessarily a UILabel; a VStack is not a native stack view. Some SwiftUI elements have no corresponding object to find.

The OS version is part of the type

The version list is not only a runtime safety check. It also determines the type of the closure’s argument.

Consider SwiftUI List on iOS. The project’s Sources/ViewTypes/List.swift maps iOS 13–15 to UITableView, while iOS 16, 17, 18, 26, and 27 map to UICollectionView. Both are UIKit scrolling controls, but they are not interchangeable APIs.

The library models that difference through PlatformViewVersion<SwiftUIViewType, PlatformSpecificEntity>. The expected native type appears in the generic arguments. PlatformViewVersionPredicate chooses a matching selector for the running platform, and IntrospectModifier installs the marker views only if a selector is available.

A caller can therefore write separate .introspect modifiers for different OS groups, each with the native type appropriate to those versions. The alternative—one closure that receives an untyped UIView and casts it at runtime—would push more of the compatibility work onto every caller.

Version 27.0.0 also supports range predicates such as .iOS(.v17...). The code in PlatformViewVersion.swift treats this as “the chosen version or a later one” and reuses the lower bound’s selector. It does not prove that future SwiftUI releases will keep the same native backing class. If that class changes, the search can return no match and the customization will not run. Explicit version lists are the more cautious choice for a setting that matters to the app.

Finding a view is not the same as owning it

The first successful lookup can happen during a native controller’s load, move, layout, or appearance callback. Once it finds a target, the controller stops its initial search handler. Later SwiftUI updates can still run the customization closure through updatePlatformViewController.

That explains an important rule for consumers: customization must be safe to repeat. Setting decelerationRate = .fast is straightforward. Adding another gesture recognizer or observer every time the closure runs would need a guard or cleanup strategy.

The coordinator stores the discovered target weakly, and the marker store also uses weak controller references. On dismantling, the code clears the handler and removes the ID from the store. Those choices avoid turning a temporary view lookup into ownership of a UIKit or AppKit object.

The library also does not promise that the same native object will live as long as the SwiftUI view value. SwiftUI can rebuild part of the native hierarchy. A cached reference outside the closure may become stale, so keeping the customization close to the modifier is usually easier to reason about.

Where I would use it

A document reader on macOS. A reader may need tighter control over how an NSScrollView moves at its edges. For a SwiftUI document canvas, AppKit exposes verticalScrollElasticity. If a native SwiftUI modifier cannot express the desired behavior for the supported macOS versions, introspection can apply it without replacing the whole reader with NSViewRepresentable.

import SwiftUI
import SwiftUIIntrospect

struct DocumentCanvas: View {
    var body: some View {
        ScrollView {
            Text("Document content")
                .frame(maxWidth: .infinity)
        }
        .introspect(.scrollView, on: .macOS(.v12, .v13, .v14, .v15, .v26, .v27)) {
            scrollView in
            scrollView.verticalScrollElasticity = .none
        }
    }
}

This example targets macOS. The closure receives NSScrollView; the earlier iOS example receives UIScrollView. The library finds each one through the same marker mechanism, but the settings and types are platform-specific.

A writing screen with a native text editor setting. On iOS, TextEditor can be inspected as UITextView. An app that needs a particular text inset can set textContainerInset through that native view. This can be a smaller change than building an entire editor wrapper, but the app must check whether SwiftUI’s own text layout modifiers already cover the requirement and whether the native setting survives view updates.

An older app with a customized list. A project supporting iOS 15 and newer may still need a UIKit-specific behavior on List. This is where the version-to-type mapping is valuable: code for UITableView can stay with iOS 13–15, while code for UICollectionView handles newer versions. The cost is real testing across those OS groups. A native SwiftUI modifier should be the first choice when it provides the same behavior.

I would not add this dependency just to hide scroll indicators, change a tint color, or do something SwiftUI already supports well. It is most useful when the missing setting belongs to a specific native control and a full UIKit/AppKit wrapper would create more work than the customization itself.

A useful escape hatch, with a visible limit

SwiftUI Introspect is MIT-licensed and has a public Swift Package Manager target without external package dependencies. Release 27.0.0, published on September 14, 2026, uses Swift tools 6.2 and supports iOS, Mac Catalyst, macOS, tvOS, and visionOS; watchOS is outside its model. The repository includes tests for native traversal, platform versions, and individual view types.

The core idea is a narrow search, not a hidden bridge into SwiftUI internals. Paired markers tell the library where to look. Typed version rules tell it what to look for. Weak references and repeated callbacks acknowledge that the native view is owned by the framework, not the caller.

That is also the limit of the technique: you can customize a native view that SwiftUI currently creates, but you cannot make SwiftUI promise to keep creating it. The version predicates make that assumption visible in code, which is exactly where an escape hatch like this should put it.

Sources