3

I'm working on a SwiftUI app using SwiftData for state management. I've encountered an issue with view updates when mutating a model. Here's a minimal example to illustrate the problem:

import SwiftUI
import SwiftData

// MARK: - Models

@Model
class A {
  @Relationship var bs: [B] = [B]()
  
  init(bs: [B]) {
    self.bs = bs
  }
}

@Model
class B {
  @Relationship var cs: [C] = [C]()
  
  init(cs: [C]) {
    self.cs = cs
  }
}

@Model
class C {
  var done: Bool
  
  init(done: Bool) {
    self.done = done
  }
}

// MARK: - Views

struct CView: View {
  var c: C
  
  var body: some View {
    @Bindable var c = c
    HStack {
      Toggle(isOn: $c.done, label: {
        Text("Done")
      })
    }
  }
}

struct BView: View {
  var b: B
  
  var body: some View {
    List(b.cs) { c in
      CView(c: c)
    }
  }
}

struct AView: View {
  var a: A
  
  var body: some View {
    List(a.bs) { b in
      NavigationLink {
          BView(b: b)
      } label: {
        Text("B \(b.cs.allSatisfy({ $0.done }).description)")
      }
    }
  }
}

struct ContentView: View {
  @Query private var aList: [A]
  
  var body: some View {
    NavigationStack {
      List(aList) { a in
        NavigationLink {
          AView(a: a)
        } label: {
          Text("A \(a.bs.allSatisfy({ $0.cs.allSatisfy({ $0.done }) }).description)")
        }
      }
    }
  }
}

@main
struct Minimal: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

#Preview {
  let config = ModelConfiguration(isStoredInMemoryOnly: true)
  let container = try! ModelContainer(for: A.self, configurations: config)
  
  var c = C(done: false)
  container.mainContext.insert(c)
  var b = B(cs: [c])
  container.mainContext.insert(b)
  var a = A(bs: [b])
  container.mainContext.insert(a)
  
  return ContentView()
    .modelContainer(container)
}

In this setup, I have a CView where I toggle the state of a model C. After toggling C and navigating back, the grandparent view AView does not reflect the updated state (it still shows false instead of true). However, if I navigate back to the root ContentView and then go to AView, the status is updated correctly.

Why doesn't AView update immediately after mutating C in CView, but updates correctly when navigating back to the root ContentView? I expected the grandparent view to reflect the changes immediately as per SwiftData's observation mechanism.

3
  • I added your code to a new project and I can't reproduce the issue. All views are updated as expected. Commented Nov 12, 2023 at 18:07
  • @JoakimDanielson thanks for giving it a try. If you don't mind confirming, I uploaded a gif here of me going through the flow as described: imgur.com/a/d6SwJSD -- I'm seeing "B false" instead of "B true" after toggling in C. Is that what you see? Commented Nov 12, 2023 at 18:27
  • Actually I see a different result now, or I made a mistake earlier. In the preview it works fine (and not as in your gif) but when I run it in the simulator it doesn't update until I go back to the root view but once I have done that once it works fine and updates correctly. Commented Nov 12, 2023 at 19:42

1 Answer 1

1

You views have static models. When class variables are updated they are not updating the views because views are mapped to constant variables. You need to "notify" all the related views via correct view models. Under view models I mean either @State (and related @Binding) or @StateObject (and related @ObservableObject).

Here is a correct way to update done variable (actually it's very ugly, but it's correct in the above meaning of the view model updates; you should implement your own correct way):

struct CView: View {
    let c: C
    @Binding var done: Bool // this binding allows up update upper view models
    
    var body: some View {
        HStack {
            Toggle(isOn: $done, label: {
                Text("Done")
            })
        }
    }
}

struct BView: View {
    let b: B
    @ObservedObject var model: ViewModel // this model allows to update this view when model.cs is updated.
    
    class ViewModel: ObservableObject {
        @Published var cs: [C]
        
        init(b: B) {
            cs = b.cs
        }
        
        func update(c: C) {
            /// this updates the ViewModel and all dependent views (B, C)
            let cModified = cs
            cs = cModified
            /// any other modification will also work,
            /// e.g. you might have:
            /// ```
            /// @Published var map: [ID: C] // this is for updates and getting `c` for CView
            /// private (set) var cs: [C] // this is for List
            /// ```
        }
    }
    
    init(b: B) {
        self.b = b
        self.model = ViewModel(b: b)
    }
    
    var body: some View {
        List(model.cs) { c in
            CView(c: c, done: Binding(get: {
                c.done
            }, set: { value in
                c.done = value
                model.update(c: c)
            }))
        }
    }
}
Sign up to request clarification or add additional context in comments.

2 Comments

Thanks for responding. I avoided using ObservedObject as I'm trying to learn the new SwiftData framework that uses Observable-- developer.apple.com/documentation/swiftui/…
That's the same. This Marco just adds ObservedObject conformance for you. But one or another way you need to have it.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.