- はじめに
- サンプルに関して
- 開発環境
- 実装パターン1:@Stateのみを使う場合
- 実装パターン2:@Stateと@Bindingを使う場合
- 実装パターン3:@ObservedObjectを使う場合
- 実装パターン4:@EnvironmentObjectを使う場合
- 実装パターン5:@StateObjectを使う場合
- おわりに
- 参考
はじめに
SwiftUIを学習する上で、@Stateや@Binding、@ObservedObject、@EnvironmentObject、@StateObjectそれぞれの使いどころについて理解しにくいと感じたことはないでしょうか。私はこれらのProperty Wrapperのドキュメントを読んだ時、「どれを使っても同様の機能要件を満たすことができるのではないか」、「使い分けのイメージは実際に自分で実装してみないと理解できない・・・」と感じました。この記事では、下記5パターンの方法で実装したサンプルを比較することで、それぞれの使いどころを理解したいと思います。
・@Stateのみを使う場合
・@Stateと@Bindingを組み合わせて使う場合
・@ObservedObjectを使う場合
・@EnvironmentObjectを使う場合
・@StateObjectを使う場合
サンプルに関して
TextFieldに数字が入力されていない場合に、「数字を入力してください」とエラーメッセージを画面に表示するサンプルを使用します。
サンプルを構成する要素としては以下3つです。
・文字を入力するTextField
・エラーメッセージを表示するText
・TextFieldに数字が入力されているかどうか判別するメソッド
※画面イメージは以下
開発環境
・Xcode 12.0.1
・Swift 5.3
実装パターン1:@Stateのみを使う場合
ContentView内にTextFieldやエラーメッセージを表示するText、TextFieldに数字が入力されているかどうか判別するメソッドをまとめて実装すれば、@Stateのみでサンプルの要件を満たすことができます。
実装したTextField、Text、メソッドを他の画面で使い回すことがないシンプルなアプリだったり、アーキテクチャを考慮しない場合はこの実装でもよさそうです。
import SwiftUI struct ContentView: View { // @Stateをつけることでtextの値の変化を監視 @State var text: String = "" var body: some View { VStack { // 変数textの値をTextFieldに入力された文字に更新 TextField("数字を入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 250) // 変数textの値が数字でなければメッセージを表示 if !isNumericString(text: text) { Text("数字を入力してください") .frame(width: 250, height: 50) .foregroundColor(Color.red) } } } // 引数の文字列が数字の文字列かどうか判定 func isNumericString(text: String) -> Bool { if Int(text) == nil { return false } else { return true } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
※@Stateに関しては以下の記事で説明しています。
実装パターン2:@Stateと@Bindingを使う場合
この実装パターンは、@Stateのみを使った実装パターンと比較すると、@Bindingによって、「TextFieldに数字が入力されていない場合に、エラーメッセージを表示する」という要件を構造体ContentViewから分離できることがメリットです。(TextFieldSample.swiftに実装された構造体TextFieldSampleが該当箇所です。)TextFieldSampleに要件を切り出せたことで、「数字の入力チェックをするTextField」というひとまとまりの部品を複数の画面で使い回すことができるようになります。部品を共通化して繰り返し使いたい場合は、@Stateと@Bindingを組み合わせると効率的に実装することができます。
ContentView.swift
import SwiftUI struct ContentView: View { // TextFieldSampleに入力された値の変化を監視 @State var text: String = "" var body: some View { // 引数に代入したContentViewの変数textが // TextFieldSampleの変数textに結びつく TextFieldSample(placeholder: "数字を入力", text: $text) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
TextFieldSample.swift
import SwiftUI // 使いまわせるようにContentViewから切り出したTextField struct TextFieldSample: View { var placeholder : String @Binding var text: String var body: some View { VStack { TextField(placeholder, text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 250) // 変数textの値が数字でなければメッセージを表示 if !isNumericString(text: text) { Text("数字を入力してください") .frame(width: 250, height: 50) .foregroundColor(Color.red) } } } // 引数の文字列が数字の文字列かどうか判定 func isNumericString(text: String) -> Bool { if Int(text) == nil { return false } else { return true } } } struct TextFieldSample_Previews: PreviewProvider { static var previews: some View { TextFieldSample(placeholder: "テスト", text: .constant("test")) } }
※ @Bindingに関しては以下の記事で説明しています。
実装パターン3:@ObservedObjectを使う場合
アーキテクチャに沿って処理を分ける場合はどのようにすればよいでしょうか。例えばMVVMであれば、View、ViewModel、Modelそれぞれの役割に沿って処理を分割する必要があります。
MVVMを構成する3つのコンポーネント
・View
→ユーザー操作の受付と画面表示を担当
・ViewModel
→Viewに表示するためのデータを保持したり、
Viewからイベントを受け取ってModelの処理を呼び出したりデータを更新する
・Model
→UIに関係しないロジックを持つ
※参考:『PEAKS(ピークス)|iOSアプリ設計パターン入門』
今回のサンプルに当てはめると、以下のように処理を分けて実装することになりそうです。
・View
→文字を入力するためのTextFieldとエラーメッセージ用のTextを表示
・ViewModel
→TextFieldに入力した文字を扱う変数を持つ
→TextFieldに入力した文字が数字の文字列であるかどうかを表す変数を持つ
→TextFieldで文字が入力されたこと(Viewからイベント)を受け取ったら
Modelに実装された数字の文字列かどうか判定するメソッドを呼び出す
→呼び出したメソッドの処理結果に沿って、数字の文字列であるかどうかを表す変数を更新する
・Model
→数字の文字列かどうか判定するメソッドを持つ
MVVMではViewの状態とViewModelが持つ変数の状態を結びつけることで状態を更新する仕組みであることから、前述の@Stateと@Bindingを使えば実現できそうです。実際に実装してみると・・・
import SwiftUI class ViewModel { // TextFieldに入力した文字を扱う変数 @Binding var text: String // TextFieldに入力した文字が数字の文字列であるかどうかを表す変数 @Binding var isNumericString: Bool }
初期化のエラーが出ます。@Bindingの初期化・・・?違うような気がしますが実装してみます。
import SwiftUI class ViewModel { // TextFieldに入力した文字を扱う変数 @Binding var text: String // TextFieldに入力した文字が数字の文字列であるかどうかを表す変数 @Binding var isNumericString: Bool init(text: String, isNumericString: Bool) { self.text = text self.isNumericString = isNumericString } }
やはりエラーとなってしまいました。@Stateと@Bindingに関するAppleのドキュメントを再度確認してみましょう。
State
"You should only access a state property from inside the view’s body, or from methods called by it."
※引用
https://developer.apple.com/documentation/swiftui/state
「@Stateプロパティには、Viewのbodyから、またはViewによって呼び出されるメソッドからのみアクセスする必要があります。」
Binding
"For example, a button that toggles between play and pause can create a binding to a property of its parent view using the Binding property wrapper."
"The parent view declares a property to hold the playing state, using the State property wrapper to indicate that this property is the value’s source of truth."
※引用
https://developer.apple.com/documentation/swiftui/binding
「例えば、再生と一時停止を切り替えるトグルは、Bindingプロパティラッパーを使用して、親ビューのプロパティへのバインディングを作成できます。」
「親ビューは、再生状態を保持するプロパティを宣言し、Stateプロパティラッパーを使用して、このプロパティが値の信頼できる情報源であることを示します。」
上記を見ればわかるように、@StateはViewのbodyから、またはViewによって呼び出されるメソッドからのみアクセスされるものであり、@Bindingは子ビューから親ビューのプロパティへバインディングを作成するもののため、クラスで@Stateや@Bindingは使用できません。では、ViewModelもViewプロトコルに準拠した構造体にすればよいでしょうか?
import SwiftUI struct ViewModel: View { // TextFieldに入力した文字を扱う変数 @Binding var text: String // TextFieldに入力した文字が数字の文字列であるかどうかを表す変数 @Binding var isNumericString: String var body: some View { } }
ViewのbodyでTextなどを宣言しないとエラーとなります。しかし、エラーを解消するとViewModelの役割を超えてしまうため、Viewプロトコルに準拠した構造体をViewModelと見立てることは難しそうです。
では、どうすればよいでしょうか。ここで登場するのが、@ObservedObjectです。@ObservedObjectはオブジェクトを監視することができます。
まずは監視したいオブジェクト(ViewModelクラス)をObservableObjectプロトコルに準拠させます。
// ObservableObjectに準拠したクラス class ViewModel: ObservableObject { }
監視したいプロパティに@Publishedをつけます。
// 共有したい値に@Publishedをつける @Published var text: String = "" @Published var isNumericString: Bool = false
オブジェクトの変更を監視するために@ObservedObjectをつけると、@Publishedをつけたプロパティを監視することができます。
// ViewModelの@Publishedをつけた変数を利用するため@ObservedObjectをつける @ObservedObject var viewModel: ViewModel
@ObservedObjectによって、クラスの値を利用する(監視する)ことができるため、MVVMであればViewとViewModel間のデータバインディングが実現できます。その他のアーキテクチャでも、Modelクラスの値を監視する際に@ObservedObjectが活躍します。
@ObservedObjectを使ってサンプルの要件を満たしたコードは以下のとおりです。
ContentView.swift
import SwiftUI struct ContentView: View { // ContentViewModelの@Publishedをつけた変数を利用するため@ObservedObjectをつける @ObservedObject var viewModel: ContentViewModel init(viewModel: ContentViewModel) { self.viewModel = viewModel } var body: some View { VStack { // 文字を入力するごとにContentViewModelクラスの変数textの値が更新される TextField("数字を入力", text: $viewModel.text) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 250) // ContentViewModelクラスの変数isNumericStringが更新されるたびに以下の処理を実行する if !viewModel.isNumericString { Text("数字を入力してください") .frame(width: 250, height: 50) .foregroundColor(Color.red) } } } }
ContentViewModel.swift
import Combine // ObservableObjectに準拠したクラス class ContentViewModel: ObservableObject { // 共有したい値に@Publishedをつける @Published var text: String = "" @Published var isNumericString: Bool = false private let model: CharacterDiscrimination private var disposables = [AnyCancellable]() init(model: CharacterDiscrimination) { self.model = model // 変数textの値が変更されるたびに、 // Modelクラス(CharacterDiscriminationクラス)のメソッドhasNumericStringを呼び出す $text .sink(receiveValue: { if model.hasNumericString(text: $0) { // textが数字の文字列であれば変数isNumericStringの状態をtrueに変更 self.isNumericString = true } else { // textが数字の文字列でなければ変数isNumericStringの状態をfalseに変更 self.isNumericString = false } }) .store(in: &disposables) } }
CharacterDiscrimination.swift(Modelクラス)
class CharacterDiscrimination { // 引数の文字列が数字の文字列かどうか判定 func hasNumericString(text: String) -> Bool { if Int(text) == nil { return false } else { return true } } }
※正直文字列チェックだけであればStringの拡張として実装すればよいのですが、今回はMVVMに沿って責務を分割した場合にどのような実装イメージになるかを伝えたいので、無理やりModelクラスに文字列チェックの処理を実装しています・・・。
SceneDelegate.swift
実際にビルドして動かしてみたい場合は、SceneDelegate.swiftのscene(_:willConnectTo:options:)メソッドを以下のように実装してください。
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { if scene is UIWindowScene { if let windowScene = scene as? UIWindowScene { let model = CharacterDiscrimination() let viewModel = ContentViewModel(model: model) let view = ContentView(viewModel: viewModel) let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: view) self.window = window window.makeKeyAndVisible() } } }
実装パターン4:@EnvironmentObjectを使う場合
@EnvironmentObjectは、ObservableObjectプロトコルに準拠したクラスの@Publishedをつけたプロパティを監視できる点は@ObservedObjectと同じなので、サンプルの要件を満たす実装は可能です。
実装パターン3のコードを修正してみましょう。ContentView.swiftの変数viewModelの@ObservedObjectを@EnvironmentObjectに変更し、scene(_:willConnectTo:options:)メソッド内のContentsViewModelクラスのインスタンス化の処理を変更、ContentViewModel.swiftの初期化処理を削除してビルドしてみてください。要件通りの動作が確認できると思います。
ContentView.swift
@ObservedObjectを@EnvironmentObjectに変更
// ContentViewModelの@Publishedをつけた変数を利用するため@EnvironmentObjectをつける @EnvironmentObject var viewModel: ContentViewModel
SceneDelegate.swift
ContentsViewModelクラスのインスタンス化の処理を変更
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { if let windowScene = scene as? UIWindowScene { let model = CharacterDiscrimination() let view = ContentView().environmentObject(ContentViewModel(model: model)) let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: view) self.window = window window.makeKeyAndVisible() } }
ContentView.swift
以下を削除
init(viewModel: ContentViewModel) { self.viewModel = viewModel }
ただし、@EnvironmentObjectは@ObservedObjectと違い、アプリ全体で特定のインスタンス(ObservableObjectプロトコルに準拠していて、@Publishedを付けた変数があるクラスのインスタンス)を共有できるものです。そのため、サンプルの要件は満たせますが、@EnvironmentObjectである必要がなく、むしろコードの読み手を混乱させるため、今回は@ObservedObjectを使用した方がよさそうです。
せっかくなので、@EnvironmentObjectを使い、複数のViewで値を共有できるか試してみましょう。サンプルに、「ContentViewに配置されたボタンを選択すると、TextFieldに入力した文字を確認するViewに遷移する」という要件を追加します。
全体のコードは以下の通りです。
ContentView.swift
import SwiftUI struct ContentView: View { // SceneDelegateでインスタンス化したContentViewModelの@Publishedをつけた変数を利用するため@EnvironmentObjectをつける @EnvironmentObject var viewModel: ContentViewModel var body: some View { NavigationView { VStack { VStack(alignment: .center) { // 文字を入力するごとにContentViewModelクラスの変数textの値が更新される TextField("数字を入力", text: $viewModel.text) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 250) // ContentViewModelクラスの変数isNumericStringが更新されるたびに以下の処理を実行する if !viewModel.isNumericString { Text("数字を入力してください") .frame(width: 250, height: 50) .foregroundColor(Color.red) } } .padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0)) NavigationLink(destination: SharedTextView().environmentObject(self.viewModel)) { Text("値の共有を確認する") .frame(width: 200, height: 50) .foregroundColor(Color.white) .background(Color.blue) .cornerRadius(10, antialiased: true) } .navigationBarTitle("Sample") } } } }
ContentViewModel.swift
import Combine // ObservableObjectに準拠したクラス class ContentViewModel: ObservableObject { // 共有したい値に@Publishedをつける @Published var text: String = "" @Published var isNumericString: Bool = false private let model: CharacterDiscrimination private var disposables = [AnyCancellable]() init(model: CharacterDiscrimination) { self.model = model // 変数textの値が変更されるたびに、 // Modelクラス(CharacterDiscriminationクラス)のメソッドhasNumericStringを呼び出す $text .sink(receiveValue: { if model.hasNumericString(text: $0) { // textが数字の文字列であれば変数isNumericStringの状態をtrueに変更 self.isNumericString = true } else { // textが数字の文字列でなければ変数isNumericStringの状態をfalseに変更 self.isNumericString = false } }) .store(in: &disposables) } }
CharacterDiscrimination.swift(Modelクラス)
class CharacterDiscrimination { // 引数の文字列が数字の文字列かどうか判定 func hasNumericString(text: String) -> Bool { if Int(text) == nil { return false } else { return true } } }
SharedTextView.swift
import SwiftUI struct SharedTextView: View { @EnvironmentObject var viewModel: ContentViewModel var body: some View { Text("TextFieldには「\(viewModel.text)」が入力されています") } }
SceneDelegate.swift
SceneDelegate.swiftのscene(_:willConnectTo:options:)メソッドを以下のように実装してください。
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { if let windowScene = scene as? UIWindowScene { let model = CharacterDiscrimination() // environmentObject(_:)でContentViewModelクラスのインスタンスを指定することで、 // アプリ全体でこのインスタンスの@Publishedを付けたプロパティを共有できる let view = ContentView().environmentObject(ContentViewModel(model: model)) let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: view) self.window = window window.makeKeyAndVisible() } }
上記のコードをビルドすると、以下のように遷移前と遷移後のViewそれぞれで入力した文字の値が共有できていることを確認することができます。
※補足
話が本題から徐々に逸れてしまうため、サンプルなどを使った説明は省略しますが、@ObservedObjectはオブジェクト全体を子ビューに渡すことで値を共有することもできます。
つまり、上記のような遷移元と遷移先間でオブジェクトを共有したい場合に関しては、@EnvironmentObjectだけではなく、@ObservedObjectでも実現できます。(サンプルがよくないですね・・・すみません。。)
ただし、@ObservedObjectだとオブジェクトを共有しなくてもよい階層にもオブジェクトを共有しなければならない場合もあるかもしれません。例えば、以下の場合で@ObservedObjectを使う場合は、画面Aの持つオブジェクトが必要ない画面Bにもオブジェクトを渡す必要が出てきてしまいます。このような場合は@EnvironmentObjectを使った方がよさそうです。
・画面A:画面Cに共有したいオブジェクトを持つ
・画面B:画面Aの持つオブジェクトは必要ない
・画面C:画面Aが持つオブジェクトが必要
・画面Aから画面Bに遷移し、画面Bから画面Cに遷移する必要がある
実装パターン5:@StateObjectを使う場合
ObservableObjectのプロトコルに準拠したクラスをView内でインスタンス化する場合は、@StateObjectを使います。@StateObjectを使わずにView内でインスタンス化した場合、Viewが再生成される時にObservableObjectのプロトコルに準拠したクラスも新たにインスタンスを生成してしまうため、意図した値の監視ができなくなってしまうためです。
※参考:
・https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
・What is the @StateObject property wrapper? - a free SwiftUI by Example tutorial
@StateObjectを使っても、サンプルの要件を満たすことはできます。
ContentView.swift
import SwiftUI struct ContentView: View { // View内で監視対象のクラスをインスタンス化する場合は、 // インスタンスが1つしか生成しないことを保証するため@StateObjectをつける // これによって仮にContentViewが再生成されたとしてもviewModelのインスタンスは再生成されなくなるため、 // 意図した値の監視が可能になる @StateObject var viewModel = ContentViewModel(model: CharacterDiscrimination()) var body: some View { VStack(alignment: .center) { // 文字を入力するごとにContentViewModelクラスの変数textの値が更新される TextField("数字を入力", text: $viewModel.text) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 250) // ContentViewModelクラスの変数isNumericStringが更新されるたびに以下の処理を実行する if !viewModel.isNumericString { Text("数字を入力してください") .frame(width: 250, height: 50) .foregroundColor(Color.red) } } } }
ContentViewModel.swift
実装パターン3、実装パターン4と同内容ですが、実装パターン5だけ読んでいる方もいるかと思うので、再掲します。
import Combine // ObservableObjectに準拠したクラス class ContentViewModel: ObservableObject { // 共有したい値に@Publishedをつける @Published var text: String = "" @Published var isNumericString: Bool = false private let model: CharacterDiscrimination private var disposables = [AnyCancellable]() init(model: CharacterDiscrimination) { self.model = model // 変数textの値が変更されるたびに、 // Modelクラス(CharacterDiscriminationクラス)のメソッドhasNumericStringを呼び出す $text .sink(receiveValue: { if model.hasNumericString(text: $0) { // textが数字の文字列であれば変数isNumericStringの状態をtrueに変更 self.isNumericString = true } else { // textが数字の文字列でなければ変数isNumericStringの状態をfalseに変更 self.isNumericString = false } }) .store(in: &disposables) } }
CharacterDiscrimination.swift(Modelクラス)
こちらも実装パターン3、実装パターン4と同内容です。実装パターン5だけ読んでいる方のために再掲します。
class CharacterDiscrimination { // 引数の文字列が数字の文字列かどうか判定 func hasNumericString(text: String) -> Bool { if Int(text) == nil { return false } else { return true } } }
SceneDelegate.swift
scene(_:willConnectTo:options:)メソッドはプロジェクト生成時のままで問題ないので、実装パターン3や実装パターン4を写経してみた方は以下に戻してください。
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { let contentView = ContentView() if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: contentView) self.window = window window.makeKeyAndVisible() } }
こちらも実装パターン4と同様に、要件は満たせるものの、@StateObjectである必要はなさそうです。このサンプルの場合はViewModelをView内で絶対にインスタンス化する必要がないためです。また、@StateObjectはiOS14にならないと使えないという点でも、実装パターン3のように@ObservedObjectを使うケースが多そうです。
@StateObjectが活躍するケースに関しては、以下の記事に詳しく書かれていたため、参考リンクを貼っておきます。
※参考
SwiftUIのProperty Wrappersとデータへのアクセス方法 - Qiita
おわりに
どのProperty Wrapperを使ってもサンプルの機能要件を満たすコードは書けましたが、Viewの用途であったり、アーキテクチャに沿った実装をする場合には使い分けが発生することが分かりました。今後実践する中でさらに理解を深めていきたいと思います。
参考
・https://developer.apple.com/documentation/swiftui/state
・https://developer.apple.com/documentation/swiftui/binding
・https://developer.apple.com/documentation/swiftui/observedobject
・https://developer.apple.com/documentation/swiftui/environmentobject
・https://developer.apple.com/documentation/swiftui/view/environmentobject(_:)
・https://developer.apple.com/documentation/swiftui/stateobject
・https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
・https://developer.apple.com/documentation/uikit/uiscenedelegate/3197914-scene
・詳細! SwiftUI iPhoneアプリ開発入門ノート iOS 13 + Xcode11対応 | 大重 美幸 |本 | 通販 | Amazon
・What is the @StateObject property wrapper? - a free SwiftUI by Example tutorial
・SwiftUIのProperty Wrappersとデータへのアクセス方法 - Qiita
・GitHub - manchan/MVVM-with-Combine-Tutorial-for-iOS: MVVM with Combine Tutorial for iOS