Enumerating elements in ForEach

Updates:

  1. Jan 19, 2020
    Improved the code in the final section by adding key-path-based member lookup as suggested by reader Min Kim (thanks!).

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)
      }
    }
  }
}
iPhone showing a plain, unnumbered list of people
The plain, unnumbered list.

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:

iPhone showing a numbered list of people
The numbered list.

We might try one of these approaches:

  • Call enumerated() on the array we pass to ForEach, 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:

  1. The collection passed to ForEach must be a RandomAccessCollection, but zip produces a Sequence. We can fix this by converting the zipped sequence back into an array.

  2. The element type of the numbered sequence, (Int, Person), no longer conforms to Identifiable — 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 the Person 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.