this program works correctly if useSubView is set to false. but when i set useSubView to true it doesn't (doesn't move the box). The two versions shall work identically, no? SwiftUI bug?
import SwiftUI
let useSubView = false
struct RawItem: Equatable {
var x: Double
var y: Double
let red = Double.random(in: 0...1)
let green = Double.random(in: 0...1)
let blue = Double.random(in: 0...1)
}
struct PublishedItem: Identifiable, Equatable {
let id: Int
var rawItem: RawItem {
Model.singleton.rawItem(for: id)
}
public static func == (a: Self, b: Self) -> Bool {
assert(a.id == b.id)
return a.rawItem == b.rawItem
}
}
class Model: ObservableObject {
static let singleton = Model()
@Published var publishedItems: [PublishedItem] = []
var rawItems: [RawItem] = []
private init() {
rawItems = (0 ..< 10).map { i in
RawItem(x: .random(in: 100...500), y: .random(in: 100...500))
}
publishedItems = (0 ..< 10).map { i in
PublishedItem(id: i)
}
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
self.modify_third_item()
// trigger swiftUI update
let copy = self.publishedItems
self.publishedItems = copy
}
}
func modify_third_item() {
var item = rawItems[3]
item.x += .random(in: -20...20)
item.y += .random(in: -20...20)
rawItems[3] = item
}
func rawItem(for id: Int) -> RawItem {
rawItems[id]
}
}
struct ItemView: View {
let item: PublishedItem
var body: some View {
Color(red: item.rawItem.red, green: item.rawItem.green, blue: item.rawItem.blue, opacity: 0.5)
.frame(width: 200, height: 200)
.position(x: item.rawItem.x, y: item.rawItem.y)
}
}
struct ContentView: View {
@ObservedObject var model = Model.singleton
var body: some View {
ZStack {
ForEach(model.publishedItems) { item in
if useSubView {
ItemView(item: item)
} else {
Color(red: item.rawItem.red, green: item.rawItem.green, blue: item.rawItem.blue, opacity: 0.5)
.frame(width: 200, height: 200)
.position(x: item.rawItem.x, y: item.rawItem.y)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
@main
struct SUIApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
How is this supposed to work? Your Equality check for PublishedItem always returns true since a.rawItem always returns the same as b.rawItem if the id of both items are the same.
My understanding is that SwiftUI has to recalculate the complete body when any property changes. SwiftUI then compares the old body with the new body to determine which views to rerender. Each subview has to recursively do this too.
In both your cases, the objectWillChange notification from the observedObject triggers a recalculation of the body from ContentView.
In the first case, the subview "Color" detects a change because properties have changed, in this case the old View has a different value stored for x and y than the new view. It doesn't matter where this value comes from (item.rawItem.x) as the view is only aware of the x and y property which obviously change. So the body is different and the view gets rerendered.
In your second case, the subview "ItemView" has the property item. The Subview has to check for changes recursively and can do so (because it only has one property) by comparing oldView.item with newView.item. Here it can compare the complete PublishedItem and determines that the view doesn't need to be rerendered. It doesn't even matter if it uses the implemented == function or not. If it uses the method, the items are the same. If it only compares the properties (Published Item only has the property id), they are also the same. So SwiftUI now has decided that the body of ItemView doesn't need to be recalculated.
Anyways I would highly recommend to not use a Singleton in that way. SwiftUI works best if you strictly use Value Types for States. And check out this presentation, it is very good: Demystify SwiftUI - WWDC21 - Videos - Apple Developer
This makes perfect sense. In both variants, the properties change so the body needs to be reevaluated.
In your variant with PublishedItem, the property does not change. The values are exactly the same all the time (Computed property is not part of the value).
If you want to stick with your current architecture, make the an environment object instead of a singleton and make rawItem in PublishedItem a method which takes the Model as argument. Then you can access the Model ItemView through the Environment and use it in the rawItem method.
how does swiftUI know if the value is the same or different if it doesn't call the equivalence function? in essence i want computed properties to be part "the value".
Well, that is something I am not 100% sure about and its also a little bit SwiftUI magic. But what SwiftUI is doing often is just a memcompare and comparing the memory of the views. If it is the same, the body won't be reevaluated.
If you want to force SwiftUI to use your equatable implementation, you can make the view equatable and wrap it in an EquatableView, see: Optimizing views in SwiftUI using EquatableView | Swift with Majid.
what would be the proper way to force SwiftUI update on a computed property change? this really looks like a hack:
let copy = self.publishedItems
self.publishedItems = copy
another hack would be something like this:
class Model {
@Published var hack = 1.0
func forceUpdate() {
hack = 1.0 // ok to set to the same value?
// or set to some random number very close to 1.
}
}
SwiftUI Views are value types so it is somewhat expected in my opinion. There is also a lot of optimisation going on in the background to make SwiftUI performant so we should not rely on these implizit mechanisms. If we use value types correctly everything works as expected.
Ideally, a computed property should be property which is computed based on the underlying values. For example:
struct foo {
let width: CGFloat = 100
let height: CGFloat = 100
var size: CGSize {
.init(width: width, height: height)
}
}
In that case you could use the computed property in SwiftUI, as it only changes, when also the underlying values change.
Your usage of a computed property is already really hacky and suggest that you should overthink your architecture. It would be easy to improve the architecture in your initial example but I guess that your real life use case is more complex so it might be hard to help you there.
my computed properties are derived from a different place (source of truth), which in the sample app above i represented as RawValue for simplicity, and in the real source RawValue items are not swift but C structs. i can have a duplicated state in my PublishedItem items and rebuild (all or some) published items' duplicated state whenever something changes in RawItems, but i want to avoid this duplication.
that works, thanks. [doesn't address the main issue of this thread but definitely the code looks less hacky than before.]
yes, i can do one of the alternatives, including not using a subview. i wonder if passing "published value" vs "raw value" doesn't work here because of some swiftUI bug, or by design. and why EQ is not called is still a big mystery for me.
It's definitely by design. If the value doesn't change, the subview isn't rerendered. Computed properties are not part of the value. For example you could add a computed property to Int but that doesn't change the underlying Int value.
EQ is not called because SwiftUI doesn't require all properties to be equatable. Without this requirement SwiftUI has to rely on other mechanisms to detect changes. Since SwiftUI views are value types, it can just compare the values.
you mean EQ is never called by SwiftUI to do the diffing? or it's not called in this particular case but might be called in other cases? if the latter, how do i force swiftUI to call my EQ implementation? (i'd still want to use dynamic properties and avoid state duplication in my published items as discussed above).
yes, SwiftUI doesn't know that your property is equatable. SwiftUI compares the view structs (with all properties) and the only thing it knows about all your views is, that they, in fact, conform to "View". The View protocol does not define anything about being equatable so SwiftUI has to use different methods to determine if a View has changed.
I think you can force SwiftUI to use your EQ implementation as described above. Make the View Equatable (not only the property) and wrap the view in EquatableView. I never tried this tho so no guarantees.
Try this:
important quote from SwiftUI developer John Harper:
SwiftUI assumes any Equatable.== is a true equality check, so for POD views it compares each field directly instead (via reflection). For non-POD views it prefers the view’s == but falls back to its own field compare if no ==. EqView is a way to force the use of ==.
When it does the per-field comparison the same rules are applied recursively to each field (to choose direct comparison or == if defined). (POD = plain data, see Swift’s _isPOD() function.)
FTM, EQ is called in this simpler app when PublishedItem is marked Equatable (and not called when it is not marked Equatable), so SwiftUI does call items' EQ in some cases:
import SwiftUI
class Model: ObservableObject {
static let singleton = Model()
@Published var publishedItems: [PublishedItem] = []
private init() {
publishedItems = [PublishedItem(id: 0, x: 100, y: 100), PublishedItem(id: 1, x: 200, y: 200), PublishedItem(id: 2, x: 300, y: 300)]
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
var item = self.publishedItems[1]
item.x += .random(in: -10...10)
item.y += .random(in: -10...10)
self.publishedItems[1] = item
}
}
}
struct PublishedItem: Identifiable, Equatable /* try commenting out Equatable */ {
let id: Int
var x: CGFloat
var y: CGFloat
public static func == (a: Self, b: Self) -> Bool {
assert(a.id == b.id)
let r = a.x == b.x && a.y == b.y
return r
}
}
struct ItemView: View {
@ObservedObject var model = Model.singleton
let item: PublishedItem
var body: some View {
Color.red.opacity(0.5).frame(width: 200, height: 200)
.position(x: item.x, y: item.y)
}
}
struct ContentView: View {
@ObservedObject var model = Model.singleton
var body: some View {
ZStack {
ForEach(model.publishedItems) { item in
ItemView(item: item)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
@main
struct SUIApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
that quote from John Harper actually reveals some dangerous feature of SwiftUI... depending upon whether the value is not POD or is POD my EQ function will be either called or not called. and my EQ function might have quite different idea how to do the comparison / or it can have side effects! this is a very dangerous optimization that will lead to unexpected behaviour!
(on the positive side now i know how to force EQ being called - just make the value non POD.)
an illustrative example showing that merely commenting out the class reference (which is otherwise unused) causes behaviour change in this simple app (two boxes are moving instead of one):
import SwiftUI
class SomeClass {}
class Model: ObservableObject {
@Published var publishedItems: [PublishedItem] = []
init() {
publishedItems = [
PublishedItem(id: 0, x: 100, y: 100),
PublishedItem(id: 1, x: 200, y: 200),
PublishedItem(id: 2, x: 300, y: 300)
]
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
var items = self.publishedItems
var item = items[1]
item.x += .random(in: -10...10)
item.y += .random(in: -10...10)
items[1] = item
item = items[2]
item.x += .random(in: -10...10)
item.y += .random(in: -10...10)
items[2] = item
self.publishedItems = items
}
}
}
struct PublishedItem: Identifiable, Equatable {
let id: Int
var x: CGFloat = 0
var y: CGFloat = 0
let classVar = NSObject() // try commenting this out
public static func == (a: Self, b: Self) -> Bool {
print("EQ")
assert(a.id == b.id)
if a.id == 2 {
return true
}
let r = a.x == b.x && a.y == b.y
return r
}
}
struct ItemView: View {
let item: PublishedItem
var body: some View {
Color.red.opacity(0.5).frame(width: 200, height: 200)
.position(x: item.x, y: item.y)
}
}
struct ContentView: View {
@ObservedObject var model = Model()
var body: some View {
ZStack {
ForEach(model.publishedItems) { item in
ItemView(item: item)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
@main
struct SUIApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
this is a dangerous pitfall that must be at least very well documented (and ideally fixed).