SwiftUI list references deleted NSManagedObjects - ios

When using #FetchRequest to populate a List, SwiftUI will attempt to access a deleted NSManagedObject even after it has been deleted from the store (for example, via swipe action).
This bug is very common and easy to reproduce: simply create a new Xcode project with the default SwiftUI + CoreData template. Then replace ContentView with this code:
struct ContentView: View {
#Environment(\.managedObjectContext) private var moc
#FetchRequest(sortDescriptors: []) private var items: FetchedResults<Item>
var body: some View {
List {
ForEach(items) { item in
ItemRow(item: item)
}
.onDelete {
$0.map{items[$0]}.forEach(moc.delete)
try! moc.save()
}
}
.toolbar {
Button("Add") {
let newItem = Item(context: moc)
newItem.timestamp = Date()
try! moc.save()
}
}
}
}
struct ItemRow: View {
#ObservedObject var item: Item
var body: some View {
Text("\(item.timestamp!)")
}
}
Add a few items to the list, then swipe to delete a row: the app will crash. An ItemRow is attempting to draw with the now-deleted Item.
A common workaround is to wrap the whole subview in a fault check:
struct ItemRow: View {
#ObservedObject var item: Item
var body: some View {
if !item.isFault {
Text("\(item.timestamp!)")
}
}
}
But this is a poor solution and has side effects (objects can be faults when not deleted).
This answer suggests that wrapping the deletion in viewContext.perform{} would work, but the crash still occurs for me.
Any better solutions/workarounds out there?

This does seem to be the result of the ObservedObject switching to a fault as part of the delete operation, while timestamp! is force unwrapped. The force unwrap works for the full object but not the fault.
An approach which does seem to work is to remove the .onDelete action at the ForEach level, and replace it with a swipeAction at the row level:
ForEach(items) { item in
ItemRow(item: item)
.swipeActions {
Button("Delete", role: .destructive) {
viewContext.delete(item)
try? viewContext.save()
}
}
}
In any respect, it shows that even with a simple NSManagedObject like Item, relying on force unwrapping of attributes comes with risks.

Related

SwiftUI List Row Context Menu Label Based on Index Array Property

Searched around and have not found an answer. Believe I know what the issue is, but not sure how to resolve it.
I have a swiftUI list that displays a context menu when a certain type of row is selected(not all rows qualify, this works as it should. When the context menu is displayed, the label is generated by the index of the array populating the lists object property.
The context menu selection performs performs a task that should result in the context menu label changing. And sometimes it works, other times it does not. This is resolved by scrolling that particular row off screen and scrolling back too it (has to be far enough away). The Object array is from a singleton data store passed as an environment object.
I believe this is related to the size of the array and the data being lazy loaded in swiftUI lists. I also would use the List selection property for this, but the context menu being populated by the row does not update the lists selected row.
A snippet example of my code is below.
#EnvironmentObject var singletonStore: MyObjectStore
#State private var selectedRow: Int?
var body: some View {
VStack {
List(singletonStore.myArray.indices, id: \.self, selection: $selectedRow) { index in
LazyVGrid(columns: gridColumns) {
ItemGridView(item: $singletonStore.myArray[index], opacityOffset: getRowOpacity(index: index))
}
.contextMenu {
if singletonStore.myArray[index].thisDate == nil {
if singletonStore.myArray[index].thisNeedsDone > 0 {
Button {
selectedRow = index
//these functions will add or remove a users id or initials to the appropriate property, and this updates values in my list view.
if singletonStore.myArray[index].id != nil {
//do this
} else {
//do that
}
} label: {
Label{
//This is where my issue is - even though the items in the list view are updating, the label of the context menu is not updating until the row is reloaded
Text(singletonStore.myArray[index].initials != nil ? "This Label" : "That Label") } icon: {
Image(systemName: "aqi.medium")
}
}
}
}
} //context menu close
} // list close
.listStyle(PlainListStyle())
}
}
My closures may be off, but its because I modified the code significantly on this platform to make is easier to follow. Nothing removed would affect this issue.
if there was a way to have opening the context menu update the lists selected row, that would solve the issue and I could use the selected row for the index of the singletonStores array objects property, but I may be approaching the problem from thinking the index is incorrect when the actual issue is the context menu is not being reloaded with the environment objects new information. Any help is always appreciated!
EDIT:
After some tinkering I further found that the issue must be related to the context menu itself not refreshing its data. I separated my views and used a #Viewbuilder function to return the needed view for the button - however it still does not refresh the context menus data.
EDIT 2:
currently (and subject to change) my SingletonStore class loads the data from another network class and publishes that data in the form of an array
final class SingletonStore: ObservableObject {
static private(set) var shared = singletonStore()
static func reset() {
shared = StagingStore()
}
#Published var myArray: [CustomObject] = []
private func getMyData() {
//uses other class and methods to retrieve and set data
//works and updates view on refresh
}
}
My View is called from a different View that is just a Tab bar controller, that code looks as follows:
struct ContainerView: View {
#StateObject var singletonStore = SingletonStore.shared
var body: some View {
TabView{
GenericView().environmentObject(singletonStore)
.tabItem {
Label("This View", systemImage: "camera.metering.matrix")
}
}
}
I have created a demo project inspired by your sample code above. In order to reproduce the issue I had to improvise some.
List is binded to a collection, when any of the item change, view hierarchy gets built and changes reflects.
Code for reference is as follow. Notice I am calling a view model method from button action, which makes a change in the collection that is binded.
import Foundation
class ContentViewModel: ObservableObject {
#Published var myArray: [Item] = []
init() {
for i in 0...100 {
let obj = Item(id: UUID().uuidString, thisDate: Date.now, thisNeedsDone: i, initials: "That Label")
myArray.append(obj)
}
}
func updateTheRow(item: Item) {
if let indexOfItem = myArray.firstIndex(where: { obj in
obj.id == item.id
})
{
myArray[indexOfItem] = item
}
}
}
struct Item: Identifiable, Equatable, Hashable {
var id: String
var thisDate: Date?
var thisNeedsDone: Int
var initials: String?
}
import SwiftUI
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
let columns = [
GridItem(.adaptive(minimum: 80))
]
var body: some View {
VStack {
List(viewModel.myArray, id: \.self) { item in
LazyVGrid(columns: columns) {
VStack{
Text("My bad")
}
}
.contextMenu {
if item.thisDate != nil {
if item.thisNeedsDone > 0 {
Button {
//these functions will add or remove a users id or initials to the appropriate property, and this updates values in my list view.
var modifiedItem = item
modifiedItem.initials = "Modified Label"
viewModel.updateTheRow(item: modifiedItem)
} label: {
Label{
//This is where my issue is - even though the items in the list view are updating, the label of the context menu is not updating until the row is reloaded
Text(item.initials!) } icon: {
Image(systemName: "aqi.medium")
}
}
}
}
} //context menu close
} // list close
.listStyle(PlainListStyle())
}
}
}

How to retrieve and show data on screen in SwiftUI?

I am storing simple data in core data which i want to retrieve and show on screen, but I am not sure how and where should I write that code to show it on screen as I am always get out of bound error..
Also not all data is saving at time its only saving when i scroll til bottom
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.title, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
#StateObject private var viewModel = HomeViewModel()
var body: some View {
GeometryReader { geometry in
NavigationView {
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible()),
count: UIDevice.current.userInterfaceIdiom == .pad ? 4 : 2)) {
ForEach(viewModel.results, id: \.self) {
let viewModel = ResultVM(model: $0)
NavigationLink(destination: {
DetailView(data: viewModel.trackName)
}, label: {
SearchResultRow(resultVM: viewModel, coreDM: PersistenceController())
})
}
}
}
}
.onAppear(perform: {
viewModel.performSearch()
})
}
}
}
struct SearchResultRow: View {
let resultVM: ResultVM
let coreDM: PersistenceController
var body: some View {
HStack {
RoundedRectangle(cornerRadius: 16).fill(.yellow)
.frame(maxWidth: .infinity).aspectRatio(1, contentMode: .fit)
.overlay(Text(resultVM.trackName)) // want to show data from Core data here
}.padding()
.background(Color.red)
.onAppear(perform: {
coreDM.saveResult(title: resultVM.trackName)
})
}
}
Data showing from API which same data storing in CoreData
Method to save and retrieve (which is working fine)
func saveResult(title: String) {
let result = Item(context: container.viewContext)
result.title = title
do {
try container.viewContext.save()
}
catch {
print("error")
}
}
func getResult() -> [Item] {
let fetchRequest: NSFetchRequest<Item> = Item.fetchRequest()
do {
return try container.viewContext.fetch(fetchRequest)
}
catch {
return []
}
}
API call
import Foundation
import CoreData
class HomeViewModel: ObservableObject {
#Published var results = [ResultItem]()
func performSearch() {
guard let gUrl = URL(
string: "https://api.artic.edu/api/v1/artworks"
) else { return }
Task {
do {
let (data, _) = try await URLSession.shared.data(from: gUrl)
let response = try JSONDecoder()
.decode(ResponseData.self, from: data)
DispatchQueue.main.async { [weak self] in
self?.results = response.data ?? []
}
} catch {
print("*** ERROR ***")
}
}
}
}
Remove the view model we don't use view model objects in SwiftUI. The View struct stores the view data and does dependency tracking, and the property wrappers give it external change tracking features like an object. If you add an actual object on top you'll get consistency bugs that SwiftUI was designed to eliminate. So first remove this:
#StateObject private var viewModel = HomeViewModel()
You already have the fetch request property wrapper and as I said that gives the View change tracking, so when the items change (e.g. an item is added, removed or moved), the body will be called, so simply use it in your ForEach like this:
ForEach(items) { item in
NavigationLink(destination: {
DetailView(item: item)
}, label: {
SearchResultRow(item: item)
})
}
Then use #ObservedObject in your subview, this gives the View the ability to track changes to the item so body will be called when the item's properties change, like item.title. Do the same in the DetailView.
struct SearchResultRow: View {
#ObservedObject var item: Item
It seems to me you are trying to download and cache data in Core Data. That is unnecessary since you can simply set a NSURLCache on the NSURLSession and it will automatically cache it for you, it even works if offline. However if you really want to cache it yourself in Core Data then the architecture should be your UI fetches it from Core Data and then you have another object somewhere responsible for syncing down the remote data into Core Data. Normally this would be at the App struct level not in the View appearing. When the data is saved to core data the managed object context context will be changed which the #FetchRequest is listening for and body will be called.

SwiftUI: View does not fully update, lags behind when its #ObservedObject is updated from parent?

I have the following code:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#State var selectedItemMOID:NSManagedObjectID?
var body: some View {
NavigationView {
VStack {
if let selectedItemMOID = selectedItemMOID, let item = viewContext.object(with: selectedItemMOID) as? Item {
ItemView(item: item)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
if let item = addItem() {
selectedItemMOID = item.objectID
}
} label: {
Image(systemName: "plus")
.font(.system(size: 14, weight: .bold))
}
}
}
}
}
private func addItem() -> Item? {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
return newItem
} catch {
return nil
}
}
}
}
struct ItemView: View {
#ObservedObject var item:Item
#State var itemIDString:String = ""
var body: some View {
List {
VStack {
Text(itemIDString)
Text("the item: \(item.objectID)")
}
.onChange(of: item, perform: { newValue in
updateIdString()
})
.onAppear {
updateIdString()
}
}
}
func updateIdString() {
itemIDString = "\(item.objectID)"
}
}
Problem:
when I press the plus button (top right), ItemView partially updates. The bottom Text view updates immediately, the top one lags behind.
See this behavior:
You can see the item id at top is always one behind (except first render).
So, for example, hit plus (after first time) and top is p45, bottom, p46. And so on.
Why is the top Text lagging one behind?
I added the onChange as well, thinking that would sync things up, but it didn't. Without it, the top Text never updates after first item is set.
.onChange(of: item, perform: { newValue in
updateIdString()
})
I need to understand why this is happening so I can understand how to properly build views in general that use #ObservedObjects, so things update immediately when the ObservedObject is set to a new one.
So, to be clear my question is how to fix this code so that the entire ItemView updates immediately when item is set on it and why this is happening at a high level so I can avoid this same type of mistake going forward.
To address Asperi's approach (thanks for your answer!):
His answers fixes this specific case, but, I am not sure how I would go about using that pattern. For example, if ItemView observes multiple objects, lets say a Car and a User entity, then .id(car.id) would only react to setting a new Car on ItemView and not react to setting a new User necessarely. So, then, what is the point of #ObservedObject in this context. With the suggested .id(car.id) it would only react to one and not the other (or many more if ItemView had like 6 #ObservedObjects...).
Does this mean a view should only have one #ObservedObject? I wouldn't think so. Am I wrong?
Just trying to understand how to scale the id(item.id) pattern if it's the agreeable answer. I don't understand yet.
I think it is due to reuse/caching, in short rendering optimisation, try
List {
// content is here
}
.id(item) // << this one !!
It's because you didn't use the newValue, change it to:
.onChange(of: item, perform: { newValue in
itemIDString = "\(newValue.objectID)"
})

How to delete data from SwiftUI List and Realm

The following code properly displays all of the 'Users' from Realm database in a SwiftUI List. My issue is deleting records when I swipe a row.
When I swipe a row and tap the delete button, I immediately get an uncaught exception error, the List does not update but I know the right item gets deleted from the Realm database since the next time I run the app the selected record doesn't show up.
Here is my code.
SwiftUI Code
import RealmSwift
struct ContentView: View {
#State private var allUsers: Results<User> = realm.objects(User.self)
var body: some View {
VStack{
Text("Second Tab")
List{
ForEach(allUsers, id:\.self) { user in
HStack{
Text(user.name)
Text("\(user.age)")
}
}.onDelete(perform: deleteRow)
}
}
}
private func deleteRow(with indexSet: IndexSet){
indexSet.forEach ({ index in
try! realm.write {
realm.delete(self.allUsers[index])
}
})
}
}
Realm Model
import RealmSwift
class User:Object{
#objc dynamic var name:String = ""
#objc dynamic var age:Int = 0
#objc dynamic var createdAt = NSDate()
#objc dynamic var userID = UUID().uuidString
override static func primaryKey() -> String? {
return "userID"
}
}
ERROR
Terminating app due to uncaught exception 'RLMException', reason: 'Index 4 is out of bounds (must be less than 4).'
Of course, the 4 changes depending on how many items are in the Realm database, in this case, I had 5 records when I swiped and tapped the delete button.
My expectation was that the List was going to update every time the allUsers #State variable changes, I know my issue is not fully understanding how binding works.
What am I doing wrong?
My expectation was that the List was going to update every time the
allUsers #State variable changes
It is correct, but state was not changed... The following should work
private func deleteRow(with indexSet: IndexSet){
indexSet.forEach ({ index in
try! realm.write {
realm.delete(self.allUsers[index])
}
})
self.allUsers = realm.objects(User.self) // << refetch !!
}
Note: the below is just assigning initial state value
#State private var allUsers: Results<User> = realm.objects(User.self)
This is a very common bug when working with Realm. It happens in ''traditional'' view controllers too.
The only solid solution I found, and it has considerably improved applications stability is to always use interface items instead of realm object.
Moreover, in real life, we need to do some processes, load avatar images from server, add some check boxes, selections or whatever, and we often don't need all properties of objects to be displayed. Somehow, it's a MVVM approach.
By the way, you can call realm.write before the loop, not sure, but I think it's good to minimise context switching.
Using this technique, either with SwiftUI or UIViewControllers, will solve RLM crashes for good.
struct ContentView: View {
struct UserItem: Hashable {
var id: String
var name: String
var age: Int
}
private var allUsers: Results<User> = realm.objects(User.self)
#State private var userItems: [UserItem] = []
func updateItems() {
userItems = allUsers.map {
UserItem(id: $0.userID, name: $0.name, age: $0.age)
}
}
var body: some View {
VStack {
Text("Second Tab")
List{
ForEach(userItems, id:\.self) { user in
HStack{
Text(user.name)
Text("\(user.age)")
}
}
.onDelete(perform: deleteRow)
}
}.onAppear() {
updateItems()
}
}
private func deleteRow(with indexSet: IndexSet){
try! realm.write {
indexSet.forEach {
realm.delete(self.allUsers[$0])
}
}
updateItems()
}
}

SwiftUI: #FetchRequest For Core Data is not Working

I am using a simple coredata model and I have an Entity that is called Movements and it has 4 fields, date, category, description, amount.
This the view:
struct TransactionsView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(entity: Movements.entity(), sortDescriptors: []) var transactions:FetchedResults<Movements>
#State private var showAddModal = false;
var body: some View {
VStack {
List {
ForEach(transactions, id: \.id) { t in
Text(t.desc ?? "No Cat")
}
}
}
}
}
struct TransactionsView_Previews: PreviewProvider {
static var previews: some View {
return TransactionsView()
}
}
I used the same code for another model, categories, and it worked. For some reason here the previ keeps crashing with a generic error, I have no idea that says basically that my app has crashed.
I thought that maybe Transaction could be a reserved word and renamed to Movements but still the same error.
Is there also a way to debug the #FetchRequest to see the data returned?

Resources