Enumerating elements in ForEach – Ole Begemann

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)
      }
    }
  }
}

The simple, unnumbered list.

Person is a structure that is consistent with Identifiable protocol:

struct Person: Identifiable {
  var id: UUID = UUID()
  var name: String
}

ForEach user Identifiable conformity to determine where elements have been inserted or deleted when the input matrix changes, to animate these changes correctly.

Now suppose we want to number the items in the list as in this screen:

iPhone displays a numbered list of people

The numbered list.

We can try one of these methods:

  • Call enumerated() on the array we pass on to ForEach, which produces a tuple of the mold (offset: Int, element: Element) for each item.

  • Alternatively can zip(1..., people) produces tuples of the same shape (albeit without labels), but allows us to select a starting number other than 0.

I usually prefer zip over enumerated for this reason, let’s use it here:

ForEach(zip(1..., people)) { number, person in
  Text("(number). (person.name)")
}

This is not compiled for two reasons:

  1. The collection passed to ForEach must be one RandomAccessCollection, men zip produces one Sequence. We can solve this by converting the zipped sequence back to an array.

  2. The element type in the numbered sequence, (Int, Person), is no longer compliant Identifiable – and can not because tuples can not comply with protocols.

    That means we need someone else ForEach initializer, which lets us pass in a key path to the item’s identification field. The correct key path in this example is .1.id, where .1 selects the second element in the tuplet and .id denotes the property to Person type.

The work code then looks like this:

ForEach(Array(zip(1..., people)), id: .1.id) { number, person in
  Text("(number). (person.name)")
}

It’s not super clear what’s going on there at a glance; I especially do not like it .1 in the keyway, and Array(…) wrapping is just noise. To improve the clarity of use, I wrote a little helper as an extension on Sequence which adds labels to the tupler and hides some of the internal parts:

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 pages quite nicer:

ForEach(people.numbered(), id: .element.id) { number, person in
  Text("(number). (person.name)")
}

The key road is more readable, but it’s a shame we can not omit it completely. We can not make the tuple Identifiable, but we were able to introduce a custom struct that acts as the item 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 }
  }
}

Note that I added a keyway-based dynamic member lookup subscription. This is not strictly necessary, but it does allow customers to use one Numbered<Person> value almost as if it were a delete Person. Many thanks My Kim for suggesting this, it had not dawned on me.

Let’s change 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 forgive Numbered structure to Identifiable when its item 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 ForEach initializer we originally used:

ForEach(people.numbered()) { numberedPerson in
  Text("(numberedPerson.number). (numberedPerson.name)")
}

This is where the key-path-based membership posting we added above shows its strength. That numberedPerson variable is of type Numbered<Person>, but it behaves almost as usual Person struct with an added number property because the compiler forwards non-existent field access to it wrapped Person value in a completely type-safe way. Without a subscription to member postings, we would have to write numberedPerson.element.name. This only works for access to properties, not methods.

William

Leave a Reply

Your email address will not be published.