Reputation: 427
Given an hierarchical structure of @OberservableObject
s - I often find myself in a situation where I need a publisher which provides some kind of updated aggregate of the entire structure (the example below calculates a sum, but it could be anything)
Below is the solution I have come up with - which kinda works, but also not... :)
Problem #1: It looks way to complicated - and I feel I am missing something...
Problem #2: It does not work as the $foo publisher on top does emit changes to foo before foo changes, which are then not present in the second self.$foo publisher (which shows the old state).
Sometimes I need the aggregate in sync with swiftUI view updates - so that I need to utilize the @Published value and no separate publisher that emits during didSet of the variable.
I did not find a good solution... So how would you guys resolve this?
class Foo:ObservableObject {
@Published var bar:Int = 0
}
class Foobar:ObservableObject {
@Published var foo:[Foo] = []
var sumPublisher:AnyPublisher<Int,Never> {
// Whenever the foo array or one of the foo.bar values change
//
$foo
.map { fooArray in
Publishers.MergeMany( fooArray.map { foo in foo.$bar } )
}
.switchToLatest()
// Calclulate a new sum by collecting and reducing all foo.bar values.
//
.map { [unowned self] _ in
self.$foo // <--- in case of a foo change, this is still the unchanged foo, therefore not correct.
.map { fooArray -> AnyPublisher<Int,Never> in
Publishers.MergeMany( fooArray.map { foo in foo.$bar.first() } )
.collect()
.map { barArray -> Int in
barArray.reduce(0, { $0 + $1 })
}
.eraseToAnyPublisher()
}
.switchToLatest()
}
.switchToLatest()
.removeDuplicates()
.eraseToAnyPublisher()
}
}
Upvotes: 0
Views: 1639
Reputation: 1917
Problem #2 : @Published
fire signals on "willSet" and not "didSet".
You can use this extension :
extension Published.Publisher {
var didSet: AnyPublisher<Value, Never> {
self.receive(on: RunLoop.main).eraseToAnyPublisher()
}
}
and
self.$foo.didSet
.map { _ in
//...//
}
Problem #1 : Maybe so :
class Foobar:ObservableObject {
@Published var foo:[Foo] = []
@Published var sum = 0
var cancellable: AnyCancellable?
init() {
cancellable =
sumPublisher
.sink {
self.sum = $0
}
}
var sumPublisher: AnyPublisher<Int,Never> {
let firstPublisher = $foo.didSet
.flatMap {array in
array.publisher
.flatMap { $0.$bar.didSet }
.map { _ -> [Foo] in
return self.foo
}
}
.eraseToAnyPublisher()
let secondPublisher = $foo.didSet
.dropFirst(1)
return Publishers.Merge(firstPublisher, secondPublisher)
.map { barArray -> Int in
return barArray
.map {$0.bar}
.reduce(0, { $0 + $1 })
}
.removeDuplicates()
.eraseToAnyPublisher()
}
}
And to test :
struct FooBarView: View {
@StateObject var fooBar = Foobar()
var body: some View {
VStack {
HStack {
Button("Change list") {
fooBar.foo = (1 ... Int.random(in: 5 ... 9)).map { _ in Int.random(in: 1 ... 9) }.map(Foo.init)
}
Text(fooBar.sum.description)
Button("Change element") {
let idx = Int.random(in: 0 ..< fooBar.foo.count)
fooBar.foo[idx].bar = Int.random(in: 1 ... 9)
}
}
List(fooBar.foo, id: \.bar) { foo in
Text(foo.bar.description)
}
.onAppear {
fooBar.foo = [1, 2, 3, 8].map(Foo.init)
}
}
}
}
If you really prefer to use @Published
(the willSet
publisher), it sends the new value of bar
therefore you could deduce the new value of foo
(the array) :
var sumPublisher: AnyPublisher<Int, Never> {
let firstPublisher = $foo
.flatMap { array in
array.enumerated().publisher
.flatMap { index, value in
value.$bar
.map { (index, $0) }
}
.map { index, value -> [Foo] in
var newArray = array
newArray[index] = Foo(bar: value)
return newArray
}
}
.eraseToAnyPublisher()
let secondPublisher = $foo
.dropFirst(1)
return Publishers.Merge(firstPublisher, secondPublisher)
.map { barArray -> Int in
barArray
.map { $0.bar }
.reduce(0, { $0 + $1 })
}
.removeDuplicates()
.eraseToAnyPublisher()
}
Upvotes: 1
Reputation: 49590
By far, the easiest approach here is to use a struct
instead of a class
with @Published
:
struct Foo {
var bar: Int = 0
}
Then you can simply create a computed property:
class Foobar: ObservableObject {
@Published var foo: [Foo] = []
var sum: Int {
foo.map(\.bar).reduce(0, +)
}
// ...
}
For SwiftUI views, you wouldn't even need to make it a Publisher - when foo
changes, because it's @Published
, it will cause the View to access sum
again, which would give it the recomputed value.
If you insist on it being a Publisher, it's still easy to do, since foo
itself changes when any of its values Foo
change (since they are value-type struct
s):
var sumPublisher: AnyPublisher<Int, Never> {
self.$foo
.map { $0.map(\.bar).reduce(0, +) }
.eraseToAnyPublisher()
}
Sometimes, it's not possible to change a class
into a struct
for whatever reason (maybe each class
has its own life cycle that self-updates). Then you'd need to manually keep track of all the additions/removals of Foo
objects in the array (via willSet
or didSet
), and subscribe to changes in their foo.bar
.
Upvotes: 0