Using TextField with ForEach in SwiftUI - swiftui

I am trying to display a dynamic list of text fields using a ForEach. The following code is working as expected: I can add/remove text fields, and the binding is correct. However, when I move the items in a ObservableObject view model, it does not work anymore and it crashes with an index out of bounds error. Why is that? How can I make it work?
struct ContentView: View {
#State var items = ["A", "B", "C"]
var body: some View {
VStack {
ForEach(items.indices, id: \.self) { index in
FieldView(value: Binding<String>(get: {
items[index]
}, set: { newValue in
items[index] = newValue
})) {
items.remove(at: index)
}
}
Button("Add") {
items.append("")
}
}
}
}
struct FieldView: View {
#Binding var value: String
let onDelete: () -> Void
var body: some View {
HStack {
TextField("item", text: $value)
Button(action: {
onDelete()
}, label: {
Image(systemName: "multiply")
})
}
}
}
The view model I am trying to use:
class ViewModel: Observable {
#Published var items: [String]
}
#ObservedObject var viewModel: ViewModel
I found many questions dealing with the same problem but I could not make one work with my case. Some of them do not mention the TextField, some other are not working (anymore?).
Thanks a lot

By checking the bounds inside the Binding, you can solve the issue:
struct ContentView: View {
#ObservedObject var viewModel: ViewModel = ViewModel(items: ["A", "B", "C"])
var body: some View {
VStack {
ForEach(viewModel.items.indices, id: \.self) { index in
FieldView(value: Binding<String>(get: {
guard index < viewModel.items.count else { return "" } // <- HERE
return viewModel.items[index]
}, set: { newValue in
viewModel.items[index] = newValue
})) {
viewModel.items.remove(at: index)
}
}
Button("Add") {
viewModel.items.append("")
}
}
}
}
It is a SwiftUI bug, similar question to this for example.

I can not perfectly explain what is causing that crash, but I've been able to reproduce the error and it looks like after deleting a field,SwiftUI is still looking for all indices and when it is trying to access the element at a deleted index, it's unable to find it which causes the index out of bounds error.
To fix that, we can write a conditional statement to make sure an element is searched only if its index is included in the collection of indices.
FieldView(value: Binding<String>(get: {
if viewModel.items.indices.contains(index) {
return viewModel.items[index]
} else {
return ""
}
}, set: { newValue in
viewModel.items[index] = newValue
})) {
viewModel.items.remove(at: index)
}
The above solution solves the problem since it makes sure that the element will not be searched when the number of elements (items.count) is not greater than the index.
This is just what I've been able to understand, but something else might be happening under the hood.

Related

How to stop text cursor from jumping to the end in SwiftUI?

I'm curious, has anyone seen this before, or do they know how to solve it? I have a situation where editing a textfield that's in a NavigationStack always pops the text cursor to the end of the field on every keystroke. I suspect it has something to do with SwiftUI's management of views and state, but I am not spotting anything unusual that I might be doing, other than the index lookup in the navigationDestination part. I don't understand why that would be a problem.
Here's some pretty minimal code demonstrating the problem (just try correcting the well-known Shakespeare quote):
struct CursorResetting: View {
struct Record: Identifiable {
var string = ""
var id = UUID()
}
#State var path = NavigationPath()
#State private var records = [
Record(string: "To be and not to be"),
Record(string: "That begs the question")
]
var body: some View {
NavigationStack(path: $path) {
Form {
List {
ForEach(records) { record in
NavigationLink(value: record.id) {
Text(record.string)
}
}
}
}
.navigationDestination(for: UUID.self) { id in
let index = records.firstIndex { $0.id == id }
if let index {
Form {
TextField("Value", text: $records[index].string)
}
} else {
Text("Invalid ID")
}
}
}
}
}
This is a known thing, and the reason why you can't use a Binding type with navigationDestination.
Binding triggers the View to redraw and it resets everything in the body including the navigationDestination modifier.
You have to use NavigationLink(destination:, label:)
import SwiftUI
struct CursorResettingView: View {
struct Record: Identifiable {
var string = ""
var id = UUID()
}
#State var path = NavigationPath()
#State private var records = [
Record(string: "To be and not to be"),
Record(string: "That begs the question")
]
var body: some View {
NavigationStack(path: $path) {
Form {
List {
ForEach($records) { $record in
NavigationLink {
Form {
TextField("Value", text: $record.string)
}
} label: {
Text(record.string)
}
}
}
}
.navigationDestination(for: UUID.self) { id in
//Use this for `View`s that don't use `Binding` type as argument.
}
}
}
}
struct CursorResettingView_Previews: PreviewProvider {
static var previews: some View {
CursorResettingView()
}
}
Anything that uses an array's index is generally considered unsafe with SwiftUI so "solutions" that depend on it will be inherently fragile.

SwiftUI List messed up after delete action on iOS 15

It seems that there is a problem in SwiftUI with List and deleting items. The items in the list and data get out of sync.
This is the code sample that reproduces the problem:
import SwiftUI
struct ContentView: View {
#State var popupShown = false
var body: some View {
VStack {
Button("Show list") { popupShown.toggle() }
if popupShown {
MainListView()
}
}
.animation(.easeInOut, value: popupShown)
}
}
struct MainListView: View {
#State var texts = (0...10).map(String.init)
func delete(at positions: IndexSet) {
positions.forEach { texts.remove(at: $0) }
}
var body: some View {
List {
ForEach(texts, id: \.self) { Text($0) }
.onDelete { delete(at: $0) }
}
.frame(width: 300, height: 300)
}
}
If you perform a delete action on the first row and scroll to the last row, the data and list contents are not in sync anymore.
This is only happening when animation is attached to it. Removing .animation(.easeInOut, value: popupShown) workarounds the issue.
This code sample works as expected on iOS 14 and doesn't work on iOS 15.
Is there a workaround for this problem other then removing animation?
It isn't the animation(). The clue was seeing It appears that having the .animation outside of the conditional causes the problem. Moving it to the view itself corrected it to some extent. However, there is a problem with this ForEach construct: ForEach(texts, id: \.self). As soon as you start deleting elements of your array, the UI gets confused as to what to show where. You should ALWAYS use an Identifiable element in a ForEach. See the example code below:
struct ListDeleteView: View {
#State var popupShown = false
var body: some View {
VStack {
Button("Show list") { popupShown.toggle() }
if popupShown {
MainListView()
.animation(.easeInOut, value: popupShown)
}
}
}
}
struct MainListView: View {
#State var texts = (0...10).map({ TextMessage(message: $0.description) })
func delete(at positions: IndexSet) {
texts.remove(atOffsets: positions)
}
var body: some View {
List {
ForEach(texts) { Text($0.message) }
.onDelete { delete(at: $0) }
}
.frame(width: 300, height: 300)
}
}
struct TextMessage: Identifiable {
let id = UUID()
let message: String
}

Does SwiftUI's ForEach cache #State variables of child views beyond their existence?

So here is a little piece of code that sums up a problem I cannot figure out atm.
In the code below I add and remove entries to a dictionary keyed by an Enum.
What I would expect is that every time I add an item a new random number is being generated in the Element view and displayed.
What happens is that for every same Ident the same random number shows up - event though the ForEach loop has had a state where that Ident key was not in the dictionary any more. It appears as if ForEach does not purge the #State vars of the Element views that are not present any more, but reuses them when a new entry to the dictionary is added with the same Ident.
Is this expected behavior? What am I doing wrong?
Here is the code:
import Foundation
import SwiftUI
enum Ident:Int, Comparable, CaseIterable {
case one=1, two, three, four
static func < (lhs: Ident, rhs: Ident) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
extension Dictionary where Key == Ident,Value== String {
var asSortedArray:Array<(Ident,Value)> {
Array(self).sorted(by: { $0.key < $1.key })
}
var nextKey:Ident? {
if self.isEmpty {
return .one
}
else {
return Array(Set(Ident.allCases).subtracting(Set(self.keys))).sorted().first
}
}
}
struct ContentView: View {
#State var dictionary:[Ident:String] = [:]
var body: some View {
Form {
Section {
ForEach(dictionary.asSortedArray, id: \.0) { (ident, text) in
Element(dictionary: $dictionary, ident: ident, text: text)
}
}
Section {
Button(action: {
if let nextIdent = dictionary.nextKey {
dictionary[nextIdent] = "Something"
}
}, label: {
Text("Add one")
})
}
}
}
}
struct Element:View {
#Binding var dictionary:[Ident:String]
var ident:Ident
var text:String
#State var random:Int = Int.random(in: 0...1000)
var body: some View {
HStack {
Text(String(ident.rawValue))
Text(String(random))
Button(action: {
dictionary.removeValue(forKey: ident)
}, label: {
Text("Delete me.")
})
Spacer()
}
}
}

Conditionally Text in SwiftUI depending on Array value

I want make placeholder custom style so i try to use the method of Mojtaba Hosseini in SwiftUI. How to change the placeholder color of the TextField?
if text.isEmpty {
Text("Placeholder")
.foregroundColor(.red)
}
but in my case, I use a foreach with a Array for make a list of Textfield and Display or not the Text for simulate the custom placeholder.
ForEach(self.ListeEquip.indices, id: \.self) { item in
ForEach(self.ListeJoueurs[item].indices, id: \.self){idx in
// if self.ListeJoueurs[O][O] work
if self.ListeJoueurs[item][index].isEmpty {
Text("Placeholder")
.foregroundColor(.red)
}
}
}
How I can use dynamic conditional with a foreach ?
Now I have a another problem :
i have this code :
struct EquipView: View {
#State var ListeJoueurs = [
["saoul", "Remi"],
["Paul", "Kevin"]
]
#State var ListeEquip:[String] = [
"Rocket", "sayans"
]
var body: some View {
VStack { // Added this
ForEach(self.ListeEquip.indices) { item in
BulleEquip(EquipName: item, ListeJoueurs: self.$ListeJoueurs, ListeEquip: self.$ListeEquip)
}
}
}
}
struct BulleEquip: View {
var EquipName = 0
#Binding var ListeJoueurs :[[String]]
#Binding var ListeEquip :[String]
var body: some View {
VStack{
VStack{
Text("Équipe \(EquipName+1)")
}
VStack { // Added this
ForEach(self.ListeJoueurs[EquipName].indices) { index in
ListeJoueurView(EquipNamed: self.EquipName, JoueurIndex: index, ListeJoueurs: self.$ListeJoueurs, ListeEquip: self.$ListeEquip)
}
HStack{
Button(action: {
self.ListeJoueurs[self.EquipName].append("") //problem here
}){
Text("button")
}
}
}
}
}
}
struct ListeJoueurView: View {
var EquipNamed = 0
var JoueurIndex = 0
#Binding var ListeJoueurs :[[String]]
#Binding var ListeEquip :[String]
var body: some View {
HStack{
Text("Joueur \(JoueurIndex+1)")
}
}
}
I can run the App but I have this error in console when I click the button :
ForEach, Int, ListeJoueurView> count (3) != its initial count (2). ForEach(_:content:) should only be used for constant data. Instead conform data to Identifiable or use ForEach(_:id:content:) and provide an explicit id!
Can someone enlighten me?
TL;DR
You need a VStack, HStack, List, etc outside each ForEach.
Updated
For the second part of your question, you need to change your ForEach to include the id parameter:
ForEach(self.ListeJoueurs[EquipName].indices, id: \.self)
If the data is not constant and the number of elements may change, you need to include the id: \.self so SwiftUI knows where to insert the new views.
Example
Here's some example code that demonstrates a working nested ForEach. I made up a data model that matches how you were trying to call it.
struct ContentView: View {
// You can ignore these, since you have your own data model
var ListeEquip: [Int] = Array(1...3)
var ListeJoueurs: [[String]] = []
// Just some random data strings, some of which are empty
init() {
ListeJoueurs = (1...4).map { _ in (1...4).map { _ in Bool.random() ? "Text" : "" } }
}
var body: some View {
VStack { // Added this
ForEach(self.ListeEquip.indices, id: \.self) { item in
VStack { // Added this
ForEach(self.ListeJoueurs[item].indices, id: \.self) { index in
if self.ListeJoueurs[item][index].isEmpty { // If string is blank
Text("Placeholder")
.foregroundColor(.red)
} else { // If string is not blank
Text(self.ListeJoueurs[item][index])
}
}
}.border(Color.black)
}
}
}
}
Explanation
Here's what Apple's documentation says about ForEach:
A structure that computes views on demand from an underlying collection of of [sic] identified data.
So something like
ForEach(0..2, id: \.self) { number in
Text(number.description)
}
is really just shorthand for
Text("0")
Text("1")
Text("2")
So your ForEach is making a bunch of views, but this syntax for declaring views is only valid inside a View like VStack, HStack, List, Group, etc. The technical reason is because these views have an init that looks like
init(..., #ViewBuilder content: () -> Content)
and that #ViewBuilder does some magic that allows this unique syntax.

SwiftUI dynamic List with #Binding controls

How do I build a dynamic list with #Binding-driven controls without having to reference the array manually? It seems obvious but using List or ForEach to iterate through the array give all sorts of strange errors.
struct OrderItem : Identifiable {
let id = UUID()
var label : String
var value : Bool = false
}
struct ContentView: View {
#State var items = [OrderItem(label: "Shirts"),
OrderItem(label: "Pants"),
OrderItem(label: "Socks")]
var body: some View {
NavigationView {
Form {
Section {
List {
Toggle(items[0].label, isOn: $items[0].value)
Toggle(items[1].label, isOn: $items[1].value)
Toggle(items[2].label, isOn: $items[2].value)
}
}
}.navigationBarTitle("Clothing")
}
}
}
This doesn't work:
...
Section {
List($items, id: \.id) { item in
Toggle(item.label, isOn: item.value)
}
}
...
Type '_' has no member 'id'
Nor does:
...
Section {
List($items) { item in
Toggle(item.label, isOn: item.value)
}
}
...
Generic parameter 'SelectionValue' could not be inferred
Try something like
...
Section {
List(items.indices) { index in
Toggle(self.items[index].label, isOn: self.$items[index].value)
}
}
...
While Maki's answer works (in some cases). It is not optimal and it's discouraged by Apple. Instead, they proposed the following solution during WWDC 2021:
Simply pass a binding to your collection into the list, using the
normal dollar sign operator, and SwiftUI will pass back a binding to
each individual element within the closure.
Like this:
struct ContentView: View {
#State var items = [OrderItem(label: "Shirts"),
OrderItem(label: "Pants"),
OrderItem(label: "Socks")]
var body: some View {
NavigationView {
Form {
Section {
List($items) { $item in
Toggle(item.label, isOn: $item.value)
}
}
}.navigationBarTitle("Clothing")
}
}
}