Aaron Brager
Aaron Brager

Reputation: 66262

SwiftUI / Combine subscribe to updates in multiple nested collections

I have a SummaryView with a Report as @State.

A Report is a protocol which includes some changes a user might want to make:

protocol Report {
    var changeGroups: [ChangeGroup] { get set }
}

There are several kinds of reports; individual reports are implemented as a struct:

struct RealEstateReport: Report {
    static let name = "Real Estate Report"
    
    var changeGroups = [ChangeGroup]()
}

A ChangeGroup is a struct with (among other stuff) a human-readable summary and a handful of proposed changes:

struct ChangeGroup: Identifiable {
    var summary: String
    var proposedChanges = [ProposedChange]()
}

A ProposedChange is a class that represents one discrete change the app proposes to the user, which is enabled by default:

class ProposedChange: ObservableObject, Identifiable {
    @Published var enabled = true
    let summary: String

(In a detail view, enabled is bound to a Toggle so a user can flip each proposed change on and off.)

So a Report has many ChangeGroups which themselves have many ProposedChanges.

I'm trying to include some high level details on the SummaryView:

struct SummaryView: View {
    @State var report: Report
    
    var body: some View {
        Text("Summary")
            .foregroundColor(…) // ???
    }

I want foregroundColor to be red, yellow, or green:

I've read a bit about Combine, and I think I need to create a new Combine subscription for each ChangeGroup, and map that to a new Combine subscription for each ProposedChange's enabled property, flatten the values when one changes, and check if they're all the same.

I'm a little lost on the exact syntax I'd use. And also it seems like structs don't publish changes in the same way (I guess since the structs are value vs. reference types).

How can I set the foregroundColor of the Text view based on the above logic?

Upvotes: 1

Views: 782

Answers (2)

Aaron Brager
Aaron Brager

Reputation: 66262

I ended up mapping all the enabled flags to their publisher, combining them all using the CombineLatest operator, and then recalculating when the value changes:

class ViewModel: ObservableObject {
    enum BoolState {
        case allTrue, allFalse, mixed
    }
    
    @Published var boolState: BoolState?

    private var report: Report

    init(report: Report) {
        self.report = report
        
        report
            .changeGroups // [ChangeGroup] 
            .map { $0.proposedChanges } // [[ProposedChange]]
            .flatMap { $0 } // [ProposedChange]
            .map { $0.$enabled }  // [AnyPublisher<Bool, Never>]
            .combineLatest() // AnyPublisher<[Bool], Never>
            .map { Set($0) } // AnyPublisher<Set<Bool>, Never>
            .map { boolSet -> BoolState in
                switch boolSet {
                case [false]:
                    return .allFalse
                case [true]:
                    return .allTrue
                default:
                    return .mixed
                }
            }  // AnyPublisher<BoolState, Never>
            .assign(to: &$boolState)
    }
}

Note: .combineLatest() is not part of Combine but it's just an extension I wrote that iterates each pair of publishers in the array and calls them iteratively, like first.combineLatest(second).combineLatest(third) etc. If you need something more robust than this, it looks like the CombineExt project has a CombineLatestMany extension with several options.

At this point my view just does a @ObservedObject var viewModel: ViewModel and then uses viewModel.boolState in the body. Whenever any of the enabled flags change for any reason, the view updates successfully!

Upvotes: 0

New Dev
New Dev

Reputation: 49590

Your issue is immediately solved if ProposedChange is a struct and not a class. Unless its instances have their own life cycle, then they are just holders of value, so should be semantically a struct.

The reason your issue is solved is because mutating a property of a struct mutates the struct, so SwiftUI knows to recompute the view, whereas with a class you need to subscribe to changes.

Assuming ProposedChange is a struct:

struct ProposedChange {
    var enabled = true
    var summary: String
}

the following should work:

struct SummaryView: View {
    @State var report: Report
    
    var body: some View {
        Text("Summary")
            .foregroundColor(summaryColor) 
    }

    var summaryColor: Color {
       let count = report.changeGroups.flatMap { $0.proposedChanges }
                         .map { ($0.enabled ? 1 : 0, 1) }
                         .reduce((0, 0), { ($0.0 + $1.0, $0.1 + $1.1) })

       if count.0 == count.1 { return Color.green }
       else if count.0 == 0 { return Color.red }
       else { return Color.yellow }
    }
}

Upvotes: 2

Related Questions