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.
@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.
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.
Throwing properties in Swift can only be defined for computed properties. This limitation exists to make the implementation as simple as possible for now.
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.
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:
Sequence
publisher of each array elementTallies
object, into its objectWillChange
publisherobjectWillChange.send()
, to notify of the view that observes it of its own changes.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 }
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With