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 の利用とデータの持ち方が起因の不具合でしたが、様々な場面で起こりうる問題かと思います。
以上





コメントを残す