SwiftUI Introspect
When it comes to building apps, SwiftUI guarantees iteration times that were not possible before.
SwiftUI can probably cover about 95% of any modern app needs, with the last 5% being polishing SwiftUI's rough edges by falling back on one of the previous UI frameworks.
In Four ways to customize TextFields, we've seen the two main fallback methods:
- SwiftUI's
UIViewRepresentable
/NSViewRepresentable
- SwiftUI Introspect
In this article, let's take a look at SwiftUI Introspect.
What's SwiftUI Introspect
SwiftUI Introspect is an open-source library created by Loïs Di Qual. Its primary purpose is to fetch and modify the underlying UIKit or AppKit elements of any SwiftUI view.
This is possible thanks to many SwiftUI views (still) relying on their UIKit/AppKit counterparts, for example:
- in macOS,
Button
usesNSButton
behind the scenes - in iOS,
TabView
uses aUITabBarController
behind the scenes
We rarely need to know such implementation details. However, knowing so gives us yet another powerful tool we can reach for when needed. This is precisely where SwiftUI Introspect comes in.
Using SwiftUI Introspect
SwiftUI Introspect provides us a series of view modifiers following the func introspectX(customize: @escaping (Y) -> ()) -> some View
pattern, where:
X
is the view we're targetingY
is the underlying UIKit/AppKit view/view-controller type we'd like to reach for
Let's say that we'd like to remove the bouncing effect from a ScrollView
.
Currently, there's no SwiftUI parameter/modifier letting us do so (FB9106829).
ScrollView
uses UIKit's UIScrollView
behind the scenes, and AppKit's NSScrollView
in macOS. We can use Introspect's func introspectScrollView(customize: @escaping (UIScrollView) -> ()) -> some View
to grab the underlying UIScrollView
, and disable the bouncing:
import Introspect
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Color.red.frame(height: 300)
Color.green.frame(height: 300)
Color.blue.frame(height: 300)
}
.introspectScrollView { $0.bounces = false }
}
}
}
In iOS, users can dismiss sheets by swiping them down. In UIKit, we can prevent this behavior via the isModalInPresentation
UIViewController
property, letting our app logic control the sheet presentation. In SwiftUI, we don't have an equivalent way to do so yet (FB9106857).
Once again, we can use Introspect to grab the presenting sheet UIViewController
, and set the isModalInPresentation
property:
import Introspect
import SwiftUI
struct ContentView: View {
@State var showingSheet = false
var body: some View {
Button("Show sheet") { showingSheet.toggle() }
.sheet(isPresented: $showingSheet) {
Button("Dismiss sheet") { showingSheet.toggle() }
.introspectViewController { $0.isModalInPresentation = true }
}
}
}
Other examples:
- add pull to refresh to
List
s (FB8506858) - add toolbars to
TextField
s (seeThe introspect way
paragraph, FB9081641) - ...and much more.
Imagine having to re-implement a whole complex screen in UIKit/AppKit because of a minor feature missing in SwiftUI: Introspect is an incredible time (life?) saver.
We've seen its clear benefits: next, let's uncover how SwiftUI Introspect works.
How SwiftUI Introspect works
We will take the UIKit route: beside the
UI
/NS
prefixes, AppKit's code is identical.
The code shown in the article has been slightly adjusted for clarity's sake. The original implementation is available in SwiftUI Introspect's repository.
The injection
As shown in the examples above, Introspect provides us various view modifiers. If we look at their implementation, they all follow a similar pattern. Here's one example:
extension View {
/// Finds a `UITextView` from a `TextEditor`
public func introspectTextView(
customize: @escaping (UITextView) -> ()
) -> some View {
introspect(
selector: TargetViewSelector.siblingContaining,
customize: customize
)
}
}
All these public introspectX(customize:)
view modifiers are convenience implementations of a more generic introspect(selector:customize:)
one:
extension View {
/// Finds a `TargetView` from a `SwiftUI.View`
public func introspect<TargetView: UIView>(
selector: @escaping (IntrospectionUIView) -> TargetView?,
customize: @escaping (TargetView) -> ()
) -> some View {
inject(
UIKitIntrospectionView(
selector: selector,
customize: customize
)
)
}
}
Here we see the introduction of one more inject(_:)
View
modifier, and the first Introspect view, UIKitIntrospectionView
:
extension View {
public func inject<SomeView: View>(_ view: SomeView) -> some View {
overlay(view.frame(width: 0, height: 0))
}
}
inject(_:)
takes our original view and adds on top an overlay with the given view, with its frame minimized.
For example, if we have the following view:
TextView(...)
.introspectTextView { ... }
The final view will be:
TextView(...)
.overlay(UIKitIntrospectionView(...).frame(width: 0, height: 0))
Let's take a look at UIKitIntrospectionView
next:
public struct UIKitIntrospectionView<TargetViewType: UIView>: UIViewRepresentable {
let selector: (IntrospectionUIView) -> TargetViewType?
let customize: (TargetViewType) -> Void
public func makeUIView(
context: UIViewRepresentableContext<UIKitIntrospectionView>
) -> IntrospectionUIView {
let view = IntrospectionUIView()
view.accessibilityLabel = "IntrospectionUIView<\(TargetViewType.self)>"
return view
}
public func updateUIView(
_ uiView: IntrospectionUIView,
context: UIViewRepresentableContext<UIKitIntrospectionView>
) {
DispatchQueue.main.async {
guard let targetView = self.selector(uiView) else { return }
self.customize(targetView)
}
}
}
UIKitIntrospectionView
is Introspect's bridge to UIKit, which does two things:
- injects an
IntrospectionUIView
UIView
in the hierarchy - reacts to
UIViewRepresentable
'supdateUIView
life-cycle event (more on this later)
This is IntrospectionUIView
's definition:
public class IntrospectionUIView: UIView {
required init() {
super.init(frame: .zero)
isHidden = true
isUserInteractionEnabled = false
}
}
IntrospectionUIView
is a minimal, hidden, and noninteractive UIView
: its whole purpose is to give SwiftUI Introspect an entry point into UIKit's hierarchy.
In conclusion, all .introspectX(customize:)
view modifiers overlay a tiny, invisible, noninteractive view on top of our original view, making sure that it doesn't affect our final UI.
The crawling
We've now seen how SwiftUI Introspect reaches the UIKit hierarchy. All it's left to do for the library is to find the UIKit view/view-controller we're looking for.
Going back to UIKitIntrospectionView
's implementation, the magic happens in updateUIView(_:context)
, which is one of the UIViewRepresentable
life-cycle methods:
public struct UIKitIntrospectionView<TargetViewType: UIView>: UIViewRepresentable {
let selector: (IntrospectionUIView) -> TargetViewType?
let customize: (TargetViewType) -> Void
...
public func updateUIView(
_ uiView: IntrospectionUIView,
context: UIViewRepresentableContext<UIKitIntrospectionView>
) {
DispatchQueue.main.async {
guard let targetView = self.selector(uiView) else { return }
self.customize(targetView)
}
}
}
In UIKitIntrospectionView
's case, this method is called by SwiftUI mainly in two scenarios:
- when
IntrospectionUIView
is about to be added into the view hierarchy - when
IntrospectionUIView
is about to be removed from the view hierarchy
The async
dispatch has two functions:
- if the method is called when the view is about to be added into the view hierarchy, we need to wait for the current runloop cycle to complete before our view is actually added (into the view hierarchy), then, and only then, we can start our search for our target view
- if the method is called when the view is about to be removed from the view hierarchy, waiting for the runloop cycle to complete assures that our view has been removed (thus making our search fail)
When SwiftUI triggers updateUIView(_:context)
, UIKitIntrospectionView
calls the selector
method that we've been carrying over from the original convenience modifier implementation:selector
has a (IntrospectionUIView) -> TargetViewType?
signature, a.k.a. it takes in Introspect's IntrospectionUIView
's view as input, and returns an optional TargetViewType
, which is a generic representation of our original view/view-controller type that we'd like to reach for.
If this search succeeds, then we call customize
on it, which is the method we pass/define when we apply an Introspect's view modifier on our views, thus making our change to the underlying UIKit/AppKit view/view-controller.
Going back to our introspectTextView(customize:)
example, we pass TargetViewSelector.siblingContaining
to our selector
:
extension View {
/// Finds a `UITextView` from a `TextEditor`
public func introspectTextView(
customize: @escaping (UITextView) -> ()
) -> some View {
introspect(
selector: TargetViewSelector.siblingContaining,
customize: customize
)
}
}
TargetViewSelector
is a caseless Swift enum
, making it a container of static
methods meant to be called directly, all TargetViewSelector
methods, more or less, follow the same pattern as our siblingContaing(from:)
:
public enum TargetViewSelector {
public static func siblingContaining<TargetView: UIView>(from entry: UIView) -> TargetView? {
guard let viewHost = Introspect.findViewHost(from: entry) else {
return nil
}
return Introspect.previousSibling(containing: TargetView.self, from: viewHost)
}
...
}
The first step is finding a view host:
SwiftUI wraps each UIViewRepresentable
view within a host view, something along the lines of PlatformViewHost<PlatformViewRepresentableAdaptor<IntrospectionUIView>>
, which is then wrapped into a "hosting view" of type _UIHostingView
, representing an UIView
capable of hosting a SwiftUI view.
To get the view host, Introspect uses a findViewHost(from:)
static method from another caseless Introspect
enum
:
enum Introspect {
public static func findViewHost(from entry: UIView) -> UIView? {
var superview = entry.superview
while let s = superview {
if NSStringFromClass(type(of: s)).contains("ViewHost") {
return s
}
superview = s.superview
}
return nil
}
...
}
This method starts from our IntrospectionUIView
and recursively queries each superview
s until a view host is found: if we cannot find a view host, our IntrospectionUIView
is not yet part of the screen hierarchy, and our crawling stops right away.
Once we have our view host, we have our starting point to look for our target view, which is exactly what TargetViewSelector.siblingContaing
does via the next Introspect.previousSibling(containing: TargetView.self, from: viewHost)
command:
enum Introspect {
public static func previousSibling<AnyViewType: UIView>(
containing type: AnyViewType.Type,
from entry: UIView
) -> AnyViewType? {
guard let superview = entry.superview,
let entryIndex = superview.subviews.firstIndex(of: entry),
entryIndex > 0
else {
return nil
}
for subview in superview.subviews[0..<entryIndex].reversed() {
if let typed = findChild(ofType: type, in: subview) {
return typed
}
}
return nil
}
...
}
This new static method takes all viewHost
's parent's subviews (a.k.a. viewHost
's siblings), filter the subviews that come before viewHost
, and recursively search for our target view (passed as a type
parameter), from closest to furthest sibling, via the final findChild(ofType:in:)
method:
enum Introspect {
public static func findChild<AnyViewType: UIView>(
ofType type: AnyViewType.Type,
in root: UIView
) -> AnyViewType? {
for subview in root.subviews {
if let typed = subview as? AnyViewType {
return typed
} else if let typed = findChild(ofType: type, in: subview) {
return typed
}
}
return nil
}
...
}
This method, called by passing our target view and one of our viewHost
siblings, will crawl each sibling complete subtree view hierarchy looking for our target view, and return the first match, if any.
Analysis
Now that we've uncovered all the inner workings of SwiftUI Introspect, it's much easier to answer common questions that we might have:
- Is it safe to use?
As long as we don't do too daring things, yes. It's important to understand that we do not own the underlying AppKit/UIKit views, SwiftUI does. Changes applied via Introspect should work, however SwiftUI might override them at will and without notice.
- Is it future proof?
No. As SwiftUI evolves, things might break and already have when new OS versions come out. The library is updated with new patches when this happens, however our users would need to update the app before they see a fix.
- Should we use it?
If the choice is either a complete rewrite or SwiftUI Introspect, the answer is probably yes. Anyone who has read this far fully understands how the library works: if anything breaks, we should know where to look for and find a fix.
- Where does SwiftUI Introspect shine?
Backward compatibility. Let's imagine, for example, that iOS 15 brings pull to refresh to List
(fingers crossed! FB8506858):
we know that SwiftUI Introspect lets us add pull to refresh to List
in iOS 13 and 14. At that point, we can use Introspect when targeting older OS versions, and use the new SwiftUI way when targeting iOS 15 or later.
Doing so guarantees that things won't break, as newer OS version will use SwiftUI's "native" approach, and only past iOS versions use Introspect.
- When not to use SwiftUI Introspect?
When we want complete control over a view and can't afford things to break with new OS releases: if this is our case, it's safer and more future-proof to go with UIViewRepresentable
/NSViewRepresentable
. Of course, we should always attempt as best we can to find a "pure" SwiftUI way first, and only when we're confident that it's not possible, look for alternatives.
Conclusions
SwiftUI Introspect is one of the few SwiftUI libraries that is probably a must-have to any SwiftUI app. Its execution is elegant, safe, and its advantages far outweigh the cons of adding it as a dependency.
When adding a dependency to our project, we should understand as best as we can what that dependency does, and I hope this article helps you in doing so for SwiftUI Introspect.
What other SwiftUI library do you use in your projects? Please let me know via email or twitter!