14

Suppose I have a data model in my SwiftUI app that looks like the following:

class Tallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var count = 0
}

class GroupOfTallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var elements: [Tallies] = []
}

I want to add a computed property to GroupOfTallies that resembles the following:

// Returns the sum of counts of all elements in the group
var cumulativeCount: Int {
  return elements.reduce(0) { $0 + $1.count }
}

However, I want SwiftUI to update views when the cumulativeCount changes. This would occur either when elements changes (the array gains or loses elements) or when the count field of any contained Tallies object changes.

I have looked into representing this as an AnyPublisher, but I don't think I have a good enough grasp on Combine to make it work properly. This was mentioned in this answer, but the AnyPublisher created from it is based on a published Double rather than a published Array. If I try to use the same approach without modification, cumulativeCount only updates when the elements array changes, but not when the count property of one of the elements changes.

erdekhayser
  • 6,537
  • 2
  • 37
  • 69

3 Answers3

9

There are multiple issues here to address.

First, it's important to understand that SwiftUI updates the view's body when it detects a change, either in a @State property, or from an ObservableObject (via @ObservedObject and @EnvironmentObject property wrappers).

In the latter case, this is done either via a @Published property, or manually with objectWillChange.send(). objectWillChange is an ObservableObjectPublisher publisher available on any ObservableObject.

This is a long way of saying that IF the change in a computed property is caused together with a change of any @Published property - for example, when another element is added from somewhere:

elements.append(Talies())

then there's no need to do anything else - SwiftUI will recompute the view that observes it, and will read the new value of the computed property cumulativeCount.


Of course, if the .count property of one of the Tallies objects changes, this would NOT cause a change in elements, because Tallies is a reference-type.

The best approach given your simplified example is actually to make it a value-type - a struct:

struct Tallies: Identifiable {
  let id = UUID()
  var count = 0
}

Now, a change in any of the Tallies objects would cause a change in elements, which will cause the view that "observes" it to get the now-new value of the computed property. Again, no extra work needed.


If you insist, however, that Tallies cannot be a value-type for whatever reason, then you'd need to listen to any changes in Tallies by subscribing to their .objectWillChange publishers:

class GroupOfTallies: Identifiable, ObservableObject {
   let id = UUID()
   @Published var elements: [Tallies] = [] {
      didSet {
         cancellables = [] // cancel the previous subscription
         elements.publisher
            .flatMap { $0.objectWillChange }
            .sink(receiveValue: self.objectWillChange.send) 
            .store(in: &cancellables)
      }
   }

   private var cancellables = Set<AnyCancellable>

   var cumulativeCount: Int {
     return elements.reduce(0) { $0 + $1.count } // no changes here
   }
} 

The above will subscribe a change in the elements array (to account for additions and removals) by:

  • converting the array into a Sequence publisher of each array element
  • then flatMap again each array element, which is a Tallies object, into its objectWillChange publisher
  • then for any output, call objectWillChange.send(), to notify of the view that observes it of its own changes.
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • This but you'll also need to sink elements that you add later. – Casper Zandbergen Jul 20 '20 at 09:06
  • @CasperZandbergen is your comment about the last approach? What do you mean by sink elements that you add later? If anything, the problem is that with every addition, this would duplicate publishers. I fixed it, I think. Ultimately, the last approach isn't the way I'd recommend solving OP's problem - just here for completeness – New Dev Jul 20 '20 at 14:03
  • The data store I'm using relies on using objects, so the approach you mention at the end seems like the best way to solve this problem. – erdekhayser Jul 21 '20 at 00:55
  • The solution has a retain cycle though, `GroupOfTallies` will never be released unless `elements` is manually set to `[]`. The `.sink` closure needs a `[weak self]` annotation. – lassej Jul 21 '20 at 08:24
3

This is similar to the last option of @New Devs answer, but a little shorter, essentially just passing the objectWillChange notification to the parent object:

import Combine

class Tallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var count = 0

  func increase() {
    count += 1
  }
}

class GroupOfTallies: Identifiable, ObservableObject {
  let id = UUID()
  var sinks: [AnyCancellable] = []
  
  @Published var elements: [Tallies] = [] {
    didSet {
      sinks = elements.map {
        $0.objectWillChange.sink( receiveValue: objectWillChange.send)
      }
    }
  }

  var cumulativeCount: Int {
    return elements.reduce(0) { $0 + $1.count }
  }
}

SwiftUI Demo:

struct ContentView: View {
  @ObservedObject
  var group: GroupOfTallies

  init() {
    let group = GroupOfTallies()
    group.elements.append(contentsOf: [Tallies(), Tallies()])
    self.group = group
  }

  var body: some View {
    VStack(spacing: 50) {
      Text( "\(group.cumulativeCount)")
      Button( action: group.elements.first!.increase) {
        Text( "Increase first")
      }
      Button( action: group.elements.last!.increase) {
        Text( "Increase last")
      }
    }
  }
}
lassej
  • 6,256
  • 6
  • 26
  • 34
2

The simplest & fastest is to use value-type model.

Here is a simple demo. Tested & worked with Xcode 12 / iOS 14

struct TestTallies: View {
    @StateObject private var group = GroupOfTallies()     // SwiftUI 2.0
//    @ObservedObject private var group = GroupOfTallies()     // SwiftUI 1.0

    var body: some View {
        VStack {
            Text("Cumulative: \(group.cumulativeCount)")
            Divider()
            Button("Add") { group.elements.append(Tallies(count: 1)) }
            Button("Update") { group.elements[0].count = 5 }
        }
    }
}

struct Tallies: Identifiable {       // << make struct !!
  let id = UUID()
  var count = 0
}

class GroupOfTallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var elements: [Tallies] = []

    var cumulativeCount: Int {
      return elements.reduce(0) { $0 + $1.count }
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690