iOSエンジニアの入江です。HStack{ }のように宣言的なViewパーツを自作する方法についての投稿です。
課題
複数の画面で、画面のライフサイクルをトリガーとする同じような処理を行う機能(例えばロギングなど)がある場合、UIKitのViewControllerはクラスなので継承が可能です。viewDidLoadなどで共通処理を実装した継承用の基盤クラスを作成し、その基盤クラスを継承することで、簡単に複数の画面で共通処理を実現できます。
しかし、SwiftUIの場合はレイアウト要素の宣言がクラスではなく構造体で書かれるので、継承ができません。毎回全てのSwiftUIViewに対してonAppearやonDisappearを書き加えるという設計は、どこかで書き漏れ・書き間違えの事故が起きそうなので、避けたいです。
解決策
そこで、HStack { }のようなパーツの構造体を自分で定義し、その中で共通化したい処理を定義すれば、SwiftUIの宣言ルールに則りつつ拡張的に処理を追加できます。
まず、完成形が下記の通りです。特定の画面が表示されたらロギングを開始し、画面を離れる際にロギングを完了するような仕様を想定しています。
struct Logging<Content>:View where Content: View {
let content: () -> Content
public init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
let loggingContent = content()
.onAppear() { print("ロギング開始") }
.onDisappear() { print("ロギング終了") }
return loggingContent
}
}
使い方は下記の通りです。SwiftUIの宣言ルールを守りつつ、簡単かつ明示的に共通処理を呼ぶことができます。
struct SampleSwiftUIView: View {
var body: some View {
Logging {
VStack {
Text("sample")
Text("sample")
}
HStack {
Text("sample")
Text("sample")
}
}
}
}
SwiftUIの仕組み
この実装を理解するには、そもそもSwiftUIの宣言が、関数化された構造体のネストによって表現されていることを理解する必要があります。
SwiftUIは、HStackなどのパーツを階層ごとにネストさせていくことで、レイアウトの階層や親子関係を宣言的に表現できていますが、なぜこれが実現できているのかというと、構造体を関数化することで、関数型プログラミング式に、常にContent型の結果を返す関数を呼び出し続けているからです。
関数なので、途中でContent型以外の値が返されることが許されず、つまり、SwiftUIのView構築ルールから逸脱するような宣言はできないようになっています。 構造体を関数化するには、イニシャライザの引数に関数を設定します。実際にHStackのイニシャライザを見てみると、
@inlinable public init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
このように、Content型を返す関数が引数として用意されています。
また、構造体自身がContent型のジェネリクスになっていることで、HStackの中にHStackを入れて、Viewのパーツをどんどんネストさせていく・・・ということが可能になっています。
さらに、@ViewBuilderがあることで、引数のContentが複数であっても、関数の呼び出し元(=親パーツ)では1つのContent型としてまとめることができます。
これを理解できれば、自分で好きなContent型の構造体を定義することが可能です。
今回はViewのライフサイクルに応じた拡張を行いたいので、.onAppear()・onDisappear()を活用したいのですが、.onAppear()・onDisappear()は、Content型ではなく、Content型より限定されたView型が持つメソッドであるため、構造体はContent型のジェネリクスであることに加えて
struct Logging<Content>:View where Content: View {
View型という制約付きにしなければいけない点もポイントです。
注意点
宣言する階層によって関数の作用対象が異なるので、例えばLoggingの中にNavigationStackを宣言してしまうと、NavigationStackが消えるまで.onDisappearが呼ばれないため、意図した挙動にならない場合があります。Navigation遷移している中身の画面のライフサイクルに合わせたいのであれば、NavigationStackの中でLoggingを宣言する必要があります。
まあこのあたりの注意はSwiftUI全体に言えることですね。 逆に言えば、宣言する箇所次第で作用を限定できるので、共通処理対象を柔軟にコントロールできるメリットがあるとも言えます。
最後に
様々な共通処理に応用できそうなので、楽しくなって大量のカスタムViewを作成してしまいそうですが、注意点に書いてある通り、SwiftUIは楽に書けるようで階層を意識しないで書くと落とし穴があるので、用法用量を守って正しく使おうと思います。
最後に、少しでも弊社で働くことに興味がおありでしたらぜひ求人もチェックして見てください。 www.openwork.co.jp