jtouzy
jtouzy

Reputation: 1639

SwiftUI / Combine : Listening array items value change

I want to display multiple text fields, representing scores of each part of a match.

Example : For a volleyball match, we have 25/20, 25/22, 25/23. The global score is 3/0.

The global components architecture :

>> ParentComponent
    >> MainComponent
        >> X TextFieldsComponent (2 text fields, home/visitor score)

The lowest component, TextFieldsComponent, contains basic bindings :

struct TextFieldsComponent: View {
    @ObservedObject var model: Model

    class Model: ObservableObject, Identifiable, CustomStringConvertible {
        let id: String
        @Published var firstScore: String
        @Published var secondScore: String

        var description: String {
            "\(firstScore) \(secondScore)"
        }

        init(id: String, firstScore: String = .empty, secondScore: String = .empty) {
            self.id = id
            self.firstScore = firstScore
            self.secondScore = secondScore
        }
    }

    var body: some View {
        HStack {
            TextField("Dom.", text: $model.firstScore)
                .keyboardType(.numberPad)
            TextField("Ext.", text: $model.secondScore)
                .keyboardType(.numberPad)
        }
    }
}

The parent component needs to show the total score of all parts of the match. And I wanted to try a Combine binding/stream to get the total score.

I tried multiple solutions and I ended up with this non-working code (the reduce seems to not be take all the elements of the array but internally stores a previous result) :

struct MainComponent: View {
    @ObservedObject var model: Model
    @ObservedObject private var totalScoreModel: TotalScoreModel

    class Model: ObservableObject {
        @Published var scores: [TextFieldsComponent.Model]

        init(scores: [TextFieldsComponent.Model] = [TextFieldsComponent.Model(id: "main")]) {
            self.scores = scores
        }
    }

    private final class TotalScoreModel: ObservableObject {
        @Published var totalScore: String = ""
        private var cancellable: AnyCancellable?

        init(publisher: AnyPublisher<String, Never>) {
            cancellable = publisher.print().sink {
                self.totalScore = $0
            }
        }
    }

    init(model: Model) {
        self.model = model
        totalScoreModel = TotalScoreModel(
            publisher: Publishers.MergeMany(
                model.scores.map {
                    Publishers.CombineLatest($0.$firstScore, $0.$secondScore)
                        .map { ($0.0, $0.1) }
                        .eraseToAnyPublisher()
                }
            )
            .reduce((0, 0), { previous, next in
                guard let first = Int(next.0), let second = Int(next.1) else { return previous }
                return (
                    previous.0 + (first == second ? 0 : (first > second ? 1 : 0)),
                    previous.1 + (first == second ? 0 : (first > second ? 0 : 1))
                )
            })
            .map { "[\($0.0)] - [\($0.1)]" }
            .eraseToAnyPublisher()
        )
   }

    var body: some View {
        VStack {
            Text(totalScoreModel.totalScore)
            ForEach(model.scores) { score in
                TextFieldsComponent(model: score)
            }
        }
    }
}

I'm searching for a solution to get an event on each binding change, and merge it in a single stream, to display it in MainComponent.

N/B: The TextFieldsComponent needs to be usable in standalone too.

Upvotes: 1

Views: 3371

Answers (1)

New Dev
New Dev

Reputation: 49590

MergeMany is the correct approach here, as you started out yourself, though I think you overcomplicated things.

If you want to display the total score in the View (and let's say the total score is "owned" by Model instead of TotalScoreModel, which makes sense since it owns the underlying scores), you'd then need to signal that this model will change when any of the underlying scores will change.

Then you can provide the total score as a computed property, and SwiftUI will read the updated value when it recreates the view.

class Model: ObservableObject {
   @Published var scores: [TextFieldsComponent.Model]

   var totalScore: (Int, Int) {
      scores.map { ($0.firstScore, $0.secondScore) }
            .reduce((0,0)) { $1.0 > $1.1 ? ( $0.0 + 1, $0.1 ) : ($0.0, $0.1 + 1) }
   }

   private var cancellables = Set<AnyCancellable>()

   init(scores: [TextFieldsComponent.Model] = [.init(id: "main")]) {
      self.scores = scores
      
      // get the ObservableObjectPublisher publishers
      let observables = scores.map { $0.objectWillChange } 

      // notify that this object will change when any of the scores change
      Publishers.MergeMany(observables)
         .sink(receiveValue: self.objectWillChange.send)
         .store(in: &cancellables)
   }
}

Then, in the View, you can just use the Model.totalScore as usual:

@ObservedObject var model: Model

var body: some View {
   Text(model.totalScore)
}

Upvotes: 6

Related Questions