Reputation: 515
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.
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
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
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.
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)
}
}
}
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