onDisappear if Core Data instance exists - swift

TL;DR version:
I am using .onDisappear method in SwiftUI in my app having Core Data. How to make this method conditional if a particular instance of my Entity exists? (Specifically, based on existence of the item details I am looking at, self.item)
--
MORE DETAILS:
Simple example of the problem
Here is a very simple app with a list of items. One Entity is: Item. It has 2 attributes: date (Date), name (String). Please create this Entity if you will use the code below.
There are 2 SwiftUI views:
ContentView.swift
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
var fetchRequest: FetchRequest<Item>
var items: FetchedResults<Item> { fetchRequest.wrappedValue }
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) {item in
NavigationLink(destination: DetailsView(item: item)) {
Text("\(item.name ?? "default item name")")
}
}
}
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button(action: {
for number in 1...3 {
let item = Item(context: self.moc)
item.date = Date()
item.name = "Item \(number)"
do {
try self.moc.save()
}catch{
print(error)
}
}
}) {
Text("Add 3 items")
}
)
}
}
init() {
fetchRequest = FetchRequest<Item>(entity: Item.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Item.name, ascending: true)
])
}
}
DetailsView.swift
import SwiftUI
struct DetailsView: View {
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var item: Item
var body: some View {
VStack {
Text("\(item.name ?? "default item name")")
}
.navigationBarItems(
trailing:
Button(action: {
self.moc.delete(self.item)
do {
try self.moc.save()
self.presentationMode.wrappedValue.dismiss()
}catch{
print(error)
}
}) {
Text("Delete")
.foregroundColor(.red)
}
)
//This is causing problems. It tries to save new item name, even if I just deleted this item.
.onDisappear {
self.item.name = "new name"
try? self.moc.save()
}
}
}
What this example app does is:
Creates 3 items in Core Data for testing purposes.
When you tap on one item, you will go to DetailsView.
I want to be able to Delete and item from the DetailsView.
I am aware of this great tutorial from Paul Hudson: https://www.hackingwithswift.com/books/ios-swiftui/deleting-from-a-core-data-fetch-request on how to add Delete on the view with list. And it works great. But this is not what I need for my app.
THE PROBLEM
When I delete an item from the DetailsView, I am getting an error:
020-06-26 19:02:24.534275+0700 list-delete[57017:18389643] [error]
error: Mutating a managed object 0x80784bd2555f567e
x-coredata://99AA316D-D816-49BB-9C09-943F307C3174/Item/p41
(0x600002f03d40) after it has been removed from its context. CoreData:
error: Mutating a managed object 0x80784bd2555f567e
x-coredata://99AA316D-D816-49BB-9C09-943F307C3174/Item/p41
(0x600002f03d40) after it has been removed from its context.
I have eventually figured out that the problem is with .onDisappear method. Which is triggered even after deleting the item.
The main purpose of using .onDisappear here was to make it available to edit an item in DetailsView and .onDisappear would save the changes in CoreData. And this alone works great too.
How I fixed the bug
I have added a #State private var itemExists = true. When I tap Delete button, self.itemExists changes to false. And I have added an IF statement to .onDisappear using this itemExists variable. Updated code of DetailsView looks like this:
import SwiftUI
struct DetailsView: View {
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var item: Item
#State private var itemExists = true
var body: some View {
VStack {
Text("\(item.name ?? "default item name")")
}
.navigationBarItems(
trailing:
Button(action: {
self.moc.delete(self.item)
self.itemExists = false
do {
try self.moc.save()
self.presentationMode.wrappedValue.dismiss()
}catch{
print(error)
}
}) {
Text("Delete")
.foregroundColor(.red)
}
)
.onDisappear {
if self.itemExists {
self.item.name = "new name"
try? self.moc.save()
}
}
}
}
Now the question is if this additional variable is necessary? Or is there a simpler way of checking if Core Data's Entity instance exists?
I have tried another IF:
if self.item != nil {
But this just gave me a warning:
Comparing non-optional value of type 'Item' to 'nil' always returns
true

isDeleted will be true on any managed object that has been marked for deletion. You can check that instead of tracking with a separate variable. You might want to only save the context on onDisappear instead of after the deletion, though, as the documentation seems to suggest it's intended for use between deleting and saving.

Related

How do I instantly load core data in a view after closing my popup?

The point of this app is to use core data to permanently add types of fruit to a list. I have two views: ContentView and SecondScreen. SecondScreen is a pop-up sheet. When I input a fruit and press 'save' in SecondScreen, I want to immediately update the list in ContentView to reflect the type of fruit that has just been added to core data as well as the other fruits which have previously been added to core data. My problem is that when I hit the 'save' button in SecondScreen, the new fruit is not immediately added to the list in ContentView. Instead, I have to restart the app to see the new fruit in the list.
Here is the class for my core data:
class CoreDataViewModel: ObservableObject {
let container: NSPersistentContainer
#Published var savedEntities: [FruitEntity] = []
init() {
container = NSPersistentContainer(name: "FruitsContainer")
container.loadPersistentStores { (description, error) in
if let error = error {
print("Error with coreData. \(error)")
}
}
fetchFruits()
}
func fetchFruits() {
let request = NSFetchRequest<FruitEntity>(entityName: "FruitEntity")
do {
savedEntities = try container.viewContext.fetch(request)
} catch let error {
print("Error fetching. \(error)")
}
}
func addFruit(text: String) {
let newFruit = FruitEntity(context: container.viewContext)
newFruit.name = text
saveData()
}
func saveData() {
do {
try container.viewContext.save()
fetchFruits()
} catch let error {
print("Error saving. \(error)")
}
}
}
Here is my ContentView struct:
struct ContentView: View {
//sheet variable
#State var showSheet: Bool = false
#StateObject var vm = CoreDataViewModel()
#State var refresh: Bool
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button(action: {
showSheet.toggle()
}, label: {
Text("Add Fruit")
})
List {
ForEach(vm.savedEntities) { entity in
Text(entity.name ?? "NO NAME")
}
}
}
.navigationTitle("Fruits")
.sheet(isPresented: $showSheet, content: {
SecondScreen(refresh: $refresh)
})
}
}
}
Here is my SecondScreen struct:
struct SecondScreen: View {
#Binding var refresh: Bool
#Environment(\.presentationMode) var presentationMode
#StateObject var vm = CoreDataViewModel()
#State var textFieldText: String = ""
var body: some View {
TextField("Add fruit here...", text: $textFieldText)
.font(.headline)
.padding(.horizontal)
Button(action: {
guard !textFieldText.isEmpty else { return }
vm.addFruit(text: textFieldText)
textFieldText = ""
presentationMode.wrappedValue.dismiss()
refresh.toggle()
}, label: {
Text("Save")
})
}
}
To try and solve this issue, I've created a #State boolean variable called 'refresh' in ContentView and bound it with the 'refresh' variable in SecondScreen. This variable is toggled when the user hits the 'save' button on SecondScreen, and I was thinking that maybe this would change the #State variable in ContentView and trigger ContentView to reload, but it doesn't work.
In your second screen , change
#StateObject var vm = CoreDataViewModel()
to
#ObservedObject var vm: CoreDataViewModel
then provide for the instances that compiler will ask for
hope it helps
You need to use #FetchRequest instead of #StateObject and NSFetchRequest. #FetchRequest will call body to update the Views when the fetch result changes.

SwiftUI and CoreData : passing FetchResults to a DetailView and updating them

code updated to reflect answer below. Would still love to skip $newName and do something like TextField("New name...", text: $pcard.name) but that throws an error.
long time searcher first time asker. StackOverflow has been invaluable.
Looking for some guidance on best practices to update single "rows" of a core data entity in a Detail View. Clearly I am going about this wrong because I can't get it to work, and there doesn't seem to be a lot of discussion out there about this specific seemingly simple use case.
My app has a standard MasterList/Detail structure, and I would like to bind updates made in the detail view to the entity that was fetched in the List View.
Code below, It's the passing/binding/updating part that I am interested in. Any help appreciated, either conceptually (here's how its designed to work) or practical (do it this way).
Thanks.
import SwiftUI
struct ListView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
entity: PCard.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \PCard.name, ascending: true)],
animation: .default
) private var pcards: FetchedResults<PCard>
var body: some View {
NavigationView {
List {
ForEach(pcards) { pcard in
NavigationLink(destination: DetailView(pcard: pcard)) {
Text(pcard.name ?? "no name specified")
}
}
}
.navigationTitle("My Cards")
}
}
}
struct DetailView: View {
#Environment(\.managedObjectContext) private var viewContext
#State private var newName: String = ""
#ObservedObject var pcard: PCard
var body: some View {
VStack {
Text(pcard.name ?? "no name specified").font(.largeTitle)
TextField("New name...", text: $newName).textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
pcard.name = newName
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}) {
//label
Label("Update", systemImage: "sparkles")
}
Spacer()
}
.padding()
}
}
struct ListView_Previews: PreviewProvider {
static var previews: some View {
ListView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
There is no need to create #State newName. #ObservedObject already do what are you looking for. Please read this article:
https://purple.telstra.com/blog/swiftui---state-vs--stateobject-vs--observedobject-vs--environme

SwiftUI ObservedObject does not updated when new items are added from a different view

I have a view model which handles the loading of new data once the app launches and when a new item is added. I have an issue when it comes to showing new items when are added from a new view, for example, a sheet or even a NavigationLink.
View Model
class GameViewModel: ObservableObject {
//MARK: - Properties
#Published var gameCellViewModels = [GameCellViewModel]()
var game = [GameModel]()
init() {
loadData()
}
func loadData() {
if let retrievedGames = try? Disk.retrieve("games.json", from: .documents, as: [GameModel].self) {
game = retrievedGames
}
self.gameCellViewModels = game.map { game in
GameCellViewModel(game: game)
}
print("Load--->",gameCellViewModels.count)
}
func addNew(game: GameModel){
self.game.append(game)
saveData()
loadData()
}
private func saveData() {
do {
try Disk.save(self.game, to: .documents, as: "games.json")
}
catch let error as NSError {
fatalError("""
Domain: \(error.domain)
Code: \(error.code)
Description: \(error.localizedDescription)
Failure Reason: \(error.localizedFailureReason ?? "")
Suggestions: \(error.localizedRecoverySuggestion ?? "")
""")
}
}
}
View to load the ViewModel data, leading add button is able to add and show data but the trailing which opens a new View does not update the view. I have to kill the app to get the new data.
NavigationView{
List {
ForEach(gameList.gameCellViewModels) { gameList in
CellView(gameCellViewModel: gameList)
}
}.navigationBarTitle("Games Played")
.navigationBarItems(leading: Text("Add").onTapGesture {
let arr:[Int] = [1,2,3]
self.gameList.addNew(game: GameModel(game: arr))
}, trailing: NavigationLink(destination: ContentView()){
Text("Play")
})
}
Play View sample
#State var test = ""
var body: some View {
VStack(){
TextField("Enter value", text: $test)
.keyboardType(.numberPad)
Button(action: {
var arr:[Int] = []
arr.append(Int(self.test)!)
self.gameList.addNew(game: GameModel(game: arr))
}) {
Text("Send")
}
}
}
To what I can see the issue seems to be here:
List {
// Add id: \.self in order to distinguish between items
ForEach(gameList.gameCellViewModels, id: \.self) { gameList in
CellView(gameCellViewModel: gameList)
}
}
ForEach needs something to orientate itself on in order to know what elements are already displayed and which are not.
If this did not solve the trick. Please update the code you provided to Create a minimal, Reproducible Example

How to change toggle on just one Core Data item using ForEach in SwiftUI?

How to change just one toggle in a list without subviews? I know how to make it work if I extract Subview from everything inside ForEach, but how to do it on one view?
I cannot use subview, because I have a problem later if I want to delete an item from this subview. It gives me some errors I don't know how to fix, so I am trying to make it on one view where I don't have this error.
The code for the list is quite simple:
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
var fetchRequest: FetchRequest<Item>
var items: FetchedResults<Item> { fetchRequest.wrappedValue }
#State private var doneStatus : Bool = false
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) {item in
HStack {
Text("\(item.name ?? "default item name")")
Spacer()
Toggle(isOn: self.$doneStatus) {
Text("Done")
}
.labelsHidden()
.onAppear {
self.doneStatus = item.done
}
.onTapGesture {
self.doneStatus.toggle()
item.done.toggle()
try? self.moc.save()
}
}
}
.onDelete(perform: removeItem)
}
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button(action: {
for number in 1...3 {
let item = Item(context: self.moc)
item.date = Date()
item.name = "Item \(number)"
item.done = false
do {
try self.moc.save()
}catch{
print(error)
}
}
}) {
Text("Add 3 items")
}
)
}
}
init() {
fetchRequest = FetchRequest<Item>(entity: Item.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Item.name, ascending: true)
])
}
func removeItem(at offsets: IndexSet) {
for offset in offsets {
let item = items[offset]
moc.delete(item)
}
try? moc.save()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
//Test data
let testItem = Item.init(context: context)
testItem.date = Date()
testItem.name = "Item name"
testItem.done = false
return ContentView().environment(\.managedObjectContext, context)
}
}
I am using 1 Core Data Entity: Item. With 3 attributes: date (Date), done (Boolean), name (String).
PROBLEM
When I tap on one toggle, all other toggles change as well.
I couldn't find a solution working with Core Data. I guess maybe I should use .id instead of .self? And add another attribute to my entity: id (UUID). But I tried to do it and failed.
I will appreciate any kind of help.
You bound all Toggle to one state... so
remove this
// #State private var doneStatus : Bool = false
bind Toggle dynamically to currently iterating item (note: .onAppear/.onTapGesture not needed anymore)
Toggle(isOn: Binding<Bool>(
get: { item.done },
set: {
item.done = $0
try? self.moc.save()
})) {
Text()
}
.labelsHidden()

Why saving managed object context changes isDeleted value?

I am writing an iOS app using SwiftUI and Core Data. I am very new to Core Data and try to understand something:
Why try self.moc.save() changes self.item.isDeleted from true to false?
It happens after I delete a Core Data object (isDeleted changes to true), but later saving managed object context changes it to false. Why is that?
Here is an example:
ContentView.swift
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
var fetchRequest: FetchRequest<Item>
var items: FetchedResults<Item> { fetchRequest.wrappedValue }
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) {item in
NavigationLink(destination: DetailsView(item: item)) {
Text("\(item.name ?? "default item name")")
}
}
}
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button(action: {
for number in 1...3 {
let item = Item(context: self.moc)
item.date = Date()
item.name = "Item \(number)"
do {
try self.moc.save()
}catch{
print(error)
}
}
}) {
Text("Add 3 items")
}
)
}
}
init() {
fetchRequest = FetchRequest<Item>(entity: Item.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Item.name, ascending: true)
])
}
}
DetailsView.swift
import SwiftUI
struct DetailsView: View {
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var item: Item
var body: some View {
VStack {
Text("\(item.name ?? "default item name")")
}
.navigationBarItems(
trailing:
Button(action: {
self.moc.delete(self.item)
print(self.item.isDeleted)
self.presentationMode.wrappedValue.dismiss()
print(self.item.isDeleted)
do {
try self.moc.save()
print(self.item.isDeleted)
}catch{
print(error)
}
}) {
Text("Delete")
.foregroundColor(.red)
}
)
.onDisappear {
print(self.item.isDeleted)
if !self.item.isDeleted {
print(self.item.isDeleted)
self.item.name = "new name"
print(self.item.isDeleted)
do {
try self.moc.save()
}catch{
print(error)
}
}
}
}
}
What I expected will happen:
self.moc.delete(self.item) will delete an object and mark self.item.isDeleted as true.
try self.moc.save will save moc
if !self.item.isDeleted will prevent code execution if item is deleted (without this, I was getting an error: Mutating a managed object (...) after it has been removed)
It didn't work. I have added print(self.item.isDeleted) on few lines and breakpoints on those lines to check what exactly happens.
What happened is this:
self.moc.delete(self.item) deleted an object and marked self.item.isDeleted as true.
try self.moc.save saved moc and...
self.item.isDeleted changed to be false
if !self.item.isDeleted didn't prevent code execution, because isDeleted was false at this point.
Is it a bug? Or I don't understand the life cycle of Core Data objects and isDeleted changes as it should?
Why try self.moc.save() changes self.item.isDeleted from true to
false? It happens after I delete a Core Data object (isDeleted changes
to true), but later saving managed object context changes it to false.
Why is that?
It behaves as documented - returns true before save, and not in other cases
Here is snapshot of Apple documentation for NSManagedObject:
Summary
A Boolean value that indicates whether the managed object will be
deleted during the next save. Declaration
var isDeleted: Bool { get } Discussion
true if Core Data will ask the persistent store to delete the object
during the next save operation, otherwise false. It may return false
at other times, particularly after the object has been deleted. The
immediacy with which it will stop returning true depends on where the
object is in the process of being deleted. If the receiver is a fault,
accessing this property does not cause it to fire.