It’s now or never

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

【SwiftUI】@BindingとBinding<型>について

概要

SwiftUIでは、変数を参照型として扱うためのBindingという型があります。
Bindingを扱うためには、@Binding という PropertyWrapperを使うことが多いと思いますが、Binding型 というデータ型と@Bindingの扱いでたまに混乱してしまうことがあります。

そのため、整理のための @Binding と Binding型について自分なりにまとめてみました。

自分が混乱しがちなことでもあり、誤った解釈があるかもしれませんので、お気づきの方がいればご指摘いただければ幸いです。

環境

  • Swift: 5.2.2
  • Xcode: 11.4.1

@Binding

@Bindingは、Binding型を扱うための PropertyWrapper です。
一般的な使い方としては、別のViewに対して参照型ではない値(Bool型など)を渡す場合に使われることが多いかと思います。

例えばモーダルのViewを表示するための .sheet というModifireでは、表示/非表示の制御のために Binding<Bool> の値を使っています。

■ モーダルを呼び出すときのサンプル

import SwiftUI

struct ContentView: View {
    @State var isPresented: Bool = false
    var body: some View {
        VStack {
            Button(action: {
                self.isPresented = true
            }) {
                Text("モーダルを表示")
            }
            .sheet(isPresented: $isPresented) {
                ModalView(isPresented: self.$isPresented)
            }
        }
    }
}

sheet Modifireに渡している isPresented という値が、Binding型の値です。
PropertyWrapperで$ をつけた値は、射影プロパティ と呼ばれますが、 @State射影プロパティ としてBinding型の値を返します。

この例では、ModalView にも $isPresented を渡しています。
このBinding型の値isPresentedを受け取るために ModalViewでは @Binding というPropertyWrapper を使います。

■ モーダルViewのサンプル

struct ModalView: View {
    @Binding var isPresented: Bool
    var body: some View {
        VStack {
            Button(action: {
                self.isPresented = false
            }) {
                Text("モーダルを閉じる")
            }
        }
    }
}

このように、@Binding をつけた変数を持つことでBinding型の値を受け取ることができます。
Binding型として、isPresented を受け取ることで ModalView 内部からも ContentViewが持っている変数を変更できるようになるというのが利点です。
この例では、ModalView内でisPresentedをfalseに変更して画面を閉じる処理を実装しています。

要するに @Binding は、指定のプロパティの型を Binding<型> として受け取るため使われる PropertyWrapperです。

@BindingのプロパティにBinding型の変数を格納する

@Binding は、Binding型を扱うためのPropertyWrapperですが、このプロパティに Binding型の変数を格納するにはどうすればいいのでしょうか?

上記の例では、struct が自動生成するイニシャライザに代入処理を任せているため、意識していないですが、自分でイニシャライザを作る場合は次のように記述します。

init(isPresented: Binding<Bool>) {
    self._isPresented = isPresented
}

受け取る引数は、Bindig型の変数で宣言して、プロパティに代入するときは、 _isPresented というプロパティ名に アンダースコア(_)をつけたものに代入しています。

init(isPresented: Binding<Bool>) {
    self.isPresented = isPresented
}

このように書いてしまうと Cannot assign value of type 'Binding<Bool>' to type 'Bool' というコンパイルエラーになってしまいます。
isPrerentedのプロパティは、Boo型なのでBinding<Bool> とは型が異なるためです。

このように PropertyWrapperを定義したプロパティに(_)をつけると PropertyWrapperの内部変数にアクセスできます

つまり @Binding var isPresented: Bool の場合は、@Bindigが管理してくれている Binding<Bool> にアクセスするために(_)をつける必要があります。

余談ですが、$ をつけると射影プロパティにアクセスできますが、これは内部変数(_をつけた変数)から projectedValue というプロパティへアクセスするのと等価になります。

以下の構文は等価です。

_isPresented.projectedValue
$isPresented

@Bindingを使わない場合の例

先程のModalViewのサンプルコードを、@Bindingを使わずにBinding型の変数を使って実装することも可能です。

struct ModalView: View {
    var isPresented: Binding<Bool>
    init(isPresented: Binding<Bool>) {
        self.isPresented = isPresented
    }
    var body: some View {
        VStack {
            Button(action: {
                self.isPresented.wrappedValue = false
            }) {
                Text("モーダルを閉じる")
            }
        }
    }
}

この場合のイニシャライザは、引数とプロパティの方はともにBinding<Bool>のため直接代入できます。
異なる点は、「値の更新方法」でBinding型から内包している値へアクセスするために wrappedValue というプロパティを使います。

@Bindingを使って値を更新する場合

@Binding var isPresented: Bool
...
self.isPresented = false

Binding<変数>を使って値を更新する場合

var isPresented: Binding<Bool>
...
self.isPresented.wrappedValue = false

このように、Binding型を直接プロパティとして持つことは可能です。
しかし、実践上は @Bindingを使ことでBindingされていないデータ型と同じ記述で参照/更新を行えるため、特別な理由がない限りは@Bindingで受け取るのが良いかと思います。

Binding型のデータの値変更の注意点

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
  ...
    /// The value referenced by the binding. Assignments to the value
    /// will be immediately visible on reading (assuming the binding
    /// represents a mutable location), but the view changes they cause
    /// may be processed asynchronously to the assignment.
    public var wrappedValue: Value { get nonmutating set }
  ...
}

Binding型で内部の値を変更するには、wrappedValueを使いますが、wrappedValueの定義には上記のような記載があります。
このコメントによると、wrappedValueへの代入による変更は 非同期で行われる場合がある とのことです。 nomutating がセッターについていることからも直接代入により値を変更するわけではないようです。

簡単なサンプルで確認してみます。

struct ContentView: View {
    @State var num: Int = 1
    var body: some View {
        VStack {
            SecondView(num1: $num)
        }
    }
}

struct SecondView: View {
    @Binding var num1: Int

    var body: some View {
        return VStack {
            Button(action: {
                self.num1 += 1
                print(self.$num1.projectedValue)
                print(self.num1)
            }) {
                Text("count up")
            }
        }
    }
}

このサンプルでは、Binding<Int> 型の値をSecondViewで受け取り、ボタン操作によって値を増加させています。

print(self.num1) ではゲッターにより値を直接参照していて、print(self.$num1.projectedValue)Binding<Int> オブジェクトの中身を表示しています。

このボタンを押下するとコンソールは以下のように出力されます。

■ print(self.num1)

2

■ print(self.$num1.projectedValue)

Binding<Int>(transaction: SwiftUI.Transaction(plist: []), location: SwiftUI.LocationBox<SwiftUI.Binding<Swift.Int>.(unknown context at $7fff2c9b3008).ScopedLocation>, _value: 1)

getter自体は即時2を返していることがわかります。
しかし、Bindingオブジェクトの_valueは1のままのようです。
これを見ると、値セット時に直接値を変更しているわけではないということが確認できます。