It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

【SwiftUI】SwiftUIで使われるDSL構文について(@ViewBuilderとか@_functionBuilderとか)

概要

SwiftUIのViewの書き方には今までのSwiftにはなかった特徴があります。
次のような、クロージャにViewを連続で渡すようなDSL記法です。

今までのSwiftを書いたことがある人は、「なぜこんな書き方ができるんだろう?」と疑問に思われた人もいるかもしれません。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("1行目")
            Text("2行目")
            Text("3行目")
        }
    }
}

これには、@ViewBuilder という仕組みが使われているのですが、今回はこの @ViewBuilderとその元の拡張につかわれている @_functionBuilderについて調べてみました。

環境

VStackのイニシャライザを確認

今回は、VStackという標準ViewComponentを例にみてみます。
まずは、VStackのイニシャライザを確認してみましょう。

@frozen public struct VStack<Content> : View where Content : View {
    @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
    public typealias Body = Never
}

init() の引数の最後にクロージャが定義されています。

VStack {
    Text("1行目")
    Text("2行目")
    Text("3行目")
}

これが、上記の{} で囲まれた部分です。

VStackのイニシャライザのクロージャには、@ViewBuilder という修飾子がついています。
この @ViewBuilderDSL記法を実現している仕組みの箇所です。

@ViewBuilder

次は、@ViewBuilder の中身を見ていきます。

@_functionBuilder public struct ViewBuilder {
    public static func buildBlock() -> EmptyView
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}

ViewBuilder structの定義は上記のようになっています。
buildBlock というメソッドが2つ定義されており、1つはContentが引数として渡されます。
なんとなくこのContentにクロージャで返す、Contentが渡されてくるのではないかと想像できます。

このViewBuilderは @_functionBuilder という拡張が定義されています。
この @_functionBuilder という拡張がこの記法を実現している仕組みです。

@_functionBuilder

@_functionBuilder 拡張は、クロージャの引数に付与する修飾を定義するための拡張 で、この@_functionBuilderで作られた修飾をつけられたクロージャは出力されるまえに buildXXXX で定義されたメソッドで処理されます。

struct VStack<Content> : View where Content : View {
  init(... @ViewBuilder content: () -> Content)
}
VStack {
    Text("1行目")
    Text("2行目")
    Text("3行目")
}

つまり、今回の場合、このVStackに渡される3つのTextは一度buildBlock メソッドで処理され結果が出力されるということになります。

複数のViewが渡される仕組み

ViewBuilderでは、複数のViewを処理するために、その数分の引数を持つ buildBlock が定義されています。
ViewBuilder の定義を見ていくと、 buildBlock メソッドは 2つだけではなく、extentionとして他にも定義されています。

extension ViewBuilder {
    public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View
    public static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View
    public static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> TupleView<(C0, C1, C2, C3)> where C0 : View, C1 : View, C2 : View, C3 : View
  ・・・
    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View
}

このように引数が最大10個まで buildBlock メソッドが拡張されています。

VStack {
    Text("1行目")
    Text("2行目")
    Text("3行目")
}

例えば上記のような、3つのViewが渡されるクロージャでは、次のような3つの引数の buildBlock を使って処理をします。

public static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View

つまり、クロージャの1構文(=1View)が各buildBlockメソッドの引数に割り当てられるということです。

このため、現在のバージョンでは、指定するクロージャでは、最大10個までしかViewを渡せない という制限があります。

また buildBlockメソッドでは、複数のViewを処理する場合は、TupleView というタプルをイニシャライザに渡せるViewをつかって1つのViewとして返しています。

試してみる

なんとなく、ViewBuilderについて理解が深まってきたので実際にコードで試してみます。

@_functionBuilder struct CustomViewBuilder {
    static func buildBlock<Content>(_ content: Content) -> Content where Content : View {
        content
    }
    static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View {
        // NOTE: TupleViewという複数のViewをタプルで受け取って1つのViewにするViewを使う
        TupleView((c0, c1))
    }
}

まずは、@_functionBuilder CustomViewBuilder を作ります。
このViewBuilderでは引数に2つまでしかViewを受け取れません。
また、1つのViewを返すために、タプルで複数のViewを受け取れる TupleView というViewを使っています。

struct MyVStack<Content>: View where Content: View {
    var content: () -> Content
   
    // NOTE: CustomViewBuilderをつけることでクロージャにViewを複数受け取れるようになる
    init(@CustomViewBuilder content: @escaping () -> Content) {
        self.content = content
    }
    
    var body: some View {
        VStack {
            self.content()
        }
    }
}

このCustomViewBuilderを使って、contentを受け取れるようなViewを作っていきます。

struct MainView: View {
    var body: some View {
        VStack {
            MyVStack {
                Text("1行目")
                Text("2行目")
            }
        }
    }
}

所感

初めは???となることも多かったViewBuilderですが仕組みがわかってしまえば、「なるほど〜」という感じでした。
bodyの直下では、普通にforやif構文が使えるのにViewの引数のクロージャではifしか使えないなどの理由がわかりスッキリしました。

個人的には、クロージャの構文をパースして引数に割り当てる処理がどのように実現されてるのかが気になりましたが、swiftのコードを読めば理解できるのでしょうか?(余裕があればいつか見てみたいです)