SwiftData: 明細画面で追加されたレコードが@Queryに反映されない件
概要
SwiftDataを使って、あるビューで一覧もしくはメニューなどを表示し、別のビューで追加・削除などをするケースがあります。
基本的にはシンプルに動くのですが、うまく行かないときもあります。
問題点
まずはそれっぽく動いている様子
「あるビューで一覧もしくはメニューなどを表示し、別のビューで追加・削除などをするケース」というのは↓のようなものです。
※並び順がめちゃくちゃなのは済みません。サンプルなので。
.sheet
やNavigationStack
などを使って、一覧ビューを開いてそこで追加・削除するというような感じです。
上記のサンプル動画ではそれっぽく動いています。
これがシンプルに書けるのは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
を使った通知や、Environment
・Preference
を使った通知など
(更新は今回同様に.id
を使う、もしくは上記手動フェッチ)
Discussion