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