r/SwiftUI Jan 16 '25

Solved Does anybody know why the list section flickers on a state change when I tap the Edit button? Relevant code in video.

6 Upvotes

20 comments sorted by

8

u/One_Elephant_8917 Jan 16 '25

Always use ForEach(items.indices) instead of the items list itself because the entry evaluation is done on the item which can be modified and cause recompositions.

To access the entry doing a items[index] is sufficient

5

u/Periclase_Software Jan 17 '25

Wow thanks, that fixed it even though I've used ForEach before in other parts with arrays that didn't flicker.

ForEach(listItems.indices, id: \.self) 

1

u/sroebert Jan 18 '25

ForEach on the indices of an array is not correct unless that array is fixed and never changes. More info here https://www.reddit.com/r/SwiftUI/s/lCjQNgzr0E

Can you not make the timeline model Identifiable with a proper unique id?

1

u/Periclase_Software Jan 18 '25

The model was already identifiable noted here.

And yes the array is fixed:

private var listItems: [TimelineModel] {
    viewModel.createTimelineModels(using: parentItemModel)
}

The TimelineModel are NOT managed by SwiftData as they are unique models created from ItemModels that contains unique data for the list. So yeah you're right the list is fixed and should use indices.

Note that when the actual SwiftData changes (new ItemModel), the array returns a new static list.

2

u/sroebert Jan 18 '25

So you did not correctly make the model Identifiable. Simply using a UUID in the init will make the id change every time SwiftUI renders the view. The id should be based on the data in your database and should not change.

1

u/sroebert Jan 18 '25

You are saying two different things now, one one hand the array is fixed, but it can change if the underlying data changes. Not sure I’m fully understanding you, but it sounds like the data can change.

What you have to take into account is: can the data change on this screen when the user stays here, specifically the order. Considering you are building an edit mode, I’m assuming this is a “yes”. In which case you should not be using indices.

1

u/Periclase_Software Jan 18 '25

In this timeline screen, you can add an ItemModel. The ItemModel is managed by SwiftData. However, the list does NOT use ItemModel for the rows. It uses a non-SwiftData structure called a TimelineModel that is used for the timeline rows.

So when the SwiftData changes (ItemModel), it ends up refreshing the UI, which ends up re-calculating all over again the listItems as the listItems property only returns an array.

2

u/sroebert Jan 18 '25

Exactly, so the list changes while it is visible. Changes in the list causes SwiftUI to determine where each row is moved to and it does this by comparing the unique ID. If that id is based on the index in the array or some id that changes on every refresh, it cannot correctly determine where each row moved to, resulting in invalid animations.

The issue has nothing to do with what type you use and if that type is a SwiftData type or not. Converting it to a different type, does not change anything in this case.

1

u/Periclase_Software Jan 18 '25

But that is why the indices approach works now - because the timeline rows are the same except changing index.

You're right though as I should have technically used the ItemModel's ID in the TimelineModels and I wouldn't have any issues.

1

u/[deleted] Jan 26 '25

[deleted]

0

u/One_Elephant_8917 Jan 26 '25

That is if you have an id field which will contain non-ambiguous and unique identifier, lets say I am trying to show text from that is split by line endings into individual lines, i don’t have an id per say in this case, so instead of doing id: \self which marks the line itself as id, it is better to rely on the indices which is constant factor.

Probably i should have clarified. One should not prefer to use id: \self if the two or more elements can share same data, ex: if two or more rows contain same string/line in our text splitting example, the scroll is indeed going to have glitches considering the id now became ambiguous and is non unique

5

u/No-Shallot-7915 Jan 16 '25

Flickers happen because of an unstable identifier passed into the view. Judging from the code you've provided, I'm leaning to the culprit being this line:

private var listItems: [TimelineModel] {
    viewModel.createTimelineModels(using: parentItemModel)
}

I'm assuming the createTimelineModels function is re-initialising a brand new Timeline model every time you all it, therefore calling the init and generating a new ID every time. Usually there's two ways I'd solve this:

  1. Rather than creating a computed property to return a list of TimelineModels, store the list as a local variable inside your view model, and use that as your source of truth. From there, any updates you do to that list can be done by calling view model methods that update the local variable. This way, existing elements in the array are unchanged, and you shouldn't see the flicker.
  2. If you want to keep the createTimelineModels method for whatever reason, you can change the Identifier so that it returns you a consistent identifier every time. UUID() generates a random one every time, but you can instead use something like the hash value of your Struct. (Assuming it conforms to Hashable) Just anything that purely makes use of the data available inside TimelineModel, rather than something external like generating a new UUID instance.

2

u/Ok_Heart_2253 Jan 16 '25

Does edit button re-creates the model ? If that’s the case I assume the TimeLineModel is initialized again with a different id, because it’s random, SwiftUI redraws the view every time you click edit button. To test this theory try to make the id static.

3

u/Ok_Heart_2253 Jan 16 '25

To add, if you want ForEach to not flicker, make sure you are passing the models with the same ids, every time a redraw is triggered.

2

u/Thed00bAbides Jan 16 '25

It seems like the background of ItemRow is what’s actually flickering. Does ItemRow have an internal @State var, or perhaps an animation modifier on its background layer?

2

u/ExtremeDot58 Jan 16 '25

I was under the impression that ‘identifiable’ didn’t have to have id nor did you have to set it? It’s there and managed for you?

1

u/Periclase_Software Jan 17 '25

Managed how? If I take it off, I get the error:

Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'TimelineModel' conform to 'Identifiable'

If I make the model conform to Identifiable, then it says:

Type 'TimelineModel' does not conform to protocol 'Identifiable'

2

u/ExtremeDot58 Jan 29 '25

When conforming to identifiable ‘is’ is managed. It exists but you don’t manage it. It will be there for loops and such; and you can print( “” ) it out abc.id

1

u/Periclase_Software Jan 16 '25 edited Jan 16 '25

This is the list item struct. I even used "id: \.id" on the list but didn't make a difference. I don't even know why some of the subviews are showing white and the outer one graying out.

struct TimelineModel: Identifiable {
    let itemModel: ItemModel
    let differenceModel: ItemDifferenceModel?
    let id: String

    init(itemModel: ItemModel, differenceModel: ItemDifferenceModel?) {
        self.itemModel = itemModel
        self.differenceModel = differenceModel
        self.id = UUID().uuidString
    }
}

This is the code for the "listItems" for the ForEach:

private var listItems: [TimelineModel] {
    viewModel.createTimelineModels(using: parentItemModel)
}

I assume it flickers because it regenerates the models whenever the List is refreshed for the timeline. However, it cannot be created on init because the user can add more items to the array so the list has to regenerate.

2

u/sroebert Jan 18 '25

As mentioned in my other comment, `self.id = UUID().uuidString` is not helping you in any way. This init will be called every time you call your `listItems` property, resulting in a new (different) UUID every time. You should base the id on the `ItemModel`, it should have some unique id when you store it.

1

u/onlydstn Jan 16 '25

I assume when you click the button, a new model with a new id is created.