SwiftUI: Cannot dismiss sheet after creating CoreData object - ios

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)
}
}
}

Related

LazyVGrid child views don't update bindings unless they're part of the first rendered group

My app uses a LazyVGrid to display a collection of child views and I'm using a $selection bound to the child views to ensure that only one child view is selected at once. This "selection" just displays an overlay on the child view with buttons (so when a child view is selected, an overlay shows up, and if user selects a second child view, the overlay disappears from first child view and appears on second child view).
I simplified the problem down to the example shown below, where the $selection merely changes the text to red and ensures only one child view has red text at a time. This code is based on the default Xcode project.
Problem
The child view bindings only get updated if they're part of the first 8 child views, the first 8 that are rendered. Scrolling to further child views and selecting them causes the $selection variable to update properly in the main view, but only the first 8 child views register this change.
Video of Problem
https://youtube.com/shorts/7zBjwZwPkYA
This video shows that long pressing on the child views only works for the first 8. If you long press on view 12, for example, child views 1-8 register the changed $selection but no others do.
Code
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.index, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
#State var selection: UUID?
#State var index: Int16 = 1
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: [(GridItem(.adaptive(minimum: 160)))], content: {
ForEach(items) { item in
ItemView(selection: $selection, item: item)
.aspectRatio(1.0, contentMode: .fill)
.padding(20)
}
.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.index = index
index += 1
newItem.id = UUID()
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)")
}
}
}
}
struct ItemView: View {
#Binding var selection: UUID?
#ObservedObject var item: Item
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
if selection == item.id {
Text(String(item.index))
.font(.largeTitle)
.foregroundColor(.red)
} else {
Text(String(item.index))
.font(.largeTitle)
}
Spacer()
}
Spacer()
}
.background(Color.secondary)
.onLongPressGesture {
if selection == item.id {
selection = nil
} else {
selection = item.id
}
}
.onChange(of: selection, perform: { _ in
print("Child view \(String(item.index)) says selection changed")
})
}
}

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 [non-optional property]?

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
}

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)
}
}

Xcode default Core Data project template has empty List and no Button to add items [duplicate]

This question already has an answer here:
Unmodified SwiftUI / Core Data default app code does not run in simulator?
(1 answer)
Closed 1 year ago.
With Xcode 12.5 (for iOS 14.5) I create a new iOS project and set the "Core Data" checkbox:
As you can see in the above screenshot the List in the preview Canvas is filled.
But when I run the project in Simulator or at the real iPhone 11, then the List is empty (which is to be expected) and there is no "Edit" or "Add Item" button displayed, to add any new items.
I have not modified the project except trying other colors (to make sure that the missing button is not black on black). Also I tried adding a List with hardcoded array of strings and it worked:
Here is my ContentView.swift, why there is no Button for adding or editing items displayed?
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
private var items2:[String] = (1...200).map { number in "Item \(number)" }
var body: some View {
VStack {
Text("FetchJsonEscapable").foregroundColor(.orange)
List {
ForEach(items2, id: \.self) { item in
Text("Item \(item)")
.foregroundColor(.green)
}
/*
ForEach(items) { item in
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
.foregroundColor(.blue)
}
.onDelete(perform: deleteItems)
*/
}
.toolbar {
EditButton()
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
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(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
}()
UPDATE:
I have followed the advice by Scott (thanks!) and added NavigationView.
Now the "Edit" button is visible, but there is still no way to add new items:
When toolbar items are expected to be added to the top of an iOS view, they won't appear unless the current view is part of a NavigationView hierarchy.
If you were to add a NavigationView to your ContentView's body you would find the buttons appear:
var body: some View {
NavigationView {
// rest of the body
}
}

Resources