Firing objectWillChange for an array in Swift

342 Views Asked by At

I understand how a swift ObservableObject isn't seen as changed/updated when an array element (class) is changed. I have added a listener to the array items which works, however I can't fire off an objectWillChange for the array because an array doesn't have the objectWillChange function.

I have a global singleton like this:

final class GlobalData: ObservableObject {
    static let shared = GlobalData()
    
    var cancellables = [AnyCancellable]()
    @Published private(set) var lists: [List] = [] {
        didSet {
            lists.forEach({ [unowned self] list in
                let c = list.objectWillChange.sink { _ in
                    self.objectWillChange.send()
                }
                self.cancellables.append(c)
            })
        }
    }
}

I want to monitor changes in SwiftUI of the array like this:

.onChange(of: globalData.lists) { _ in
    print("✅", "lists changed")
}

The problem is, as it's a singleton I don't want to trigger the objectWillChange on self as it always remains the same, I need to trigger it on the published array.

Although the Array is @Published I cannot fire off lists.objectWillChange.send() as I get the error:

Value of type '[WishList]' has no member 'objectWillChange'

So I guess my question is, how can an Array be happy with @Published if I cannot trigger it?

Can I add this function to the Array, or is there another way to trigger the publisher?

There is a slight workaround, but it's not great...

I can add a new published property:

@Published private(set) var listsChanged = UUID()

Then in my array listener I change the UUID. Now in SwiftUI if I watch listsChanged for changes, I get the callbacks I need. But there must be a way to get them from the array?

EDIT -------

Here's an example of my view:

struct MainMenuView: View {
    
    @EnvironmentObject private var globalData: GlobalData

    var body: some View {
        VStack {}
        .onChange(of: globalData.lists) { _ in
            print("✅", "lists changed")
        }
    }
}

I get the lists changed call when I initially set the array, but not if I fire off self. objectWillChange.send() on the GlobalData object.

2

There are 2 best solutions below

0
Scott Thompson On

I really think you are trying to abuse the @Published and ObservableObject mechanism.

That scheme is for an object to report its own changes to another object, and it's specifically targeted at making it easy for Swift UI views observing their own state. It's not really meant as a general implementation of the Observer pattern.

From a logical standpoint, using @Published and ObservableObject for GlobalData is incorrect because it's not the GlobalData that's changing. It's trying to report that something it owns is changing, and that's a logically distinct (albeit related) message.

Another use of the Observable pattern may be more appropriate to report that event.

For example, a delegate pattern:

protocol GlobalDataDelegate {
  func listsChanged()
}

class List: ObservableObject {
}

class GlobalData {
  var delegate: (any GlobalDataDelegate)?

  var cancellables: [AnyCancellable] = []

  var lists: [List] = [] {
    didSet {
        cancellables = lists.map { [weak self] in
          $0.objectWillChange.sink { self?.delegate?.listsChanged() }
        }
    }
  }
}

Or if want to use Combine:

import UIKit
import Combine

class List: ObservableObject {
}

enum GlobalDataEvent {
  case listsChanged(List)
}

class GlobalData {
  let listsChanged: AnyPublisher<GlobalDataEvent, Never>
  private let listsChangedSubject = PassthroughSubject<GlobalDataEvent, Never>()

  var cancellables: [AnyCancellable] = []
  var lists: [List] = [] {
    didSet {
        cancellables = lists.map { [weak self] list in
          list.objectWillChange.sink { self?.listsChangedSubject.send(.listsChanged(list)) }
        }
    }
  }

  init() {
    listsChanged = listsChangedSubject.eraseToAnyPublisher()
  }
}

In either of these two examples the logical event that is happening is clearly defined, more precisely than simply "object changed". In the observer case what's being reported is clear from the context of which delegate method was called. In the Combine case, the event has a distinct enum case that makes the notification explicit. Either should be more readable and understandable in your code.

In the delegate case your view will have to set itself as the delegate and implement the delegate protocol. In the Combine case your view can use onReceive view modifier to watch the listsChanged publisher.

(You could also use NSNotificationCenter with a distinct notification name to get the same effect).

However you solve your problem, I believe ObservableObject and @Published are the wrong implementation of the Observer pattern to use here.

0
malhal On

Seems to me you are trying to reimplement SwiftUI's change tracking which is a waste of time IMO.

Just make a View struct and pass in the object's properties that you want to display. body will be called when any of the properties change. If you want to transform a property use a computed property into the View init so the result can be change tracked.

Use .task or .task(id: aProperty) to download your extra data.

Then simply call objectWillChange.send() when you load or save the array of objects in your store object and SwiftUIs change tracking/diffing will take care of the rest. Make sure you are observing the store object in the View that has the ForEach.