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

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

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

Cannot dismiss multiple Detail-Views after CoreData save in SwiftUI

I have an app with multiple Detail Views that use as source instances of NSManagedObject.
Imagine View 1 fetches all persistent instances of Entity Item with #FetchRequeset and displays them in a List View.
When clicking on one item in the list, a second View (Detail-View) is opened.
If a user navigates from View 1 to View 2 a persistence instance is shared with the View 2.
View 2 has a NavigationLink zu another Detail-View View3. View 2 also shares the persistence instance with View 3.
On View3 a user can click on a Button ("DELETE this Item"), which initiates the deletion of the CoreData persistence instance and a save of the NSManagedObjectContext.
After saving I want that all my Detail-Views (View2 and View3) are dismissed, and a user returns back to the entry view, View 1 (List-View).
My app listens for Notifications of NSManagedObjectContextDidSave and sets Bindings for isActive on NavigationLink instances to false. Instead of working with Bindings to dismiss the DetailViews, I also tried to use the presentationMode environment Variable with self.presentationMode.wrappedValue.dismiss().
However, it does not work to dismiss View 2 and View 3. After saving the NSManagedObjectContext just View 3 gets dismissed and View 2 is stuck and cannot be dismissed.
I hope someone also faces this issue and knows how to solve it. I appreciate any support! Thank you!
1. UPDATE on 13th of January 2020: Let me clarify my post here: My notification closures are executed and Bindings representing whether my Views are presented are also updated. However, my only question here is why my View 2 is not dismissed and stuck, after View 3 has been dismissed. Am I understanding something wrong? My example code is quite big, but for reproducing the issue it needs at least 3 Views (i.e. 2 Detail-Views). With just 1 List and 1 Detail-View the issue will not occur.
The following GIF shows the issue.
I built an example project for reproducibility. First, I created a new Xcode Project with Core Data enabled. I modified the existing Item entity just a little bit, by adding a name attribute of type String. I currently use Xcode 12.2 and iOS 14.2.
This is the SwiftUI code for View 1, View 2 and View 3:
import SwiftUI
struct View1: View {
#FetchRequest(entity: Item.entity(), sortDescriptors: [])
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(self.items, id: \.self) { item in
View1_Row(item: item)
}
}.listStyle(InsetGroupedListStyle())
.navigationTitle("View 1")
}
}
}
struct View1_Row: View {
#ObservedObject var item: Item
#State var isView2Presented: Bool = false
var body: some View {
NavigationLink(
destination: View2(item: item, isView2Presented: $isView2Presented),
isActive: $isView2Presented,
label: {
Text("\(item.name ?? "missing item name") - View 2")
})
.isDetailLink(false)
}
}
struct View2: View {
#Environment(\.managedObjectContext) var moc
#ObservedObject var item: Item
#Binding var isView2Presented: Bool
var body: some View {
List {
Text("Item name: \(item.name ?? "item name unknown")")
View2_Row(item: item)
Button(action: { isView2Presented = false }, label: {Text("Dismiss")})
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("View 2")
.onReceive(NotificationCenter.default.publisher(for: Notification.Name(rawValue: "Reset"))) { _ in
print("\(Self.self) inside reset notification closure")
self.isView2Presented = false
}
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave, object: self.moc),
perform: dismissIfObjectIsDeleted(_:))
}
private func dismissIfObjectIsDeleted(_ notification: Notification) {
if notification.isDeletion(of: self.item) {
print("\(Self.self) dismissIfObjectIsDeleted Dismiss view after deletion of Item")
isView2Presented = false
}
}
}
struct View2_Row : View {
#ObservedObject var item: Item
#State private var isView3Presented: Bool = false
var body: some View {
NavigationLink("View 3",
destination: View3(item: item,
isView3Presented: $isView3Presented),
isActive: $isView3Presented)
.isDetailLink(false)
}
}
struct View3: View {
#Environment(\.managedObjectContext) var moc
#ObservedObject var item: Item
#State var isAddViewPresented: Bool = false
#Binding var isView3Presented: Bool
var body: some View {
Group {
List {
Text("Item name: \(item.name ?? "item name unknown")")
Button("DELETE this Item") {
moc.delete(self.item)
try! moc.save()
/*adding the next line does not matter:*/
/*NotificationCenter.default.post(Notification.init(name: Notification.Name(rawValue: "Reset")))*/
}.foregroundColor(.red)
Button(action: {
NotificationCenter.default.post(Notification.init(name: Notification.Name(rawValue: "Reset")))
}, label: {Text("Reset")}).foregroundColor(.green)
Button(action: {isView3Presented = false }, label: {Text("Dismiss")})
}
}
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave, object: self.moc),
perform: dismissIfObjectIsDeleted(_:))
.onReceive(NotificationCenter.default.publisher(for: Notification.Name(rawValue: "Reset"))) { _ in
print("\(Self.self) inside reset notification closure")
self.isView3Presented = false
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("View 3")
.toolbar {
ToolbarItem {
Button(action: {isAddViewPresented.toggle()}, label: {
Label("Add", systemImage: "plus.circle.fill")
})
}
}
.sheet(isPresented: $isAddViewPresented, content: {
Text("DestinationDummyView")
})
}
private func dismissIfObjectIsDeleted(_ notification: Notification) {
if notification.isDeletion(of: self.item) {
print("\(Self.self) dismissIfObjectIsDeleted Dismiss view after deletion of Item")
isView3Presented = false
}
}
}
This is the code of my Notification extension -- used for checking if the NSManagedObject is deleted:
import CoreData
extension Notification {
/*Returns whether this notification is about the deletion of the given `NSManagedObject` instance*/
func isDeletion(of managedObject: NSManagedObject) -> Bool {
guard let deletedObjectIDs = self.deletedObjectIDs
else {
return false
}
return deletedObjectIDs.contains(managedObject.objectID)
}
private var deletedObjectIDs: [NSManagedObjectID]? {
guard let deletedObjects =
self.userInfo?[NSManagedObjectContext.NotificationKey.deletedObjects.rawValue]
as? Set<NSManagedObject>,
deletedObjects.count > 0
else {
return .none
}
return deletedObjects.map(\.objectID)
}
}
This is the code of my app #main entry point. It generates example data on app start and my app has 2 Tabs.:
import SwiftUI
import CoreData
#main
struct SwiftUI_CoreData_ExApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
TabView {
View1().tabItem {
Image(systemName: "1.square.fill")
Text("Tab 1")
}
View1().tabItem {
Image(systemName: "2.square.fill")
Text("Tab 2")
}
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.onAppear(perform: {
let moc = persistenceController.container.viewContext
/*Create persistence instances in Core Data database for test and reproduction purpose*/
print("Preparing test data")
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: Item.entity().name!)
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
try! moc.execute(deleteRequest)
for i in 1..<4 {
let item = Item(context: moc)
item.name = "Item \(i)"
}
try! moc.save()
})
}
}
}

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