Michael J
Michael J

Reputation: 427

How to best create a publisher aggregate of @Published values in Combine?

Given an hierarchical structure of @OberservableObjects - 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

Answers (2)

Adrien
Adrien

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)
            }
        }
    }
}

EDIT :

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

New Dev
New Dev

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 structs):

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

Related Questions