🛟

SwiftData: 明細画面で追加されたレコードが@Queryに反映されない件

2024/11/29に公開

概要

SwiftDataを使って、あるビューで一覧もしくはメニューなどを表示し、別のビューで追加・削除などをするケースがあります。
基本的にはシンプルに動くのですが、うまく行かないときもあります。

問題点

まずはそれっぽく動いている様子

「あるビューで一覧もしくはメニューなどを表示し、別のビューで追加・削除などをするケース」というのは↓のようなものです。

※並び順がめちゃくちゃなのは済みません。サンプルなので。

.sheetNavigationStackなどを使って、一覧ビューを開いてそこで追加・削除するというような感じです。
上記のサンプル動画ではそれっぽく動いています。
これがシンプルに書けるのはSwiftDataのいいところですね。
コードも以下に載せておきます。

コード

Xcode 16.1/iOS 18.1のシミュレータで確認

import SwiftUI
import SwiftData

@Model
class TestData {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

struct ListView: View {
    @State var newName: String = ""
    @Query var testData: [TestData]
    @Environment(\.modelContext) var modelContext
    
    var body: some View {
        Form {
            ForEach(testData) { data in
                HStack {
                    Text(data.name)
                    Spacer()
                    Button(action: {
                        deleteRecord(data)
                    }, label: {
                        Image(systemName: "trash")
                    })
                    .buttonStyle(.borderless)
                }
            }
            HStack {
                TextField("新規項目名", text: $newName)
                Button("追加") {
                    addNewRecord()
                }
                .disabled(newName.isEmpty)
            }
        }
    }
    
    private func addNewRecord() {
        modelContext.insert(TestData(name: newName))
        newName = ""
    }
    
    private func deleteRecord(_ record: TestData) {
        modelContext.delete(record)
    }
}

struct MainViewNavigationStackVersion: View {
    @State var selectedName: String?
    @Query var testData: [TestData]
    
    var body: some View {
        NavigationStack {
            Form {
                Section("何かの設定") {
                    Picker("メニューから選択", selection: $selectedName) {
                        ForEach(testData) { data in
                            Text(data.name).tag(data.name)
                        }
                    }
                    .pickerStyle(.automatic)
                    NavigationLink("メニュー項目を一覧で編集", destination: {
                        ListView()
                    })
                }
            }
        }
    }
}

struct MainViewSheetVersion: View {
    @State var selectedName: String?
    @Query var testData: [TestData]
    @State var listViewPresented: Bool = false
    
    var body: some View {
        Form {
            Section("何かの設定") {
                Picker("メニューから選択", selection: $selectedName) {
                    ForEach(testData) { data in
                        Text(data.name).tag(data.name)
                    }
                }
                .pickerStyle(.automatic)
                Button("メニュー項目を一覧で編集") {
                    listViewPresented.toggle()
                }
            }
        }
        .sheet(isPresented: $listViewPresented) {
            VStack(spacing: 0) {
                ZStack {
                    Rectangle()
                        .fill(Color(uiColor: UIColor.systemGroupedBackground))
                        .frame(height: 32)
                    HStack {
                        Spacer()
                        Button("閉じる") {
                            listViewPresented.toggle()
                        }
                        .padding(.trailing, 16)
                    }
                }
                Divider()
                ListView()
            }
        }
    }
}

#Preview {
    MainViewNavigationStackVersion()
}
#Preview {
    MainViewSheetVersion()
}

@main
struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            TabView {
                MainViewNavigationStackVersion().tabItem {
                    Text("NavigationStack")
                }
                MainViewSheetVersion().tabItem {
                    Text("Sheet")
                }
            }
        }
        .modelContainer(for: TestData.self)
    }
}

実際の不具合動作の様子

とは言え、実際にこれを作って動かすと最初の1件が入りません。
あるいは、全部削除しても選択プルダウンが残ります。

全削除→そのあと追加、という動作の様子は↓になります。

  • 全削除後にはプルダウンが残っている
  • 全削除後に残ったプルダウンをタップすると(空なので)プルダウンが消える
  • TabViewでロード前のビューに切り替えると、初回は@Queryが更新されている様子
    (全削除→追加した後にNavigationStackタブからsheetタブに切り替えた際、プルダウンが出ているため)
  • でもいったんビューがロードされてしまうと、以後は全削除→追加で@Queryが更新されない

回避策

原因は、レコードが0件の状態で@Quqeyでの更新検知ができていないせいで、ビューの更新がされないということなのではないかと推測してはいますが、そうはいってもなんとかしないといけません。

回避策として、シンプルに「一覧側で追加・削除したらそれを検知(通知)して再描画する」ということにします。
具体的には以下の通りです。

  • 一覧側にクロージャを渡せるようにする
  • 一覧側では追加・削除があったらそのクロージャを呼ぶ
  • クロージャは呼ばれたら呼んだ側の再描画をする(ための@State変数を更新する)

一覧側にクロージャを渡せるようにする

ListViewの側にonListChangeのようなハンドラメンバ変数を用意し、追加・削除の関数内で、このハンドラが設定されていれば呼ぶようにします。

struct ListView: View {
    @State var newName: String = ""
    @Query var testData: [TestData]
    @Environment(\.modelContext) var modelContext
    var onListChange: (() -> Void)?  // ←ここ
    // ...中略
}

一覧側では追加・削除があったらそのクロージャを呼ぶ

追加・削除の関数内で、そのクロージャを呼びます。

    private func addNewRecord() {
        modelContext.insert(TestData(name: newName))
        newName = ""
        if let handler = onListChange { // ←ここ
            handler()
        }
    }
    
    private func deleteRecord(_ record: TestData) {
        modelContext.delete(record)
        if let handler = onListChange { // ←ここ
            handler()
        }
    }

クロージャは呼ばれたら呼んだ側の再描画をする

呼び出し側の画面に@State.id用の変数を用意します。
.idは値が更新されると再描画されるというモディファイヤです。便利です。

値の型は何でも良いですが、整数にして単純に加算するぐらいが簡単です。

struct MainViewNavigationStackVersion: View {
    @State var selectedName: String?
    @Query var testData: [TestData]
    @State var listRevision: Int = 0 // ←ここ
    // ...中略
}

Bool型の変数で更新されたらtrueにする、などでもいいのですが、それだとfalseに戻す処理をどこかに書かないといけません。

.idはビュー内のどこにつけても良いのですが、今回はPickerにつけました。

                    Picker("メニューから選択", selection: $selectedName) {
                        ForEach(testData) { data in
                            Text(data.name).tag(data.name)
                        }
                    }
                    .pickerStyle(.automatic)
                    .id(listRevision)   // ← ここ

そして一覧側に渡すクロージャでこの値を更新します。

                    NavigationLink("メニュー項目を一覧で編集", destination: {
                        ListView(onListChange: { // ← ここ
                            self.listRevision += 1
                        })
                    })

ちょっとこれではうまく行かない…

実際にこれで試してみると、NavigationStack側で追加・削除するとNavigationStack側はうまく検知しますが、.sheet側では追加・削除が検知されません(当然ですけども)。

もし複数の画面で検知を共有化したいのであれば、共有の変数をさらに親側に作って、@Stateではなく@Bindingにする必要があります。

修正版コード
import SwiftUI
import SwiftData

@Model
class TestData {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

struct ListView: View {
    @State var newName: String = ""
    @Query var testData: [TestData]
    @Environment(\.modelContext) var modelContext
    var onListChange: (() -> Void)?
    
    var body: some View {
        Form {
            ForEach(testData) { data in
                HStack {
                    Text(data.name)
                    Spacer()
                    Button(action: {
                        deleteRecord(data)
                    }, label: {
                        Image(systemName: "trash")
                    })
                    .buttonStyle(.borderless)
                }
            }
            HStack {
                TextField("新規項目名", text: $newName)
                Button("追加") {
                    addNewRecord()
                }
                .disabled(newName.isEmpty)
            }
        }
    }
    
    private func addNewRecord() {
        modelContext.insert(TestData(name: newName))
        newName = ""
        if let handler = onListChange {
            handler()
        }
    }
    
    private func deleteRecord(_ record: TestData) {
        modelContext.delete(record)
        if let handler = onListChange {
            handler()
        }
    }
}

struct MainViewNavigationStackVersion: View {
    @State var selectedName: String?
    @Query var testData: [TestData]
    @Binding var listRevision: Int
    
    var body: some View {
        NavigationStack {
            Form {
                Section("何かの設定") {
                    Picker("メニューから選択", selection: $selectedName) {
                        ForEach(testData) { data in
                            Text(data.name).tag(data.name)
                        }
                    }
                    .pickerStyle(.automatic)
                    .id(listRevision)
                    NavigationLink("メニュー項目を一覧で編集", destination: {
                        ListView(onListChange: {
                            self.listRevision += 1
                        })
                    })
                }
            }
        }
    }
}

struct MainViewSheetVersion: View {
    @State var selectedName: String?
    @Query var testData: [TestData]
    @State var listViewPresented: Bool = false
    @Binding var listRevision: Int
    
    var body: some View {
        Form {
            Section("何かの設定") {
                Picker("メニューから選択", selection: $selectedName) {
                    ForEach(testData) { data in
                        Text(data.name).tag(data.name)
                    }
                }
                .pickerStyle(.automatic)
                .id(listRevision)
                Button("メニュー項目を一覧で編集") {
                    listViewPresented.toggle()
                }
            }
        }
        .sheet(isPresented: $listViewPresented) {
            VStack(spacing: 0) {
                ZStack {
                    Rectangle()
                        .fill(Color(uiColor: UIColor.systemGroupedBackground))
                        .frame(height: 32)
                    HStack {
                        Spacer()
                        Button("閉じる") {
                            listViewPresented.toggle()
                        }
                        .padding(.trailing, 16)
                    }
                }
                Divider()
                ListView(onListChange: {
                    self.listRevision += 1
                })
            }
        }
    }
}

#Preview {
    MainViewNavigationStackVersion(listRevision: .constant(0))
}
#Preview {
    MainViewSheetVersion(listRevision: .constant(0))
}

@main
struct TestApp: App {
    @State var listRevision: Int = 0
    
    var body: some Scene {
        WindowGroup {
            TabView {
                MainViewNavigationStackVersion(listRevision: $listRevision).tabItem {
                    Text("NavigationStack")
                }
                MainViewSheetVersion(listRevision: $listRevision).tabItem {
                    Text("Sheet")
                }
            }
        }
        .modelContainer(for: TestData.self)
    }
}

最終的な動作の様子

NavigationStackからの一覧画面で追加・削除しても、.sheetからの一覧画面で追加・削除しても、親画面のメニュープルダウンが反映されるようになりました。

その他の回避策

その他の回避方法として、以下のようなものがありそうです(試してはいません)。

  • 変更を検知・通知するのではなく、表示の都度、フェッチする
    例えばビューの.onAppearで、手動フェッチする(@Queryは使わない)など
  • 変更をクロージャでない方法で通知する
    例えばNotificationCenterを使った通知や、EnvironmentPreferenceを使った通知など
    (更新は今回同様に.idを使う、もしくは上記手動フェッチ)

Discussion