これは 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 にある以下の図が有用なのですが、初見ではなんのことかわからないのでこれを紐解いていくことからはじめます。
この図は 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 してビューデバッガを起動します。
まず気になるのは 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 が使われているコンポーネントではどのようなヒエラルキーになっているのかも調べてみましょう。例として、先ほどのアプリの Text
を TextField
に置き換えてみます。TextField
の実装には UIKit の UITextField
が使われています。
struct ContentView: View {
@State private var text: String = ""
var body: some View {
TextField("Hello", text: $text)
}
}
ビューデバッガの出力は以下のようになります。
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 の下に ViewHost
や ViewRepresentable
という文字列が含まれる _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
の出力については、わかりやすさのためそのコンポーネントのクラスだけわかるように簡略化して表記します。
<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()
}
}
}
introspectTextField
で TextField
の裏にある 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 です。 recursiveDescription
で IntrospectionUIView
が 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 があり、それぞれが UITextField
と IntrospectionUIView
を包んでいる...この様子に見覚えはないでしょうか?冷静に Introspect の README の図を思い出すと、上記の View ヒエラルキーとまったく同じ構図になっていることがわかります。以下に図を再掲します。
ここまでの調査から、 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)
ここで、 selector
は introspectTextField
においては TargetViewSelector.siblingContainingOrAncestorOrAncestorChild
で、 customize
はライブラリの利用者が渡す UITextField
をカスタマイズするためのクロージャです。まず selector
に UIKitIntrospectionView
を渡して呼ぶことでカスタマイズ対象の 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
に渡される entry
は IntrospectionUIView
です。 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 ヒエラルキー上の兄弟から条件に会う型を探します。今回の例では、 type
は UITextField
、 entry
は IntrospectionUIView
を包む View Host です。
まずは entry
の superview を取得しています。実際のヒエラルキーを見ると2つの View Host を含む Hosting View が superview であることがわかりますね。続いて、その Hosting View の subviews の中で entry
である View Host よりもインデックスが小さい兄弟について findChild
を呼ぶことで、兄弟の中に subview として含まれる UITextField
を返しています。再び今回の例の実際のヒエラルキーを見直すと、カスタマイズしたかった UITextField
がちゃんと返されることがわかると思います。うまくできていますね。
さて、ここまでがカスタマイズ対象の UIView を探す処理でした。以下の UIKitIntrospectionView
の updateUIView
のうち、 selector
を呼んで targetView
を取得するまでです。
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 で内部実装が変わることにより急に表示が壊れるリスクを抑えることができます。
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 に存在しない 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://www.fivestars.blog/articles/swiftui-introspect/
- https://www.objc.io/books/thinking-in-swiftui/
- 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://qiita.com/maiyama18/items/e36608af7e39f81af01c
- https://github.com/SwiftUIX/SwiftUIX