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

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

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: 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