Reputation: 1639
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
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