Search code examples
swiftswiftuicombine

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

Solution

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