TabView の選択中のタブを取得・設定するには、以下のように selection: Int プロパティを利用します。
@State var selection: Int = 0
var body: some View {
TabView(selection: $selection) {
HomeView().tabItem {
Text("ホーム")
}.tag(0)
MyPageView().tabItem {
Text("マイページ")
}.tag(1)
SettingsView().tabItem {
Text("設定")
}.tag(2)
}
}
これだけであれば特に問題は起きません。
実際のアプリではもう少し画面要素もデータの持ち方も複雑になり、TabView の 各View の画面プロパティは、ViewModel や Presenter などの ObservableObject プロトコルに準拠した class を定義し、View と紐づけることが多いかと思います。
TabView の selection を使用する時に、この ViewModel(or Presenter)の持ち方を注意しなければならない場合があります。
ポイントは @ObservedObject と @StateObject の理解にあります。
筆者がハマってしまった実例を元に解説していきます。
@ObservedObject と @StateObject の違い
先ず、@ObservedObject と @StateObject の違いを確認しましょう。
@ObservedObject は親Viewのプロパティに更新があるとリセットされるのに対して、@StateObject は値を保持したままになります。
検証例
例えば、下記の例ですと、親Viewの counter が変化すると、@ObservedObject の counter は 0 に戻り、@StateObject の counter は保持されたままになります。
以下のコードは実際に動作するので試してみて下さい。
struct ContentView: View {
@State var counter = 0
var body: some View {
VStack {
VStack {
Button("タップしてカウント(親View)") {
counter += 1
}
Text("親ViewのCount:\(counter)")
}
ChildView(observedViewModel: .init(), stateViewModel: .init())
}
}
}
struct ChildView: View {
@ObservedObject var observedViewModel: ViewModel
@StateObject var stateViewModel: ViewModel
var body: some View {
VStack {
Button("タップしてカウント(@ObservedObject)") {
observedViewModel.counter += 1
}
Button("タップしてカウント(@StateObject)") {
stateViewModel.counter += 1
}
Text("@ObservedObjectのCount:\(observedViewModel.counter)")
Text("@StateObjectのCount:\(stateViewModel.counter)")
}
}
}
class ViewModel: ObservableObject {
@Published var counter = 0
}
この違いを良く理解していなかったが為に、TabView の selection を使用した際に問題が発生し、原因究明に時間を要してしまいました。
@ObservedObject か @StateObject のどちらを適用するかよく考えよう
前述の通り、@ObservedObject は 親View の変更によってリセットされ、@StateObject は保持されます。
そのため、親View の変更によって子Viewが再描画される際にデータをリセットされてよい、若しくはリセットして欲しい時は @ObservedObject を、リセットされてほしくない場合は @StateObject を選択することになります。
この特性から、TabView の selection に ViewModel の @Published プロパティを紐づけると、タブの選択時に selection の値が変化するため、各タブの子View に影響を与えます。
当初、子View の ViewModel を @ObservedObject としていたのですが、タブを切り替えると処理が途中で終わってしまい、画面が描画されないという現象に悩まされました。
この時の原因は、onAppear() で Modelクラスがデータのロードを開始した後に、ViewModel のイニシャライザーで Model クラスのインスタンスが再作成され、ロードが完了した際に 既に解放されてしまった Model インスタンスが ViewModel に応答を返そうとしたところ nil 参照で処理が中断したためでした。
あくまで一例ですが、大体以下のような流れで発生しました。
子View の処理の流れ
View が ViewModel を @ObservedObject で保持し、ViewModel は Model を持ち、Model は weak で ViewModel を参照しています。
struct HomeView: View {
@ObservedObject var viewModel: HomeViewModel
var body: some View {
〜
}
.onAppear() {
viewModel.viewAppear()
}
}
class HomeViewModel: ObservableObject {
var model: HomeModel
init() {
model = HomeModel()
model.viewModel = self
}
func viewAppear() {
model.fetchData()
}
func didFetchData() {
〜
}
}
class HomeModel {
weak var viewModel: HomeViewModel?
func fetchData() {
DataRepository.fetch(
fetched: { [weak self] data in
self?.viewModel?.didFetchData()
}
error: {〜}
)
}
}
タブが選択され、View の再描画により、onAppear() が呼ばれ、ViewModel の viewAppear() から Model の fetchData() がコールされます。
しかし、その後、ViewModel がリセットされ、init() で Model が再生成されます。
データのフェッチが成功し、Model から ViewModel へ didFetchData() で応答を返そうとしますが、fetchData() の呼び出し元の Model インスタンスは解放されてしまっているので [weak self] が nil となっており、didFetchData() を呼び出す前に処理が終わってしまった、という流れでした。
ViewModel を @StateObject にして解決
結論としては、ViewModel を @ObservedObject から @StateObject に変更し ViewModel と Model の再生成を防ぐことで問題は解決しました。
@StateObject var viewModel: HomeViewModel
しかし、@ObservedObject 自体が悪いわけではありません。
データの保持が必要ない場合は @ObservedObject として保持している方がメモリ使用の観点からもスマートです。
アプリの仕様や画面の設計によって、@ObservedObject とするか @StateObject とするかはよく検討する必要があります。
今回は TabView の selection の利用とデータの持ち方が起因の不具合でしたが、様々な場面で起こりうる問題かと思います。
以上
コメントを残す