SwiftUI: deleting Managed Object from cell view crashes app [non-optional property]? - ios

I posted this question:
SwiftUI: deleting Managed Object from cell view crashes app?
as I worked on trying to understand why it crashes, I tried to change the model Item to have timestamp NON-optional:
extension Item {
#nonobjc public class func fetchRequest() -> NSFetchRequest<Item> {
return NSFetchRequest<Item>(entityName: "Item")
}
#NSManaged public var timestamp: Date
}
extension Item : Identifiable {
}
As Asperi pointed out, using this:
if let timestamp = item.timestamp {
Text(timestamp, formatter: itemFormatter)
}
does fix the crash when timestamp is optional.
However, this is just some code I am testing to understand how to properly build my views. I need to use models that do not have optional properties, and because of that I can't resort to use the provided answer to the question I linked to above.
So this question is to address the scenario where my CellView uses a property that is not optional on a ManagedObject.
If I were to put this code straight in the ContentView without using the CellView it does not crash. This does not crash:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text(item.timestamp, formatter: itemFormatter)
} label: {
// CellView(item: item)
HStack {
Text(item.timestamp, formatter: itemFormatter) // <<- CRASH ON DELETE
Button {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
}
} label: {
Text("DELETE")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach { item in
viewContext.delete(item)
}
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
However, I need to know how to keep the CellView, use #ObservedObject and make this work. In this case is not a big deal to do that, but in real cases where the CellView is much bigger this approach does not scale well. Regardless, why would using #ObservedObject in a separate view be wrong anyway?
So, why is the app crashing when the timestamp is NOT optional in the model?
Why is the view trying to redraw the CellView for an Item that was deleted? How can this be fixed?
FOR CLARITY I AM POSTING HERE THE NEW CODE FOR THE NON-OPTIONAL CASE, SO YOU DON'T HAVE TO GO BACK AND LOOK AT THE LINKED QUESTION AND THEN CHANGE IT TO NON-OPTIONAL. THIS IS THE CODE THAT CRASHES IN ITS ENTIRETY:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text(item.timestamp, formatter: itemFormatter)
} label: {
CellView(item: item)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach { item in
viewContext.delete(item)
}
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
struct CellView: View {
#Environment(\.managedObjectContext) private var viewContext
#ObservedObject var item:Item
var body: some View {
HStack {
Text(item.timestamp, formatter: itemFormatter) // <<- CRASH ON DELETE
Button {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
}
} label: {
Text("DELETE")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}

The explicit handling is needed in anyway, because of specifics of CoreData engine. After object delete it can still be in memory (due to kept references), but it becomes in Fault state, that's why code autogeneration always set NSManagedObject properties to optional (even if they are not optional in model).
Here is a fix for this specific case. Tested with Xcode 13.4 / iOS 15.5
if !item.isFault {
Text(item.timestamp, formatter: itemFormatter) // << NO CRASH
}

Related

SwiftUI NavigationLink iOS16 selected tint?

I have the following code:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
#State private var selectedMainItem:Item?
var body: some View {
NavigationSplitView {
List(selection: $selectedMainItem) {
ForEach(items) { item in
NavigationLink(value: item) {
Text("Item \(item.id.debugDescription)")
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
NavigationStack {
if let selectedMainItem = selectedMainItem {
Text("Item at \(selectedMainItem.timestamp!, formatter: itemFormatter)")
} else {
Text("Please select a main item")
}
}
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
I simply created it using the default core data SwiftUI app template. Then transitioned it to using NavigationSplitView and the new NavigationLink.
I want to change the color of the selected state of the cell when I select an item in the list. Now it looks like this:
I want to make it so the blue selection is actually red.
Is there a way to change the selection color from the default to any color I want?
you could try this approach, using listRowBackground and selectedMainItem with item.id
List(selection: $selectedMainItem) {
ForEach(items) { item in
NavigationLink(value: item) {
Text("Item \(item.id.debugDescription)")
}
// -- here
.listRowBackground(selectedMainItem != nil
? (selectedMainItem!.id == item.id ? Color.red : Color.clear)
: Color.clear)
}
.onDelete(perform: deleteItems)
}

SwiftUI: deleting Managed Object from cell view crashes app?

I have the following code, I just modified the sample SwiftUI project Xcode creates for you.
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text(item.timestamp!, formatter: itemFormatter)
} label: {
CellView(item: item)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach { item in
viewContext.delete(item)
}
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
struct CellView: View {
#Environment(\.managedObjectContext) private var viewContext
#ObservedObject var item:Item
var body: some View {
HStack {
Text(item.timestamp!, formatter: itemFormatter) // <<- CRASH ON DELETE
Button {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
}
} label: {
Text("DELETE")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
Item is defined like this:
extension Item {
#nonobjc public class func fetchRequest() -> NSFetchRequest<Item> {
return NSFetchRequest<Item>(entityName: "Item")
}
#NSManaged public var timestamp: Date?
}
extension Item : Identifiable {
}
When I delete an item either via swipe or by pressing the delete button the app crashes. If I do away with the CellView and put all the code directly in the navigation link... it does not crash.
How can I fix this while keeping the separate CellView?
What I've learned so far:
If I change:
#ObservedObject var item:Item
to:
#State var item:Item
The crash goes away. Any idea?
Try to avoid force-unwrap optionals as much as possible and use conditions (view builder allows now such constructions)
HStack {
if let timestamp = item.timestamp {
Text(timestamp, formatter: itemFormatter)
}

SwiftUI: master details default to item when not on iPhone?

The following code:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
} label: {
Text(item.timestamp!, formatter: itemFormatter)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
Renders to this:
I would like the details to default to the first item in the list by default when the app first runs, but only if the details is showing, so, on an iPhone for example I don't want it to be pushed automatically.
How can the details screen automatically show the first item in the list and not say "Select an item" when master-details is showing and not just master (left view)?

SwiftUI: #StateObject deinit NOT called?

I have the following code:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
} label: {
// Text(item.timestamp!, formatter: itemFormatter)
ItemCellView(model: ItemCellViewModel(item: item))
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
struct ItemCellView: View {
#StateObject var model:ItemCellViewModel
var body: some View {
Text(model.item.timestamp!, formatter: itemFormatter)
.foregroundColor(.blue)
}
}
class ItemCellViewModel: ObservableObject {
#Published var item:Item
init(item:Item) {
self.item = item
}
deinit {
print("ItemCellViewModel EDINIT \(self)")
}
}
It draws this:
PROBLEM:
ItemCellViewModel deinit is NOT called after I swipe to delete the item.
Can someone tell me why the ItemCellViewModel sticks around even after the ItemCellView is gone?
This is a simplified version of a codebase I am working in. I need that model to go away when the view is "deleted" by the user. Why is SwiftUI keeping ItemCellViewModel around??
View is not deleted in a fact (just removed from visible area) because List caches some number of views (visible area + ~2) and StateObject is persistent storage of view which keeps its state. So observed behavior is by-design.
Get rid of the view model object, in SwiftUI we use value types and the View struct is the view model that SwiftUI uses to create and update UIKit/AppKit views on our behalf. Learn this in SwiftUI Essentials WWDC 2019. Also you can’t nest an ObservableObject inside an ObservableObject either. To fix this change the ItemCellView to this:
struct ItemCellView: View {
#ObservedObject var item: Item
var body: some View {
Text(item.timestamp!, formatter: itemFormatter)
.foregroundColor(.blue)
}
}

SwiftUI: Cannot dismiss sheet after creating CoreData object

I am trying to create a very basic app with two views. The main view displays CoreData objects in a NavigationView and has a toolbar button to open an "add item" modal sheet. The "add item" sheet should be able to insert a new CoreData object and dismiss itself. What I am observing is that the modal sheet can be dismissed as long as it doesn't insert a CoreData object, but once it does interact with CoreData, all my attempts to dismiss the sheet no longer work. Also, when the sheet creates a CoreData object, I get the following error:
[Presentation] Attempt to present <TtGC7SwiftUI22SheetHostingControllerVS_7AnyView: 0x7f8fcbc49a10> on <TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier_: 0x7f8fcbd072c0> (from <TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVVS_22_VariadicView_Children7ElementGVS_18StyleContextWriterVS_19SidebarStyleContext__: 0x7f8fbbd0ba80>) which is already presenting <TtGC7SwiftUI22SheetHostingControllerVS_7AnyView: 0x7f8fcbd310b0>.
Most of the code I am using is Xcode's own boilerplate code, plus common patterns borrowed from the web. Can you please help me to debug?
To reproduce, in Xcode 12, create a new SwiftUI iOS app using CoreData. Replace the ContentView.swift with this:
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
#State var showingSheet: Bool = false
var body: some View {
NavigationView {
List {
//Text("static text")
ForEach(items) { item in
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
}
.onDelete(perform: deleteItems)
}
.navigationTitle("List of items")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: {
showingSheet = true
}) {
Text("Open sheet")
}
.sheet(isPresented: $showingSheet) {
MySheetView(isPresented: $showingSheet)
.environment(\.managedObjectContext, viewContext)
}
}
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
Create a new MySheetView.swift:
struct MySheetView: View {
#Environment(\.managedObjectContext) private var viewContext
#Binding var isPresented: Bool
var body: some View {
NavigationView {
Form {
Button(action: {
self.isPresented = false
}) {
Text("Dismiss this sheet")
}
Button(action: {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}) {
Text("Add Item")
}
}
}
}
}
When running, if you tap "Open sheet", you can then tap "Dismiss this sheet" and it works. But if you tap "Open sheet" and then tap "Add Item", the error is printed and the "Dismiss this sheet" button stops working. (You can still swipe down to dismiss the sheet.)
I have also tried the presentationmode.wrappedvalue.dismiss() method with no success.
I have found that if you comment out the ForEach loop on the main view and uncomment the Text("static text"), then adding items on the sheet no longer prints out the error or breaks dismissing of the sheet. So the problem somehow is related to presenting the data on the main sheet. But obviously I do need to actually display the data, and it seems like a really common and basic pattern. Am I doing it wrong?
Your .sheet is placed on the ToolbarItem. Just change it on the NavigationView and it works.
Here is the body of the ContentView
var body: some View {
NavigationView {
List {
//Text("static text")
ForEach(items, id:\.self) { item in
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
}
.onDelete(perform: deleteItems)
}
.navigationTitle("List of items")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: {
showingSheet = true
}) {
Text("Open sheet")
}
}
}
.sheet(isPresented: $showingSheet) {
MySheetView(isPresented: $showingSheet)
.environment(\.managedObjectContext, viewContext)
}
}
}

Resources