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)
}
}
この例ではParentViewはtitleというプロパティを、ChildViewはcolorというプロパティをそれぞれ保持しています。
データの更新は行わず、それぞれそのまま表示しています。
データの更新を行わないので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)
}
}
この例では、ChildViewのcounterに@Bindingがつけられています。
counterは値型で、親ViewParentViewから渡されます。そしてChildViewのButtonがタップされると更新されます。
値型のデータで、更新をし、外から渡されるので@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追加し、Textでcounterの値を表示しています。
@ObservedObject
参照型データオブジェクトを扱い、データの発生源は親Viewなど外から渡される場合は@ObservedObjectが使うと良いです。
データオブジェクトのライフサイクルはViewのbodyが更新されるまでです。
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)")
}
}
}
この例ではParentViewのdataSourceをChildViewに渡しています。
そしてChildViewではそのdataSourceを@ObservedObjectをつけて保持しています。
ボタンをタップするとdataSourceのcounterを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自身でデータオブジェクトを保持していることに注目してください。
そしてこれらのStateObjectCounterViewとObservedObjcetCounterViewを子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としてStateObjectCounterViewとObservedObjcetCounterViewを配置しています。
また、SwitchColorView自身はisDangerというプロパティを@Stateを付与して定義しています。
isDangerは「Change the Color」ボタンをタップするとtrue/falseが切り替わります。
するとSwitchColorViewのbodyプロパティが更新されます。
この場合StateObjectCounterViewとObservedObjcetCounterViewはどうなるか、次のgifをみてみましょう。

「Change the Color」ボタンをタップするとStateObjectCounterViewのカウントはそのままですが、ObservedObjcetCounterViewのカウントはリセットされました。
これは@StateObjectのデータオブジェクトのライフサイクルはViewが表示してから非表示してからですが、@ObservedObjectはbodyが更新されるまでだからです。
SwitchColorViewのbodyが更新されたので@ObservedObjectのデータオブジェクトが破棄され、カウントがリセットされたのです。
SwiftUIではbodyはデータに変更ある度に更新されます。
そのためView自身で保持するデータオブジェクトに@ObservedObjectを付与するとそのデータオブジェクトは頻繁に破棄されてしまいます。
そのため、@ObservedObjectを使う場合はView自身のプロパティでは使わず、親Viewから渡すときに使うようにしましょう。
@EnvironmentObject
@EnvironmentObjectを使うと、参照型データオブジェクトを親Viewに渡すことで、その子孫Viewはいつでもそのデータオブジェクトにアクセスできるようになります。
親Viewに.environmentObject修飾子でデータオブジェクトを渡すと子孫Viewがデータにアクセスできる
SwiftUIはViewをたくさん作成してもオーバーヘッドがとても少ないです。
なので、細かくViewを分けて実装するのが定石です。
ただしそうするとデータモデルを子孫Viewに渡すのが面倒になることがあります。
この図をご覧ください。

左は親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内でParentViewにenvironmentObject修飾子を使って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での複数ウィンドウのことです。
各ウィンドウは別のメモリ空間となりデータは独立します。

詳しくは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を解説しました。
それぞれに特徴がありますが、それを一覧で確認できるフローチャーとを作成しました。

各Property Wrapperの使い方に迷ったらこのフローチャートを参考にしてください。
宣伝
インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。
今回解説したデータ管理のProperty Wrapperについての解説もあります。
iOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!
https://nextpublishing.jp/book/12491.html
