The Pragmatic Ball boy

iOSを中心にやってる万年球拾いの老害エンジニアメモ

SwiftUI-Introspectの仕組み

これはiOSアドベントカレンダー2021その3の25日目の記事です!

SwiftUIのViewに対応するUIKitの参照をとってくるSwiftUI-Introspectという謎ライブラリがあります。

なんのことを言っているのかわからないと思うので例をあげると、SwiftUI-Introspectを使うと以下のようにSwiftUIのTextFieldをUITextFieldのAPIを使って変更したりすることができるのです。

TextField("Text Field", text: $textFieldValue)
.introspectTextField { textField in
    textField.layer.backgroundColor = UIColor.red.cgColor
}

なぜこのようなことができるかというと、SwiftUIには色々Viewがありますが、PureなSwiftUIで実装されているViewはXcode13の時点でText, Image, Buttonだけで、それ以外は中身は実はUIKitだからです。以下の表がSwiftUIとUIKitの対応表になっています。

f:id:yanamura:20211223233914p:plain
SwiftUI-IntrospectのGitHubより抜粋

SwiftUI-IntrospectiveはSwiftUIの中身のUIKitの参照をとってきているわけなのです。

それではSwiftUI-IntrospectiveがどうやってUIKitの参照を取得しているのか具体的にみていきたいと思います。

PureじゃないSwiftUIのViewの実装

まず、PureじゃないSwiftUIのView(つまりText, Image, Button以外のView)はどうやって実装されているのか調べてみます。

仮説としてはUIKitをSwiftUIとして使うためにUIViewRepresentableが用意されているのでこれが使われているのではないかと思われます。この仮説があっているか検証してみます。

まずUIViewRepresentableにするとどうなるのか確認してみます。 このようにWKWebViewをUIViewRepresentableでSwiftUI化します。

struct HogeView: UIViewRepresentable {

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.load(URLRequest(url: URL(string: "https://google.com")!))
    }
}

struct ContentView: View {
    @State private var isOn = true
    var body: some View {
        HogeView()
    }
}

そしてDebug View HierarchyでViewf:id:yanamura:20211224000229p:plainの構造を見てみます。

そうするとContentViewと同階層にWKWebViewを内包したViewHostが存在しています。 f:id:yanamura:20211223235706p:plain

ViewHostは厳密には PlatformViewHost<PlatformViewRepresentableAdaptor<HogeView>> というClassになっていました。どうやらUIViewRepresentableを使うとViewHostというViewでUIKitのViewを内包するようです。

次にSwiftUIのViewのToggleを見てみます

struct ContentView: View {
    @State private var isOn = true
    var body: some View {
        Toggle(isOn: $isOn, label: {
            Text("Label")
        })
    }
}

こちらもViewHostにUISwitchが内包されていました。

f:id:yanamura:20211224000229p:plain

ということで、ToggleなどといったSwiftUIのViewも実はUIViewRepresentableを使ってUIKitをwrapしてSwiftUIのように扱えるようにしていたということになりそうです。

どうやってUIKitの参照を取得するか

SwiftUI-IntrospectiveのようにUIViewRepresentableでwrapされたUIKitの参照をとってくるにはどうすればよいでしょうか? すぐに思いつくのはView階層をたどるというアイデアです。

しかし残念ながらSwiftUIのViewにはUIViewのsuperviewやsubviewsのようにView階層をたどるようなAPIは提供されていません。

ではどうするかというと、UIKitからはView階層をたどっていくことができるので、UIViewRepresentableで見えないViewを用意し、目当てのViewにoverlayする形で配置します。そして、UIViewRepresentableのupdateUIView でUIViewRepresentableがwrapしているUIKitにアクセスしそのViewから階層をたどっていくことで目当てのViewのUIKitを取得します。

言葉だとわかりづらいので簡単なサンプルをつくってみました。

struct InspectorView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        // 見えないUIViewを生成
        let view = UIView(frame: .zero)
        view.isHidden = true
        view.isUserInteractionEnabled = false
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        DispatchQueue.main.async {
            // InspectorViewの親がViewHostで、さらにその親の子を探索してUISwitchを探す
            guard let viewhost = uiView.superview else { return }
            guard let superview = viewhost.superview else { return }
            let targetView = findChild(type: UISwitch.self, in: superview)
            print(targetView)
        }
    }

    func findChild<T: UIView>(type: T.Type, in view: UIView) -> UIView? {
        for subview in view.subviews {
            if let foundView = subview as? T {
                return foundView
            } else if let foundView = findChild(type: type, in: subview) {
                return foundView
            }
        }
        return nil
    }
}

あとは、対象のViewにこのInspectorViewをoverlayしてやるだけです。

struct ContentView: View {
    @State private var isOn = true
    var body: some View {
        Toggle(isOn: $isOn, label: {
            Text("Label")
        })
        .overlay(InspectorView())
    }
}

こちらを実行すると、以下のようにUISwitchのインスタンスが取得できていることが確認できます。

Optional(<UISwitch: 0x7fd2df411590; frame = (0 0; 51 31); layer = <CALayer: 0x600000aef400>>)

Viewの構造はこのようになっており、一番下のViewHostがIntrospectorViewのViewHostでこれを介してUISwitchを探しています。ViewHost同士が同じ階層にあるので、他にもたくさんViewがあったりすると目当てのUIKit以外を間違って探してしまわないかといった不安も感じます。Viewの構造に依存しているので危うさは非常にありますね。 f:id:yanamura:20211224003823p:plain

まとめ

SwiftUIのViewのうちText, Image, ButtonだけがいまのところPureなSwiftUIのViewで、それ以外はUIViewRepresentableを使ってUIKitを内包しているだけでした。

SwiftUI-IntrospectiveはUIViewRepresentableを使ってView階層をたどってUIKitを取得しています。よって、Viewの構造が変わってしまうと動かなくなってしまうので、今後のアップデートにより動かなくなる可能性があり注意する必要があります。