SwiftUI の裏にある UIKit をカスタマイズする Introspect の使い方と仕組み

これは [Kyash Advent Calender 2021](https://adventar.org/calendars/6274) の20日目の記事です。最近 Kyash の iOS アプリで使っているライブラリのコードを読んだら動作の仕組みが面白かったので、使い方も含めて紹介したいと思います。

## 概要

SwiftUI は Apple のデバイス上で UI を作るための新しいフレームワークです。2019 年の登場からまだ間もないこともあり、成熟した UIKit と比べると足りないコンポーネントや機能が多いです。とくに、iOS 13 など最新ではない OS もサポートしているアプリで SwiftUI を使うのはかなり苦労する場面があるのではないでしょうか。

SwiftUI の機能不足を解決するライブラリのひとつに SwiftUI-Introspect があります。

https://github.com/siteline/SwiftUI-Introspect

Introspect は SwiftUI の View の裏側で使われている UIKit のコンポーネントを探し出し、それを直接操作することで UIKit と同等のカスタマイズを可能にするという方針をとっています。ライブラリとしてシンプルかつ便利なだけでなく仕組みがとても面白いので、この記事で基本的な使い方と内部実装を紹介しようと思います。その方針から想像できると思いますが、 Introspect は本番での利用には注意が必要なライブラリなのでできるだけ安全に利用する方法についても触れたいと思います。

## 検証環境

この記事中では以下の環境で動作検証を行っています。

- Xcode 13.2 Beta 2
- iOS 15.2

## Introspect の使い方

たとえば SwiftUI の `List` についてくる要素間のセパレータを消したいとします。iOS 15 で追加された [`listRowSeparator` という modifier](https://developer.apple.com/documentation/swiftui/form/listrowseparator(_:edges:)) を使えば簡単に消せるのですが、それ以前の OS では別の方法を考える必要があります。 

この問題を解決する方法の１つに [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) というライブラリの利用があります。Introspect を使って以下のように書くことで `List` のセパレータを消すことができます。

```swift
import Introspect

// ...

List(1...30, id: \.self) { i in
    Text("Item #\(i)")
}
.introspectTableView { tableView in
    tableView.separatorStyle = .none
}
```

Introspect は SwiftUI の裏側に UIKit のコンポーネントがいるということを利用します。 `List` の実装には `UITableView` が使われており、 Introspectが提供する `introspectTableView` メソッドを呼ぶことで `List` の裏の `UITableView` のインスタンスを取得できます。 `UITableView` のセパレータは `separatorStyle` に `.none` を指定することで簡単に消せ、その操作により `List` のセパレータが消えるというわけです。

このように、Introspect は裏で使われている UIView のインスタンスを通じて SwiftUI のコンポーネントをカスタマイズできるようにしてくれるライブラリです。もちろん場合によりますが、UIKit のコンポーネントを直接いじれるため基本的には UIKit で可能なカスタマイズであればそれを SwiftUI で実現することができます。

ここでは例として `List` を紹介しましたが、たとえば `ScrollView` の裏では `UIScrollView` が、 `TextField` の裏では `UITextField` が使われているので他の SwiftUI のコンポーネントに関しても UIKit を使ったカスタマイズが可能です。Introspect のメソッドは `.introspectX` という命名規則になっていて、たとえば `ScrollView` に対しては `.introspectScrollView` を呼ぶことで裏の `UIScrollView` を取得してカスタマイズすることができます。他にどのようなメソッドが利用可能で、それによりどの UIKit のコンポーネントが取得できるのかについては [README の表](https://github.com/siteline/SwiftUI-Introspect#implemented) を参照してください。

## Introspect の実装

Introspect が SwiftUI から UIKit を触っていることはわかりましたが、これがどのように実現されているのかかなり不思議ではないですか？自分はこのライブラリを最初に知ったときその仕組みが想像もできませんでした。気になって調べたことを以下にまとめます。

Introspect の仕組みを知る上で github の README にある以下の図が有用なのですが、初見ではなんのことかわからないのでこれを紐解いていくことからはじめます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/5299ec80-f5ea-55e1-a259-c6dafb605bf3.png)

この図は `TextField` に対して Introspect を使って裏の `UITextField` をカスタマイズしているときのことを表しています。Hosting View という View の下に View Host にラップされた `UITextField` があり、これが `TextField` の実体です。この `UITextField` がカスタマイズ対象なので Target View と書かれています。カスタマイズに使われる `IntrospectionUIView` は Introspect が差し込む View で、こちらも View Host にラップされており、その View Host は `UITextField` をラップする View Host と兄弟の関係になっています。
ひとまず登場人物である Hosting View と View Host が何者なのかについて知りたいですが、残念ながら README には説明がありません。これを知るために SwiftUI の View のヒエラルキーについて調べていきます。以降の話についてはまとまったドキュメントがなく、ネット上の断片的な情報や自分が行ったデバッグをもとに書いている部分があるため勘違いが含まれているかもしれません。もし何か発見された方はコメントで教えていただけると助かります。

### SwiftUI の View ヒエラルキー

簡単な SwiftUI のプロジェクトを作成して、その View ヒエラルキーがどのようになっているか見てみます。以下のような `Text` を表示するだけのアプリを作成します。

```swift
@main
struct SwiftUIIntrospectExplorerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    var body: some View {
        Text("Hello")
    }
}
```

アプリを Run してビューデバッガを起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/d714c064-1069-2523-d23c-559c20196cd6.png)


まず気になるのは Hosting View Controller で、これは UIKit の中で SwiftUI を使うときに開発者が作成する `UIHostingController` と同じものだと思います。このプロジェクトは SwiftUI のライフサイクルで作成しているのですが、それでもやっぱり画面には View Controller が使われているんですね。README の図にあった Hosting View はその Hosting View Controller の view であることがわかります。つまり、 Hosting View は SwiftUI で作る画面の root となる view のような存在と考えるのがよさそうです。

View ヒエラルキーを見る別の方法として、`UIView` のメソッドとして Obj-C から使える `recursiveDescription` があります。こちらの方法でどうなっているかも見てみましょう。lldb で以下のコマンドを実行して、 `UIWindow` 以下のヒエラルキーを出力します。

```swift
(lldb) expr -l objc++ -O -- [[UIWindow keyWindow] recursiveDescription]

<UIWindow: 0x151f0a610; frame = (0 0; 390 844); gestureRecognizers = <NSArray: 0x600001672b50>; layer = <UIWindowLayer: 0x600001834b40>>
   | <UITransitionView: 0x151d05b80; frame = (0 0; 390 844); autoresize = W+H; layer = <CALayer: 0x60000184c5a0>>
   |    | <UIDropShadowView: 0x151d05610; frame = (0 0; 390 844); autoresize = W+H; layer = <CALayer: 0x60000184cb80>>
   |    |    | <_TtGC7SwiftUI14_UIHostingViewGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__: 0x151f09360; frame = (0 0; 390 844); autoresize = W+H; gestureRecognizers = <NSArray: 0x600001672430>; layer = <CALayer: 0x600001834240>>
   |    |    |    | <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView: 0x151e0a450; frame = (175.667 418.333; 39 20.3333); anchorPoint = (0, 0); opaque = NO; autoresizesSubviews = NO; layer = <CALayer: 0x60000182eca0>>
```

この出力でも `UIHostingView` という文字列が含まれる View が存在し、これが Hosting View であることがわかります。その下に `SwiftUI` という文字列が名前に含まれる View があり、 `frame` の情報からこれが `Text` であることが想像できます。

SwiftUI のコンポーネントには裏で UIKit が使われているものと使われていないものがあることが知られており、上で確認した `Text` は後者の UIKit が使われていないコンポーネントです。 UIKit が使われているコンポーネントではどのようなヒエラルキーになっているのかも調べてみましょう。例として、先ほどのアプリの `Text` を `TextField` に置き換えてみます。`TextField` の実装には UIKit の `UITextField` が使われています。

```swift
struct ContentView: View {
    @State private var text: String = ""
    var body: some View {
        TextField("Hello", text: $text)
    }
}
```

ビューデバッガの出力は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/3ec693ec-087d-67a7-4b5d-3e8bd0327864.png)

`Text` に対しては存在しなかった View Host というコンポーネントが登場しており、その下に `UITextField` があります。`recursiveDescription` の出力も見てみましょう。Hosting View 以下のもののみ示します。

```swift
<_TtGC7SwiftUI14_UIHostingViewGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__: 0x122d09020; frame = (0 0; 390 844); autoresize = W+H; gestureRecognizers = <NSArray: 0x600002cbc900>; layer = <CALayer: 0x6000022f7e40>>
   | <_TtGC7SwiftUI16PlatformViewHostGVS_P10$1b9c0f56832PlatformViewRepresentableAdaptorGVS_P10$1b9b9664016_SystemTextFieldVS_20_TextFieldStyleLabel___: 0x122d0fdb0; frame = (0 417.667; 390 22); anchorPoint = (0, 0); tintColor = UIExtendedSRGBColorSpace 0 0.478431 1 1; layer = <CALayer: 0x60000228b200>>
   |    | <UITextField: 0x12301e400; frame = (0 0; 390 22); opaque = NO; gestureRecognizers = <NSArray: 0x600002cbf6c0>; placeholder = Hello; borderStyle = None; background = <_UITextFieldNoBackgroundProvider: 0x6000020c8460: textfield=<UITextField 0x12301e400>>; layer = <CALayer: 0x600002288cc0>>
   |    |    | <UITextFieldLabel: 0x122d10cf0; frame = (0 1; 390 20.3333); text = 'Hello'; opaque = NO; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x6000001dd4f0>>
   |    |    | <_UITextLayoutCanvasView: 0x122d06c70; frame = (0 0; 390 22); userInteractionEnabled = NO; layer = <CALayer: 0x600002289cc0>>
   |    |    |    | <_UITextLayoutFragmentView: 0x122d18740; frame = (0 17; 0 1); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x6000022add80>>
```

Hosting View の下に `ViewHost` や `ViewRepresentable` という文字列が含まれる `_TtGC7SwiftUI16PlatformViewHostGVS...` というコンポーネントがあり、その下に `UITextField` があることがわかりますね。この `_TtGC7SwiftUI16PlatformViewHostGVS...` が View Host で、SwiftUI の中で UIKit のコンポーネントをラップする役割を果たしているのではないかと想像できます。

View Host に含まれる `ViewRepresentable` という文字列が気になるので UIViewRepresentable を画面に置いた場合のヒエラルキーも見てみます。UIViewRepresentable は SwiftUI の中で UIView を使うための公式の仕組みです。UIViewRepresentable はそれ自体とても有用な仕組みですし、この記事で説明する Introspect の仕組みの理解にも役立ちます。以前に別の記事にまとめているので、もしよければ参照ください。

https://qiita.com/maiyama18/items/e36608af7e39f81af01c


UILabel をラップする `UILabelRepresentable` という UIRepresentable を作成し、これで `TextField` を置き換えてみます。

```swift
struct ContentView: View {
    var body: some View {
        UILabelReprentable(text: "Hello")
    }
}

struct UILabelReprentable: UIViewRepresentable {
    let text: String
    
    func makeUIView(context: Context) -> UILabel {
        UILabel()
    }
    
    func updateUIView(_ uiLabel: UILabel, context: Context) {
        uiLabel.text = text
    }
}
```

ビューデバッガと `recursiveDescription` の出力を示します。以下、 `recursiveDescription` の出力については、わかりやすさのためそのコンポーネントのクラスだけわかるように簡略化して表記します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/2376a2a0-7f77-0cb6-0d90-a062b8851f53.png)

```swift
<UIHostingView>
   | <ViewHost>
   |    | <UILabel>
```

こちらでも先ほどと同じく View Host が登場しています。やはり View Host は SwiftUI の中に UIKit のコンポーネントを置くためのラッパーであり、UIViewRepresentable の実装にも使われていることがわかります。

さて、ここまでで Introspect の README の図に使われている Hosting View と View Host がなんのことかわかるようになったのでまとめておきます。

- Hosting View : SwiftUI の画面は Hosting Controller で実装されている。その view が Hosting View
- View Host : SwiftUI の中で UIKit を包むためのコンポーネント。裏で UIKit を使っている SwiftUI の View や、UIViewRepresentable で使われている

ここまでで準備ができたので、以下で Introspect の実装を理解していきます。Introspect は、大まかに言うと

- カスタマイズ対象の UIView を探すためのエントリポイントとして `IntrospectionUIView` を View ヒエラルキーの中に差し込む
- `IntrospectionUIView` を起点としてカスタマイズ対象の UIView を探し出し、見つかった場合はライブラリ利用者が Introspect に渡したカスタマイズ用のクロージャを実行する

という２つのことを行います。それぞれ順番に説明していきます。

### `IntrospectionUIView` を View ヒエラルキー上に差し込む

Introspect の実装を読んでいきます。Introspectのコードやコメント以外に、以下の解説記事を参考にしています。

https://www.fivestars.blog/articles/swiftui-introspect/

以下のコードを例として考えます。 `TextField`  に自動で focus を当てるために `UITextField` の機能を使っています。余談ですが、改めてこれだけわずかなコード追加でやりたいことができる Introspect はよいライブラリだなと感じます。

```swift
struct ContentView: View {
    @State private var text: String = ""
    var body: some View {
        TextField("Hello", text: $text)
            .introspectTextField { uiTextField in
                uiTextField.becomeFirstResponder()
            }
    }
}
```

`introspectTextField` で `TextField` の裏にある `UITextField` のインスタンスを取得しています。この `introspectTextField` の実装を見てみると、以下のように中で `introspect` というメソッドを呼んでいます。

```swift
public func introspectTextField(customize: @escaping (UITextField) -> ()) -> some View {
    introspect(
        selector: TargetViewSelector.siblingContainingOrAncestorOrAncestorChild,
        customize: customize
    )
}
```

`introspect` では `inject` メソッドに `UIKitIntrospectionView` を渡しています。

```swift
public func introspect<TargetView: UIView>(
    selector: @escaping (IntrospectionUIView) -> TargetView?,
    customize: @escaping (TargetView) -> ()
) -> some View {
    inject(UIKitIntrospectionView(
        selector: selector,
        customize: customize
    ))
}
```

 `inject` は渡された View をサイズ 0 の `frame` に包んで `overlay` しています。

```swift
extension View {
    public func inject<SomeView>(_ view: SomeView) -> some View where SomeView: View {
        overlay(view.frame(width: 0, height: 0))
    }
}
```

ここまでの処理をまとめると、 `introspectTextField` はサイズ 0 の View を `overlay` しているだけであることがわかります。つまり、今回の例の元々のコードは以下と同等です。

```swift
struct ContentView: View {
    @State private var text: String = ""
    var body: some View {
        TextField("Hello", text: $text)
            .overlay(
                UIKitIntrospectionView(
                    selector: TargetViewSelector.siblingContainingOrAncestorOrAncestorChild,
                    customize: { uiTextField in
                        uiTextField.becomeFirstResponder()
                    }
                )
                .frame(width: 0, height: 0)
            )
        )
    }
}
```

こうなってくると、 `overlay` されている `UIKitIntrospectionView` が Introspect の主機能を担っていることがわかります。実装を見てみます。

```swift
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)
        }
    }
}

public class IntrospectionUIView: UIView {
    required init() {
        super.init(frame: .zero)
        isHidden = true
        isUserInteractionEnabled = false
    }
}
```

`UIKitIntrospectionView` は UIViewRepresentable であり、 `IntrospectionUIView` という UIView をラップしています。この `IntrospectionUIView` は何か機能を持っているわけではなく、単なるサイズが 0 で非表示の UIView です。 `recursiveDescription` で `IntrospectionUIView` が View のヒエラルキーに存在することを `recursiveDescription` の出力から確認してみます。

```swift
<HostingView>
   | <ViewHost> // TextField の実装に使われている View Host
   |    | <UITextField>
   | <ViewHost> // UIKitIntrospectionView による View Host
   |    | <Introspect.IntrospectionUIView>
```

`TextField` の実装のため、 `UITextField` をラップする View Host との兄弟になっている View Host は、 UIRepresentable である `UIKitIntrospectionView` の実体であり、Introspect が `overlay` することにより差し込まれたものです。

Hosting View の配下に２つの View Host があり、それぞれが `UITextField` と `IntrospectionUIView` を包んでいる...この様子に見覚えはないでしょうか？冷静に Introspect の README の図を思い出すと、上記の View ヒエラルキーとまったく同じ構図になっていることがわかります。以下に図を再掲します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/5299ec80-f5ea-55e1-a259-c6dafb605bf3.png)

ここまでの調査から、 `introspectTextField` はカスタマイズしたい対象である `TextField` の実体の `UITextField` を包んだ View Host の兄弟として `IntrospectionUIView` を包んだ View Host を差し込むということがわかりました。これでようやく README の図の意味が理解できました。以下で説明していきますが、 `IntrospectionUIView` は図で Target View として書かれているカスタマイズ対象の View を Introspect が見つけるための View ヒエラルキー上のエントリポイントとして働きます。

### カスタマイズ対象の UIView を探し出しクロージャを実行する

Introspect が差し込んだ `IntrospectionUIView` を起点にしてカスタマイズ対象の `UITextField` を探す処理を見ていきます。この処理は `IntrospectionUIView` をラップする UIViewRepresentable の `updateUIView` メソッドで行われます。コードを再掲します。

```swift
public struct UIKitIntrospectionView<TargetViewType: UIView>: UIViewRepresentable {
		// ...
    public func updateUIView(
        _ uiView: IntrospectionUIView,
        context: UIViewRepresentableContext<UIKitIntrospectionView>
    ) {
        DispatchQueue.main.async {
            guard let targetView = self.selector(uiView) else {
                return
            }
            self.customize(targetView)
        }
    }
}
```

`updateUIView` は一般に

- UIViewRepresentable のライフサイクルの最初に `makeUIView` が呼ばれた直後
- UIViewRepresentable が依存する状態が更新されたとき
- UIViewRepresentable のライフサイクルの終わりに `dismantleUIView` が呼ばれる直前

に呼ばれますが、 `UIKitIntrospectionView` においては状態が更新されることはないのと、ライフサイクルの終わりの呼び出しは今回の話では重要ではないので、 `UIKitIntrospectionView` が View ヒエラルキーに追加されて `makeUIView` が呼ばれた直後の呼び出しのみを考えればよいでしょう。呼び出される処理は全体が `DispatchQueue.main.async` の中で行われていますが、Introspect の基本的な仕組みに対してそこまで本質的ではないので、今回は気にせず中身のみを見ると以下のようになっています。

```swift
guard let targetView = self.selector(uiView) else {
    return
}
self.customize(targetView)
```

ここで、 `selector` は `introspectTextField` においては `TargetViewSelector.siblingContainingOrAncestorOrAncestorChild` で、 `customize` はライブラリの利用者が渡す `UITextField` をカスタマイズするためのクロージャです。まず `selector` に `UIKitIntrospectionView` を渡して呼ぶことでカスタマイズ対象の View を探し、見つかった場合に `customize` を呼んでライブラリの利用者が行いたいカスタマイズを実現しています。

まず  `TargetViewSelector.siblingContainingOrAncestorOrAncestorChild` の処理内容を見ます。

```swift
public static func siblingContainingOrAncestorOrAncestorChild<TargetView: PlatformView>(from entry: PlatformView) -> TargetView? {
    if let sibling: TargetView = siblingContaining(from: entry) {
        return sibling
    }
    return Introspect.findAncestorOrAncestorChild(ofType: TargetView.self, from: entry)
}
```

`TargetViewSelector` にはいろいろな static メソッドが生えていますが、すべてエントリポイントである `IntrospectionUIView` を起点としてカスタマイズ対象の UIView を見つけるという目的を持っていて、 `siblingContainingOrAncestorOrAncestorChild` はその１つです。いろいろなメソッドがあるのは対象の UIView の型ごとに `IntrospectionUIView` との位置関係が異なるためです。

`siblingContainingOrAncestorOrAncestorChild` では、まず `siblingContianing` という別のメソッドの方法に基づいて対象の UIView を探します。それでも見つからなかった場合は `findAncestorOrAncestorChild` を呼んで別の方針で探すというわけです。

以下では `siblingContaining` について説明しますが、 `findAncestorOrAncestorChild` も基本的には同じような処理を行なっています。それでは `siblingContaining` の実装を見てみます。

```swift
public static func siblingContaining<TargetView: PlatformView>(from entry: PlatformView) -> TargetView? {
    guard let viewHost = Introspect.findViewHost(from: entry) else {
        return nil
    }
    return Introspect.previousSibling(containing: TargetView.self, from: viewHost)
}
```

まず `findViewHost` を呼んで `entry` を包む View Host を取得しています。

```swift
public static func findViewHost(from entry: PlatformView) -> PlatformView? {
    var superview = entry.superview
    while let s = superview {
        if NSStringFromClass(type(of: s)).contains("ViewHost") {
            return s
        }
        superview = s.superview
    }
    return nil
}
```

前の項で見た `TextField` に対して `introspectionTextField` を呼んだ場合の View ヒエラルキーを再掲しておきます。

```swift
<HostingView>
   | <ViewHost> // TextField の実装に使われている View Host
   |    | <UITextField>
   | <ViewHost> // UIKitIntrospectionView による View Host
   |    | <Introspect.IntrospectionUIView> // エントリポイント
```

ここで `findViewHost` に渡される `entry` は `IntrospectionUIView` です。 `findViewHost` の実装はクラス名に `ViewHost` という文字列を含む superview を再帰的に探すようになっているので、ちゃんと Introspect が差し込んだ View Host が見つかることがわかります。

`siblingContaining` の中身では、見つかった View Host を `previousSibling` というメソッドに渡しています。 `previousSibling` を見てみましょう。

```swift
public static func previousSibling<AnyViewType: PlatformView>(
    containing type: AnyViewType.Type,
    from entry: PlatformView
) -> 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
}
```

`previousSibling` は、その名の通り View ヒエラルキー上の兄弟から条件に会う型を探します。今回の例では、 `type` は `UITextField` 、 `entry` は `IntrospectionUIView` を包む View Host です。

まずは `entry` の superview を取得しています。実際のヒエラルキーを見ると２つの View Host を含む Hosting View が superview であることがわかりますね。続いて、その Hosting View の subviews の中で `entry` である View Host よりもインデックスが小さい兄弟について `findChild` を呼ぶことで、兄弟の中に subview として含まれる `UITextField` を返しています。再び今回の例の実際のヒエラルキーを見直すと、カスタマイズしたかった `UITextField` がちゃんと返されることがわかると思います。うまくできていますね。

さて、ここまでがカスタマイズ対象の UIView を探す処理でした。以下の `UIKitIntrospectionView` の `updateUIView` のうち、 `selector` を呼んで `targetView` を取得するまでです。

```swift
guard let targetView = self.selector(uiView) else {
    return
}
self.customize(targetView)
```

ここまでで見てきた流れで `targetView` として対象の `UITextField` が返ってくるので、あとはライブラリの利用者が渡した `customize` をそれに対して呼んであげることで望みのカスタマイズが実行されます。今回の例では `.becomeFirstResponder()` を呼ぶクロージャを渡しているのでその呼び出しによりテキストフィールドに focus が当たることになりますね。

以上が、 `introspectTextField` によって `TextField` のカスタマイズが可能になる仕組みです。ここでは `TextField` の例について説明しましたが、他の View に対して呼ぶ `introspectScrollView` や `introspectTableView` 、 `introspectTextView` も基本的には同じ仕組みを利用しており、差し込んだ `IntrospectionUIView` を起点に View ヒエラルキーから対象の UIView を見つけて利用者が渡したクロージャを実行するということを行なっています。違うのは `IntrospectionUIView` から対象の UIView を探す方法、すなわち `UIKitIntrospectionView` の `selector` で、これは UIView の種類によって View ヒエラルキーが異なるからです。

## Introspect の安全性

ここまで見てきたように、 Introspect の動作は SwiftUI のコンポーネントの実装に依存しているため新しい SwiftUI のバージョンで急に想定通り動かなくなるリスクがあります。具体的には View の ヒエラルキーが変わったりそもそも SwiftUI の裏で UIKit が使われなくなったりすると、 `selector` が対象の View を見つけることができなくなるためユーザが `customize` として渡したクロージャが呼ばれなくなります。もちろんカスタマイズが実行されなくなるだけでクラッシュするわけではありませんが、利用する上で注意は必要でしょう。

個人的には、 Introspect は新しい iOS のバージョンで公式で使える SwiftUI の機能を古い iOS でも擬似的に実現するために使うのがよいと思っています。たとえば要素間にセパレータのない縦スクロールのリストを表示したいという要件があるとします。iOS 14 以降では iOS 14 で追加された `LazyVStack` を `ScrollView` の中に入れて使えばよいですが、iOS 13 では実現が難しいです。iOS 13 でも使える `VStack` は画面に表示されていない要素も一度に生成してしまうのでパフォーマンスの問題があるし、 `List` は要素の間にセパレータが入ってしまうので SwiftUI 公式の方法では実現ができません。

この問題に対して、以下のように iOS 13 でのみ Introspect を使って `List` からセパレータを消す実装をすれば iOS 13 でも要件を満たすことができます。また、それ以降の iOS バージョンでは公式の実装しか使っていないので新しい SwiftUI で内部実装が変わることにより急に表示が壊れるリスクを抑えることができます。

```swift
if #available(iOS 14.0, *) {
    ScrollView {
        LazyVStack {
            // ...
        }
    }     
} else {
    List {
        // ...
    }
    .introspectTableView {
        $0.separatorStyle = .none
    }
}
```

## 類似の方法との比較

SwiftUI に不足した機能を補う方法は Introspect 以外にもあります。ここでは UIViewRepresentable と SwiftUIX を紹介します。

### UIViewRepresentable / UIViewControllerRepresentable

まず、UIViewRepresentable は UIKit を SwiftUI にラップするための公式の方法です。Introspect でも使われているため、この記事でも少し説明しています。UIView をラップする UIViewRepresentable に対応して、UIViewController をラップする UIViewControllerRepresentable もあります。この記事では Introspect について掘り下げましたが、公式の方法ですし動かなくなるリスクが低いので、 SwiftUI に不足を感じたら基本的には何らかのライブラリを入れるのは最終手段にしてまずは UIViewRepresentable を使うのがいいのではないかと思います。

### SwiftUIX

UIViewRepresentable はそこそこ記述が面倒です。また、みんな同じような UIViewRepresentable を書いているはずなのに車輪の再発明をしたくないと思うこともあるでしょう。この問題を解決するライブラリとして SwiftUIX があります。

https://github.com/SwiftUIX/SwiftUIX

SwiftUIX では UIKit の機能を持った SwiftUI のコンポーネントを用意してくれていて、開発者はそのコンポーネントを使うだけでよくなっています。 README を読むとわかりますが、SwiftUI に存在しない `UICollectionView` や `UISearchBar` などに対応する SwiftUI のコンポーネントもあります。また、 `UIScrollView` に対応する SwiftUI の View にはすでに `ScrollView` がありますが、SwiftUIX にも別途 `CocoaScrollView` という View が存在します。 `CocoaScrollView` には `UIScrollView` にあって `ScrollView` にない機能が入っていて、例えば pull to refresh を簡単に実現することができます。

SwiftUIX が提供する View の内部実装を見ると、 SwiftUI で自前実装しているものもありますがほとんどは UIViewRepresentable が使われているため、 OS のバージョンアップで動かなくなるなどのリスクは低いと思います。ただ、SwiftUIX を使い出すとサードパーティのライブラリに強く依存することになるので抵抗がある方も多いでしょう。とは言え、とても便利であることは間違いないので検討してみるのはありだと思います。

## まとめ

- Introspect は SwiftUI のコンポーネントに対して UIKit と同等のカスタマイズを実現するライブラリです
- SwiftUI の実装に UIKit が使われていることを利用しており、View ヒエラルキーから対象の UIView のインスタンスを探し出し、ユーザが渡したカスタマイズのためのクロージャを適用してくれます
- 新しい SwiftUI で動作しなくなる可能性がありますが、古い OS バージョンに限定して使うことでそのリスクを抑えることができます
- SwiftUI の機能不足に困ったらまず UIViewRepresentable を使うことを考えるべきだと思いますが、状況によっては Introspect を検討してみるのもよいのではないでしょうか

## 参考

- [https://github.com/siteline/SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect)
- [https://www.fivestars.blog/articles/swiftui-introspect/](https://www.fivestars.blog/articles/swiftui-introspect/)
- [https://www.objc.io/books/thinking-in-swiftui/](https://www.objc.io/books/thinking-in-swiftui/)
- [https://www.hackingwithswift.com/books/ios-swiftui/wrapping-a-uiviewcontroller-in-a-swiftui-view](https://www.hackingwithswift.com/books/ios-swiftui/wrapping-a-uiviewcontroller-in-a-swiftui-view)
- [https://stackoverflow.com/questions/25900606/recursivedescription-method-in-swift/52141481](https://stackoverflow.com/questions/25900606/recursivedescription-method-in-swift/52141481)
- [https://qiita.com/maiyama18/items/e36608af7e39f81af01c](https://qiita.com/maiyama18/items/e36608af7e39f81af01c)
- [https://github.com/SwiftUIX/SwiftUIX](https://github.com/SwiftUIX/SwiftUIX)