Potential SwiftUI bug with subview redraw

I somewhat agree with you.

It's not well documented at all but in the end performance is an important factor for SwiftUI and its not stated that you should be able to use equatable properties for SwiftUI diffing.

In the end it's pretty simple, if you want that your Equatable implementation gets used, use EquatableView or .equatable(). If you don't have an equatable implementation, rely on SwiftUI to correctly calculate changes. And in 99% of cases this works perfectly.

i followed the mentioned "The Mystery Behind View Equality" sample but can't make "equatable" or EquatableView work... adding "@State var xxx = 1" indeed changes the view to non-POD (and thus enabling EQ) but without that "state" var neither this:

        NumberParity(number: n).equatable()

nor that:

        EquatableView(content: NumberParity(number: n))

has desired effect - body is always recalculated (because EQ not actually called). does it work for you as described in the article?

it would be much better if SwiftUI simply checks if Equatable is implemented and either calls EQ ("slow" path) or memcmp ("fast" path) respectively. and documentation can have the relevant wording like "for ultimate SwiftUI diff performance do not make your values Equatable".

looking at it from a slightly different angle, there's an inconsistency: if SwiftUI has this superpower to compare things without relying on Equatable why Swift itself doesn't have this feature as well, so that i can call "==" on "struct A { var x: Int }" values without making A equatable?!

You can (doesn't mean you should):

struct Item {
    var a: Int
}

var firstItem = Item(a: 1)
var secondItem = Item(a: 1)

memcmp(&firstItem, &secondItem, MemoryLayout.size(ofValue: firstItem))

secondItem.a = 2

memcmp(&firstItem, &secondItem, MemoryLayout.size(ofValue: firstItem))

firstItem.a = 2

memcmp(&firstItem, &secondItem, MemoryLayout.size(ofValue: firstItem))

1 Like

does the "equatable()" / "EquatableView" trick work for you? it doesn't work for me for some reason. tested on Xcode 13 and 12.5, iOS 15 and 14.

details here:

I run into the same problem as you. Looks like it only works for preventing redraw and not for forcing redraw. I didn't know that but I also never had to use it.

No, EquatableView won' help.

As GrafHubertus pointed out, SwiftUI definitely has some aggressive optimization strategies. I guess it's because your ItemView and PublishedItem are both true for _isPod, after simple in-memory comparison, SwiftUI just marks corresponding ItemViews as unchanged, and it won't call ==, nor for var body.

I think the core reason for this "bug" is that there's a gap between your codes and intuition. See, although PublishedItem is a struct, it definitely does not hold "source of truth" on its own, as it relies on an external global singleton. However, if it is passed as a const property to ItemView, we are creating a strong dependency between them, this could mislead SwiftUI. As a matter of fact, unchanged PublishedItem cannot imply unchanged ItemView.

Based on the thoughts above, here's a simple and more logical solution without changing existing model structure:

struct ItemView: View {
    @Binding
    var item: PublishedItem
}

struct ContentView: View {

    var body: some View {
        // ...
        ForEach(0..<model.publishedItems.count) { index in
            ItemView(item: $model.publishedItems[index])
        }
    }
}

// ... everything else are the same as before

By using Binding, we are stating ItemView should always rely on the latest value of the real source, Model.

As far as I'm concerned, it might still be a better way to refactor existing model codes.

2 Likes

it must have been working before, at the time the article was written...

note that adding "binding" to the view also converts the previously POD view to non-POD, which in turn enables EQ to be called and thus allows for the optimisation of not calling body unnecessarily. the same thing can be achieved by introducing an unused dummy state variable to the view (which definitely sounds like a hack).

in regards to this:

    ForEach(0..<model.publishedItems.count) { index in
        ItemView(item: $model.publishedItems[index])
    }

i remember reading elsewhere that it's not good to have a variable iteration range here (so probably not so good if items can be added/removed)

i am not afraid of refactoring, the only restriction i have is that the ultimate source of truth model is in C... how that maps to swift is up to me. and so far i tried to (and managed to) avoid duplication of the actual object state variables keeping swift's PublishedItem tiny.

You're right, creating a ForEach from a Range<Int> is not always what we want, because that loses each item's explicit identity info, and each subview gains its implicit identity for its position in the view structure.

But in this case I think it's OK, because in the original code, the identity of each item is actually just an index, and the index never changes. However if in some day the items will be say reorder or removed, then this will result in UI glitches.

i see. i just oversimplified it for this thread... in the actual code ID's are UUID strings, and items can be dynamically added/removed.

What does this mean? What is POD?

Plain Old Data. Also known as a "trivial type". Value type, stores data only, no extra copy, move, destruction operations.

https://github.com/apple/swift/blob/main/docs/ABIStabilityManifesto.md#type-properties

2 Likes

you can use the method _isPOD() to check if a type is POD like so: _isPOD(Int.self)

and in relation to the thread someone from SwiftUI team decided it is good idea to do something like this (pseudocode):

if !_isPOD(a) {
    equal = a == b
} else {
    equal = memcmp(a, b) == 0
}

IMHO, a much better check there would be "is equatable" instead.

I thought about it a little bit and it makes absolutely no sense to use equatable when the values are byte-equal.

There is no possible way to differentiate two values which are equal byte wise.
Yes you could return false in the equatable implementation, for example based on a random number or always, but you can never in a deterministic way distinguish two values which are exactly the same.

Yes you could have an external global dependency which marks items as 'dirty' based on an id. But that won't work. When do you reset this flag? Is item A != Item B when the flag is dirty and then you reset the flag and suddenly they are equal although they didn't change at all? How do you know when these items will be compared and which items are compared right now? It's just not possible.

If two values are equal in memory, they are equal no matter what.

that's what i've settled upon so far:

struct PublishedItem: Identifiable, Equatable {
    var id: String
    var version: Int // getting bumped on every change

    // dynamic properties here based on external model
    var x: Double { ... }
    // ...
}

every time external model changes the relevant version field is bumped in both external model data structure (in my case C based) and the corresponding swift's PublishedItem. with this approach i don't even have to have a custom Equatable implementation, and indeed memcmp compare works just fine.

still, the SwiftUI optimisation of not calling EQ for pod types feels totally wrong. for example my custom EQ can return true even if items are bitwise different (similar to what the "The Mystery Behind View Equality - The SwiftUI Lab" is doing).

sounds like a good solution to me

Yes I agree, it makes sense that its not called when they are the same but it would make sense to call it when they are not. From the documentation it sounds like that's what it should do. So maybe this is a bug or we miss understand the documentation.

i didn't test it thoroughly but it looks like this works on iOS13 but not 14 / 15. perhaps a bug.

EQV is indeed for preventing redraw, use id(_:) to force redraw instead.

Terms of Service

Privacy Policy

Cookie Policy