swiftuimacos-sonoma

What is the correct approach to refresh a view based on changes to objects inside an array?


I have an observable class like this for a macOS app:

final class MyModel:ObservableObject {
  @Published var objects = (0..<3).compactMap {_ in
    MyObject()
  }
}

class MyObject {
    let name:String
    let brand:String
    let isVehiche:Bool
}

Inside a view I have something like:

@StateObject var model = MyModel()

List {
  ForEach($model.objects, id:\.id) {$object in
    HStack {
      Toggle("is vehicle", isOn: $object.isVehicle)
      Text(object.name)
      Text(object.brand)   
    }
  }
}

Notice that the elements in the ForEach and the array itself, are called with $.

This works almost perfectly.

If I switch the toggle element of an item using the mouse, the list updates correctly.

But if I have a function triggered by a button elsewhere in the interface, which changes all elements programmatically, like

func switchAllToOn() {
  for object in objects {
    object.isVehicle = true
  }
}

The list does not update at all.

But if I add or delete an item from objects the list updates.

In resume: the list updates if I modify individually items inside objects with the mouse, but does not update if I run a code to modify all items programmatically.

I know I can do all kinds of ugly juggling to make this work, like creating and id for the list and changing it after modifying the array, but I would like to understand why this happens and what is the best approach to solve it.

This is the full code:

import SwiftUI

final class MyModel:ObservableObject {
  @Published var objects = (0..<3).compactMap {_ in
    MyObject()
  }
  
  func switchAllToOn() {
    // does not work
//    for object in $objects {
//      object.isVehicle.wrappedValue = true
//    }
  }
}

class MyObject:Identifiable {
  let name:String
  let brand:String
  var isVehicle:Bool
  
  init(name: String = "no name", brand: String = "no brand" , isVehicle: Bool = false) {
    self.name = name
    self.brand = brand
    self.isVehicle = isVehicle
  }
}


struct ContentView: View {
  
  @StateObject var model = MyModel()
  
  private let columns = [GridItem(.flexible()),
                         GridItem(.flexible()),
                         GridItem(.flexible())]
  
  
    var body: some View {
        VStack {
          Button(action: {
            model.switchAllToOn()
          }, label: {
            Text("CHANGE ALL")

          })
          LazyVGrid(
            columns: columns,
            alignment: .center,
            spacing: 0,
            pinnedViews: []
          ) {
            ForEach($model.objects, id:\.id) {$object in
              Toggle("is vehicle", isOn: $object.isVehicle)
              Text(object.name)
              Text(object.brand)
              
            }
          }
        }
        .padding()
    }
}

Solution

  • I've run your code, and after trying things here and there I've found a solution. I've modified the loop to be like this:

    // If you have the function inside a View struct
    func switchAllToOn() {
        for object in $model.objects {
            object.isVehicle.wrappedValue = true
        }
    }
    
    // If you have the function inside the ObservableObject class
    func switchAllToOn() {
          objectWillChange.send() // Notify SwiftUI about the upcoming change
          for object in objects {
            object.isVehicle = true
        }
      }
    

    Basically, the toggles works because they use bindings, that's why they refersh the view instantly. So, I've used bindings in the for loop too and it worked. This works if your function is in a view though. If you happen to have that kind of function in the ObservableObject class you need to call objectWillChange.send() because when you directly modify the property of an item in an array, SwiftUI might not detect the change and update the view accordingly. Let me know it that works for you too!