Get index in ForEach in SwiftUI
SwiftSwiftuiSwift Problem Overview
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.
Swift Solutions
Solution 1 - Swift
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) }
}
}
}
Solution 2 - Swift
Another approach is to use:
enumerated()
ForEach(Array(array.enumerated()), id: \.offset) { index, element in
// ...
}
Solution 3 - Swift
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
Solution 4 - Swift
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/
Solution 5 - Swift
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
}
Solution 6 - Swift
I created a dedicated View
for this purpose based on the awesome Stone's solution:
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(self.data.enumerated()), id: \.offset) { idx, item in
self.content(idx, item)
}
}
}
Now you can use it like this:
EnumeratedForEach(items) { idx, item in
...
}
Solution 7 - Swift
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()
}
}
}
}
}
Solution 8 - Swift
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)
}
}
}
Solution 9 - Swift
ForEach is SwiftUI isn’t the same as a for loop, it’s actually doing generating 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 on the actual array of identifiable items. This is so SwiftUI can track the row Views moving around.
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")
}
Solution 10 - Swift
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
}
Solution 11 - Swift
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 }