SwiftUI の裏側:Variadic View
更新履歴
- 2024/06:iOS 18 に追加された
Group.init(subviewsOf:transform:)
について説明 - 2024/09:iOS 13から18まで統一な API で Subview にアクセスできるライブラリ SwifUI-Anysubviews を作りました
この記事は、SwiftUI の Variadic View という半公開的な API を使い、独自のコンポーネントの API をより SwiftUI らしいものにする方法を紹介します。
背景
SwiftUI では TupleView
という View があります。通常は自分で作成することはないが、ViewBuilder の中に複数の View が書かれた場合、最終的に構造された View の型は TupleView
になります。
let stack = VStack {
Text("First")
Text("Second")
Text("Third")
}
print(type(of: stack))
// VStack<TupleView<(Text, Text, Text)>>
TupleView
や、私たちが普段使っている Group
、ForEach
は、View のレイアウトや描画に影響せず、ただ View の集合体として、外側の List
や VStack
などのコンテナに内容を提供しているだけです。最終的にこれらの View をどう描画するのかはコンテナによって違います。
例えば、List
はその内容をリストに描画した上で、各 View の間に Separator を差し込みます:
List {
ForEach(texts, id: \.self) { Text($0) }
}
ここで問題。List
はどのように ForEach
や TupleView
の中にある内容を取得しているでしょうか?私たちも似たようなコンテナ View を作成したいとき、同じことはできますか?
例えば、DividedVStack
というコンポネントを実装したくて、DividedVStack
は ViewBuilder で任意の View を受け取り、その中の要素に Divider
を差し込んでから VStack
でレイアウトする:
// こういうものがほしい
DividedVStack {
ForEach(texts, id: \.self) { text in
Text(text)
}
}
struct DividedVStack<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
// ...ここに何を書く?
content() // ただの some View
}
}
だが実際に書いてみると、困ってしまうことになるでしょう。なぜなら、外部から Content View を受け取る場合、ViewBuilder を使うのが一般的ですが、そのパラメータの型は some View
でしかなく、その中にある要素にアクセスする方法は公開されていません。そのため各要素の間に何かを挿入することもできません。
結局こう書くことになる。
VStack {
ForEach(texts, id: \.self) { text in
Text(text)
if text != texts.last {
Divider()
}
}
}
しかし、実はその方法は存在しています。それが Variadic View というもの。
iOS 18 更新:その方法が公開されました
iOS 18 でコンテナ View を作るための、Subview を取得する方法が Group の元に追加されました。
API の構造と使用方法は、これから紹介する Variadic View とは全く同じですね。
struct CardsView<Content: View>: View {
var content: Content
init(@ViewBuilder content: () -> Content) {…}
var body: some View {
VStack {
Group(subviews: content) { subviews in
HStack {
if subviews.count >= 2 {
SecondaryCard { subview[1] }
}
if let first = subviews.first {
FeatureCard { first }
}
if subviews.count >= 3 {
SecondaryCard { subviews[2] }
}
}
if subviews.count > 3 {
subviews[3...]
}
}
}
}
}
また、独自の Key を定義し、Subview にその Key と紐づいた値を保持させる方法、iOS 17 までは_ViewTraitKey
があったが(本記事では紹介されなかったが)、iOS 18 では ContainerValueKey
として追加されました。
そのため、iOS 18 では上記の API、iOS 17 までは本記事で紹介した Variadic View で、うまく使い分けてください。また、統一された API で使いたい場合、筆者が作った SwiftUI-AnySubviews を使っていただければと。
Variadic View の存在を探る
SwiftUI の API ファイル [1] を見ていきます。VStack
の部分で、このようなものがあります:
@frozen
public struct VStack<Content>: View where Content: View {
@usableFromInline
internal var _tree: _VariadicView.Tree<_VStackLayout, Content>
@inlinable
public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
_tree = .init(
root: _VStackLayout(alignment: alignment, spacing: spacing),
content: content()
)
}
}
私たちが VStack
を使う時、SwiftUI は _VStackLayout
を生成し、Content と合わせて_VariadicView.Tree
というものを作成しています。_VStackLayout
は VStack
のレイアウトを担当していることでしょう。続いて _VariadicView.Tree
を見ていきます:
public enum _VariadicView { // ← ただの namespace としての enum
@frozen
public struct Tree<Root, Content> where Root: _VariadicView_Root {
public var root: Root
public var content: Content
@inlinable
internal init(root: Root, content: Content) {
self.root = root
self.content = content
}
}
}
_VariadicView.Tree
は2つのジェネリック型を受け、1つは Content、もう1つは _VariadicView_Root
に準拠している Root。
更に、Content が View
に、Root が _VariadicView_ViewRoot
というものに準拠している場合、_VariadicView.Tree
も View
になります。VStack
がこのパターンに当てはまります。
extension _VariadicView.Tree: View where Root: _VariadicView_ViewRoot, Content: View {}
@frozen public struct _VStackLayout { ... }
extension _VStackLayout: _VariadicView_ViewRoot { ... }
そしてプロトコルの _VariadicView_ViewRoot
の内容が面白いです:
public protocol _VariadicView_ViewRoot: _VariadicView_Root {
// ...
associatedtype Body: View
@ViewBuilder
func body(children: _VariadicView_Children) -> Body
}
コードの構造が ViewModifier
と似てますね。
このプロトコルの唯一の関数は _VariadicView_Children
というものを受け取り、View を出力しています。
では最後に _VariadicView_Children
を見ていきます:
public struct _VariadicView_Children {}
extension _VariadicView_Children: RandomAccessCollection {
public struct Element: View, Identifiable {
public var id: AnyHashable { get }
}
}
_VariadicView_Children
は RandomAccessCollection
であり、その要素の Element が View そのもので、更に Identifiable
にも準拠しています(= id が取得できる)。これは「View の中にある各要素を取得する」ために最適なものなのでは?
どうやら _VariadicView.Tree
は Content View を _VariadicView_Children
に変換し、_VariadicView_ViewRoot
の body(children:)
に代入して最終的な View を生成してくれるようです。
つまり自前で _VariadicView_ViewRoot
に準拠するものを作って、_VariadicView.Tree
と組み合わせれば、Child View たちにアクセスできるようになりますね。
使ってみる
早速 _VariadicView_ViewRoot
を使って DividedVStackLayout
を作ってみます。
struct DividedVStackLayout: _VariadicView_ViewRoot {
@ViewBuilder
func body(children: _VariadicView.Children) -> some View {
VStack {
// 1
ForEach(children) { child in
// 2
child
// 3
if child.id != children.last?.id {
Divider()
}
}
}
}
}
- 上述の通り、
_VariadicView_Children
はRandomAccessCollection
であり、Element もIdentifiable
で、そのままForEach
に代入できます - Element も View そのもので、そのまま使えますね
- そして嬉しいことに id も付いていて、View が
Equatable
じゃなくても比較に使えます
次に、DividedVStackLayout
と Content を用意して _VariadicView.Tree
に渡します。
struct DividedVStack<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
_VariadicView.Tree(DividedVStackLayout(), content: content)
}
}
これで、シンプルな DividedVStack
の完成です。
DividedVStack {
Text("First")
Text("Second")
Text("Third")
}
より汎用的にする
「View の各要素を取得したい」時に毎回 _VariadicView_ViewRoot
に触れなきゃいけないのは不便なので、より汎用的な方法を用意します:
// 1
private struct Root<Result: View>: _VariadicView_ViewRoot {
let childrenHandler: (_VariadicView_Children) -> Result
func body(children: _VariadicView_Children) -> some View {
childrenHandler(children)
}
}
extension View {
// 2
func variadic<Result: View>(
@ViewBuilder childrenHandler: @escaping (_VariadicView_Children) -> Result
) -> some View {
_VariadicView.Tree(
Root(childrenHandler: childrenHandler),
content: { self }
)
}
}
- プロトコル
_VariadicView_ViewRoot
の代理用構造体を用意し、そのbody
の実装をクロージャーの形で外側から注入できるようにします -
variadic
という View の extension func 作って、そのクロージャーを更に外側に委ねます。クロージャーのパラメータは View を分解して得た Child View たち
これで View の中に各要素にアクセスしたいとき、.variadic
を呼んで、クロージャーの中に提供された Child View たちで View を構築することができます。
例えば、各要素に任意の Separator View を差し込む Interleave
、このように実装していきます:
struct Interleave<Content: View, Separator: View>: View {
@ViewBuilder let content: () -> Content
@ViewBuilder let separator: () -> Separator
var body: some View {
content().variadic { children in
ForEach(children) { child in
child
if child.id != children.last?.id {
separator()
}
}
}
}
}
これで、柔軟性が高く、綺麗なコードの仕上げです:
VStack {
Interleave {
Text("First")
Text("Second")
Text("Third")
} separator: {
// 例えば自前実装の破線
HDashline()
}
}
App Store の審査的に大丈夫?
かなり危険なものを触ってきましたね。
アンダーラインが付いていて、ドキュメントも全く存在しない API ですが、使うと審査で却下されませんか?
大丈夫だ、問題ない
このテクニックを実際のアプリで使っている Andrew Zheng 氏 と Ryan Lintott 氏 にツイッターで聞いてみたのですが、二人とも問題ないとおっしゃいました。
筆者も普通に使っています。
~iOS 17 と iOS 18 を統一する
VariadicView と iOS 18 の新 API が似てるので、両者をラップして統一的な API で使えるライブラリを作りました、よければ使ってみてください。
まとめ
この記事は、VStack
を切り口に、SwiftUI の内部に使われている Variadic View について調べました。Variadic View は Stack や List などのコンテナ View で、受け取った Child View たちにアクセスするために使われています。
Variadic View を使って、私たちも View の、特に ViewBuilder で作られた TupleView
や ForEach
など、View の集合体の中にある Child View たちを一つずつアクセスすることができるようになりました。
そのおかげで、自作コンポネントの種類がより多くて、API もよりシンプルで、SwiftUI らしいものなりました。
参考
-
ターミナルで
/Applications/Xcode.app
に移動して、find . -name "SwiftUI.swiftmodule"
を入力して検索できます。arm64-apple-ios.swiftinterface
のようなファイルにいろんな API が見れます。.swift
に改名してから開くと読みやすくなります。 ↩︎
Discussion