2

In my view model I have this property and method:

@Published var cats: [Cat] = [] //this gets populated later

When I update one of the cats as such:

func updateCatQuantity(_ cat:Cat, qty: Int) {
    if let index = cats(of: cat) {
        cats[index].quantity = qty
    }
}

The view does not get refreshed. didSet on cats does not get called. I think it has something to do with the fact that Cat is a class instead of a struct. Here's how it's defined:

class Cat: BaseMapperModel, Identifiable, Hashable {
    static func == (lhs: Cat, rhs: Cat) -> Bool {
       return ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
    }

    var id = UUID()

    var title: String = ""
    var quantity: Int = 0

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    override func mapping(map: Map) {
        title <- map["title"]
        quantity <- map["qty"]
    }
}

How can I get the view to refresh when the quantity of a cat is changed?

soleil
  • 12,133
  • 33
  • 112
  • 183
  • 1
    In this scenario with a `@published` array, I have got it working with Cat being a `struct` - any change to a property makes a whole new struct. You might need to use mutating functions with variables within the Cat struct. Classes don’t update in the way you expect as the array holds references, which don’t change. – Chris Aug 12 '23 at 18:05
  • If you have difficulty updating a single instance of a Cat struct (based on above comment), you can just set the `cats[index]` equal to a new instance of Cat with the relevant property changed. – Chris Aug 12 '23 at 18:07
  • 1
    If you want to use a class like this, you will need to call objectWillChange manually on your ObservableObject. Better to switch to a struct, which SwiftUI is built to work with. – jnpdx Aug 12 '23 at 19:02
  • I appreciate the comments, but maybe a full answer post could help? For example I tried setting cats[index] to a new instance but it didn't work. `cats[index] = Cat.init(JSON: ["title": cat.title, "quantity": qty])!` And trying to change Cat to a struct throws a lot of errors... – soleil Aug 12 '23 at 20:30
  • Does this answer your question? [Why is an ObservedObject array not updated in my SwiftUI application?](https://stackoverflow.com/questions/57459727/why-is-an-observedobject-array-not-updated-in-my-swiftui-application) – soundflix Aug 12 '23 at 22:09
  • Well, yes, I knew that converting the class to a struct was a potential solution. Due to other constraints that wasn't easy, but it's eventually what I did because I couldn't get it to work as a class. – soleil Aug 15 '23 at 02:11
  • In SwiftUI the View struct is a view model already you don't need another one – malhal Aug 15 '23 at 16:39

1 Answers1

6

There are 2 issues

  1. @Published only triggers a refresh when the "value" changes. Changing a variable in a class is not considered a "value" change.

Switching to a struct is the "easy" change.

struct Cat: Identifiable, Hashable {
    var id = UUID()

    var title: String = ""
    var quantity: Int = 0
}

But issue #2 is a bigger deal. By overriding Hashable you are telling SwiftUI to only trigger View reloads when the id changes.

To observe a class in iOS 13-16 the object has to conform to ObservableObject and be wrapped with @StateObject, @ObservedObject or @EnvironmentObject, appropriately at every level you want to see changes.

class Cat: Identifiable, Hashable, ObservableObject {
    var id = UUID()

    @Published var title: String = ""
    @Published var quantity: Int = 0

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(title)
        hasher.combine(quantity)
    }
    static func == (lhs: Cat, rhs: Cat) -> Bool {
        return lhs.id == rhs.id 
        && lhs.title == rhs.title
        && lhs.quantity == rhs.quantity
    }
}

in iOS 17+ you can use @Observable instead of an ObservableObject with @State and @Bindable appropriately.

@Observable
class Cat: Identifiable{
    var id = UUID()

    var title: String = ""
    var quantity: Int = 0
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(title)
        hasher.combine(quantity)
    }
    
    static func == (lhs: Cat, rhs: Cat) -> Bool {
        return lhs.id == rhs.id
        && lhs.title == rhs.title
        && lhs.quantity == rhs.quantity
    }
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • Dang, this looked promising. I'm supporting 16+ so I tried your `ObservableObject` suggestion (and updated the `hash` and `==` functions). Still no luck. The view still only updates once I tap some other button. – soleil Aug 12 '23 at 20:59
  • @soleil make sure you are using a wrapper at every level like I mentioned. SwiftUI can’t detect changes without them. StateObject to initialize and ObservedObject to pass around. At every level using subviews if necessary. – lorem ipsum Aug 12 '23 at 21:05
  • what should the wrapper look like? Is `@Published var cats: [Cat] = []` not correct? Do I need a wrapper on the view model or something? Currently I have `class MyViewModel: ObservableObject, Identifiable, Hashable` – soleil Aug 12 '23 at 21:09
  • Published can’t observe ObservableObjects (issue 1 above), the View needs a wrapper https://developer.apple.com/documentation/swiftui/observedobject – lorem ipsum Aug 12 '23 at 21:12
  • This is so confusing. If I change it to `@ObservedObject var cats: [Cat] = []`, I get `Generic struct 'ObservedObject' requires that '[Cat]' conform to 'ObservableObject'`. But `Cat` already does. – soleil Aug 12 '23 at 21:16
  • In THE View, each cat needs a wrapper. @soleil not the array the array is fine. – lorem ipsum Aug 12 '23 at 21:18
  • How would I do that? In the view I have this: `ForEach(viewModel.cats.indices, id: \.self) { index in let cat = viewModel.cats[index] ...do stuff with cat, including updating quantity when the user selects a new value in a sheet` – soleil Aug 12 '23 at 21:23
  • @soleil with a subview. Also, indices are unsafe with SwiftUI you should not use them – lorem ipsum Aug 12 '23 at 21:42
  • I guess I don't understand. The main view already has `@ObservedObject var viewModel: MyViewModel` so I don't see how making another subclass with that same wrapper will help anything :( – soleil Aug 12 '23 at 21:56
  • @soleil NP but this is the answer. This is how SwiftUI is setup every ObservableObject needs their own, it is a SwiftUI requirement until iOS 17 with the new macro – lorem ipsum Aug 12 '23 at 21:59
  • @soleil I just notice you said another subclass, you aren’t creating a subclass to get it to work, cat is the ObservableObject. You need another View which is a struct. – lorem ipsum Aug 12 '23 at 22:04