I made a practice app where the main view is a simple list. When the item of the list is tapped, it presents the detail view. Inside the detail view is a “textField” to change the items title.
I always have the error by making this steps:
add 3 items to the list
change the title of the second item
delete the third item
delete the second item (the one you changed the title.
When you delete the item that you changed the name, the app will crash and presente me the following error: “Fatal error: Index out of range in SwiftUI”
How can I fix it?
The main view:
struct ContentView: View {
#EnvironmentObject var store: CPStore
var body: some View {
NavigationView {
VStack {
List {
ForEach(0..<store.items.count, id:\.self) { index in
NavigationLink(destination: Detail(index: index)) {
VStack {
Text(self.store.items[index].title)
}
}
}
.onDelete(perform: remove)
}
Spacer()
Button(action: {
self.add()
}) {
ZStack {
Circle()
.frame(width: 87, height: 87)
}
}
}
.navigationBarTitle("Practice")
.navigationBarItems(trailing: EditButton())
}
}
func remove(at offsets: IndexSet) {
withAnimation {
store.items.remove(atOffsets: offsets)
}
}
func add() {
withAnimation {
store.items.append(CPModel(title: "Item \(store.items.count + 1)"))
}
}
}
The detail view:
struct Detail: View {
#EnvironmentObject var store: CPStore
let index: Int
var body: some View {
VStack {
//Error is here
TextField("Recording", text: $store.items[index].title)
}
}
}
The model:
struct CPModel: Identifiable {
var id = UUID()
var title: String
}
And view model:
class CPStore: ObservableObject {
#Published var items = [CPModel]()
}
Instead of getting the number of items in an array and using that index to get items from your array of objects, you can get each item in the foreach instead. In your content view, change your For each to this
ForEach(store.items, id:\.self) { item in
NavigationLink(destination: Detail(item: item)) {
VStack {
Text(item.title)
}
}
}
And Change your detail view to this:
struct Detail: View {
#EnvironmentObject var store: CPStore
#State var item: CPModel
var body: some View {
VStack {
//Error is here
TextField("Recording", text: $item.title)
}
}
}
My guess is that when you delete item index 1, Detail for index 1 is triggered to re-render before ContentView is triggered. Since SwiftUI doesn't know index has to be updated first because index is a value type (independent of anything).
As another answer has already pointed out, give it a self-contained copy should solve the problem.
In general you should avoid saving a copy of the index since you now have to maintain consistency at all times between two sources of truth.
In terms of your usage, index is implying "index that is currently legit", which should come from your observed object.
Related
I have a screen where I'm trying to display a list of NavigationLink and a grid of items (using LazyVGrid). I first tried putting everything in a List, like this:
List() {
ForEach(items) { item in
NavigationLink(destination: MyDestination()) {
Text("Navigation link text")
}
}
LazyVGrid(columns: columns) {
ForEach(gridItems) { gridItem in
MyGridItem()
}
}
}
However, it seems that putting a LazyVGrid in a List doesn't load the items in the grid lazily, it loads them all at once. So I replaced the List with a ScrollView and it works properly. However, I do want to keep the style of the NavigationLink that is shown when they are in a List. Basically what this looks like https://www.simpleswiftguide.com/wp-content/uploads/2019/10/Screen-Shot-2019-10-05-at-3.00.34-PM.png instead of https://miro.medium.com/max/800/1*LT7ZwIaidXrMuR6pu1Jvgg.png.
How can this be achieved? Or is there a way to put a LazyVGrid in a List and still have it load lazily?
Take a loot at this, the List is already a built-in Lazy load scroll view, you can check that is lazy on the onAppear event
import SwiftUI
struct item:Identifiable {
let id = UUID()
let value: String
}
struct listTest: View {
#State var items: [item]
init () {
var newItems = [item]()
for i in 1...50 {
newItems.append(item(value: "ITEM # \(i)"))
}
self.items = newItems
}
var body: some View {
NavigationView {
List($items) { $item in
NavigationLink(destination: MyDestination(value: item.value)) {
Text("Navigation link text")
.onAppear{
print("IM ITEM: \(item.value)")
}
.id(UUID())
}
}
}
}
}
struct MyDestination: View {
let value: String
var body: some View {
ZStack{
Text("HI A DETAIL: \(value)")
}
}
}
I managed to have a nice insert and delete animation for items displayed in a ForEach (done via .transition(...) on Row). But sadly this animation is also triggered when I just update the name of Item in the observed array. Of course this is because it actually is a new view (you can see that, since onAppear() of Row is called).
As we all know the recommended way of managing lists with cool animations would be List but I think that many people would like to avoid the standard UI or the limitations that come along with this element.
A working SwiftUI example snippet is attached (Build with Xcode 11.4)
So, the question:
Is there a smart way to suppress the animation (or have another one) for just updated items that would keep the same position? Is there a cool possibility to "reuse" the row and just update it?
Or is the answer "Let's wait for the next WWDC and let's see if Apple will fix it..."? ;-)
Cheers,
Orlando 🍻
Edit
bonky fronks answer is actually a good approach when you can distinguish between edit/add/delete (e.g. by manual user actions). As soon as the items array gets updated in background (for example by synced updates coming from Core Data in your view model) you don't know if this is an update or not. But maybe in this case the answer would be to manually implement the insert/update/delete cases in the view model.
struct ContentView: View {
#State var items: [Item] = [
Item(name: "Tim"),
Item(name: "Steve"),
Item(name: "Bill")
]
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}
private var AddButton: some View {
Button(action: {
self.items.insert(Item(name: "Jeff"), at: 0)
}) {
Text("Add")
}
}
private var RenameButton: some View {
Button(action: {
self.items[0].name = "Craigh"
}) {
Text("Rename first")
}
}
}
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.animation(.spring())
.transition(.move(edge: .leading))
}
}
struct Item: Identifiable, Hashable {
let id: UUID
var name: String
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Luckily this is actually really easy to do. Simply remove .animation(.spring()) on your Row, and wrap any changes in withAnimation(.spring()) { ... }.
So the add button will look like this:
private var AddButton: some View {
Button(action: {
withAnimation(.spring()) {
self.items.insert(Item(name: "Jeff"), at: 0)
}
}) {
Text("Add")
}
}
and your Row will look like this:
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.transition(.move(edge: .leading))
}
}
The animation must be added on the VStack with the modifier animation(.spring, value: items) where items is the value with respect to which you want to animate the view. items must be an Equatable value.
This way, you can also animate values that you receive from your view model.
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
.animation(.spring(), value: items) // <<< here
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}
I looked through different questions here, but unfortunately I couldn't find an answer. This is my code:
SceneDelegate.swift
...
let contentView = ContentView(elementHolder: ElementHolder(elements: ["abc", "cde", "efg"]))
...
window.rootViewController = UIHostingController(rootView: contentView)
ContentView.swift
class ElementHolder: ObservableObject {
#Published var elements: [String]
init(elements: [String]) {
self.elements = elements
}
}
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(elementHolder: self.elementHolder, index: index)
}
}
}
}
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("...", text: self.$elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
When pressing on the delete button, the app is crashing with a Index out of bounds error.
There are two strange things, the app is running when
1) you remove the VStack and just put the ForEach into the body of the ContentView.swift or
2) you put the code of the SecondView directly to the ForEach
Just one thing: I really need to have the ObservableObject, this code is just a simplification of another code.
UPDATE
I updated my code and changed Text to a TextField, because I cannot pass just a string, I need a connection in both directions.
The issue arises from the order in which updates are performed when clicking the delete button.
On button press, the following will happen:
The elements property of the element holder is changed
This sends a notification through the objectWillChange publisher that is part of the ElementHolder and that is declared by the ObservableObject protocol.
The views, that are subscribed to this publisher receive a message and will update their content.
The SecondView receives the notification and updates its view by executing the body getter.
The ContentView receives the notification and updates its view by executing the body getter.
To have the code not crash, 3.1 would have to be executed after 3.2. Though it is (to my knowledge) not possible to control this order.
The most elegant solution would be to create an onDelete closure in the SecondView, which would be passed as an argument.
This would also solve the architectural anti-pattern that the element view has access to all elements, not only the one it is showing.
Integrating all of this would result in the following code:
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(
element: self.elementHolder.elements[index],
onDelete: {self.elementHolder.elements.remove(at: index)}
)
}
}
}
}
struct SecondView: View {
var element: String
var onDelete: () -> ()
var body: some View {
HStack {
Text(element)
Button(action: onDelete) {
Text("delete")
}
}
}
}
With this, it would even be possible to remove ElementHolder and just have a #State var elements: [String] variable.
Here is possible solution - make body of SecondView undependable of ObservableObject.
Tested with Xcode 11.4 / iOS 13.4 - no crash
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
let value: String
init(elementHolder: ElementHolder, index: Int) {
self.elementHolder = elementHolder
self.index = index
self.value = elementHolder.elements[index]
}
var body: some View {
HStack {
Text(value) // not refreshed on delete
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Another possible solution is do not observe ElementHolder in SecondView... for presenting and deleting it is not needed - also no crash
struct SecondView: View {
var elementHolder: ElementHolder // just reference
var index: Int
var body: some View {
HStack {
Text(self.elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Update: variant of SecondView for text field (only changed is text field itself)
struct SecondViewA: View {
var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("", text: Binding(get: { self.elementHolder.elements[self.index] },
set: { self.elementHolder.elements[self.index] = $0 } ))
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
I want to show the empty view (here: Text("Please select a person.")) after the deletion of a row has happend on an iPad.
Currently: The detail view on an iPad will not get updated after the deletion of an item.
Expected: Show the empty view after the selected item gets deleted.
struct DetailView: View {
var name: String
var body: some View {
Text("Detail of \(name)")
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user)) {
Text(user)
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
}
}
NavigationView example from Hacking With Swift.
In the example below, the detail view is shown correctly after the first launch: here
But after the deletion of a row, the previously selected detail view (here: Paul) is still shown: here
As of iOS 14, deleting an element from a list in a navigation view does not seem to be supported.
The NavigationLink type takes an isActive binding, but that does not work in the case of deletion. When you receive the .onDelete callback it is too late. That NavigationLink is not in the list anymore and any change to the binding you passed to it is not going to have any effect.
The workaround is to pass a binding to the DetailView with all the elements, so that it can verify if an element is present and display some content accordingly.
struct DetailView: View {
var name: String
#Binding var users: [String]
var body: some View {
if users.contains(name) {
Text("Detail of \(name)")
} else {
EmptyView()
}
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user, users: $users)) {
Text(user)
}
}
.onDelete(perform: delete)
}
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
}
}
You can use a binding from parent view to manually trigger re-rendering (otherwise the child view won't get notified):
import SwiftUI
struct DetailView: View {
var name: String
#Binding var notifier: Int
#State var deleted: Bool = false
var body: some View {
Group {
if !deleted {
Text("Detail of \(name)")
.onChange(of: notifier, perform: { _ in deleted = true })
} else {
Text("Deleted")
}
}
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
#State private var deleteNotifier: Int = 0
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user,
notifier: $deleteNotifier)) {
Text(user)
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
deleteNotifier += 1
}
}
You can use the second choice for the list updating: index. That way it will reinforce the refreshing:
var body: some View {
NavigationView {
List {
ForEach(users.indices, id: \.self) { index in
NavigationLink(destination: DetailView(name: self.users[index])) {
Text(self.users[index])
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}
Environment
Xcode 11.2.1 (11B500)
Problem
In order to implement editable teble with TextField on SwiftUI, I used ForEach(0..<items.count) to handle index.
import SwiftUI
struct DummyView: View {
#State var animals: [String] = ["🐶", "🐱"]
var body: some View {
List {
EditButton()
ForEach(0..<animals.count) { i in
TextField("", text: self.$animals[i])
}
}
}
}
However, problems arise if the table is changed to be deleteable.
import SwiftUI
struct DummyView: View {
#State var animals: [String] = ["🐶", "🐱"]
var body: some View {
List {
EditButton()
ForEach(0..<animals.count) { i in
TextField("", text: self.$animals[i]) // Thread 1: Fatal error: Index out of range
}
.onDelete { indexSet in
self.animals.remove(atOffsets: indexSet) // Delete "🐶" from animals
}
}
}
}
Thread 1: Fatal error: Index out of range when delete item
🐶 has been removed from animals and the ForEach loop seems to be running twice, even though animals.count is 1.
(lldb) po animals.count
1
(lldb) po animals
▿ 1 element
- 0 : "🐱"
Please give me advice on the combination of Foreach and TextField.
Thanks.
Ok, the reason is in documentation for used ForEach constructor (as you see range is constant, so ForEach grabs initial range and holds it):
/// Creates an instance that computes views on demand over a *constant*
/// range.
///
/// This instance only reads the initial value of `data` and so it does not
/// need to identify views across updates.
///
/// To compute views on demand over a dynamic range use
/// `ForEach(_:id:content:)`.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
So the solution would be to provide dynamic container. Below you can find a demo of possible approach.
Full module code
import SwiftUI
struct DummyView: View {
#State var animals: [String] = ["🐶", "🐱"]
var body: some View {
VStack {
HStack {
EditButton()
Button(action: { self.animals.append("Animal \(self.animals.count + 1)") }, label: {Text("Add")})
}
List {
ForEach(animals, id: \.self) { item in
EditorView(container: self.$animals, index: self.animals.firstIndex(of: item)!, text: item)
}
.onDelete { indexSet in
self.animals.remove(atOffsets: indexSet) // Delete "🐶" from animals
}
}
}
}
}
struct EditorView : View {
var container: Binding<[String]>
var index: Int
#State var text: String
var body: some View {
TextField("", text: self.$text, onCommit: {
self.container.wrappedValue[self.index] = self.text
})
}
}
it is because editbutton is IN your list. place it ouside or better in navigationbar.