えいむーさんは明日も頑張るよ

SwiftUIのBindingの理解を深める

価格

# SwiftUI + Binding

SwiftUI では変数を SwiftUI フレームワークで管理するための@State, @Binding, @Published, @ObservedObject, @StateObjectのような仕組みがあります。

で、これを使えば本来値を変えることができない構造体のプロパティを更新して、ビューを再レンダリングできます。

# @State (opens new window)

@Stateはわかりやすくてstruct内で値が変更できる変数です。

// trueで初期化する場合(通常の利用方法)
@State var isPresented: Bool = true

// 任意の値でStateを初期化する場合
@State var isPresented: Bool

// イニシャライザを利用する
init(isPresented: Bool) {
    self._isPresented = State(initialValue: isPresented)
}

# @Binding (opens new window)

@Bindingは子コンポーネントで変更した場合に親コンポーネントも再レンダリングされるようなプロパティです。

@Stateと違い、親コンポーネントから値を受け取ることを前提としているので初期値の設定は不要です。

// 通常の利用方法
@Binding var isPresented: Bool

// イニシャライザを利用する
init(isPresented: Bool) {
    self._isPresented = isPresented
}

# 結論

このあと、とても長い検証コードが続いて読むのがめんどくさくなると思うので先に結論だけ書いておきます。

  • @Stateは配列の中身が変わったときにもビューが再レンダリングされる
  • @Stateを用いずに直接中身を書き換えると再レンダリングされない
  • @Bindingのイニシャライザを利用するときには注意

例えば、

Binding(
    get: {},
    set: {}
)

を使うと、set で中身を書き換えても再レンダリングされない(内部的には反映されている)

# 利用方法

例えば、以下のようなコンポーネントを作ってみる。

import SwiftUI

struct ContentView: View {
    @State var isEnabled: Bool = false

    var body: some View {
        Form(content: {
            Text(isEnabled ? "Enable" : "Disable")
            ToggleView(isEnabled: $isEnabled)
        })
    }
}

struct ToggleView: View {
    @Binding var isEnabled: Bool

    var body: some View {
        Toggle(isOn: $isEnabled, label: {
            Text("Toggle")
        })
    }
}

このとき、親コンポーネントはContentViewで、子コンポーネントがToggleViewになります。

そして@Bindingの特性として Toggle の値が変更されると親コンポーネントにもそれが伝わり、テキストの内容が書き換わるというわけです。

ちなみに以下のコードと等価です。

struct ToggleView: View {
    @Binding var isEnabled: Bool

    init(isEnabled: Binding<Bool>) {
        self._isEnabled = isEnabled // 初期化
    }

    var body: some View {
        Toggle(isOn: $isEnabled, label: {
            Text("Toggle")
        })
    }
}

特に Binding の初期値をいじりたいとかそういうことがなければこちらの方式を採用する意味はないです。

で、やっかいになるのがこの@Bindingの初期化なんです。@Stateは通常の変数から@State(initialValue: )を使って初期化できるのですが、@Bindingはそう簡単にはできません。

そして、やり方が分からなかったので今まで放置していました。

しかしそれではまずかろうということで今回しっかりと@Bindingについて理解を深めようと思いました。

# Binding の初期化

実は@Binding.constant()を使うと初期化することができます。

struct ToggleView: View {
    @Binding var isEnabled: Bool

    var body: some View {
        // .constant()は定数値なのでToggleを切り替えられない
        Toggle(isOn: .constant(true), label: {
            Text("Toggle")
        })
    }
}

で、これは問題なくコンパイルが通るのですが実はこれだけでは意味がないです。

というのも、.constant()は定数という意味なので値を変化させることができないからです。やればわかりますが、上のコードは常にトグルが有効化されてしまいます。

# 初期化が必要になるパターン

例えば、次のような仕様を考えてみます。

  • プロパティ名と有効かどうかのフラグを持つプロパティがある
  • それらの配列がある
  • その配列から Toggle を ForEach で作成したい

すると、以下のようなコードが思いつくかと思われます。

struct ToggleType {
    let title: String
    var isEnabled: Bool
}

struct ToggleView: View {
    let toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]

    var body: some View {
        ForEach(toggles) { toggle in
            // toggle.isEnabledが@Bindingではないのでエラーが発生
            Toggle(isOn: toggle.isEnabled, label: { // Error!!
                Text(toggle.title)
            })
        }
    }
}

extension ToggleType: Identifiable {
    var id: String { title }
}

しかしこれはコンパイルエラーが発生する。何故ならToggleに渡されている値であるtoggle.isEnabled@Bindingでないからですね。

かと言って単純に@Bindingに変換する.constant()では値が変更できなくなってやっぱり意味がありません。

struct ToggleView: View {
    let toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]

    var body: some View {
        ForEach(toggles) { toggle in
            // .constant()なので初期値から変えられない
            Toggle(isOn: .constant(toggle.isEnabled), label: {
                Text(toggle.title)
            })
        }
    }
}

ではどうすればいいのかといえば、至極シンプルな話で@Bindingのイニシャライザを使えば良い。

struct ToggleView: View {
    let toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]

    var body: some View {
        ForEach(toggles) { toggle in
            Toggle(isOn: Binding(
                get: { toggle.isEnabled },
                set: { toggle.isEnabled = $0 } // 代入できないエラー
            ), label: {
                Text(toggle.title)
            })
        }
    }
}

@BindingのイニシャライザはComputed Propertyに近いので、そちらのやり方がわかっている人であれば特に悩むことがないと思います。

要するに値を参照したときにどの値を返すか、値を更新したときにどこにその値をセットするかを記述すれば良いので、今回の場合はisEnabledを更新すればよいだけなので上のようなコードになるわけですね。

が、ここでCannot assign to property: 'toggle' is a 'let' constantというエラーが発生します。なんだこれ。

それはToggleStylestructで定義されていて、構造体のコピーの値を書き換えて元々のデータは書き換えられないことが原因です。

簡単にいうとForEachでループして生成されているtoggletogglesの要素のコピーなので、toggleの中身を書き換えてもtogglesの要素は書き換えられません。

そんな意味のないことをさせないように SwiftUI では構造体をコピーするときはletとして処理するので、そもそも上書きという操作ができないわけです。じゃあどうすればいいのか。

構造体ではなく、クラスを使おうという話になります。

クラスであればオブジェクトのコピーではなく、参照渡しになるので ForEach でループしているtoggleの中身を書き換えればtogglesの中身も書き換えられます。

// 構造体だったことが問題なのでクラスに変更
class ToggleType {
    // クラスには必ずイニシャライザが必要なので追加する
    internal init(title: String, isEnabled: Bool) {
        self.title = title
        self.isEnabled = isEnabled
    }

    let title: String
    var isEnabled: Bool
}

struct ToggleView: View {
    let toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]

    var body: some View {
        Group(content: {
            ForEach(toggles) { toggle in
                Toggle(isOn: Binding(
                    get: { toggle.isEnabled },
                    // エラーが発生しなくなる
                    set: { toggle.isEnabled = $0 }
                ), label: {
                    Text(toggle.title)
                })
            }
            Button(action: {
                print(toggles.map({ $0.isEnabled }))
            }, label: {
                Text("SHOW")
            })
        })
    }
}

で、例えばこのようにかけばビューの中身を切り替えられるようになります。

ToggleType イニシャライザ コピー プロパティの上書き
struct 不要 値渡し 不可能
class 必要 参照渡し 可能

構造体のプロパティ上書きについて

実は構造体でもmutatingを利用すれば値を上書きすることができるのだが、これはSwiftUIの View 内では利用できないことを覚えておいて欲しい。

mutatingをつけろみたいなエラーがでたら、@Stateプロパティをつけるか、classにするかのどちらかしか解決手段はないと思う。

# 再レンダリングされない場合

さて、今の仕組みでビューが再レンダリングされたのはToggleの操作自体がビューの再レンダリングがかかるような操作だったためです。それらを伴わないような操作で中身を切り替えた場合には反映されません。

struct ToggleView: View {
    let toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]

    var body: some View {
        Group(content: {
            ForEach(toggles) { toggle in
                Toggle(isOn: Binding(
                    get: { toggle.isEnabled },
                    set: { toggle.isEnabled = $0 }
                ), label: {
                    Text(toggle.title)
                })
            }
            Button(action: {
                print(toggles.map({ $0.isEnabled }))
            }, label: {
                Text("SHOW")
            })
            Button(action: {
                toggles[0].isEnabled.toggle() // ボタンのActionでToggleを切り替え
            }, label: {
                Text("SWITCH")
            })
        })
    }
}

たとえばこのような構造ではSWITCHを押してもビューの再レンダリングはされません。

じゃあtoggles@Stateプロパティにすればいいのかというとそうでもありません。

再レンダリング

ちなみに、再レンダリングされていないだけで値自体はしっかり変わっています。

なので、直接変数の中身を見るようなprint(toggles.map({ $0.isEnabled }))を実行すれば中身が書き換わっていることがわかります。この場合の問題は、値が変わっているにも関わらず SwiftUI フレームワークがその変更を検出できずビューが再レンダリングされないというところにあります。

struct ToggleView: View {
    // @State属性にしてもビューは再レンダリングされない
    @State var toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]
    // 省略
}

何故なら@Stateはあくまでも[ToggleType]しかみていないからです。[ToggleType]が増えたりすれば再レンダリングされますが、プロパティが変わっても再レンダリングはされないというわけです。

Button(action: {
    // @Stateプロパティ自体が変更されるので再レンダリングされる
    toggles.append(ToggleType(title: "CCC", isEnabled: true))
}, label: {
    Text("APPEND")
})
Button(action: {
    // @Stateプロパティのプロパティが変更されるので再レンダリングされない
    toggles[0].isEnabled.toggle()
    toggles[0].title = "DDD"
}, label: {
    Text("SWITCH")
})

再レンダリング

ちなみにこれもさっきと同じで、内部的にはちゃんと値が切り替わっているので、APPEND のボタンを押せば正しくその時のtogglesの中身がビューに反映されます。

# 上手くいかないコード

じゃあToggleTypeのプロパティに対して@Stateをつければ良いと考えるかも知れない。

class ToggleType {
    internal init(title: String, isEnabled: Bool) {
        self.title = title
        self.isEnabled = isEnabled
    }

    @State var title: String
    @State var isEnabled: Bool
}

struct ToggleView: View {
    @State var toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]
    // 省略
}

つまり、上のようなコードである。

だが、残念ながらこのコードは正しく動作しない。ToggleTypeの中身を書き換えてもビューは再レンダリングされない。

class ToggleType: ObservableObject {
    internal init(title: String, isEnabled: Bool) {
        self.title = title
        self.isEnabled = isEnabled
    }

    var title: String
    @Published var isEnabled: Bool
}

struct ToggleView: View {
    @State var toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]
    // 省略
}

ちなみに、@Publishedをつけた場合も上手くいかない。

なんとなく長くなりそうなので、表にしてまとめたいと思う。

# 動作する組み合わせ

struct ContentView: View {
    @State var toggles: [ToggleType] = [
        ToggleType(title: "AAA", isEnabled: true),
        ToggleType(title: "BBB", isEnabled: true)
    ]

    var body: some View {
        Form(content: {
            // コード1
            Section(content: {
                ForEach(toggles) { toggle in
                    Toggle(isOn: Binding(
                        get: { toggle.isEnabled },
                        set: { toggle.isEnabled = $0 }
                    ), label: {
                        Text(toggle.title)
                    })
                }
            })
            // コード2
            Section(content: {
                Toggle(isOn: $toggles[0].isEnabled, label: {
                    Text(toggles[0].title)
                })
                Toggle(isOn: $toggles[1].isEnabled, label: {
                    Text(toggles[1].title)
                })
            })
        })
    }
}

一見するとどちらも同じようなことをしているように見えるが、その本質は全く異なることに注意したい。

ちなみに、個人的には ForEach で回せるコード 1 の方を積極的に利用したいと考えている。これらの二つのコードの違いを見てみよう。

まずToggleTypeの違いからどのような差がでるかを考える。どちらのコードも結局はisEnabledの値を変更するので、letで宣言されている場合はコンパイルエラーが発生する。

# 構造体の場合

ToggleTypeが構造体の場合はコード 1 は全く動作しない。よって、動作するのはコード 2 のみである。

動作しない理由

何度も述べたように構造体はForEachでループさせると値渡しでコピーを作成するため。

よって(@Stateでないかぎり)toggleの値を変えてもtogglesの中身は変わらないし、配列自身は@StateになっていてもToggleType自体はただの構造体のまま。よって、toggle.isEnabled.toggle()によって値を書き換えることはできないのでコンパイルエラーがでます。

できればコード 1 を使いたいのでそうであればToggleTypeを構造体で宣言することのメリットはなにもないということになる。

この時、コード 2 が正しく動くためにはToggleTypeのプロパティがvartogglesのプロパティが@Stateで宣言する必要があります。

これ以外の組み合わせでは正しく動作しません。

理由

ToggleTypeのプロパティは変更可能であるためにvarでなければないらない。@Published varでもいけそうなきがするが、これをすると@State変数の中の中の@State変数という入れ子の状態が発生するため挙動がおかしくなる。

toggles@Stateにしなければいけない理由はToggleTypeを構造体で宣言しているためにmutatingを使わないと値を上書きできなくなっているためである。SwiftUI の View では基本的にmutatingは使えないので@State varで宣言して SwiftUI フレームワークに値を上書きしてもらう必要がある。逆にいえばToggleTypeが構造体でないならtogglesはどんな宣言をしていても良いことになる。

ただし、コード 2 の場合は$toggles[0].isEnabledというコードで参照しているのでToggleTypeが構造体でなくクラスであったとしても@State varで宣言しなければならない。

# クラスの場合

クラスの場合でもコード 2 が正しく動作する条件は変わりません。ただし、クラスの場合はコード 1 が動作するようになります。

その条件は単純でToggleTypeのプロパティが@Published varもしくはvarのどちらかであることです。

# 両者の違い

ここで簡単におさらいをしておきます。

Section(content: {
    ForEach(toggles) { toggle in
        Toggle(isOn: Binding(
            get: { toggle.isEnabled },
            set: { toggle.isEnabled = $0 }
        ), label: {
            Text(toggle.title)
        })
    }
})
// コード2
Section(content: {
    Toggle(isOn: $toggles[0].isEnabled, label: {
        Text(toggles[0].title)
    })
    Toggle(isOn: $toggles[1].isEnabled, label: {
        Text(toggles[1].title)
    })
})

一見そっくりのコードですが、意味する内容は全く違います。

コード 1 はクラスのオブジェクトの参照渡しですが、コード 2 はクラスのオブジェクトの配列の構造体を読み込んでいるからです。

なので結局コード 2 は構造体、コード 1 はクラスを扱っていることになります。構造体を扱っているからこそ、コード 2 では@State宣言をしなければ値が上書きできなかったのです。

# ForEach をどうやって使うか

ForEach を利用することの最大のデメリット

ForEach を使って値を変更するようなコードを書くと今回の例でいうとToggleTypeが SwiftUI 管轄になっていないために、中身のプロパティの値を上書きしてもビューが再レンダリングされないという問題がある。

  • ForEach でループさせるとコードが書きやすくスッキリする
  • ForEach でループさせるのはクラスでなくてはならない
  • クラスは@Stateを持たないので(実装すると普通にバグる)

@State 中の@State を定義すると

コンパイルはできるのですが、Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.という表示がでてビューが何も変化しません。そりゃ当たり前かという気もします。

これについては[SwiftUI] SwiftUI の ForEach 内で Binding 変数を渡したい (opens new window)Binding an element of an array of an ObservableObject : subscript is deprecated (opens new window)でも議論されている内容のようで、やはり Binding 属性を持つ配列をループさせてもその要素が Binding にならないようです。

で、解決法も提示されていて(それはあまり使いたくなかったのですが)@State属性を持つプロパティを経由して配列にアクセスすれば@Bindingを使うことができます。

Section(header: Text("Class + Index"), content: {
    // インデックスでループする
    ForEach(columns.indices) { index in
        // イテレータでアクセスする
        Toggle(isOn: $columns[index].isEnabled, label: {
            Text(columns[index].isEnabled ? "YES" : "NO")
        })
    }
})

ただこれ、個人的にはイテレータを使ってアクセスするのってForEachの良さを全部殺してる感じがしてあんまり好きじゃないんですよね。

しかし、どうも現状はこれ以外の解決方法はなさそうな感じです。

価格
    えいむーさんは明日も頑張るよ © 2022