Search code examples
swiftuiswiftui-list

Getting Index out of range when using onDelete


When I'm trying to delete an item in list I'm getting the following error:

Swift/ContiguousArrayBuffer.swift:580: Fatal error: Index out of range 2021-05-07 09:59:38.171277+0200 Section[4462:220358] Swift/ContiguousArrayBuffer.swift:580: Fatal error: Index out of range

I have been searching for a solution but none of the once I'v found seems to work on my code.

FIY this is my first app in SwiftUI or Swift for that matter so I'm totally new to this.

So if someone could help me out and explain what I need to change and why I would be so grateful :)

Here is some code I hope will help get to the bottom of the problem.

My model:

//
//  Item.swift
//  Section
//
//  Created by Niklas Peterson on 2021-03-11.
//

import Foundation

struct Item: Identifiable, Hashable, Codable {
    var id = UUID().uuidString
    var name: String
}

extension Item {
    static func getAll() -> [Item] {
        let key = UserDefaults.Keys.items.rawValue
        guard let items: [Item] = UserDefaults.appGroup.getArray(forKey: key) else {
            let items: [Item] = [.section1]
            UserDefaults.appGroup.setArray(items, forKey: key)
            return items
        }
        return items
    }

    static let section1: Item = {
        return Item(name: "Section")
    }()
}

extension Item {
    static func fromId(_ id: String) -> Item? {
        getAll().first { $0.id == id }
    }
}

UserDefaults:

//
//  UserDefaults+Ext.swift
//  Section
//
//  Created by Niklas Peterson on 2021-03-11.
//

import Foundation

extension UserDefaults {
    static let appGroup = UserDefaults(suiteName: "************ hidden ;) ")!
}

extension UserDefaults {
    enum Keys: String {
        case items
    }
}

extension UserDefaults {
    func setArray<Element>(_ array: [Element], forKey key: String) where Element: Encodable {
        let data = try? JSONEncoder().encode(array)
        set(data, forKey: key)
    }

    func getArray<Element>(forKey key: String) -> [Element]? where Element: Decodable {
        guard let data = data(forKey: key) else { return nil }
        return try? JSONDecoder().decode([Element].self, from: data)
    }
}

ContentView:

//
//  ContentView.swift
//  Section
//
//  Created by Niklas Peterson on 2021-03-11.
//

import SwiftUI

struct ContentView: View {

    @State private var items = Item.getAll()
    
    func saveItems() {
        let key = UserDefaults.Keys.items.rawValue
        UserDefaults.appGroup.setArray(items, forKey: key)
    }

    func move(from source: IndexSet, to destination: Int) {
        items.move(fromOffsets: source, toOffset: destination)
        saveItems()
    }
    
    func delete(at offsets: IndexSet) {
        items.remove(atOffsets: offsets)
        saveItems()
    }
    
    var body: some View {
        
        NavigationView {
            List {
                ForEach(items.indices, id: \.self) { index in
                    TextField("", text: $items[index].name, onCommit: {
                        saveItems()
                    })
                }
                .onDelete(perform: delete)
                .onMove(perform: move)
            }
            
            .toolbar {
                ToolbarItemGroup(placement: .primaryAction) {
                    HStack {
                        Button(action: {
                            self.items.insert(Item(name: ""), at: 0)
                        }) {
                            Image(systemName: "plus.circle.fill")
                        }
                        
                        EditButton()
                    }
                }
            }
            .navigationBarTitle("Sections")
            .listStyle(InsetGroupedListStyle())
        } 
    }
    
}


Solution

  • Note: This is no longer an issue in iOS15 since List now supports binding

    This certainly seems to be a bug within SwiftUI itself. After research, It found that the problem comes from the TextField binding. If you replace the TextField with a simple Text View, everything will work correctly. It looks like after deleting an item, the TextField binding is trying to access the deleted item, and it can not find it which causes a crash. This article helped me tackle this problem, Check out SwiftbySundell

    So to fix the problem, we’re going to have to dive a bit deeper into Swift’s collection APIs in order to make our array indices truly unique.

    1. Introduce a custom collection that’ll combine the indices of another collection with the identifiers of the elements that it contains.
    struct IdentifiableIndices<Base: RandomAccessCollection>
    where Base.Element: Identifiable {
        
        typealias Index = Base.Index
        
        struct Element: Identifiable {
            let id: Base.Element.ID
            let rawValue: Index
        }
        
        fileprivate var base: Base
    }
    
    1. Make our new collection conform to the standard library’s RandomAccessCollection protocol,
    extension IdentifiableIndices: RandomAccessCollection {
        var startIndex: Index { base.startIndex }
        var endIndex: Index { base.endIndex }
        
        subscript(position: Index) -> Element {
            Element(id: base[position].id, rawValue: position)
        }
        
        func index(before index: Index) -> Index {
            base.index(before: index)
        }
        
        func index(after index: Index) -> Index {
            base.index(after: index)
        }
    }
    
    1. Make it easy to create an IdentifiableIndices instance by adding the following computed property to all compatible base collections (that is, ones that support random access, and also contains Identifiable elements):
    extension RandomAccessCollection where Element: Identifiable {
        var identifiableIndices: IdentifiableIndices<Self> {
            IdentifiableIndices(base: self)
        }
    }
    
    1. Finally, let’s also extend SwiftUI’s ForEach type with a convenience API that’ll let us iterate over an IdentifiableIndices collection without also having to manually access the rawValue of each index:
    extension ForEach where ID == Data.Element.ID,
                            Data.Element: Identifiable,
                            Content: View {
        init<T>(
            _ data: Binding<T>,
            @ViewBuilder content: @escaping (T.Index, Binding<T.Element>) -> Content
        ) where Data == IdentifiableIndices<T>, T: MutableCollection {
            self.init(data.wrappedValue.identifiableIndices) { index in
                content(
                    index.rawValue,
                    Binding(
        get: { data.wrappedValue[index.rawValue] },
        set: { data.wrappedValue[index.rawValue] = $0 }
    )
                )
            }
        }
    }
    
    1. Finally, in your ContentView, you can change the ForEach into:
    ForEach($items) { index, item in
        TextField("", text: item.name, onCommit: {
            saveItems()
        })
    }
    

    The @Binding property wrapper lets us declare that one value actually comes from elsewhere, and should be shared in both places. When deleting the Item in our list, the array items changes rapidly which in my experience causes the issue. It seems like SwiftUI applies some form of caching to the collection bindings that it creates, which can cause an outdated index to be used when subscripting into our underlying Item array — which causes the app to crash with an out-of-bounds error.