Using Foreach Loop with Binding Causes Index Out of Range When Array Shrinks (Swiftui)

Using ForEach loop with Binding causes index out of range when array shrinks (SwiftUI)

Finally got the ins and outs of that issue that I was experiencing myself.

The problem is architectural. It is 2 folds:

  1. You are making a copy of your unique source of truth. ForEach loops Textfield but you are passing a copy through Binding. Always work on the single source of truth
  2. Combined with ForEach ... indices is supposed to be a constant range (hence the out of range when you remove an element)

The below code works because it loops through the single source of truth without making a copy and always updates the single source of truth. I even added a method to change the string within the subview since you originally passed it as a binding, I imagine you wanted to change it at some point


import SwiftUI

class DataSource: ObservableObject {
@Published var textArray = ["A","B","C"]
}

struct Test: View {

@EnvironmentObject var data : DataSource

var body:some View {
VStack{
ForEach(self.data.textArray , id: \.self) {text in
TextView(text: self.data.textArray[self.data.textArray.firstIndex(where: {text == $0})!])
.padding()
}

//Array modifying button
Button(action: {
self.data.textArray.removeLast()
}){
Text(" Shrink array ")
.padding()
}
}
}
}

struct TextView:View {

@EnvironmentObject var data : DataSource

var text:String

var body:some View {
VStack {
Text(text)
Button(action: {
let index = self.data.textArray.firstIndex(where: {self.text == $0})!
self.data.textArray[index] = "Z"
}){
Text("Change String ")
.padding()
}
}
}
}

#if DEBUG
struct test_Previews: PreviewProvider {
static var previews: some View {
Test().environmentObject(DataSource())
}
}
#endif

Index out of range when overwriting array that UI depends on

So, initial problem was to interate over binding of a collection in ForEach as if it was a collection of bindings. That functionality will be added in the new version of swift, however, I decided to post the solution that I found, in case someone will need that type of functionality in an older version of SwiftUI

API

//$computer.program - Binding<MutableCollection<Command>>
//command - Binding<Command>
BindingForEach($computer.program) { command in
CommandCellView(...,
command: command)
}

BindingForEach.swift

import SwiftUI

struct BindingForEach<Data: MutableCollection, Content: View>: DynamicViewContent where Data.Index: Hashable,
Data.Indices == Range<Int> {
@Binding public var data: Data
private var builder: (Binding<Data.Element>) -> Content
private var elementBindings = FunctionCache<Data.Index, Binding<Data.Element>>()

init(_ collection: Binding<Data>, @ViewBuilder _ content: @escaping (Binding<Data.Element>) -> Content) {
self._data = collection
self.builder = content
self.elementBindings = FunctionCache<Data.Index, Binding<Data.Element>> { [self] (i) in
Binding<Data.Element> { [self] in
self.data[i]
} set: { [self] in
self.data[i] = $0
}
}
}

var body: some View {
ForEach(data.enumerated().map({ (i, _) in i }), id: \.self) { i in
builder(elementBindings[i])
}
}
}

FunctionCache.swift

It it important to cache created bindings, to optimize the performance

import Foundation

class FunctionCache<TSource: Hashable, TResult> {

private let function: (TSource) -> (TResult)
private var cache = [TSource : TResult]()

init (_ function: @escaping (TSource) -> TResult = { _ in fatalError("Unspecified function") }) {
self.function = function
}

subscript(_ i: TSource) -> TResult {
get {
if !cache.keys.contains(i) {
cache[i] = function(i)
}

return cache[i]!
}
}
}

How can I stop a SwiftUI TextField from losing focus when binding within a ForEach loop?

try this:

    ForEach(userPhonesManager.allPhones.indices, id: \.self) { index in
HStack {
Button(action: {
userPhonesManager.deletePhoneNumber(at: index)
}) {
Image(systemName: "minus.circle.fill")
}.buttonStyle(BorderlessButtonStyle())
TextField("Phone", text: $userPhonesManager.allPhones[index].phoneNumber)
}
}

EDIT-1:

Reviewing my comment and in light of renewed interest, here is a version without using indices.
It uses the ForEach with binding feature of SwiftUI 3 for ios 15+:

class PhoneDetailsStore: ObservableObject {
@Published var allPhones: [PhoneDetails]

init(phones: [PhoneDetails]) {
allPhones = phones
}

func addNewPhoneNumber() {
allPhones.append(PhoneDetails(phoneNumber: "", phoneType: "cell"))
}

// -- here --
func deletePhoneNumber(of phone: PhoneDetails) {
allPhones.removeAll(where: { $0.id == phone.id })
}

}

struct PhoneDetails: Identifiable, Equatable, Hashable {
let id = UUID() // <--- here
var phoneNumber: String
var phoneType: String
}

struct ContentView: View {

@ObservedObject var userPhonesManager: PhoneDetailsStore = PhoneDetailsStore(
phones: [
PhoneDetails(phoneNumber: "800–692–7753", phoneType: "cell"),
PhoneDetails(phoneNumber: "867-5309", phoneType: "home"),
PhoneDetails(phoneNumber: "1-900-649-2568", phoneType: "office")
]
)

var body: some View {
List {
ForEach($userPhonesManager.allPhones) { $phone in // <--- here
HStack {
Button(action: {
userPhonesManager.deletePhoneNumber(of: phone) // <--- here
}) {
Image(systemName: "minus.circle.fill")
}.buttonStyle(BorderlessButtonStyle())
TextField("Phone", text: $phone.phoneNumber) // <--- here
}
}
Button(action: { userPhonesManager.addNewPhoneNumber() }) {
Label {
Text("Add Phone Number")
} icon: {
Image(systemName: "plus.circle.fill")
}
}.buttonStyle(BorderlessButtonStyle())
}
}
}

SwiftUI: Deleting last row in ForEach

Here is fix

ForEach(Array(player.scores.enumerated()), id: \.element) { index, score in
HStack {
if self.isEditSelected {
Button(action: {
self.player.scores.remove(at: index)
}, label: {
Image("delete")
})
}
TextField("\(score)", value: Binding( // << use proxy binding !!
get: { self.player.scores[index] },
set: { self.player.scores[index] = $0 }),
formatter: NumberFormatter())
}
}

SwiftUI: ForEach using Array/Index crashes when rows are deleted

It seems you have complicated your code:

class UserData: ObservableObject {
@Published var rules: [Rule] = []
}

Will notice when new element is added to rules array, you could have done that just by declaring:

@State var rules = [Rule]()

You probably want to know when isEnabled in Rule class changes. Right now it is not happening. For that to ObservableObject must be the Rule class.

Keeping that in mind, if you change your code to:

import SwiftUI

class Rule: ObservableObject, Identifiable {
let id: String
var displayName: String
@Published var isEnabled: Bool

init(id: String, displayName: String, isEnabled: Bool) {
self.id = id
self.displayName = displayName
self.isEnabled = isEnabled
}
}

struct ContentView: View {
// for demonstration purpose, you may just declare an empty array here
@State var rules: [Rule] = [
Rule(id: "0", displayName: "a", isEnabled: true),
Rule(id: "1", displayName: "b", isEnabled: true),
Rule(id: "2", displayName: "c", isEnabled: true)
]

var body: some View {
VStack {
List {
ForEach(rules) { rule in
Row(rule: rule)
}
.onDelete(perform: delete)
}
}
}

func delete(at offsets: IndexSet) {
rules.remove(atOffsets: offsets)
}
}

struct Row: View {
@ObservedObject var rule: Rule

var body: some View {
HStack {
Toggle(isOn: self.$rule.isEnabled)
{ Text("Enabled") }

Text(rule.displayName)
.foregroundColor(rule.isEnabled ? Color.green : Color.red)
}
}
}

It will notice when new element is added to rules array, and also will notice when isEnabled changes.
This also solves your problem with crashing.

SwiftUI NavigationLink @Binding of an array element causes Fatal error: Index out of range

Wow ! Another question had an answer that applies here. See : https://stackoverflow.com/a/63080022

So you should change your ListView with :

struct ListView: View {
@Binding var liste: [Car]
@ObservedObject var net: NetworkFetcher

var body: some View {
List {
ForEach(liste.indices, id: \.self) { i in
NavigationLink(destination: self.row(for: i)) {
Text(self.liste[i].name)
}
}
}
}

private func row(for idx: Int) -> some View {
let isOn = Binding(
get: {
// safe getter with bounds validation
idx < self.liste.count ? self.liste[idx] : Car(id: UUID().uuidString, name: "EMPTY")
},
set: { self.liste[idx] = $0 }
)
return CarView(c: isOn, net: self.net)
}
}

Get index in ForEach in SwiftUI

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


Related Topics



Leave a reply



Submit