Observation
を使ったモデルをSwiftUIのViewで保持するときに、以下property wrapperをつけることになると思います。
@State
@Environment
@Binding
@Bindable
どのような時に何のproperty wrapperを使うのが良いのかについて、自分の理解の整理も兼ねて記事にします。
この記事はAppleの公式ドキュメントをもとに書きましたが、一部私の解釈が含まれている部分もあります。
もし誤りなどありましたら、コメントいただけると嬉しいです。
この使い分けについては、WWDCの「Discover Observation in SwiftUI」で語られていたため、今回はこれを参考にしていきます。
Observation
のモデルにおけるproperty wrapperの使い分けは、上記の図で全て説明されていました。
- モデルがViewの一部であれば
@State
- グローバルにモデルを参照したい場合は
@Environment
- バインディングが必要なだけの場合は
@Bindable
- それ以外はproperty wrapperなし
ただ自分はこれじゃすぐには理解できなかったので、具体例を見つつ理解を深めていきます。
@State
の使い所
When the view needs to have its own state stored in a model, use the @State property.
【日本語訳】
ビューが独自の状態をモデルに保存する必要がある場合は、@State プロパティを使用します。
WWDCの動画では上記のように語られていますが、自分の理解としては、
以下全てに該当しているときに使うものと解釈しています。
- サブビューではないこと
- View内でモデルのプロパティを
Binding
で渡す必要がある
struct DonutListView: View {
var donutList: DonutList
@State private var donutToAdd: Donut?
var body: some View {
List(donutList.donuts) { DonutView(donut: $0) }
Button("Add Donut") { donutToAdd = Donut() }
.sheet(item: $donutToAdd) {
TextField("Name", text: $donutToAdd.name)
Button("Save") {
donutList.donuts.append(donutToAdd)
donutToAdd = nil
}
Button("Cancel") { donutToAdd = nil }
}
}
}
上記はWWDCの動画で紹介されていた、@Stateの使用ケースのコードです。
.sheet(item: $donutToAdd)
の部分で、Binding
を渡す必要があります。
そのため、このコードでは、以下全ての条件を満たしていると言えます。
- 他の画面と共有する必要がなく、そのビューの見た目や動作を制御するためだけに使われる
- View内でモデルのプロパティを
Binding
で渡す必要がある
@Environment
の使い所
@Environment. Environment lets values be propagated as globally accessible values.
【日本語訳】
@Environment。Environment を使用すると、値をグローバルにアクセス可能な値として伝播できます。
これは先ほどよりも比較的わかりやすいと思いました。
Observableなモデルの状態をグローバルに参照したい場合は@Environmentを使いましょうということですね。
@Observable class Account {
var userName: String?
}
struct FoodTruckMenuView : View {
@Environment(Account.self) var account
var body: some View {
if let name = account.userName {
HStack { Text(name); Button("Log out") { account.logOut() } }
} else {
Button("Login") { account.showLogin() }
}
}
}
@main
struct SampleApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
.environment(Account()) // アプリのエントリーポイントで`Account`を注入
}
}
上記はWWDCの動画で紹介されていた、@Environmentの使用ケースのコードです。
上記コードだけではちょっとグローバルに参照するというイメージが湧きづらいので、追加の補足コードを付けています。
SampleApp
というアプリのエントリーポイントでAccount
をenvironment
に注入しています。
こうすることで、アプリ全体で@Environment(Account.self) var
とすることでAccount
の状態を共有することができます。
FoodTruckMenuView
では親Viewからinit
でAccount
のインスタンスを渡してもらうことなくAccount
を参照できます。
.environment(Account())
は付与したView
or Scene
以下に環境変数を注入できるため、付与する位置によってスコープを限定することもできます。
以下のようにすれば、FoodTruckMenuView
以下でAccount
の状態を共有できます。
FoodTruckMenuView()
.environment(Account())
@Bindable
の使い所
The bindable property wrapper is really lightweight. All it does is allow bindings to be created from that type.
【日本語訳】
プロパティラッパーファミリーの最新版は「@Bindable」です。このバインド可能なプロパティラッパーは非常に軽量で、その型からバインディングを作成できるようにするだけです。
WWDCの動画ではBinding
を作成できるだけの軽量なproperty wrapperだと語られています。
ただ、自分の試した限り@State
と@Binable
の挙動の違いはほとんどありませんでした。
そこで@Bindable
の使い所のヒントとして、@State
の使い所では、
「ビューが独自の状態をモデルに保存する必要がある場合」とあり、これをサブビューではないと解釈すると、@Bindable
は以下に該当する場合に使用すると考えられそうです。
- サブビューであること
- View内でモデルのプロパティを
Binding
で渡す必要がある - モデルが
@Observable
であること
以下がWWDCの動画で示されたコード例です。
@Observable class Donut {
var name: String
}
struct DonutView: View {
@Bindable var donut: Donut
var body: some View {
TextField("Name", text: $donut.name)
}
}
DonutView
は@State
の使い所のコード例ででたDonutListView
のサブビューであり、かつTextField
でBinding
を使用しています。
また、Donut
は@Observable
なオブジェクトなので、以下条件を満たしていると言えます。
- サブビューであること
- View内でモデルのプロパティを
Binding
で渡す必要がある- モデルが
@Observable
であること
@Binding
と@Bindable
てどう違う?
個人的に結構悩んだところなので、ついでに自分で調べた内容を紹介しておきます。
自分が調べた限り、違いは以下2点です。
- 参照自体の変更を追うかどうか
- プロパティ定義以外でも使えるかどうか
-
Observable
protocolに準拠している必要があるかどうか
参照自体の変更を追うかどうか
@Binding
の場合は参照の変更自体を検知することができます。
そのため、以下のようにOptional型も指定可能で、book
のインスタンスを入れ替えることもできます。
@Bindable
ではこれは行えません。
struct DeleteBookView: View {
@Binding var book: Book?
var body: some View {
Button("Delete book") {
book = nil
}
}
}
これはSwiftUIのドキュメントコメントにて記載がありますが、長いので折りたたみで表示しておきます。
SwiftUIのドキュメントコメント
/// ### Share observable state objects with subviews
///
/// To share an <doc://com.apple.documentation/documentation/Observation/Observable>
/// object stored in `State` with a subview, pass the object reference to
/// the subview. SwiftUI updates the subview anytime an observable property of
/// the object changes, but only when the subview's ``View/body`` reads the
/// property. For example, in the following code `BookView` updates each time
/// `title` changes but not when `isAvailable` changes:
///
/// @Observable
/// class Book {
/// var title = "A sample book"
/// var isAvailable = true
/// }
///
/// struct ContentView: View {
/// @State private var book = Book()
///
/// var body: some View {
/// BookView(book: book)
/// }
/// }
///
/// struct BookView: View {
/// var book: Book
///
/// var body: some View {
/// Text(book.title)
/// }
/// }
///
/// `State` properties provide bindings to their value. When storing an object,
/// you can get a ``Binding`` to that object, specifically the reference to the
/// object. This is useful when you need to change the reference stored in
/// state in some other subview, such as setting the reference to `nil`:
///
/// struct ContentView: View {
/// @State private var book: Book?
///
/// var body: some View {
/// DeleteBookView(book: $book)
/// .task {
/// book = Book()
/// }
/// }
/// }
///
/// struct DeleteBookView: View {
/// @Binding var book: Book?
///
/// var body: some View {
/// Button("Delete book") {
/// book = nil
/// }
/// }
/// }
///
/// However, passing a ``Binding`` to an object stored in `State` isn't
/// necessary when you need to change properties of that object. For example,
/// you can set the properties of the object to new values in a subview by
/// passing the object reference instead of a binding to the reference:
///
/// struct ContentView: View {
/// @State private var book = Book()
///
/// var body: some View {
/// BookCheckoutView(book: book)
/// }
/// }
///
/// struct BookCheckoutView: View {
/// var book: Book
///
/// var body: some View {
/// Button(book.isAvailable ? "Check out book" : "Return book") {
/// book.isAvailable.toggle()
/// }
/// }
/// }
///
/// If you need a binding to a specific property of the object, pass either the
/// binding to the object and extract bindings to specific properties where
/// needed, or pass the object reference and use the ``Bindable`` property
/// wrapper to create bindings to specific properties. For example, in the
/// following code `BookEditorView` wraps `book` with `@Bindable`. Then the
/// view uses the `$` syntax to pass to a ``TextField`` a binding to `title`:
///
/// struct ContentView: View {
/// @State private var book = Book()
///
/// var body: some View {
/// BookView(book: book)
/// }
/// }
///
/// struct BookView: View {
/// let book: Book
///
/// var body: some View {
/// BookEditorView(book: book)
/// }
/// }
///
/// struct BookEditorView: View {
/// @Bindable var book: Book
///
/// var body: some View {
/// TextField("Title", text: $book.title)
/// }
/// }
///
プロパティ定義以外でも使えるかどうか
@Bindable
は型のプロパティだけでなく、関数やコンピューテッドプロパティのスコープでも使用できます。
@Binding
ではこれはできません。
用途として、List
やForEach
内でBinding
を使用したい場合は便利です。
@Observable
final class SampleStore {
var children: [SampleChildStore] = [
.init(),
.init()
]
}
@Observable
final class SampleChildStore: Identifiable {
var id: ObjectIdentifier { ObjectIdentifier(self) }
var value: String = ""
}
struct BindSampleView: View {
@Bindable var store: SampleStore
var body: some View {
VStack {
ForEach(store.children) {
@Bindable var child = $0 // コンピューテッドプロパティのスコープでも使用可能
TextField("child value", text: $child.value)
}
}
.padding()
}
}
以下記事で紹介されていました。
Observable
protocolに準拠している必要があるかどうか
これはタイトル通りなので、それほど説明することはありませんが、
@Binable
を付与できる型はObservable
に準拠している必要があります。
@Binding
はそのような制約はありません。