Get index in ForEach in SwiftUI

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? Thanks in advance.


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

Another approach is to use:

enumerated()

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

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


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