Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Published computed properties in SwiftUI model objects

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.

like image 376
erdekhayser Avatar asked Jul 19 '20 23:07

erdekhayser


People also ask

What is published var SwiftUI?

@Published is one of the property wrappers in SwiftUI that allows us to trigger a view redraw whenever changes occur. You can use the wrapper combined with the ObservableObject protocol, but you can also use it within regular classes.

What is computed stored properties in Swift?

Computed properties are for creating custom get and set methods for stored properties. Computed properties are provided by classes, structures, and enumerations to provide custom behavior for properties. Stored and computed properties are usually associated with a particular type but can be associated with any type.

Can a computed property throw Swift?

Throwing properties in Swift can only be defined for computed properties. This limitation exists to make the implementation as simple as possible for now.

How do I make a property computed?

Defining a computed property To start, you have to write a variable and explicitly declare the type of the property to help the compiler know what kind of value will be assigned to it. Do not assign a default value. Instead open a bracket after the type declaration and start working on the getter.


2 Answers

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.
like image 184
New Dev Avatar answered Sep 27 '22 19:09

New Dev


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 }
    }
}
like image 20
Asperi Avatar answered Sep 27 '22 19:09

Asperi