iOS 登場時からの UIライブラリである UIKit とは異なり、2019年に発表された SwiftUI は宣言的に UI を記述できる。ユーザ操作やネットワークからのデータ受信などの各種イベントに起因してデータが変更されると、UI に自動で反映される。データを UI に反映させるロジックを書かなくても良いその仕組みは、どのように実現されているのだろうか。

View の構造とアプリケーションの状態

SwiftUI の View は Viewプロトコルを実装した struct として表現されている。この View struct は、内部に他の View struct を複数包含することができる。複雑な図形を表現するときによく使われる Composite Pattern になっているといえる。

Composite UML class diagram (fixed).svg

By Composite_UML_class_diagram.svg: Trashtoy derivative work: Aaron Rotenberg , Public Domain, Link

例えば、以下のようなクラス図で表現される図形モデルを考えてみる。直線(line)と円(circle)、そしてそれらを複数包含可能な CompositeShape からなる、シンプルな図形モデルである。 Shape Class Diagram

このモデルで表現可能な図には、例えば以下のようなオブジェクト図で表現できるものがある。 Shape Object Diagram

実際の図形にすると、こうなる。 Shape Object Diagram

SwiftUI も、上記の例よりはるかに複雑ではあるが、Viewプロトコル(上記の例における Shape に相当)を実装した struct を階層状に組み合わせることで、iOSアプリが必要とするあらゆる画面を構築できるようになっている。

SwiftUI は、アプリケーション全体を一つの View struct のオブジェクト包含関係で表現している。アプリケーションで使うすべての UI要素が、この包含関係の一部分として階層化されている。刻々と変わるアプリケーションの状態(データ、モデル)に応じて、どの「一部分(View)」を表示するかを if - else で制御する。ボタンを表示する、しないなどの細かい制御から、ページ遷移などの画面全体を書き換える制御まで、この方式で実装する。

アプリケーションの状態(データ、モデル)は、個々の View の中に埋め込むこともできるし、View とは独立させて持つこともできる。個々の View struct の中に分散させて持つのは、その View が独立性が高いコンポーネントとみなせる場合だ。例えばトグルスイッチは最もシンプルなものの一つだろう。ON であるか OFF であるかは、トグルスイッチコンポーネントの中に持たせた方が良い。一方、アプリケーション内の Viewオブジェクト階層の複数箇所から参照されるようなデータは、View とは独立させて持つ方が良い。MVCモデルにおける「モデル」と呼ばれるデータがこれに相当する。

View に持たせるか、View から独立させるかは、そのデータが View のオブジェクト階層構造のどこで使われるかによる。 View hierarchy and state

@State

SwiftUI では、View に持たせるデータを struct の中のプロパティとして定義する。最初に一回表示するだけなら、これで事足りる。

struct SampleView: View {
    var name: String
    var body: some View {
        Text(name)
    }
}

しかし name プロパティが何らかの原因で変更されたときに表示に自動反映したいと思うなら、これを @State プロパティラッパとして定義する。これだけで、値が UI に自動反映されるようになる。

struct SampleView: View {
    @State var name: String
    var body: some View {
        Text(name)
    }
}

@State と @Binding

name を別のサブView AnotherView に渡す場合、表示したいだけなら以下のようにすれば良い。

struct SampleView: View {
    @State var name: String
    var body: some View {
        AnotherView(name: name)
    }
}

struct AnotherView: View {
    var name: String
    var body: some View {
        Text(name)
    }
}

AnotherViewname の値を変更したい場合は、以下のように Binding として渡す必要がある。

struct SampleView: View {
    @State var name: String
    var body: some View {
        VStack {
            Text(name)
            AnotherView(name: $name) // $ を付けると Binding になる
        }
    }
}

struct AnotherView: View {
    @Binding var name: String // Binding として定義する
    var body: some View {
        TextField("name", text: name)
    }
}

Binding にすると、AnotherView での name に対する変更はすべて Binding でプロキシされて SampleViewname プロパティに反映され、結果として SampleView UI の Text(name) による表示も変更される。

この、ある事実(データ)を一カ所(この例では name)に集約し、コピーしないことによってデータの整合性を保つ設計概念を Single Source of Truth と呼ぶ。