🐥

【property wrapper編】初心者がswiftUIチュートリアルをやって疑問に思ったことを調査して記していく

2023/09/18に公開

はじめに

今回は、property wrapper が「わけわかめ」だったのでしらべてみました。
@StateObject と @ObservedObject の違いなどについても触れていますので、よんでいってもらえれば嬉しいです。

Property Wrapper って何??

@Stateや@Bindingなどを総称して property wrapper というらしいが、よくわからなかったので、調査してみた。

The property gains the behavior that the wrapper specifies. The state and data flow property wrappers in SwiftUI watch for changes in your data, and automatically update affected views as necessary.

プロパティは、ラッパーが指定する動作を取得します。SwiftUIの状態およびデータフロー プロパティラッパーはデータの変更を監視し、必要に応じて影響を受けるビューを自動的に更新します。

引用:Model data

なるほど、Viewは基本的に不変だが、property wrapperでラップすることで、変更できるようになる。かつ、データの状態を監視してviewの再構築を自動でやってくれるようになる。ってことか。
(リファレンスそのまま)

それぞれ、どんなラッパーがあるのか見ていこう。

@State

Use state as the single source of truth for a given value type that you store in a view hierarchy. Create a state value in an App, Scene, or View by applying the @State attribute to a property declaration and providing an initial value. Declare state as private to prevent setting it in a memberwise initializer, which can conflict with the storage management that SwiftUI provides:

ビュー階層に格納する特定の値タイプの「信頼できる唯一の情報源」として状態を使用します。@State属性をプロパティ宣言に適用し、初期値を指定することによりApp、Scene、またはViewで状態値を作成します。SwiftUIが提供するストレージ管理と競合する可能性がある、メンバーごとのイニシャライザーでの設定を防ぐために、状態を private として宣言します。

引用:State

公式内のサンプルコード
struct PlayButton: View {
    @State private var isPlaying: Bool = false // @State属性を作成

    var body: some View {
        //読み取りとViewの更新
	Button(isPlaying ? "Pause" : "Play") {
            isPlaying.toggle() // ここで変更
        }
    }
}

@State属性の値を監視し、値の変化によってViewも更新される。
読み書きは通常の変数と同様にできる。
(リファレンスまんま)

@Binding

Use a binding to create a two-way connection between a property that stores data, and a view that displays and changes the data. A binding connects a property to a source of truth stored elsewhere, instead of storing data directly.

バインディングを使用して、データを保存するプロパティとデータを表示および変更するビューとの間に双方向接続を作成します。バインディングは、データを直接保存するのではなく、他の場所に保存されている信頼できる情報源にプロパティを接続します。

引用:Binding

サンプルコード
import SwiftUI

struct ContentView: View {
    //@Stateでラップし、viewが状態を監視できるようにする
    @State private var str = "test"
    
    var body: some View {
        VStack {
	    //$をつけてstrのBindingを渡す
            ChildView(str: $str)
            Text("text : " + str)
        }
        .padding()
    }
}


struct ChildView: View {
    //ここで、変数へのBindingを受け取るために
    //@Bindingをつける
    @Binding var str: String
    
    var body: some View {
        VStack {
	    //TextFieldでの変更を監視するために
	    //ここでも、Bindingを渡す
            TextField("test", text: $str)
                .padding()
                .border(.black ,width: 2)
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

@Bindingを使用すると監視可能な変数をview間で受け渡すことができ、双方からの接続も保たれる。

ここでは、監視可能な変数への接続を Binding と呼ぶことにします。

@ObservableObject

By default an ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its @Published properties changes.

デフォルトでは、ObservableObject@Published プロパティが変更される前に、変更された値を発行する objectWillChange パブリッシャーを合成します。

引用:ObservableObject

class型に対して@ObservableObjectプロトコルを準拠させると、@publishedを指定した変数が監視可能なものになる。

@Published属性は変数が変更される際に、publisherを発行し、その変更を接続されている各viewに送り届ける。

publisherについては以下の記事が参考になりました。
https://www.bravesoft.co.jp/blog/archives/15610

公式リファレンス: Publisher

@ObservableObjectに準拠したclassをViewやAppで定義する際に、使用する属性として

それぞれ、どのようなものなのか見ていきましょう。

以下 参考記事
https://qiita.com/junzai/items/c609e2ded02733887341

https://swiftwithmajid.com/2020/07/02/the-difference-between-stateobject-environmentobject-and-observedobject-in-swiftui/

@StateObject

Use a state object as the single source of truth for a reference type that you store in a view hierarchy. Create a state object in an App, Scene, or View by applying the @StateObject attribute to a property declaration and providing an initial value that conforms to the ObservableObject protocol.

状態オブジェクトは、ビュー階層に格納する参照型の信頼できる唯一の情報源として使用します。@StateObject属性をプロパティ宣言に適用し、ObservableObjectプロトコルに準拠する初期値を指定することにより、App、Scene、または View で状態オブジェクトを作成します。

引用:StateObject

サンプルコード
import SwiftUI

struct ContentView: View {
    @State private var str = "test"
    
    var body: some View {
        VStack {
            //画面遷移で@StateObj属性は初期化される
            NavigationView{
                NavigationLink {
                    ChildView(str: $str)
                } label: {
                    Text("go child")
                }
            }
            Text("text : " + str)
        }
        .padding()
    }
}


struct ChildView: View {
    @Binding var str: String
    @StateObject var testObj: testObject = testObject(int: 0)
    
    var body: some View {
        VStack {
            //ここでstrの更新とともに、親Viewは更新されるが
            //@StateObj属性は更新されない
            TextField("test", text: $str)
                .padding()
                .border(.black ,width: 2)
        
            Text("testObj.int : " + testObj.int.description)
                .font(.title2)
            
            Button {
                testObj.int += 1
            } label: {
                Text("plus 1")
            }
        }
        .padding()
    }
}

//ObservableObjectに準拠したclassを定義
class testObject: ObservableObject {
    @Published var int: Int
    
    init(int: Int) {
        self.int = int
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

@StateObjectはObservableObjectプロトコルに準拠したclass型しか宣言できないらしい。

@Stateと同様にprivateで宣言しなさいとリファレンス内には書かれている。

@StateObjectはインスタンスが作成される際の一度だけ初期化されます。その後は、Viewのライフサイクルに依存することなくその値を保持し続けます。
逆に言えば、インスタンスを作るたびに初期化されます。

例えば、子Viewが@StateObjectを持っている時、親Viewから子Viewに遷移した際にインスタンスが作成されます。その後、親または子のViewが変更されようが、子Viewに居続ける限り@StateObjectは値を保持し続けます。ただ、親Viewに戻り、子Viewに再度遷移すると先ほど保持していた値は初期化されています。

@StateObjectは親が低階層を管理したい時に使用するのが良さげ。

@ObservedObject

Add the @ObservedObject attribute to a parameter of a SwiftUI View when the input is an ObservableObject and you want the view to update when the object’s published properties change. You typically do this to pass a StateObject into a subview.

入力が ObservableObject であり、オブジェクトの publishedプロパティが変更されたときにビューを更新する場合は、この @ObservedObject 属性を SwiftUI View のパラメーターに追加します。通常、これを行うのは、StateObject をサブビューに渡す際です。

引用:ObservedObject

@ObservedObjectはViewのライフサイクル内で管理されるため、親Viewの更新と一緒に初期化されますが、@ObservedObjectを保持するclassのインスタンス再生成では初期化されない模様。

また、公式には、

公式内のサンプルコード
class DataModel: ObservableObject {
    @Published var name = "Some Name"
    @Published var isEnabled = false
}


struct MyView: View {
    @StateObject private var model = DataModel()


    var body: some View {
        Text(model.name)
        MySubView(model: model)
    }
}


struct MySubView: View {
    //構造内で@ObservedObject属性を初期化すると、
    //親Viewが更新された時に、
    //同時に初期化されてしまう
    @ObservedObject var model: DataModel


    var body: some View {
        Toggle("Enabled", isOn: $model.isEnabled)
    }
}

とあるので、
@ObservedObjectは子Viewに置いて、Bindingを受け取るのが良さげ。

また、@ObservableObject に準拠したインスタンス自体を渡す時に $プレフィックス をつけていないのは、publisherによって監視可能な変数への接続を確立できているからかな?
インスタンス内の@Publishe属性のプロパティ自体を渡す時は、 $プレフィックス が必要。

@EnvironmentObject

An environment object invalidates the current view whenever the observable object that conforms to ObservableObject changes. If you declare a property as an environment object, be sure to set a corresponding model object on an ancestor view by calling its environmentObject(_:) modifier.

環境オブジェクトは、ObservableObjectに準拠する監視可能なオブジェクトが変更されるたびに、現在のビューを無効にします。プロパティを環境オブジェクトとして宣言する場合は、必ず environmentObject(_:)修飾子 を呼び出して、対応するモデルオブジェクトを祖先ビューに設定してください。

引用:EnvironmentObject

サンプルコード
import SwiftUI

struct ContentView: View {
    @State private var str = "test"
    /*ここで@EnvironmentObject属性を宣言するには
    一番下のContentView()でインスタンスを生成する*/
    
    var body: some View {
        VStack {
            //画面遷移では初期化されない
            NavigationView{
                NavigationLink {
                    ChildView(str: $str)
                    //ここでインスタンス生成
                        .environmentObject(testObject(int: 0))
                } label: {
                    Text("go child")
                }
            }
            Text("text : " + str)
        }
        .padding()
    }
}


struct ChildView: View {
    @Binding var str: String
    //子から呼べる
    @EnvironmentObject var testObj: testObject
    
    var body: some View {
        VStack {
            //ここでstrの更新とともに、親Viewが更新され
            //同時にtestObjも初期化される
            TextField("test", text: $str)
                .padding()
                .border(.black ,width: 2)
        
            Text("testObj.int : " + testObj.int.description)
                .font(.title2)
            
            GrandChildView()
        }
        .padding()
    }
}

struct GrandChildView: View {
    //孫でも問題なく呼べる
    @EnvironmentObject var testObj: testObject
    
    var body: some View {
        VStack {
            Button{
                testObj.int += 1
            } label: {
                Text("button")
                Text(testObj.int.description)
            }
        }
        .padding()
    }
}

//ObservableObjectに準拠したclassを定義
class testObject: ObservableObject {
    @Published var int: Int
    
    init(int: Int) {
        self.int = int
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

これを使用する時は、 environmentObject(_:) で @ObservableObject に準拠したインスタンスを生成する必要がある。
生成されたインスタンスは、宣言された階層より低階層に対して暗黙的に与えられる。
なので、階層を跨いで Binding を保持することができて、いちいち引数として渡す必要がなくなる。

@OvservedObject と同様で、Viewのライフサイクルで管理されている模様。親Viewが更新されると初期化される。

まとめ

swift・swiftUIを勉強しはじめてまだ日が浅いので、理解の乏しい部分もあるかと思いますが、今回はリファレンス等を見ながらアウトプットの意味で記事を書いてみました。
まだまだ、疑問に思ったことはあるので第二回も書こうかと思います。

間違っている部分があれば、教えていただけるとありがたいです。

最後までお付き合いいただきありがとうございました!!

Discussion