Categories
iOS Development

Working with Arrays in SwiftUI

One of the most common things you’ll find yourself doing when building UI is dealing with arrays of data.

When all you want to do is read from an array this is fairly straightforward. Things get more complicated when you want to know an item’s offset, index, or to gain mutable access via bindings.

Here I’ll explore a way we can make this effortless going forward.

Our goal:

  • An effortless way to access the index and/or offset alongside our elements
    • this means we can also access bindings for our elements (using the index)
  • Lazy access (perform well regardless of array size)
  • Compatible with ForEach
    • including automatic identifiable support if your element is identifiable.

Why not just use enumerated() ?
I’ll discuss the difference between an item’s index and offset a bit later in this post, but for now lets say that:

a) if you’re using the ‘offset’ to access an array it could crash your app
b) you’ll find it won’t actually work with SwiftUI’s ForEach, and won’t respect the fact that your data is identifiable

Implementation:

First let’s make a wrapper type that holds an element as well as its index and offset (these are not always the same!)


struct Indexed<Element, Index> { var index: Index var offset: Int var element: Element }

We’ll also add conditional conformance to Identifiable if our array element is identifiable:

extension Indexed: Identifiable where Element: Identifiable { var id: Element.ID { element.id } }

Now let’s extend RandomAccessCollection with a method to return an indexed RandomAccessCollection using our Indexed type:

extension RandomAccessCollection { func indexed() -> AnyRandomAccessCollection<Indexed<Element, Index>> { AnyRandomAccessCollection( zip(zip(indices, 0...).lazy, self).lazy .map { Indexed(index: $0.0.0, offset: $0.0.1, element: $0.1) } ) } }

This might look complicated, but all it is doing is providing lazy access to our elements wrapped in our Indexed type. This means that we aren’t iterating over our whole array, but rather allowing access to each element on-demand.

As a final touch, lets add @dynamicMemberLookup support to our Indexed type, so we can easily access members of our wrapped element:

@dynamicMemberLookup struct Indexed<Element, Index> { var index: Index var offset: Int var element: Element //Access to constant members subscript<T>(dynamicMember keyPath: KeyPath<Element, T>) -> T { element[keyPath: keyPath] } //Access to mutable members subscript<T>(dynamicMember keyPath: WritableKeyPath<Element, T>) -> T { get { element[keyPath: keyPath] } set { element[keyPath: keyPath] = newValue } } }

Using .indexed()

Time for an example! For a very artificial scenario: let’s say we want to present a numbered list of animals…

First we’ll define an Animal type:

struct Animal: Identifiable { var id = UUID() var name: String var haveSeen: Bool = false }

Now to make our list using .indexed() on our array of animals:

struct AnimalsListView : View { // Here's some placeholder data for our example // Declared @State because we'll mutate it in the next example @State var animals: [Animal] = [ Animal(label: "Tiger"), Animal(label: "Lion"), Animal(label: "Emu"), Animal(label: "Giraffe"), Animal(label: "Elephant"), Animal(label: "Panda"), ] var body: some View { List { ForEach(items.indexed()) { indexedAnimal in Text("\(indexedAnimal.offset + 1) - \(indexedAnimal.label)") } } } }

And that’s all we need to effortlessly add numbering to our list

//If you're using a playground: import PlaygroundSupport PlaygroundPage.current.setLiveView(AnimalsListView())

Using Bindings inside our ForEach

We’ve used offset for our numbering above, as it will always start from zero and count up (great for numbering our list). However, if we’re accessing a collection, we want to use the index.

Indices often start at zero, but not always….

For example if you use an ArraySlice such as animalsSubgroup = animals[3...5], the first index will actually be 3! If you tried to then access animalsSubgroup[0] your app would crash.

Now let’s update our ForEach loop to add a toggle denoting whether we haveSeen each animal (just a mutable boolean value declared in our model):

ForEach(animals.indexed()) { indexedAnimal in Toggle(isOn: self.$animals[indexedAnimal.index].haveSeen) { Text("\(indexedAnimal.offset + 1) - \(indexedAnimal.name)") } }

And just like that we can toggle the haveSeen value of our animals.

Sidenote: This is poor UI design for the sake of a simple example… it is not at all clear to the user what this toggle represents!

Conclusion

This is a fairly straightforward implementation but should save a lot of boilerplate code going forward! Feel free to comment if you have any suggestions for improvement.

Sidenote: There is a bug in how SwiftUI handles deletions from ForEach currently, see this post if you encounter crashes on deletion of items.

Leave a Reply

Your email address will not be published. Required fields are marked *