42
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

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

概要

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

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

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

検証環境

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

  • Xcode 13.2 Beta 2
  • iOS 15.2

Introspect の使い方

たとえば SwiftUI の List についてくる要素間のセパレータを消したいとします。iOS 15 で追加された listRowSeparator という modifier を使えば簡単に消せるのですが、それ以前の OS では別の方法を考える必要があります。

この問題を解決する方法の1つに SwiftUI-Introspect というライブラリの利用があります。Introspect を使って以下のように書くことで List のセパレータを消すことができます。

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 の表 を参照してください。

Introspect の実装

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

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

image.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 を表示するだけのアプリを作成します。

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

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

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

image.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 以下のヒエラルキーを出力します。

(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 が使われているコンポーネントではどのようなヒエラルキーになっているのかも調べてみましょう。例として、先ほどのアプリの TextTextField に置き換えてみます。TextField の実装には UIKit の UITextField が使われています。

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

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

image.png

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

<_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 の下に ViewHostViewRepresentable という文字列が含まれる _TtGC7SwiftUI16PlatformViewHostGVS... というコンポーネントがあり、その下に UITextField があることがわかりますね。この _TtGC7SwiftUI16PlatformViewHostGVS... が View Host で、SwiftUI の中で UIKit のコンポーネントをラップする役割を果たしているのではないかと想像できます。

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

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

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

<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 に渡したカスタマイズ用のクロージャを実行する

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

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

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

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

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

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

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

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

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 しています。

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

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

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 の主機能を担っていることがわかります。実装を見てみます。

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 です。 recursiveDescriptionIntrospectionUIView が View のヒエラルキーに存在することを recursiveDescription の出力から確認してみます。

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

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

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

image.png

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

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

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

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 の基本的な仕組みに対してそこまで本質的ではないので、今回は気にせず中身のみを見ると以下のようになっています。

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

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

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

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 はその1つです。いろいろなメソッドがあるのは対象の UIView の型ごとに IntrospectionUIView との位置関係が異なるためです。

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

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

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 を取得しています。

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 ヒエラルキーを再掲しておきます。

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

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

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

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 ヒエラルキー上の兄弟から条件に会う型を探します。今回の例では、 typeUITextFieldentryIntrospectionUIView を包む View Host です。

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

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

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

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

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

Introspect の安全性

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

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

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

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 があります。

SwiftUIX では UIKit の機能を持った SwiftUI のコンポーネントを用意してくれていて、開発者はそのコンポーネントを使うだけでよくなっています。 README を読むとわかりますが、SwiftUI に存在しない UICollectionViewUISearchBar などに対応する 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 を検討してみるのもよいのではないでしょうか

参考

42
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?