SwiftUIのデータ管理 Property Wrapper編

SwiftUIでアプリを開発していると@Stateや@Bindingの使い分けについて迷ったりしていませんか?
SwiftUIではデータを管理するProperty Wrapperがたくさんあります。
@State、@Binding、@StateObject、@ObservedObjectなどなどです。
Property Wrapperそれぞれの特徴を理解できれば、SwiftUIのアプリ開発がはかどるでしょう。

今回はSwiftUIのデータ管理を行うProperty Wrapperの使い分けについて解説します。

この記事は私が12月9日に発表した資料、
SwiftUIのデータ管理」を記事化したものです。

SwiftUIのデータ管理記事一覧

動作環境

この記事は以下の環境で動作を確認しています。

  • Xcode 12.2
  • iOS 14.2

データ管理のProperty Wrapper利用方針

SwiftUIのProperty Wrapperはたくさんありますが、次で述べる3つの利用方針を頭に入れておけば判断に迷いはなくなります。

  • データは何か?
    • SwiftUIでは値型、参照型両方のデータを扱うProperty Wrapperが用意されています。
  • データをどのように処理するか?
    • 読み込みだけをするのか、変更もするのかでデータの扱いも変わります。
  • データはどこから来るか?
    • データの発生源です。
    • View自身から発生するか、親Viewから渡されるのか、環境値として渡されるのかで扱いが変わります。

それでは1つずつみていきましょう。

単純なProperty

データが値型で、読み込みだけで更新をしない場合、そのデータは単純なPropertyで表現できます。
データの発生源はView自身からでも親Viewから渡されてもOKです。

struct ParentView: View {
    let title = "title"
    var body: some View {
        HStack {
            ChildView(color: .blue)
            ChildView(color: .yellow)
            ChildView(color: .red)
            Text(title)
        }
    }
}

struct ChildView: View {
    let color: Color
    var body: some View {
        Circle().foregroundColor(color)
    }
}

この例ではParentViewtitleというプロパティを、ChildViewcolorというプロパティをそれぞれ保持しています。
データの更新は行わず、それぞれそのまま表示しています。
データの更新を行わないのでletで宣言しておくのがよいでしょう。

@State

データが値型で、データを更新をし、データの発生源がView自身の場合は@Stateが使えます。
そのView固有のデータになるので、他からアクセスできないようにprivateをつけておくとよいです。

struct ParentView: View {
    @State private var counter = 0
    var body: some View {
        Button(action: {
            counter += 1
        }, label: {
            Text("counter is \(counter)")
        })
    }
}

この例では、counterというデータに@Stateをつけています。
値型でデータの発生源はParentViewです。

ボタンをタップするごとにcounterを1増加させ、その値をTextで表示しています。
値型のデータで、データの発生源がView自身で、そのデータを更新しているので@Stateをつけています。

@Binding

値型のデータで、データを更新しますが、データの発生源は親Viewなど外から渡される場合@Bindingが使えます。

struct ParentView: View {
    @State private var counter = 0
    var body: some View {
        ChildView(counter: $counter)
            .frame(width: .infinity)
    }
}
struct ChildView: View {
    @Binding var counter: Int
    var body: some View {
        Button(action: {
            counter += 1
        }, label: {
            Text("\(counter)")
                .font(.title)
        })
        .border(Color.red)
    }
}

この例では、ChildViewcounter@Bindingがつけられています。
counterは値型で、親ViewParentViewから渡されます。そしてChildViewButtonがタップされると更新されます。

値型のデータで、更新をし、外から渡されるので@Bindingを使っています。

@Environment

@State@Bindingは自分で作ったデータを処理する場合に使うものでした。
@Environmentはちょっと毛色がちがっていて、Viewの環境値を読み取れるProperty Wrapperです。

どんな値があるのかはEnvironmentValuesに定義されています。
例えば

  • スクリーン解像度
  • 言語設定
  • アプリの起動状態

などがあります。

他にどんな値があるかは公式ドキュメントをご覧ください。

https://developer.apple.com/documentation/swiftui/environmentvalues

ViewからはKeyPathを指定して読み込みます。

例えば、端末がダークモードかライトモードかを判定する場合はcolorSchemeを読み込みます。

struct EnvironmentSample: View {
    @Environment(\.colorScheme) var colorScheme: ColorScheme
    var body: some View {
        if colorScheme == .dark {
            Text("dark mode")
        } else if colorScheme == .light {
            Text("light mode")
        } else {
            Text("")
        }
    }
}

@Environment(\.colorScheme) とkeyPathで指定することでcolorSchemeの状態を取得できます。
if文で分岐させています。

この環境値をオーバーライドしたい場合はenvironment修飾子を利用します。
ただし、影響範囲は指定したViewとその子孫Viewに留まり、環境値そのものがが変わるわけではないので注意が必要です。

次の例ではcolorSchemeをダークモードにオーバーライドしている例です。

EnvironmentSample()
    .environment(\.colorScheme, .dark)

このように指定してもEnvironmentSampleとその子孫Viewがダークモードになるだけでそれ以外のViewは端末のカラースキームが読み込まれます。

ObservableObjectプロトコル

@State@Bindingは値型のデータを扱うPropertyWrapperですが、参照型のデータオブジェクトを扱うものも用意されています。

  • @StateObject
  • @ObservedObject
  • @EnvironmentObject

の3つです。

これらのデータオブジェクトをSwiftUIが監視できるようにするにはObservableObjectプロトコルに準拠する必要があります。

例えばDataSourceを定義して、ObservableObjectプロトコルに準拠するにはこのようにします。

class DataSource: ObservableObject {
  @Published var counter = 0
}

@PublishedをつけたプロパティがSwiftUIで監視されます。
ここではcounterプロパティで、この値が更新されるとSwiftUIは自動的に関連するViewを更新します。

@StateObject

参照型データオブジェクトを扱い、データの発生源はView自身の場合@StateObjectが使えます。
@Stateと同じように外からこのデータオブジェクトをアクセスされないようにprivateをつけておくといいでしょう。

@StateObjectはiOS 14から導入されたPropertyWrapperです。
データオブジェクトのライフサイクルはViewが表示されてから(onAppearが呼ばれてから)非表示になる(onDisappearが呼ばれる)までです。

struct StateObjectCounterView: View {
    @StateObject private var dataSource = DataSource()
    var body: some View {
        VStack {
            Button("increment counter") {
                dataSource.counter += 1
            }
            Text("StateObject count: \(dataSource.counter)")
                .font(.title)
        }
    }
}

この例では、dataSourceプロパティに対して@StateObjectをつけています。
ボタンをタップしたらcounterを1追加し、Textcounterの値を表示しています。

@ObservedObject

参照型データオブジェクトを扱い、データの発生源は親Viewなど外から渡される場合は@ObservedObjectが使うと良いです。

データオブジェクトのライフサイクルはViewbodyが更新されるまでです。

struct ParentView: View {
    @StateObject private var dataSource = DataSource()
    var body: some View {
        ChildView(dataSource: dataSource)
    }
}

struct ChildView: View {
    @ObservedObject var dataSource: DataSource
    var body: some View {
        VStack {
            Button("increment counter") {
                dataSource.counter += 1
            }
            Text("count: \(dataSource.counter)")
        }
    }
}

この例ではParentViewdataSourceChildViewに渡しています。
そしてChildViewではそのdataSource@ObservedObjectをつけて保持しています。
ボタンをタップするとdataSourcecounterを1つ追加し、その値をTextで表示しています。

@StateObjectと@ObservedObjectの違い

さて、皆さんは@StateObject@ObservedObjectの違いについて疑問に思っているかと思います。
その違いは、データオブジェクトのライフサイクルです。

コードで見てみましょう。

まずStateObjectCounterViewという@StateObjectを付与したデータオブジェクトを保持するViewを定義します。

struct StateObjectCounterView: View {
    @StateObject private var dataSource = DataSource()
    var body: some View {
        VStack {
            Button("increment counter") {
                dataSource.counter += 1
            }
            Text("StateObject count: \(dataSource.counter)")
                .font(.title)
        }
    }
}

続いてObservedObjcetCounterViewという@ObservedObjectを付与したデータオブジェクトを保持するViewを定義します。

struct ObservedObjcetCounterView: View {
    @ObservedObject private var dataSource = DataSource()
    var body: some View {
        VStack {
            Button("increment counter") {
                dataSource.counter += 1
            }
            Text("ObservedObject count: \(dataSource.counter)")
                .font(.title)
        }
    }
}

先程の@ObservedObjectとは異なり、View自身でデータオブジェクトを保持していることに注目してください。

そしてこれらのStateObjectCounterViewObservedObjcetCounterViewを子Viewとする親ViewSwitchColorViewを定義します。

struct SwitchColorView: View {
    @State private var isDanger: Bool = false
    var body: some View {
        VStack {
            Button("Change the Color") {
                isDanger.toggle()
            }
            if isDanger {
                Circle().foregroundColor(.red)
                    .frame(width: 200, height: 200)
            } else {
                Circle().foregroundColor(.green)
                    .frame(width: 200, height: 200)
            }
            StateObjectCounterView()
            ObservedObjcetCounterView()
            Spacer()
        }
    }
}

VStackの子ViewとしてStateObjectCounterViewObservedObjcetCounterViewを配置しています。

また、SwitchColorView自身はisDangerというプロパティを@Stateを付与して定義しています。
isDangerは「Change the Color」ボタンをタップするとtrue/falseが切り替わります。
するとSwitchColorViewbodyプロパティが更新されます。

この場合StateObjectCounterViewObservedObjcetCounterViewはどうなるか、次のgifをみてみましょう。

Jan-23-2021-14-03-14

「Change the Color」ボタンをタップするとStateObjectCounterViewのカウントはそのままですが、ObservedObjcetCounterViewのカウントはリセットされました。

これは@StateObjectのデータオブジェクトのライフサイクルはViewが表示してから非表示してからですが、@ObservedObjectbodyが更新されるまでだからです。
SwitchColorViewbodyが更新されたので@ObservedObjectのデータオブジェクトが破棄され、カウントがリセットされたのです。

SwiftUIではbodyはデータに変更ある度に更新されます。
そのためView自身で保持するデータオブジェクトに@ObservedObjectを付与するとそのデータオブジェクトは頻繁に破棄されてしまいます。

そのため、@ObservedObjectを使う場合はView自身のプロパティでは使わず、親Viewから渡すときに使うようにしましょう。

@EnvironmentObject

@EnvironmentObjectを使うと、参照型データオブジェクトを親Viewに渡すことで、その子孫Viewはいつでもそのデータオブジェクトにアクセスできるようになります。

親Viewに.environmentObject修飾子でデータオブジェクトを渡すと子孫Viewがデータにアクセスできる

SwiftUIはViewをたくさん作成してもオーバーヘッドがとても少ないです。
なので、細かくViewを分けて実装するのが定石です。
ただしそうするとデータモデルを子孫Viewに渡すのが面倒になることがあります。

この図をご覧ください。

section10_5

左は親Viewに@StateObjectを定義して、子Viewに@ObservedObjectを渡す例です。
先程説明した通り@StateObjectは親Viewに、@ObservedObjectは子Viewに渡して使っています。
ただこの図だと4階層目のViewにデータモデルを渡すときに2階層目、3階層目のViewにも@ObservedObject渡しており、面倒な実装になっています。
@EnvironmentObjectを使うと、親Viewに.environmentObject修飾子をつければ、後は子孫Viewで使いたいViewだけに@EnvironmentObjectを付与することででデータオブジェクトにアクセスすることができます。

struct ParentView: View {
    var body: some View {
        ChildView()
    }
}

struct ChildView: View {
    var body: some View {
        GrandChildView()
    }
}

struct GrandChildView: View {
    @EnvironmentObject var dataSource: DataSource
    var body: some View {
        Text("\(dataSource.counter)")
    }
}

struct DataFlowSampleApp_Previews: PreviewProvider {
    @StateObject static private var dataSource = DataSource()
    static var previews: some View {
        ParentView().environmentObject(dataSource)
    }
}

この例ではDataFlowSampleApp_Previews内でParentViewenvironmentObject修飾子を使ってdataSourceを渡しています。
使用するのはGrandChildViewです。@EnvironmentObjectを使いdataSourceを読み込んでいます。

.environmentObjectを忘れるとランタイムエラー

@EnvironmentObjectの使い方の注意点として、親Viewに.environmentObject修飾子でデータオブジェクトを渡さないまま子孫Viewで@EnvironmentObjectを使うとランタイムエラーが発生する点です。

次のようなエラーが出た場合は親Viewに.environmentObjectのつけ忘れがないかどうかを確認しましょう。

Thread 1: Fatal error: No ObservableObject of type DataSource found. 
A View.environmentObject(_:) for DataSource may be missing as an ancestor of this view.

@AppStorageと@SceneStorage

@AppStorage@SceneStorageはiOS 14から登場したデータを簡単に保存できるProperty Wrapperです。
iOS 14からAppプロトコルとSceneプロトコルの登場し、SwiftUIのみでアプリを作成できるようになりました。
それに伴い、アプリ、シーンそれぞれのライフサイクルに合わせたデータを扱えるのが@AppStorage@SceneStorageです。

@AppStorage

@AppStorageは値型のデータを格納できるProperty Wrapperです。
格納先はUser Defaultなのでアプリが削除されるまでデータが保存されます。

@SceneStorage

@SceneStorageも値型のデータを格納できるProperty Wrapperです。
macOSやiPadOSにて複数ウィンドウのシーンごとにデータを保存します。
データはシステムが管理し、シーンが破棄されるとデータも破棄されます。

シーンとは?

macOSやiPadOSでの複数ウィンドウのことです。
各ウィンドウは別のメモリ空間となりデータは独立します。

section9_02

詳しくはWindowGroupをご覧ください。

@AppStorageと@SceneStorageの使い方

@AppStorage@SceneStorageの使い方はProperty Wrapperの初期値にKey名を指定します。そうするとプロパティの値がそのまま保存されます。

struct StorageSampleView: View {
    @SceneStorage("userName") private var userName: String = ""
    @AppStorage("isLogin") private var isLogin = false
    var body: some View {
        List {
            TextField("Input your name", text: $userName)
            Toggle("Login", isOn: $isLogin)
        }
    }
}

この例では@SceneStorage("userName")と記述することでStringをKey名userNameの値を初期値を空文字で保存します。保存場所は@SceneStorageなので各シーンごとです。
また@AppStorage("isLogin") と記述することでBool値をisLoginというKey名で初期値falseで保存しています。保存場所はUser Defaultです。

SwiftUIのデータ管理フローチャート

ここまでSwiftUIで使われるデータ管理のProperty Wrapperを解説しました。
それぞれに特徴がありますが、それを一覧で確認できるフローチャーとを作成しました。

section10_7

各Property Wrapperの使い方に迷ったらこのフローチャートを参考にしてください。

宣伝

インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。

今回解説したデータ管理のProperty Wrapperについての解説もあります。

iOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!




https://nextpublishing.jp/book/12491.html

参考

Author image

About Sato Takeshi

  • Tokyo, Japan