Potential SwiftUI bug with subview redraw

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.

it definitely works with useSubView = false. func == is not called in either case (perhaps swiftUI treats all items as potentially changed).

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

1 Like

definitely not called. i can even put fatalError() in there.

interestingly, these two variants of ItemView work correctly with useSubView = true:

struct ItemView2: View {
    let rawItem: RawItem
    
    var body: some View {
        Color(red: rawItem.red, green: rawItem.green, blue: rawItem.blue, opacity: 0.5)
            .frame(width: 200, height: 200)
            .position(x: CGFloat(rawItem.x), y: CGFloat(rawItem.y))
    }
}

struct ItemView3: View {
    let x, y, red, green, blue: Double
    
    var body: some View {
        Color(red: red, green: green, blue: blue, opacity: 0.5)
            .frame(width: 200, height: 200)
            .position(x: CGFloat(x), y: CGFloat(y))
    }
}

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.

1 Like

feels like voodoo magic indeed.

wow, if that's true that's unexpected.

thank you, will investigate that route.

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.
        }
    }

view.body:

    Color(red: model.hack * item.computedPropertyRed, ...)

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.

1 Like

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.

You can call objectWillChange.send() rather than anything hacky like setting properties to themselves.

1 Like

Why are you not passing the rawItem directly like you suggested in one of your alternatives earlier?

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.

ok

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:

struct ItemView: View, Equatable {
  [...]
}

struct ContentView: View {
  var body: some View {
    EquatableView {
      ItemView()
    }
  }
}

Check out this article, with some additional explanations: The Mystery Behind View Equality - The SwiftUI Lab

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.)

1 Like

that's interesting, thanks.

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).

Terms of Service

Privacy Policy

Cookie Policy