ObservableObjectを階層構造で扱う場合の注意点を解説します
環境
この記事の情報は次のバージョンで動作確認しています。
【Swift】5.4
【iOS】14.5
【macOS】Big Sur バージョン 11.5.2
階層構造にした時の問題点
次のようなモデルクラスで考えます。
ObservableObjectの配列を扱うため、階層構造のクラスになっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Model: ObservableObject { @Published var items: [SubModel] = [] func addItems(_ items: [String]) { for item in items { self.items.append(SubModel(item)) } } } class SubModel: ObservableObject, Identifiable { let id = UUID() @Published var isChecked = false @Published var name: String init(_ name: String) { self.name = name } } |
上記のクラスを使って画面を実装した例です。
リストの項目追加と、各項目をクリックした時のチェックのOn/OFFを実現します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
struct ContentView: View { @ObservedObject private var model = Model() var body: some View { VStack { Button("アイテム追加") { model.addItems(["りんご", "ばなな", "みかん"]) } List { ForEach(model.items) { item in HStack { Image(systemName: item.isChecked ? "checkmark.rectangle" : "rectangle") Text(item.name) Spacer() } .onTapGesture { item.isChecked.toggle() } } } } } } |
実行画面がこちらです。
アイテムの追加はうまくいくのですが、なぜか項目をクリックしてもチェックされません。
tapGestureイベントが発生して値は変わっているのですが、Viewは再描画されないのです。
Viewが再描画されない理由
項目のタップによってSubModelクラスの値は変更されますが、上位のModelクラスには変更が発生しません。
クラス配列の場合、要素の追加/削除・順序入れ替えなどが発生した場合の変更は感知しますが、要素クラスの中身の変更は感知できません。
この為、View側もモデルの変更を感知できず、再描画が発生しないのです。
対処方法その1:強制再描画
サブクラスの値を修正すると同時に、上位のModelクラスに対して更新イベントを強制的に発生させる方法です。
下記コードのobjectWillChange.send()がそれにあたります。
再描画のタイミングをコントロールできるので、大変便利な仕組みですが、データとViewの同期を開発者が実装しなければいけない点は、SwiftUIの思想である、Single Source of Truth に則していないように思います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
struct ContentView: View { @ObservedObject private var model = Model() var body: some View { VStack { Button("アイテム追加") { model.addItems(["りんご", "ばなな", "みかん"]) } List { ForEach(model.items) { item in HStack { Image(systemName: item.isChecked ? "checkmark.rectangle" : "rectangle") Text(item.name) Spacer() } .onTapGesture { item.isChecked.toggle() model.objectWillChange.send() // 強制再描画 } } } } } } |
対処方法その2:Viewを分割する
データモデルの階層に合わせてViewを分割する方法です。
分割したSubView内で、SubModelに対しての@ObservedObject定義ができる為、Viewが変更を感知できるようになります。
View再描画の範囲を最小限に絞る意味でも、こちらの方が理にかなっているのではないでしょうか?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
struct ContentView: View { @ObservedObject private var model = Model() var body: some View { VStack { Button("アイテム追加") { model.addItems(["りんご", "ばなな", "みかん"]) } List { ForEach(model.items) { item in SubView(item: item) } } } } } struct SubView: View { @ObservedObject var item: SubModel var body: some View { HStack { Image(systemName: item.isChecked ? "checkmark.rectangle" : "rectangle") Text(item.name) Spacer() } .onTapGesture { item.isChecked.toggle() } } } |