0

I've read a lot and watched the relevant videos more than once but I still don't fully grasp it. Consider the following example:

ForEach(0..<config.rows, id: \.self) {
  SomeOtherView {
    ...
  }
}

I would like to know:

  1. Does the use of id: \.self in this case affect things negatively/does it necessarily lead to possibly inconsistent behavior?
  2. More specifically, does the id: \.self become the identity of the the SomeOtherView or not?
  3. If yes to 1 or 2 above—and, if using id: \.self should thus be avoided at all cost—is there some low-friction solution canonical fix one should reach for first?

I think I get it why it's not a good idea. While experimenting with some grid display of views I got a first hand reminder that it is a problem by seeing console messages (from within the SwiftUI framework, I imagine) that some view identity such as 0, 1, 2, etc. is duplicate and it may lead to inconsistent/unexpected behavior.

I came up with something to solve the problem for my specific case:

struct IdentityValue<T>: Identifiable {
  let id: UUID
  let value: T
}
...
let values: [IdentityValue<Int>] = ...
ForEach(values) {
  SomeOtherView {
    ...
  }
}

but this could easily spin out of control with the need to preallocate the UUIDs and have them zipped with the values, etc. Looking at the type of my View I can see things like VStack<ForEach<Range<Int>, Int, SomeView... so I am not sure if structural identity kicks in and can be relied on here or not? For comparison, using the IdentityValue contraption, I see ForEach<Array<IdentityValue>, UUID, SomeView... which I'd call structurally identical except the identifier is clearly unique.

Again, the big question is Should ForEach's values always have stable and unique identifiers (not the views built in the closure of ForEach but the values iterated over)? And if the answer is no, I'd appreciate any in-depth details/explanation!

giik
  • 117
  • 6
  • 1
    Should `ForEach` values have stable and unique identifiers: yes. That's why it's required to have an `id` or pass `Identifiable` values. You may want to watch Demystify SwiftUI from WWDC, which sheds light on the identity behaviors of SwiftUI. – jnpdx Apr 09 '23 at 13:39

2 Answers2

1

Should ForEach's values always have stable and unique identifiers

Yes. This is a requirement of ForEach:

It’s important that the id of a data element doesn’t change, unless SwiftUI considers the data element to have been replaced with a new data element that has a new identity. If the id of a data element changes, then the content view generated from that data element will lose any current state and animations.

Does the use of id: \.self in this case affect things negatively/does it necessarily lead to possibly inconsistent behavior?

No. If an element is its own identity, then this could work fine. Classes, for example, are their own identity. If the elements of a list are unique, and defined precisely by their values, then they can be their own identity. For example, if you were displaying a series of cards from a single playing deck, the value of the suit+rank of the card could also be its identity. See comments in https://stackoverflow.com/a/63456952/97337 for more discussion on that.

The problem comes when a value is not its own identity, which is the common case. In particular, if it is possible for the value to mutate, then it clearly is not its own identity. Identity must be immutable.

I'm not certain why you've created IdentityValue. Generally the correct approach is to make the element of config.rows be directly Identifiable, often with a default-valued UUID:

struct Row: Identifiable
    let id = UUID()
    ... rest of Row ...
}

If there's a better identifier, it is preferable to use that (UUIDs are somewhat expensive to generate, and have no relationship to the data). But this is a common and easy-to-use pattern, suitable for many situations.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
1

Investigations

Let's start with a basic ForEach:

struct ContentView: View {
    var body: some View {
        ForEach(0 ..< 5) { index in
            Text("Index: \(index)")
        }
    }
}

This works perfectly fine, as you'd expect. The range is constant and never changes so a unique identifier is not needed. The identity of the views won't change so it the id parameter is not required.

What if the range may change? Xcode warns you (as of later versions):

struct ContentView: View {
    @State private var range = 0 ..< 5

    var body: some View {
        ForEach(range) { index in // ⚠️ Non-constant range: not an integer range
            Text("Index: \(index)")
        }
    }
}

This warning indicates that if range changes and therefore the data for the ForEach changes, the identity of the views may change.

Now we can investigate using the id parameter for ForEach versus adding an .id(...) modifier to the view inside. Here is a basic example:

struct Item: Identifiable {
    let id: Int
    let value: Int
}

struct ContentView: View {
    @State private var data = [
        Item(id: 0, value: 1),
        Item(id: 1, value: 2),
        Item(id: 1, value: 3)
    ]

    var body: some View {
        ForEach(data, id: \.id) { item in
            Text("Value: \(item.value)")
                .id(item.value)
        }
    }
}

The \.id key path in the above code isn't necessary since Item conforms to Identifiable, so it's only there for explicitness of the example. You can replace the id: \.id with id: \.value to see the effect of the ID here:

Using \.id Using \.value
id key path value key path

As you'll notice, the .id(...) in the body had no effect on the ID the ForEach uses - the ForEach only cares about what ID you give it with the ID key path. However, using the .id(...) inappropriately may still break animations and such.

Answers to your questions

Question 1

Does the use of id: \.self in this case affect things negatively/does it necessarily lead to possibly inconsistent behavior?

The answer here is it depends but most likely yes. If the value of config.rows isn't changing then you'd be completely fine. However, if the value of config.rows changes, then the view identities will have changed.

Question 2

More specifically, does the id: \.self become the identity of the SomeOtherView or not?

The identity of the view inside the ForEach is separate from the identity ForEach uses when rendering the views, as demonstrated in the investigations above.

Question 3

If yes to 1 or 2 above—and, if using id: \.self should thus be avoided at all cost—is there some low-friction solution canonical fix one should reach for first?

It depends on your data:

Kind of data Solution
Constant range No id needed.
Variable range Usually identifying by \.self is sufficient for simple cases, but if not, most likely you don't want a range here - but rather to pass in the actual data.
Collection of data Best uniquely identifiable value for the data. Pretend it's an ID for a database - you don't want any collisions so it must be unique. Usually, your data model would contain some unique ID - commonly the Identifiable protocol is used to remove the need for explicitly specifying the ID key path in the ForEach and UUID is usually the easiest way to do so.

You must be careful what data you give as your unique ID. For example, if we stored all students in a classroom with a unique ID from 1 to N where N is the number of students, this may seem fine at first. But imagine we remove a student from the class and then later add a new student in their place with the same ID as earlier. This would be incorrect - these students are unique and so should not share the same ID - even if not co-existing at the same time.

Why does it matter if view identities change?

The view identity changes matter. From the ForEach documentation:

It’s important that the id of a data element doesn’t change, unless SwiftUI considers the data element to have been replaced with a new data element that has a new identity. If the id of a data element changes, then the content view generated from that data element will lose any current state and animations.

Issues caused:

  1. Animations break as a view cannot animate from a previous state since the identity has changed.
  2. Worse rendering performance since SwiftUI believes the view is different for the same reason as above.

Recommended watching

I highly recommend watching the WWDC21 video Demystify SwiftUI to understand more about view identity and lifecycle in detail.

George
  • 25,988
  • 10
  • 79
  • 133
  • Thanks for explaining! I am still a bit confused by your example where the `\.id` keypath is used and the last two views both render as `Value: 2`. Does that mean that SwiftUI cached the rendering against the identity and the reused it (or something like that)? The last item in the `ForEach`—thus, the value of the closure argument `item`—should be `Item(id: 1, value: 3)` but we see `Value: 2` rendered. The only way I can think of is if SwiftUI looked up rendered views by the identity and used that somehow. Is that what happens? – giik May 29 '23 at 13:46
  • @giik I don't know what SwiftUI does internally as to why exactly this is the result, but all you need to know is to not break the invariant that IDs should all be unique. – George May 31 '23 at 16:38