Beginner
Beginner

Reputation: 515

When a Store's object is updated, auto-trigger objectWillChange.send() in ViewModel ObservableObjects

For a Store/Factory/ViewModel pattern using Combine and SwiftUI, I'd like a Store protocol-conforming class to expose a publisher for when specified model object(s) change internal properties. Any subscribed ViewModels can then trigger objectWillChange to display the changes.

(This is necessary because changes are ignored inside a model object that is passed by reference, so @Published/ObservableObject won't auto-fire for Factory-passed Store-owned models. It works to call objectWillChange in the Store and the VM, but that leaves out any passively listening VMs.)

That's a delegate pattern, right, extending @Published/ObservableObject to passed-by-reference objects? Combing through combine blogs, books, and docs hasn't triggered an idea to what's probably a pretty standard thing.

Crudely Working Attempt

I thought PassthroughSubject<Any,Never> would be useful if I exposed a VM's objectWillChange externally, but PassthroughSubject.send() will fire for every object within the model object. Wasteful maybe (although the ViewModel only fires its objectWillChange once).

Attaching a limiter (e.g., throttle, removeDuplicates) on Ext+VM republishChanges(of myStore: Store) didn't seem to limit the .sink calls, nor do I see an obvious way to reset the demand between the PassthroughSubject and the VM's sink... or understand how to attach a Subscriber to a PassthroughSubject that complies with the Protcols. Any suggestions?

Store-Side

struct Library {
   var books: // some dictionary
}

class LocalLibraryStore: LibraryStore {
    private(set) var library: Library {
         didSet { publish() }
    }

    var changed = PassthroughSubject<Any,Never>()
    func removeBook() {}
}

protocol LibraryStore: Store { 
    var changed: PassthroughSubject<Any,Never> { get }
    var library: Library { get }
}


protocol Store {
    var changed: PassthroughSubject<Any,Never> { get }
}

extension Store {
    func publish() {
        changed.send(1)
        print("This will fire once.")
    }
}

VM-Side

class BadgeVM: VM {
    init(store: LibraryStore) {
        self.specificStore = store
        republishChanges(of: jokesStore)
    }

    var objectWillChange = ObservableObjectPublisher() // Exposed {set} for external call
    internal var subscriptions = Set<AnyCancellable>()

    @Published private var specificStore: LibraryStore
    var totalBooks: Int { specificStore.library.books.keys.count }
}

protocol VM: ObservableObject {
    var subscriptions: Set<AnyCancellable> { get set }
    var objectWillChange: ObservableObjectPublisher { get set }
}

extension VM {
    internal func republishChanges(of myStore: Store) {
        myStore.changed
            // .throttle() doesn't silence as hoped
            .sink { [unowned self] _ in
                print("Executed for each object inside the Store's published object.")
                self.objectWillChange.send()
            }
            .store(in: &subscriptions)
    }
}

class OtherVM: VM {
    init(store: LibraryStore) {
        self.specificStore = store
        republishChanges(of: store)
    }

    var objectWillChange = ObservableObjectPublisher() // Exposed {set} for external call
    internal var subscriptions = Set<AnyCancellable>()

    @Published private var specificStore: LibraryStore
    var isBookVeryExpensive: Bool { ... }
    func bookMysteriouslyDisappears() { 
         specificStore.removeBook() 
    }
}

Upvotes: 1

Views: 3840

Answers (2)

New Dev
New Dev

Reputation: 49590

It seems that what you want is a type that notifies when its internal properties change. That sounds an awful lot like what ObservableObject does.

So, make your Store protocol inherit from ObservableObject:

protocol Store: ObservableObject {}

Then a type conforming to Store could decide what properties it wants to notify on, for example, with @Published:

class StringStore: Store {
   @Published var text: String = ""
}

Second, you want your view models to automatically fire off their objectWillChange publishers when their store notifies them.

The automatic part can be done with a base class - not with a protocol - because it needs to store the subscription. You can keep the protocol requirement, if you need to:

protocol VM {
   associatedtype S: Store
   var store: S { get }
}

class BaseVM<S: Store>: ObservableObject, VM {
   var c : AnyCancellable? = nil
    
   let store: S
    
   init(store: S) {
      self.store = store

      c = self.store.objectWillChange.sink { [weak self] _ in
         self?.objectWillChange.send()
      }
   }
}

class MainVM: BaseVM<StringStore> {
   // ...
}

Here's an example of how this could be used:


let stringStore = StringStore();
let mainVm = MainVM(store: stringStore)

// this is conceptually what @ObservedObject does
let c = mainVm.objectWillChange.sink { 
   print("change!") // this will fire after next line
} 

stringStore.text = "new text"

Upvotes: 1

Beginner
Beginner

Reputation: 515

Thanks @NewDev for pointing out subclassing as a smarter route.

If you want to nest ObservableObjects or have an ObservableObject re-publish changes in objects within an object passed to it, this approach works with less code than in my question.

In searching to simplify further with a property wrapper (to get at parent objectWillChange and simplify this further), I noticed a similar approach in this thread: https://stackoverflow.com/a/58406402/11420986. This only differs in using a variadic parameter.

Define VM and Store/Repo Classes

import Foundation
import Combine

class Repo: ObservableObject {
    func publish() {
        objectWillChange.send()
    }
}

class VM: ObservableObject {
    private var repoSubscriptions = Set<AnyCancellable>()

    init(subscribe repos: Repo...) {
        repos.forEach { repo in
            repo.objectWillChange
                .receive(on: DispatchQueue.main) // Optional
                .sink(receiveValue: { [weak self] _ in
                    self?.objectWillChange.send()
                })
                .store(in: &repoSubscriptions)
        }
    }
}

Example Implementation

  • Repo: add didSet { publish() } to model objects
  • VM: The super.init() accepts any number of repos to republish
import Foundation

class UserDirectoriesRepo: Repo, DirectoriesRepository {
    init(persistence: Persistence) {
        self.userDirs = persistence.loadDirectories()
        self.persistence = persistence
        super.init()
        restoreBookmarksAccess()
    }

    private var userDirs: UserDirectories {
        didSet { publish() }
    }

    var someExposedSliceOfTheModel: [RootDirectory] {
        userDirs.rootDirectories.filter { $0.restoredURL != nil }
    }

    ...
}
import Foundation

class FileStructureVM: VM {
    init(directoriesRepo: DirectoriesRepository) {
        self.repo = directoriesRepo
        super.init(subscribe: directoriesRepo)
    }
    
    @Published // No longer necessary
    private var repo: DirectoriesRepository
    
    var rootDirectories: [RootDirectory] {
        repo.rootDirectories.sorted ...
    }

    ...
}

Upvotes: 1

Related Questions