1

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:

  • Red if enabled is false for all ProposedChanges in this Report
  • Green if enabled is true for all ProposedChanges in this Report
  • Yellow if enabled is mixed for different ProposedChanges in this Report

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?

Aaron Brager
  • 65,323
  • 19
  • 161
  • 287
  • Just put in the SwiftUI View `@ObservedObject var proposedChange: ProposedChange`. When you init the View you will do something like `ChangeView(proposedChange: proposedChanges)`. In SwiftUI that allows you to observe the changes. You can use `.sink` also but you would have to do one for each value in the array as well, it would probably be best to move the `Report` to a ViewModel and somehow create a dynamic array of `AnyCancellable` – lorem ipsum Jun 07 '21 at 00:37
  • I don't think that will work because there are many arrays of `ProposedChange` for each report view; `@ObservedObject var proposedChange: ProposedChange` would only allow me to observe one – Aaron Brager Jun 07 '21 at 01:03
  • I added some code below. You can have a ViewModel that will trigger changes for all levels – lorem ipsum Jun 07 '21 at 01:26
  • Are you sure that you need `ProposedChange` to be a class? – New Dev Jun 07 '21 at 01:48
  • @NewDev I’m not sure, but I think so. I made it a class so I could mutate `enabled` without having to initialize a new struct when the Toggle is tapped. – Aaron Brager Jun 07 '21 at 02:07
  • @AaronBrager, that's ... not really a good reason, imho. First of all, with a `struct`, your issue is immediately solved. Use classes when you need reference semantics, such as when an instance has its own life cycle. Otherwise, it's a holder of value, so use a `struct`. – New Dev Jun 07 '21 at 03:09
  • @NewDev *"with a struct, your issue is immediately solved"*… can you say more or maybe post an answer? I'm unclear on why switching to struct would immediately solve the issue. – Aaron Brager Jun 07 '21 at 03:21
  • @NewDev I played around with it a bit, but it feels like I'm fighting the type system. If I make it a struct, I have to change `@ObservedObject` to `@Binding`, which [doesn't play nicely at all with `ForEach`](https://stackoverflow.com/q/57340575/1445366) since the structs are passed by value instead of by reference. So I think it does need to be a class. – Aaron Brager Jun 07 '21 at 04:18
  • Agree with @NewDev - mixing pointers in value data is bad design, you will have problems with it (like this one or worse) again and again. Your model have to be homogenous - convert everything to struct/s... or everything to classes (but model in structs are more preferable). – Asperi Jun 07 '21 at 05:06
  • I agree that the model should be a struct. Yes, there will be some mild challenges mutating data (consider a redux-like to TCA model), but trying to use classes like this will be a huge uphill battle and will go against SwiftUi’s built-in systems often. – jnpdx Jun 07 '21 at 06:49
  • I'd be happy to accept an answer that uses a struct but I haven't found a way to make it work either. I don't really understand how using `@ObservableObject` "will go against SwiftUi’s built-in systems" though. – Aaron Brager Jun 07 '21 at 13:58
  • Using `ObservableObject` itself doesn't, but the property wrapper that's meant to notify your view of updates (`@ObservedObject`) is meant to exist at the top level of a `View` as a property -- it's not meant to be buried *inside* a `@State` property. I'm happy to write an answer using just `struct`, but it would be helpful to have an easily-copy/pastable [mre] to start with. – jnpdx Jun 07 '21 at 17:00

2 Answers2

2

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 }
    }
}
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • This is a simpler solution than using ObservedObject, but unfortunately makes mutating `enabled` (either via `Toggle` or background process) very difficult due to the pass-by-value semantics in `ForEach` – Aaron Brager Jun 12 '21 at 17:20
  • @AaronBrager, what's very difficult? Via a toggle, it's as simple as using a Binding - a core part of SwiftUI. Not sure what you mean by a background process - but if the process owns the data, you could easily mutate a property and using `@Published` would achieve a similar effect. – New Dev Jun 12 '21 at 23:59
  • The issue is that the `ProposedChange`s are in an array, so when I iterate through them, the structs in the loop are passed by reference and mutating them doesn't mutate the version that the view reads. – Aaron Brager Jun 16 '21 at 15:46
  • @AaronBrager, you might have meant to say "passed by value"... but again - binding is what you'd need to mutate the source of truth, e.g. `Toggle("", isOn: $report.changeGroups[i].proposedChanges[j].enabled)` mutates `report` when some inner `proposedChanges` mutates. – New Dev Jun 17 '21 at 00:04
  • Sorry, yes that’s what I meant. :) And yea I suppose I can have lots of nested for loops and access by index but I find it makes the code a lot more cluttered / less expressive vs using an object which seems to work fine. Although as you rightly point out, there’s the benefit of not needing a subscription at all. – Aaron Brager Jun 17 '21 at 17:07
0

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!

Aaron Brager
  • 65,323
  • 19
  • 161
  • 287
  • Best to use [`assign(to:)`](https://developer.apple.com/documentation/combine/just/assign(to:)) - e.g. `.assign(to: &$boolState)`, as the one you use holds a strong reference to `self` – New Dev Jun 13 '21 at 00:02
  • @NewDev Nice, then it looks like I don't need `.store()` either. Updated – Aaron Brager Jun 16 '21 at 15:49