Reputation: 4400
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?
Upvotes: 237
Views: 198126
Reputation: 6881
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
...
}
As mentioned in comments by Robin Daugherty it's not a good practice to identify elements by their index in the array – it's fine sometimes but not when you want to reorder or insert/remove elements. Therefore if you want to do it right – conform ItemType
to Identifiable
(it's up to you then to identify each element properly) and use the element itself in the ForEach
as an id
, like this (notice id: \.1
instead of id: \.0
):
ForEach(Array(zip(data.indices, data)), id: \.1) { idx, item in
content(idx, item)
}
Small note: \.0
and \.1
correspond to element from the first array you pass to zip
and element from the second array respectively. It's coming from the tuple from array of tuples, created when you write Array(zip(..., ...))
.
In this example since we pass data.indices
as the first array, \.0
is index and \.1
is the element itself, since the second array we pass is just array of elements – data
Upvotes: 30
Reputation: 11
I use extension ForEach to resolve this issue:
extension ForEach {
public init<T: RandomAccessCollection>(
data: T,
content: @escaping (T.Index, T.Element) -> Content
) where T.Element: Identifiable, T.Element: Hashable, Content: View, Data == [(T.Index, T.Element)], ID == T.Element {
self.init(Array(zip(data.indices, data)), id: \.1) { index, element in
content(index, element)
}
}
}
How to using:
ForEach(data: array) { index, element in
Text("\(index)")
Text(element.description)
}
Upvotes: 1
Reputation: 553
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)")
}
Upvotes: 23
Reputation: 30549
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")
}
Upvotes: 15
Reputation: 58043
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")
}
Upvotes: 5
Reputation: 1356
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 }
Upvotes: -2
Reputation: 48055
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/
Upvotes: 71
Reputation: 648
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)
}
}
}
Upvotes: 6
Reputation: 506
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
}
Upvotes: 31
Reputation: 3099
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
}
Upvotes: 2
Reputation: 1998
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'scontent
closure is not a@ViewBuilder
.
The only change from that is theid
parameter is visible and can be changed
Upvotes: 33
Reputation: 3592
Another approach is to use:
ForEach(Array(array.enumerated()), id: \.offset) { index, element in
// ...
}
Source: https://alejandromp.com/blog/swiftui-enumerated/
Upvotes: 256
Reputation: 40489
This works for me:
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) }
}
}
}
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) }
}
}
}
Upvotes: 142
Reputation: 8620
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()
}
}
}
}
}
Upvotes: 9