Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Index out of bounds when using Realm with SwiftUI

I've been playing around with SwiftUI a bit and have been writing a small meal planner/todo list style app. I was able to get Realm working with SwiftUI and wrote a small wrapper object to get Realm change notifications to update the UI. This works great for adding items and the UI gets properly updated. However, when deleting an item using swipe to delete or other methods, I get an index out of bounds error from Realm.

Here's some code:

ContentView:

    struct ContentView : View {

    @EnvironmentObject var userData: MealObject
    @State var draftName: String = ""
    @State var isEditing: Bool = false
    @State var isTyping: Bool = false

    var body: some View {
        List {
            HStack {
                TextField($draftName, placeholder: Text("Add meal..."), onEditingChanged: { editing in
                    self.isTyping = editing
                },
                onCommit: {
                    self.createMeal()
                    })
                if isTyping {
                    Button(action: { self.createMeal() }) {
                        Text("Add")
                    }
                }
            }
            ForEach(self.userData.meals) { meal in
                NavigationLink(destination: DetailMealView(ingredientsObject: IngredientsObject(meal: meal))) {
                    MealRow(name: meal.name)
                }
            }.onDelete(perform: delete)
        }
        .navigationBarTitle(Text("Meals"))
    }

    func delete(at offsets: IndexSet) {
        guard let index = offsets.first else {
            return
        }
        let mealToDelete = userData.meals[index]
        Meal.delete(meal: mealToDelete)
        print("Meals after delete: \(self.userData.meals)")
    }
}

And the MealObject wrapper class:

final class MealObject: BindableObject {
    let willChange = PassthroughSubject<MealObject, Never>()

    private var token: NotificationToken!
    var meals: Results<Meal>

    init() {
        self.meals = Meal.all()
        lateInit()
    }

    func lateInit() {
        token = meals.observe { changes in
            self.willChange.send(self)
        }
    }

    deinit {
        token.invalidate()
    }
}

I was able to narrow the issue down to

   ForEach(self.userData.meals) { meal in
      NavigationLink(destination: DetailMealView(ingredientsObject: IngredientsObject(meal: meal))) {
      MealRow(name: meal.name)
     }
   }

It seems like self.userData.meals isn't updating, even though when checking the change notification in MealObject it shows the correct deletions and the meals variable in MealObject correctly updates as well.

*Edit: Also to add, the deletion does actually happen and when launching the app again, the deleted item is gone. It seems like SwiftUI gets confused about the state and tries to access the deleted item after willChange gets called.

*Edit 2: Found one workaround for now, I implemented a method checking whether the object currently exists in Realm:

    static func objectExists(id: String, in realm: Realm = try! Realm()) -> Bool {
        return realm.object(ofType: Meal.self, forPrimaryKey: id) != nil
    }

Called like this

            ForEach(self.userData.meals) { meal in
                if Meal.objectExists(id: meal.id) {
                    NavigationLink(destination: DetailMealView(ingredientsObject: IngredientsObject(meal: meal))) {
                        MealRow(name: meal.name)
                    }
                }
            }.onDelete(perform: delete)

Not very pretty but it gets the job done until I find the real cause for the crash.

like image 757
Luca Avatar asked Jul 23 '19 09:07

Luca


People also ask

What is out of bounds error in Swift programming?

If index does not satisfy the aforementioned condition, the notorious out of bounds or index out of range exception is raised and the program crashes. I would conjecture that it is among the most frequent error causes in Swift programs.

What is index out of range in Swift?

Index must be an integer between 0 and n-1, where n is the number of elements and the size of the array. If index does not satisfy the aforementioned condition, the notorious out of bounds or index out of range exception is raised and the program crashes. I would conjecture that it is among the most frequent error causes in Swift programs.

What are the data structures in Swift?

The array is probably the most widely used data structure in Swift. It organizes data in a way that each component can be picked at random and is quickly accessible. To be able to mark an individual element, an index is introduced. Index must be an integer between 0 and n-1, where n is the number of elements and the size of the array.

Does Swift override subscripts?

The final implementation overloads a subscript and is common for all Swift collections that use integer index, such as arrays and ranges. Thanks for reading! If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.


Video Answer


1 Answers

With Realm Cocoa 5.0, you now just need to freeze whatever collection you pass to ForEach:

ForEach(self.userData.meals.freeze()) { meal in
    NavigationLink(destination: DetailMealView(ingredientsObject: IngredientsObject(meal: meal))) {
        MealRow(name: meal.name)
    }
}

Pre 5.0 answer:

How SwiftUI's ForEach appears to work is that after getting sent objectWillChange() it iterates over the collection it was previously given and the new collection it is given, and then diffs them. This only works properly for immutable collections, but Realm collections are mutable and live-updating. In addition, the objects in the collection also change, so the obvious workaround of copying the collection into an Array doesn't full work either.

The best workaround I've come up with is something like the following:

// helpers
struct ListKey {
    let id: String
    let index: Int
}
func keyedEnumeration<T: Object>(_ results: Results<T>) -> [ListKey] {
    return Array(results.value(forKey: "id").enumerated().map { ListKey(id: $0.1 as! String, index: $0.0) })
}

// in the body
ForEach(keyedEnumeration(self.userData.meals), id: \ListKey.id) { key in
    let meal = self.userData.meals[key.index]
    NavigationLink(destination: DetailMealView(ingredientsObject: IngredientsObject(meal: meal))) {
        MealRow(name: meal.name)
    }
}

The idea here is to extract the array of primary keys up front and give that to SwiftUI so that it can diff them without having to touch Realm, rather than trying to read from the "old" collection that's actually been updated.

A future version of Realm will have support for frozen collections/objects that'll be a better fit for the semantics that SwiftUI wants, but no ETA on that.

like image 190
Thomas Goyne Avatar answered Oct 18 '22 01:10

Thomas Goyne