[SwiftUI] @State @Binding を理解する

Property wrapper を理解した後は、SwiftUI のキモとなっている @State, @Binding を説明します。
Swift[Swift] PropertyWrapper 手を動かして理解する(その1 wrappedValue) Swift[Swift] PropertyWrapper 手を動かして理解する(その2 wrappedValue の初期化) [Swift] PropertyWrapper 手を動かして理解する(その3 $ もしくは projectedValue の理解)

@State, @Binding の定義

property wrapper としての定義を見てみます。

以下では、

@State の定義

@State var value:Int とすると

wrappedValue : value
projectedValue: Binding<value>

@Binding の定義

@Binding var value:Int とすると

wrappedValue : value
projectedValue: Binding<value>

プチまとめ

@State と @Binding の違い

property wrapper 視点での interface 的には、違いはありません。

ですが、Binding では、transaction という情報を持つことで、変化についての情報を保持することができるようになっています。

Transaction についての Apple のドキュメントは、こちら

本題ではないので、説明は省きます。

MyState, MyBinding を作って使ってみる

MyState, MyBinding を作ってみる

Property wrapper の仕組み自体は、Swift 言語の仕様の一部ですので詳細が開示されています。

ですが、@State や @Binding は、SwiftUI の一部であり、使うことはできますが、実装の詳細は開示されていません。

Apple の説明で、@State は、source of truth であり、@Binding はあくまで、@State への reference であるというような説明がされているかと思います。

そこで、@MyState と @MyBinding を以下のように定義しました。

MyState

class Storage {
  var data:Value
  init(data:Value) {
    self.data = data
  }
}

@propertyWrapper struct MyState {
  var internalStorage: Storage          // 実際のデータ保存場所の定義   クラスで定義されているので、保存されたデータへのポインタが保持されている
  var wrappedValue: Value {                    // get されるときには、保存場所にあるデータを返す
    get {
      return self.internalStorage.data
    }
    nonmutating set {
      self.internalStorage.data = newValue     // set されるときには、保存場所は変えずに、保存されているデータを更新する
    }
  }

  init(wrappedValue:Value) {
    self.internalStorage = Storage(data: wrappedValue)   // 初期化されるときに、データ保存場所を作成する
  }
   
  var projectedValue: MyBinding {       // データ保存場所へのリファレンスを保持する MyBinding を返す
    get {
      return MyBinding(wrappedValue: self.wrappedValue, storage: self.internalStorage)
    }
  }
}

コード上にコメントを入れていますが、データの保存場所は、MyState の外部にあり、そこへの参照を保持します。
値をセットされるときには、データの保存場所にあるデータを書き換えるため、MyState という struct 自身の変更は発生しません。

projectValue としては、データ保存場所へのリファレンス MyBinding を返すようにしています。

MyBinding

@propertyWrapper struct MyBinding {
  var sharedStorage:Storage                        // 外部から渡された保存場所へのリファレンスを保持
  
  var wrappedValue: Value {
    get {
      return self.sharedStorage.data                      // get されるときは、保存場所にあるデータを返す
    }
    nonmutating set {
      self.sharedStorage.data = newValue                 // set されるときは、保存場所は変えずに、保存されているデータを更新する
    }
  }
  
  init(wrappedValue: Value, storage: Storage) {   // 初期化されるときに、外部から保存場所へのリファレンスを渡してもらう
    self.sharedStorage = storage       
  }

  var projectedValue: MyBinding {                 // データ保存場所へのリファレンスである自身を返す
    get {
      return self
    }
  }
}

MyState, MyBinding を使ってみる

以下のようなアプリを作って動作を見てみます。

MyStateMyBinding を使ったアプリ

MyStateMyBinding を使ったアプリ

以下が、ソースコード です。

MyState, MyBinding を使う SwiftUI コード

struct ContentView: View {
  @EnvironmentObject var dummy:DummyObject
  @MyState private var value:Int = 0
  var currentValue:Int = 0
  var body: some View {
    VStack {
      DisplayView(value: value)
        .padding()
      EditView(value: $value)
        .padding()
      Button(action: {
              self.dummy.refresh()
             }, label: { Text("refresh label")})
        .padding(3)
        .border(Color.green, width: 3)
    }
  }
}

struct DisplayView : View {
  @EnvironmentObject var dummy:DummyObject
  var value:Int
  var body: some View {
    Text("\(value)")
      .padding(3)
      .border(Color.orange, width: 3)
  }
}

struct EditView : View {
  @EnvironmentObject var dummy:DummyObject
  @MyBinding var value:Int
  var body: some View {
    Stepper("stepper \(value)", onIncrement: {
      self.value = self.value + 1
    }, onDecrement: {
      self.value = self.value - 1
    })
    .padding(3)
    .border(Color.red, width: 3)
  }
}

MyState, MyBinding には、画面のリフレッシュを行う仕組みを入れていないために、ダミーの ObservableObject を使ってリフレッシュするようにしています。

main

class DummyObject: ObservableObject {
  @Published var dummyVar:Int = 0
  func refresh() {
    self.objectWillChange.send()    // DummyObject の refresh を呼ぶことで、画面更新
  }
}

@main
struct UnderstandStateApp: App {
    var body: some Scene {
        WindowGroup {
          ContentView().environmentObject(DummyObject())
        }
    }
}

@State, @Binding を使っていませんが、Stepper の + ボタン、- ボタンを押下して、"refresh label" ボタンを押下すると、値が変更されていることがわかります。

# あくまで、機能性として近いものを実装したということで、Apple の実装とどれくらい近いかは不明です。例えば、値の更新について伝播させる仕組み等が入っていません。

@State という property wrapper では、 source of truth の場所を確保しデータを保存するようにしています。その上で projectValue として、その場所へのリファレンスを渡します。@Binding は、その保存場所へのリファレンスを保持する property wrapper で、保存場所へアクセスすることで、直接保存場所の値を変更することができます。

まとめ

イメージとしては、以下のようなイメージです。

Image: State, Binding

Image: @State, @Binding

@State, @Binding
  • @State は、source of truth を作り出す
  • @Binding は、source of truth へのリファレンスを保持する
  • @Binding 経由で source of truth を直接変更することができる
  • (補足) @State, @Binding で行っている変更の伝播を実装するのは大変そう。

説明は以上です。
不明な点やおかしな点ありましたら、ご連絡いただけるとありがたいです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です