Suppose we want to display the contents of an array in a SwiftUI list. We can do this with ForEach
:
struct PeopleList: View {
var people: [Person]
var body: some View {
List {
ForEach(people) { person in
Text(person.name)
}
}
}
}
Person
is a struct that conforms to the Identifiable
protocol:
struct Person: Identifiable {
var id: UUID = UUID()
var name: String
}
ForEach
uses the Identifiable
conformance to determine where elements have been inserted or deleted when the input array changes, in order animate those changes correctly.
Numbering items in ForEach
Now suppose we want to number the items in the list, as in this screenshot:
We might try one of these approaches:
-
Call
enumerated()
on the array we pass toForEach
, which produces a tuple of the form(offset: Int, element: Element)
for each element. -
Alternatively,
zip(1..., people)
produces tuples of the same shape (albeit without the labels), but allows us to choose a different starting number than 0.
I usually prefer zip
over enumerated
for this reason, so let’s use it here:
ForEach(zip(1..., people)) { number, person in
Text("\(number). \(person.name)")
}
This doesn’t compile for two reasons:
-
The collection passed to
ForEach
must be aRandomAccessCollection
, butzip
produces aSequence
. We can fix this by converting the zipped sequence back into an array. -
The element type of the numbered sequence,
(Int, Person)
, no longer conforms toIdentifiable
— and can’t, because tuples can’t conform to protocols.This means we need to use a different
ForEach
initializer, which lets us pass in a key path to the element’s identifier field. The correct key path in this example is\.1.id
, where.1
selects the second element in the tuple and.id
designates the property of thePerson
type.
The working code then looks like this:
ForEach(Array(zip(1..., people)), id: \.1.id) { number, person in
Text("\(number). \(person.name)")
}
Clarity at the point of use
It’s not super clear what’s going on there at a quick glance; I particularly dislike the .1
in the key path, and the Array(…)
wrapper is just noise. To improve clarity at the point of use, I wrote a little helper as an extension on Sequence
that adds labels to the tuple and hides some of the internals:
extension Sequence {
/// Numbers the elements in `self`, starting with the specified number.
/// - Returns: An array of (Int, Element) pairs.
func numbered(startingAt start: Int = 1) -> [(number: Int, element: Element)] {
Array(zip(start..., self))
}
}
This makes call sites quite a bit nicer:
ForEach(people.numbered(), id: \.element.id) { number, person in
Text("\(number). \(person.name)")
}
Dropping the key path
The key path is more readable, but it’s unfortunate that we can’t leave it out entirely. We can’t make the tuple Identifiable
, but we could introduce a custom struct that acts as the element type for our numbered collection:
@dynamicMemberLookup
struct Numbered<Element> {
var number: Int
var element: Element
subscript<T>(dynamicMember keyPath: WritableKeyPath<Element, T>) -> T {
get { element[keyPath: keyPath] }
set { element[keyPath: keyPath] = newValue }
}
}
Notice that I added a key-path based dynamic member lookup subscript. This isn’t strictly necessary, but it will allow clients to use a Numbered<Person>
value almost as if it were a plain Person
. Many thanks to Min Kim for suggesting this, it hadn’t occured to me.
Let’s change the numbered(startingAt:)
method to use the new type:
extension Sequence {
func numbered(startingAt start: Int = 1) -> [Numbered<Element>] {
zip(start..., self)
.map { Numbered(number: $0.0, element: $0.1) }
}
}
And now we can conditionally conform the Numbered
struct to Identifiable
when its element type is Identifiable
:
extension Numbered: Identifiable where Element: Identifiable {
var id: Element.ID { element.id }
}
This allows us to omit the key path and go back to the ForEach
initializer we used initially:
ForEach(people.numbered()) { numberedPerson in
Text("\(numberedPerson.number). \(numberedPerson.name)")
}
This is where the key-path-based member lookup we added above shows its strength. The numberedPerson
variable is of type Numbered<Person>
, but it almost behaves like a normal Person
struct with an added number
property, because the compiler forwards non-existent field accesses to the wrapped Person
value in a fully type-safe manner. Without the member lookup subscript, we’d have to write numberedPerson.element.name
. This only works for accessing properties, not methods.