188

I have an array and I want to iterate through it initialize views based on array value, and want to perform action based on array item index

When I iterate through objects

ForEach(array, id: \.self) { item in
  CustomView(item: item)
    .tapAction {
      self.doSomething(index) // Can't get index, so this won't work
    }
}

So, I've tried another approach

ForEach((0..<array.count)) { index in
  CustomView(item: array[index])
    .tapAction {
      self.doSomething(index)
    }
}

But the issue with second approach is, that when I change array, for example, if doSomething does following

self.array = [1,2,3]

views in ForEach do not change, even if values are changed. I believe, that happens because array.count haven't changed.

Is there a solution for this?

starball
  • 20,030
  • 7
  • 43
  • 238
Pavel
  • 3,900
  • 6
  • 32
  • 41

14 Answers14

198

Another approach is to use:

enumerated()

ForEach(Array(array.enumerated()), id: \.offset) { index, element in
  // ...
}

Source: https://alejandromp.com/blog/swiftui-enumerated/

Stone
  • 2,912
  • 1
  • 21
  • 18
  • 26
    I think you want `\.element` to ensure animations work properly. – Senseful Jun 29 '20 at 16:28
  • 4
    ForEach(Array(array.enumerated()), id: \.1) { index, element in ... } – MichaelMao Aug 30 '20 at 07:10
  • 1
    what does \.1 mean? – Luca Oct 05 '20 at 15:06
  • 18
    @Luca `Array(array.enumerated())` has actually `EnumeratedSequence<[ItemType]>` type and its element is a tuple of type `(offset: Int, element: ItemType)`. Writing `\.1` means you access the 1st (in human language - 2nd) element of tuple, namely - `element: ItemType`. This is the same as writing `\.element`, but shorter and IMO much less understandable – ramzesenok Oct 08 '20 at 12:50
  • 3
    Using \.element has the added benefit of swiftUI being able to determine when the contents of the array has changed, even though the size may have not. Use \.element! – smakus Mar 02 '21 at 00:02
  • 2
    Yes, use `\.element` as @ramzesenok mentioned and implement `Hashable` protocol if you are using a custom object array, and add the properties that could change/update inside your object inside `func hash(into hasher: inout Hasher) {` and there add for example in my case status update like `status.hash(into: &hasher)` – Fernando Romiti Jan 26 '22 at 01:42
  • 1
    If the element type already conforms to Identifiable, you might want to use `id: \.element.id`. If you used `id: \.element`, then the element type would require Hashable conformance. – nishanthshanmugham Jun 08 '22 at 14:46
122

This works for me:

Using Range and Count

struct ContentView: View {
    @State private var array = [1, 1, 2]

    func doSomething(index: Int) {
        self.array = [1, 2, 3]
    }
    
    var body: some View {
        ForEach(0..<array.count) { i in
          Text("\(self.array[i])")
            .onTapGesture { self.doSomething(index: i) }
        }
    }
}

Using Array's Indices

The indices property is a range of numbers.

struct ContentView: View {
    @State private var array = [1, 1, 2]

    func doSomething(index: Int) {
        self.array = [1, 2, 3]
    }
    
    var body: some View {
        ForEach(array.indices) { i in
          Text("\(self.array[i])")
            .onTapGesture { self.doSomething(index: i) }
        }
    }
}
Community
  • 1
  • 1
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • 2
    I believe, that `array.firstIndex(of: item)` won't work if there are same elements in array, for example if array is `[1,1,1]`, it will return `0` for all elements. Also, it is not very good performance to do this for each element of array. my array is a `@State` variable. `@State private var array: [Int] = [1, 1, 2]` – Pavel Jul 28 '19 at 21:02
  • Ok, I updated my answer. If the array is a `@State`, the second approach works fine for me. – kontiki Jul 28 '19 at 21:26
  • 55
    Never use `0.. – Alexander Jul 28 '19 at 21:33
  • In my case, it does render it! Are you on beta 4? Simulator or Preview? – kontiki Jul 29 '19 at 07:59
  • 34
    Please note that [the iOS 13 beta 5 release notes](https://developer.apple.com/documentation/ios_ipados_release_notes/ios_ipados_13_beta_5_release_notes?language=objc) give the following warning about passing a range to `ForEach`: “However, you shouldn’t pass a range that changes at runtime. If you use a variable that changes at runtime to define the range, the list displays views according to the initial range and ignores any subsequent updates to the range.” – rob mayoff Jul 29 '19 at 23:12
  • 2
    Thank you, Rob, that explains the array index out of bounds error generated by the above - my array's content changes over time. Problematic, as that essentially invalidates this solution for me. – TheNeil May 18 '20 at 20:19
  • @TheNeil I am seeing same problem where I want to render view based on index and want to understand why this is happening? also what solution you come up with? thank you in advance – Naren Jun 07 '20 at 02:22
  • I don’t really have a solution, else I would’ve put my own answer to this question. A proxy binding, like in the following, looks like a possible workaround, but it’s messy and far from ideal. https://stackoverflow.com/a/61687192/2272431 – TheNeil Jun 07 '20 at 03:59
  • 3
    https://developer.apple.com/videos/play/wwdc2021/10022/ Using Index is not ideal if array content position can change like adding or removing content. As index is being used for implicit identification of views. We should explicitly set id modifier and use consistent id for same view – Atif Jul 09 '21 at 08:52
  • This works but generates warning "Non-constant range: argument must be an integer literal" and can cause crash if array count changes during ForEach execution. – Michel Storms Sep 27 '22 at 03:41
60

I usually use enumerated to get a pair of index and element with the element as the id

ForEach(Array(array.enumerated()), id: \.element) { index, element in
    Text("\(index)")
    Text(element.description)
}

For a more reusable component, you can visit this article https://onmyway133.com/posts/how-to-use-foreach-with-indices-in-swiftui/

onmyway133
  • 45,645
  • 31
  • 257
  • 263
31

I needed a more generic solution, that could work on all kind of data (that implements RandomAccessCollection), and also prevent undefined behavior by using ranges.
I ended up with the following:

public struct ForEachWithIndex<Data: RandomAccessCollection, ID: Hashable, Content: View>: View {
    public var data: Data
    public var content: (_ index: Data.Index, _ element: Data.Element) -> Content
    var id: KeyPath<Data.Element, ID>

    public init(_ data: Data, id: KeyPath<Data.Element, ID>, content: @escaping (_ index: Data.Index, _ element: Data.Element) -> Content) {
        self.data = data
        self.id = id
        self.content = content
    }

    public var body: some View {
        ForEach(
            zip(self.data.indices, self.data).map { index, element in
                IndexInfo(
                    index: index,
                    id: self.id,
                    element: element
                )
            },
            id: \.elementID
        ) { indexInfo in
            self.content(indexInfo.index, indexInfo.element)
        }
    }
}

extension ForEachWithIndex where ID == Data.Element.ID, Content: View, Data.Element: Identifiable {
    public init(_ data: Data, @ViewBuilder content: @escaping (_ index: Data.Index, _ element: Data.Element) -> Content) {
        self.init(data, id: \.id, content: content)
    }
}

extension ForEachWithIndex: DynamicViewContent where Content: View {
}

private struct IndexInfo<Index, Element, ID: Hashable>: Hashable {
    let index: Index
    let id: KeyPath<Element, ID>
    let element: Element

    var elementID: ID {
        self.element[keyPath: self.id]
    }

    static func == (_ lhs: IndexInfo, _ rhs: IndexInfo) -> Bool {
        lhs.elementID == rhs.elementID
    }

    func hash(into hasher: inout Hasher) {
        self.elementID.hash(into: &hasher)
    }
}

This way, the original code in the question can just be replaced by:

ForEachWithIndex(array, id: \.self) { index, item in
  CustomView(item: item)
    .tapAction {
      self.doSomething(index) // Now works
    }
}

To get the index as well as the element.

Note that the API is mirrored to that of SwiftUI - this means that the initializer with the id parameter's content closure is not a @ViewBuilder.
The only change from that is the id parameter is visible and can be changed

Stéphane Copin
  • 1,888
  • 18
  • 18
  • This is great. One minor suggestion - if you make data public, then you can also conform to DynamicViewContent (for free) which enables modifiers like .onDelete() to work. – Confused Vorlon Jul 29 '20 at 15:46
  • 1
    @ConfusedVorlon oh indeed! I've updated the answer to have `data` & `content` public, as is the case for the original `ForEach` (I left `id` as it is), and added the conformance to `DynamicViewContent`. Thanks for the suggestion! – Stéphane Copin Jul 29 '20 at 16:44
  • Thanks for the update. Not sure if it is important -but ForEach limits the conformance slightly extension ForEach : DynamicViewContent where Content : View {} – Confused Vorlon Jul 29 '20 at 16:51
  • @ConfusedVorlon I'm not sure either, it's tough to say without knowing the inner workings here. I'll add it just in case; I don't think anyone would use this without a `View` as the `Content` haha – Stéphane Copin Jul 29 '20 at 16:53
  • 1
    Just a minor thing: `data`, `content` and `id` can be declared as constants. – Murlakatam Oct 05 '21 at 07:56
30

For non zero based arrays avoid using enumerated, instead use zip:

ForEach(Array(zip(items.indices, items)), id: \.0) { index, item in
  // Add Code here
}
Andrew Stoddart
  • 408
  • 4
  • 9
  • Out of curiosity, why don't you use a zero-based array? What's the context? – blwinters Jan 02 '22 at 19:56
  • 2
    For example, an `ArraySlice`'s indices will be the indices of its items in its parent array, so they may not start from 0. – Zev Eisenberg Feb 09 '22 at 16:00
  • 2
    By using the index (`\.0`) as the id, you're not providing a consistent identity for the item, which will lead to view inconsistencies if `items` changes. – Robin Daugherty Jan 01 '23 at 17:16
15

I created a dedicated View for this purpose:

struct EnumeratedForEach<ItemType, ContentView: View>: View {
    let data: [ItemType]
    let content: (Int, ItemType) -> ContentView

    init(_ data: [ItemType], @ViewBuilder content: @escaping (Int, ItemType) -> ContentView) {
        self.data = data
        self.content = content
    }

    var body: some View {
        ForEach(Array(zip(data.indices, data)), id: \.0) { idx, item in
            content(idx, item)
        }
    }
}

Now you can use it like this:

EnumeratedForEach(items) { idx, item in
    ...
}
ramzesenok
  • 5,469
  • 4
  • 30
  • 41
11

ForEach is SwiftUI isn’t the same as a for loop, it’s actually doing something called structural identity. The documentation of ForEach states:

/// It's important that the `id` of a data element doesn't change, unless
/// SwiftUI considers the data element to have been replaced with a new data
/// element that has a new identity.

This means we cannot use indices, enumerated or a new Array in the ForEach. The ForEach must be given the actual array of identifiable items. This is so SwiftUI can animate the rows around to match the data, obviously this can't work with indicies, e.g. if row at 0 is moved to 1 its index is still 0.

To solve your problem of getting the index, you simply have to look up the index like this:

ForEach(items) { item in
  CustomView(item: item)
    .tapAction {
      if let index = array.firstIndex(where: { $0.id == item.id }) {
          self.doSomething(index) 
      }
    }
}

You can see Apple doing this in their Scrumdinger sample app tutorial.

guard let scrumIndex = scrums.firstIndex(where: { $0.id == scrum.id }) else {
    fatalError("Can't find scrum in array")
}
Olcay Ertaş
  • 5,987
  • 8
  • 76
  • 112
malhal
  • 26,330
  • 7
  • 115
  • 133
  • 1
    this will lead to sluggishness when your have lots of rows. – mayqiyue Jan 19 '22 at 02:21
  • While this does work, I've found it increases your chances of running into the dreaded "compiler is unable to type-check this expression in reasonable time" error. onmyway133 has a great answer below on how to do this well – Todd Rylaarsdam Mar 08 '22 at 20:15
  • if you don’t use item IDs then you can’t modify the array – malhal Mar 08 '22 at 20:22
8

The advantage of the following approach is that the views in ForEach even change if state values ​​change:

struct ContentView: View {
    @State private var array = [1, 2, 3]

    func doSomething(index: Int) {
        self.array[index] = Int.random(in: 1..<100)
    }

    var body: some View {    
        let arrayIndexed = array.enumerated().map({ $0 })

        return List(arrayIndexed, id: \.element) { index, item in

            Text("\(item)")
                .padding(20)
                .background(Color.green)
                .onTapGesture {
                    self.doSomething(index: index)
            }
        }
    }
}

... this can also be used, for example, to remove the last divider in a list:

struct ContentView: View {

    init() {
        UITableView.appearance().separatorStyle = .none
    }

    var body: some View {
        let arrayIndexed = [Int](1...5).enumerated().map({ $0 })

        return List(arrayIndexed, id: \.element) { index, number in

            VStack(alignment: .leading) {
                Text("\(number)")

                if index < arrayIndexed.count - 1 {
                    Divider()
                }
            }
        }
    }
}
Peter Kreinz
  • 7,979
  • 1
  • 64
  • 49
6

You can use this method:

.enumerated()

From the Swift documentation:

Returns a sequence of pairs (n, x), where n represents a consecutive integer starting at zero and x represents an element of the sequence.

var elements: [String] = ["element 1", "element 2", "element 3", "element 4"]

ForEach(Array(elements.enumerated()), id: \.element) { index, element in
  Text("\(index) \(element)")
}
OverD
  • 2,612
  • 2
  • 14
  • 29
Boopy
  • 309
  • 3
  • 5
5

2021 solution if you use non zero based arrays avoid using enumerated:

ForEach(array.indices,id:\.self) { index in
    VStack {
        Text(array[index].name)
            .customFont(name: "STC", style: .headline)
            .foregroundColor(Color.themeTitle)
        }
    }
}
pkamb
  • 33,281
  • 23
  • 160
  • 191
moahmed ayed
  • 636
  • 7
  • 10
  • 2
    This is a bad idea because it replaces the `Identifiable` object with the index, which means that SwiftUI won't be able to detect correctly when things change vs move. – Robin Daugherty May 31 '22 at 16:56
  • @RobinDaughert how to avoid that if I still want to use ForEach with indices, because I got problem you mentioned – George Heints Dec 29 '22 at 16:45
  • Both @onmyway's answer (which is the accepted answer) and @malhal's answer can give you a better method. If you must, you can use the [id function](https://developer.apple.com/documentation/swiftui/view/id(_:)) to place a meaningful identity on the view, but that's not necessary if you use `ForEach` correctly, as Apple recommends. – Robin Daugherty Jan 01 '23 at 17:13
4

To get indexing from SwiftUI's ForEach loop, you could use closure's shorthand argument names:

@State private var cars = ["Aurus","Bentley","Cadillac","Genesis"]

var body: some View {
    NavigationView {
        List {
            ForEach(Array(cars.enumerated()), id: \.offset) {

                Text("\($0.element) at \($0.offset) index")
            }
        }
    }
}

Results:

//   Aurus at 0 index
//   Bentley at 1 index
//   Cadillac at 2 index
//   Genesis at 3 index


P. S.

Initially, I posted an answer with a "common" expression that all Swift developers are used to, however, thanks to @loremipsum I changed it. As stated in WWDC 2021 Demystify SwiftUI video (time 33:40), array indices are not stable from \.self identity (key path).

ForEach(0 ..< cars.count, id: \.self) {     // – NOT STABLE
    Text("\(cars[$0]) at \($0) index")
}
Andy Jazz
  • 49,178
  • 17
  • 136
  • 220
  • 1
    This is considered bad practice and unsafe by apple when using SwiftUI you can watch Demystifying SwiftUI from one of the WWDCs for more info. – lorem ipsum Aug 01 '22 at 10:29
  • 2
    Using count and the index. All the index versions of this answer are considered unsafe. The one with enumerated is the most acceptable version. – lorem ipsum Aug 01 '22 at 10:37
  • 1
    Using `enumerated()` doesn't change anything if you still use `\.offset` as the id. You need to use `\.element`. – Robin Daugherty Jan 01 '23 at 17:15
2

Here is a simple solution though quite inefficient to the ones above..

In your Tap Action, pass through your item

.tapAction {

   var index = self.getPosition(item)

}

Then create a function the finds the index of that item by comparing the id

func getPosition(item: Item) -> Int {

  for i in 0..<array.count {
        
        if (array[i].id == item.id){
            return i
        }
        
    }
    
    return 0
}
C. Skjerdal
  • 2,750
  • 3
  • 25
  • 50
0

I found a simple solution using Extension

struct ForEachIndex<ItemType, ContentView: View>: View {
    let data: [ItemType]
    let content: (Int, ItemType) -> ContentView

    init(_ data: [ItemType], @ViewBuilder content: @escaping (Int, ItemType) -> ContentView) {
        self.data = data
        self.content = content
    }

    var body: some View {
        ForEach(Array(zip(data.indices, data)), id: \.0) { idx, item in
            content(idx, item)
        }
    }
}

Usage:

ForEachIndex(savedModel) { index, model in
    //Do you work here
}
Muhammad Aakif
  • 193
  • 2
  • 5
  • Thanks. I however got a very strange error when using this, which was that whenever the array changes the AsyncImage did not update. Took me half a day to identify that this function was the cause of it. Going back to regular ForEach workewd – Thyselius May 11 '23 at 09:43
-1

Just like they mentioned you can use array.indices for this purpose BUT remember that indexes that you've got are started from last element of array, To fix this issue you must use this: array.indices.reversed() also you should provide an id for the ForEach. Here's an example:

ForEach(array.indices.reversed(), id:\.self) { index in }
Mehdi
  • 1,340
  • 1
  • 10
  • 5