SwiftUI Introspect

SwiftUI UIKit
18 May 2021

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:

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 uses NSButton behind the scenes
  • in iOS, TabView uses a UITabBarController 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 targeting
  • Y 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:

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:

  1. injects an IntrospectionUIView UIView in the hierarchy
  2. reacts to UIViewRepresentable's updateUIView 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:

  1. 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
  1. 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 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 superviews 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!

⭑⭑⭑⭑⭑

Related articles

More SwiftUI articles

Browse all