SwiftUI, list with indices without enumerated
One of the common things we want to display with SwiftUI are lists of our models. The basics of it are pretty easy, you just need to use a ForEach
view.
ForEach(humans) { human in
Text(human.name)
}
This works out of the box assuming your model is
Identifiable
. Otherwise you will need to pass a second argument to theForEach
giving a KeyPath to the id.
It gets a little trickier when you want to display no only your data but also its position on the list.
You may try the correct solution directly, but I would like to show here the different options we have and discard many of them, since they can provoke errors and even crashes. Sadly those are the ones you will find recommended online so always be careful. Don’t even take my word for granted!
Let’s see what options we have.
One of the obvious ways of doing it is using the enumerated()
function on your collection. This will return a new collection with a tuple of the element and its offset.
element
andoffset
are the labels of the tuple, even if there is no clear documentation saying it, it can be deduced from some signatures.
The first problem we encounter with this approach is that we need to provide an ID, since the tuple of the collection is not Identifiable.
ForEach(humans.enumerated(), id: \.offset)
But this doesn’t work, the compiler gives us this nice error:
Referencing initializer 'init(_:id:content:)' on 'ForEach' requires that 'EnumeratedSequence<[Human]>' conform to 'RandomAccessCollection'
The issue here is that ForEach
needs a RandomAccessCollection
, presumedly to access directly specific elements of the collection to reload only those that change. But when using enumerated()
we get back an EnumeratedSequence
, a sequence that doesn’t provide random access.
reversed()
You can use reversed()
to get a RandomAccessCollection
.
ForEach(humans.enumerated().reversed(), id: \.offset)
This satisfies the compiler and works as expected:
But of course, this is not really useful unless you want your list to be reversed.
Still, it helps us understand what’s going on. The reason this satisfies the compiler is because the reversed function returns an Array
, which is a RandomAccessCollection
.
So what if we do that ourselves?
Array
One correct approach is to take the enumerated sequence and consume it immediately to create an array. In that way we get our RandomAccessCollection
and the ForEach
view can do its work.
ForEach(Array(humans.enumerated()), id: \.offset)
This works fine and, as far as I know, it won’t provoke any errors.
But… performance. Remember, any performance claim needs to be backed with real data, which I don’t have, so don’t take this as pure truth but… seeing that Array creation, from a sequence is suspicious. To create that array the sequence needs to be iterated fully, so on big datasets it could be noticeable. And remember that this code is inside SwiftUI View body
function, so it will run quite often.
You can probably live with this for a while if you control your data set and is not that big, you may even want to make it nicer with some extension on collection that gives you directly an enumerated array or even a fancy custom initialiser in the ForEach
.
But there is another alternative.
Ranges
Using enumerated()
is really convenient because it gives you the offset and the element at the same time without more effort. But there is always the alternative of iterating over a range, and with each index of the iteration access the collection to get the element.
ForEach(0..<humans.count) { i in
Text("\(i+1). \(humans[i].name)")
}
This is one of the most recommended ways I see online, but in my experience this can crash your App.
Note that SwiftUI is still pretty young. This may be intended behaviour or a bug. Maybe it’s already fixed or maybe is that my code triggered some edge case.
Because there is not much documentation and we don’t have access to the source is quite tricky to know the reasons, but knowing how it may work we can deduce something.
I’ve seen this kind of code crash when the collection changes, SwiftUI runs the body function of the views again and suddenly this code accesses an invalid index. Stepping into the debugger you may see how your array has the correct data, but the index is trying to access is from an older version of it.
This seems to me that SwiftUI is trying to be smart in the way it computes UI differences. Maybe is caching the indices too heavily, but my theory is that the issue comes because we’re using a literal range and it assumes that the Range will be static, so when it needs to recompute the layout it just runs the closure again.
Interestingly enough this doesn’t happen if you are in a fully dynamic view since the entire view needs to be recomputed for a diff. But if you’re in a static view and this For is the only thing that can change… you may be surprised.
indices
The idea of iterating indices and getting the element afterwards was not bad. A literal Range can cause issues but we can use a function that gives us the indices, avoiding any literal use. We can just use indices()
.
ForEach(humans.indices) { i in
Text("\(i+1). \(humans[i].name)")
}
This seems safe to use in my experience. When SwiftUI needs to recompute the view it will correctly retrieve a new range of indices avoiding any crash.
Careful with Slices
Not specific to SwiftUI, but is worth being reminded. Keep in mind that any use of indices is dangerous unless you know that the collection you’re working with is 0-indexed. If you’re working directly with an array then you’re safe to use this techniques, otherwise be careful.
Imagine that our data is a slice of the original:
humans.suffix(2)
In my example with 4 original Humans, now we have an ArraySlice of count 2, but the index of them still is based on the original array.
In the case of a literal range you will get an Index out of bounds and crash the App. In the indices
case the App won’t crash but you won’t see the list start at the proper number on the UI:
Indices as part of your data
One alternative that I haven’t mentioned yet is to simply avoid doing this on the UI. You can always bake the index of each element as part of your data model or any other intermidiate layer.
That depends on your application but is worth mentioning ;)
Conclusion
It’s quite surprising that something so simple can get so complicated.
As you can see there is no silver bullet but I hope that with this post at least now you get an understanding of the situation and can analyse the tradeoffs on your situation.