👨‍👩‍👧‍👦

SwiftUI の裏側:Variadic View

2023/05/08に公開
3

更新履歴

  • 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 や、私たちが普段使っている GroupForEach は、View のレイアウトや描画に影響せず、ただ View の集合体として、外側の ListVStack などのコンテナに内容を提供しているだけです。最終的にこれらの View をどう描画するのかはコンテナによって違います。

例えば、List はその内容をリストに描画した上で、各 View の間に Separator を差し込みます:

List {
    ForEach(texts, id: \.self) { Text($0) }
}

ここで問題。List はどのように ForEachTupleView の中にある内容を取得しているでしょうか?私たちも似たようなコンテナ 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 の元に追加されました。

https://developer.apple.com/documentation/swiftui/group/init(subviews:transform:)

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 として追加されました。

https://developer.apple.com/documentation/swiftui/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 というものを作成しています。_VStackLayoutVStack のレイアウトを担当していることでしょう。続いて _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.TreeView になります。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_ChildrenRandomAccessCollection であり、その要素の Element が View そのもので、更に Identifiable にも準拠しています(= id が取得できる)。これは「View の中にある各要素を取得する」ために最適なものなのでは?

どうやら _VariadicView.Tree は Content View を _VariadicView_Children に変換し、_VariadicView_ViewRootbody(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()
                }
            }
        }
    }
}
  1. 上述の通り、_VariadicView_ChildrenRandomAccessCollection であり、Element も Identifiable で、そのまま ForEach に代入できます
  2. Element も View そのもので、そのまま使えますね
  3. そして嬉しいことに 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 }
        )
    }
}
  1. プロトコル _VariadicView_ViewRoot の代理用構造体を用意し、その body の実装をクロージャーの形で外側から注入できるようにします
  2. 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 で使えるライブラリを作りました、よければ使ってみてください。

https://github.com/Lumisilk/SwiftUI-AnySubviews

まとめ

この記事は、VStack を切り口に、SwiftUI の内部に使われている Variadic View について調べました。Variadic View は Stack や List などのコンテナ View で、受け取った Child View たちにアクセスするために使われています。

Variadic View を使って、私たちも View の、特に ViewBuilder で作られた TupleViewForEach など、View の集合体の中にある Child View たちを一つずつアクセスすることができるようになりました。
そのおかげで、自作コンポネントの種類がより多くて、API もよりシンプルで、SwiftUI らしいものなりました。

参考

https://movingparts.io/variadic-views-in-swiftui

https://chris.eidhof.nl/post/swiftui-views-are-lists/

https://chris.eidhof.nl/post/variadic-views/#fnref-rev1

脚注
  1. ターミナルで /Applications/Xcode.appに移動して、find . -name "SwiftUI.swiftmodule" を入力して検索できます。arm64-apple-ios.swiftinterfaceのようなファイルにいろんな API が見れます。.swiftに改名してから開くと読みやすくなります。 ↩︎

Discussion

KyomeKyome

素晴らしい記事をありがとうございます。
質問なのですが、Variadic Viewを用いて子Viewを取得した際に、その子ViewについているTagを取得する方法ってあるのでしょうか?
SwiftUI既存のselection付きPickerTabViewのようなAPIを作りたいのですが、_ViewTraitKey_traitを使った実装だと.tag()ではなく独自のModifierでTagをつける形になる前例しか見つからず、できないかなぁと思っています。

KyomeKyome

おぉ!!Mirrorですか。ちょっと無茶している感はありますが確かにできますね。
iOS 18以上ならtag(for:)がAPIに追加されているんですね。勉強になります。