SwiftUI: Run code whenever FetchedResults changes - ios

I'm new to Core Data and struggling to find a simple solution to the below scenario.
I have a view like this:
struct SomeView: 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 {
List {
ForEach(items) { item in
// Render item view
}
}
}
}
And I want to run an arbitrary handler whenever Items has changed. The list view updates as I expect, but I'm not sure how I should trigger non-ui code (say to send an API request every time Items changes). What is the simplest way to do this?
Searching around I'm seeing answers to similar questions point to NSFetchedResultsController and articles like this, but I haven't seen a clear example of how to implement exactly what I want. If the answer truly is NSFetchedResultsController, could someone show me complete code to get this working?
EDIT: I seem to be able to achieve what I want by adding something like let _ = handleItemsChanged(items: items) inside the list view, but this seems hacky. Any cleaner mechanisms that are equally simple?

For network calls tied to view lifetime and data we use task and the async code within is cancelled and restarted when the id param changes, it is also cancelled when view disappears. To run the task when the number of items changes, you could do:
var body: some View {
List {
ForEach(items) { item in
// Render item view
}
}
.task(id: items.count) {
print("perform async network task")
// e.g. stateHoldingResult = await something
}
}
For updated items, you could create a computed var using data from the items you'd like to compare (i.e. check for changes) and supply that as the id param.

Related

Swift: (How) Can I use two different Data Model Structs in one View?

I have a UI with a list of items that the user can tap. This opens a detail view listing all details for one item.
However, I want to include 2 values in that detail view that are stored in a different collection in Firestore and that also have their own Data Model struct. The reason for this is that a different app works with that collection and I want to separate "shared" collections from the rest.
I got a function that is pulling these 2 values from Firestore done
This function is passing the values to a struct called CareData in my Data Model done
I think I set up everything correctly in the detail view, but the problem is passing that data from the tabable list to the detail view.
Let me try to explain what I did with my code:
Data Model
Just simple arrays, nothing complex.
struct Items: Decodable, Identifiable {
var id: String
var name: String
…
}
struct CareData {
var avHeight: Int
var avWater: Int
}
Detail View
struct Detail: View {
#EnvironmentObject var model: ViewModel
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
// Passing the model instances
let item: Items
let care: CareData
var body: some View {
// Some (working) code where data like item.name is shown.
...
// The 2 values using the CareData Data Model
Text("Height: \(care.avHeight)")
Text("Water: \(care.avWater)")
}
}
View Model
An important note here: The documents in my shared collection are named after the item name of the not shared collection. When I call the function, I use item.name to query to the correct document in the shared collection.
class ViewModel: ObservableObject {
#Published var itemList = [Items]()
#Published var careData = [CareData]()
...
func getCareData(item: String) {
// Some code that gets the data in Firestore from the shared collection and appends it to careData. It is working all well.
}
}
Problematic List View
Detail() in the NavigationLink is expecting me to pass 2 parameters because I am trying to pass both model instances in the Detail View. I understand to use item: item, as I am looping through all items and need to define what is needed for the Detail View. But what do I need to add for care:?
struct PlantList: View {
#EnvironmentObject var model: ViewModel
var body: some View {
ForEach(model.itemList) { item in
NavigationLink(destination: Detail(item: item, care: ?????? )) { // XCode proposing to go for "CareData", but then throws the error "Cannot convert value of type 'CareData.Type' to expected argument type 'CareData'"
Text(item.name)
}
.onAppear {
model.getCareData(item: item.name)
}
}
}
}
I tried weird things like CareDate.init(avHeight: , avWater:) and it worked when I wrote numbers straight into the code, but I need the variables to be there not some static numbers I came up with.
I hope someone can help. All I want is to show the 2 values in the detail view. This is probably a stupid issue, but I'm frustrated as I seem to not understand the very basics of Swift programming yet.
try something like this (untested of course, since I don't have your database), if you only ever want the first element:
EDIT-1: update
struct PlantList: View {
#EnvironmentObject var model: ViewModel
var body: some View {
ForEach(model.itemList) { item in
NavigationLink(destination: Detail(item: item, care: model.careData.first ?? CareData(avHeight: 0, avWater: 0))) {
Text(item.name)
}
.onAppear {
model.getCareData(item: item.name)
}
}
}
}

SwiftUI 4: navigationDestination()'s destination view isn't updated when state changes

While experiments with the new NavigationStack in SwiftUI 4, I find that when state changes, the destination view returned by navigationDestination() doesn't get updated. See code below.
struct ContentView: View {
#State var data: [Int: String] = [
1: "One",
2: "Two",
3: "Three",
4: "Four"
]
var body: some View {
NavigationStack {
List {
ForEach(Array(data.keys).sorted(), id: \.self) { key in
NavigationLink("\(key)", value: key)
}
}
.navigationDestination(for: Int.self) { key in
if let value = data[key] {
VStack {
Text("This is \(value)").padding()
Button("Modify It") {
data[key] = "X"
}
}
}
}
}
}
}
Steps to reproduce the issue:
Run the code and click on the first item in the list. That would bring you to the detail view of that item.
The detail view shows the value of the item. It also has a button to modify the value. Click on that button. You'll observe that the value in the detail view doesn't change.
I debugged the issue by setting breakpoints at different place. My observations:
When I clicked the button, the code in List get executed. That's as expected.
But the closure passed to navigationDestination() doesn't get executed, which explains why the detail view doesn't get updated.
Does anyone know if this is a bug or expected behavior? If it's not a bug, how can I program to get the value in detail view updated?
BTW, if I go back to root view and click on the first item to go to its detail view again, the closure passed to navigationDestination() get executed and the detail view shows the modified value correctly.
#NoeOnJupiter's solution and #Asperi's comment are very helpful. But as you see in my comments above, there were a few details I wasn't sure about. Below is a summary of my final understanding, which hopefully clarifies the confusion.
navigationDestination() takes a closure parameter. That closure captures an immutable copy of self.
BTW, SwiftUI takes advantage of property wrapper to make it possible to "modify" an immutable value, but we won't discuss the details here.
Take my above code as an example, due to the use of #State wrapper, different versions of ContentView (that is, the self captured in the closure) share the same data value.
The key point here is I think the closure actually has access to the up-to-date data value.
When an user clicks on the "Modify it" button, the data state changes, which causes body re-evaluted. Since navigationDestination() is a function in body, it get called too. But a modifier function is just shortcut to modifier(SomeModifier()). The actual work of a Modifier is in its body. Just because a modifier function is called doesn't necessarilly means the corresponding Modifier's body gets called. The latter is a mystery (an implementation detail that Apple don't disclose and is hard to guess). See this post for example (the author is a high reputation user in Apple Developer Forum):
In my opinion, it definitely is a bug, but not sure if Apple will fix it soon.
One workaround, pass a Binding instead of a value of #State variables.
BTW, I have a hypothesis on this. Maybe this is based on a similar approach as how SwiftUI determines if it recalls a child view's body? My guess is that it might be a design, instead of a bug. For some reason (performance?) the SwiftUI team decided to cache the view returned by navigationDestination() until the NavigationStack is re-constructed. As a user I find this behavior is confusing, but it's not the only example of the inconsistent behaviors in SwiftUI.
So, unlike what I had thought, this is not an issue with closure, but one with how modifier works. Fortunately there is a well known and robust workaround, as suggested by #NoeOnJupiter and #Asperi.
Update: an alternative solution is to use EnvironmentObject to cause the placeholder view's body get re-called whenever data model changes. I ended up using this approach and it's reliable. The binding approach worked in my simple experiments but didn't work in my app (the placeholder view's body didn't get re-called when data model changed. I spent more than one day on this but unfortunately I can't find any way to debug it when binding stopped working mysteriously).
The button is correctly changing the value. By default navigationDestination does't create a Binding relation between the parent & child making the passed values immutable.
So you should create a separate struct for the child in order to achieve Bindable behavior:
struct ContentView: View {
#State var data: [Int: String] = [
1: "One",
2: "Two",
3: "Three",
4: "Four"
]
var body: some View {
NavigationStack {
List {
ForEach(Array(data.keys).sorted(), id: \.self) { key in
NavigationLink("\(key)", value: key)
}
}
.navigationDestination(for: Int.self) { key in
SubContentView(key: key, data: $data)
}
}
}
}
struct SubContentView: View {
let key: Int
#Binding var data: [Int: String]
var body: some View {
if let value = data[key] {
VStack {
Text("This is \(value)").padding()
Button("Modify It") {
data[key] = "X"
}
}
}
}
}

Why does creating a new CloudKit item with viewContext infinitely refresh views?

I am creating an app with the stored data hosted in CloudKit. When I perform a normal swipe to delete action on any of these list items, the deleteAlert() displays (as it should). However, as long as the alert is displayed, the code continuously loops and creates an infinite number of blank Category values, adding them to the list. At the same time, the alert doesn't allow you to tap on any of the buttons normally, but if you swipe your finger across the button, you can feel lots of short haptic feedback pulses (I suspect it's also looping through creating many overlapping alerts).
import SwiftUI
struct CategoryListView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(entity: Category.entity(), sortDescriptors: [], animation: .default)
private var categories: FetchedResults<Category>
// Passed value
var accountSelection: String
#State private var deletingItem = false
#State private var deleteIndexSet: IndexSet?
#State private var showingAddView = false
var body: some View {
List {
ForEach(categories) { category in
HStack {
Button(action: {
self.showingAddView.toggle()
}) {
Text("\(category.name ?? "")")
}
}
.alert(isPresented: $deletingItem, content: deleteAlert)
}
.onDelete { indexSet in
self.deletingItem = true
self.deleteIndexSet = indexSet
}
}
}
func deleteAlert() -> Alert {
var deletedCategory = Category(context: viewContext) // removing this line causes everything to work properly
try! deletedCategory = categories[deleteIndexSet?.first ?? 0]
return Alert(
title: Text("Delete \(deletedCategory.name ?? "nil")?"),
message: Text("Deleting \(deletedCategory.name ?? "nil") will not remove all entries from that category."), // TODO: make it so that if entry does not have a category, add it to a "miscellaneous" or "other" category
primaryButton: .cancel(),
secondaryButton: .destructive(Text("Delete"), action: {print("")})
)
}
}
The reason the view is constantly refreshing boils down to the way #FetchRequest works (the array of CloudKit fetched items). Whenever the value of categories changes (as with #State and #ObservedObject etc.), the view it is attached to refreshes. The following loop will repeat endlessly in this case:
Inside of deleteAlert(), var deletedCategory = Category(context: viewContext) creates a new Category and immediately adds it to the current context (the list).
This causes the view to refresh as previously mentioned.
The value of $deletingItem is still true, so another alert will display.
When an alert is displayed, it triggers the code inside of deleteAlert().
Rinse and repeat steps 1-4 infinitely.
TL;DR Don't create ManagedObjects/ObservableObjects in a View/Body (as #lorem ipsum also pointed out).

SwiftUI load very long list in async way

I've a navigation and detail view that is sending a dictionary of date (key) and array of an struct (but it's not important the struct, it contains array of string and other stuff.
If I send a very very long dictionary, the app is freezing in the selected row and the detail appears once the List finished to load each record.
struct DetailView: View {
var selectedChat: [Date: [TextStruct]]? // you can try with [Date: [String]]?
var body: some View {
List
{
ForEach(self.selectedChat.keys.sorted(), id: \.self)
{ key in //section data
Section(header:
Text("\(self.selectedChat[key]![0].date)
{
ForEach(self.selectedChat[key]!, id:\.self) {sText in
// my ChatView(sText) ....
}
}
}
}
I've tried to load some rows at the start by adding this var
#State private var dateAndText: [Date: [TextStruct]] = [:]
substitute the code above (self.selectedChat) whit self.dateAndText and on .onAppear:
.onAppear {
if let chat = self.selectedChat {
let keysDateSorted = chat.allText.keys.sorted()
self.chatLeader = chat.chatLeader
for key in keysDateSorted.prefix(30) {
self.dateAndText[key] = chat.allText[key]
}
DispatchQueue.global(qos: .utility).async {
self.dateAndText = chat.allText
self.progressBarValue = 1
}
}
}
With this solution, once I push the row, immediately I can see the first 30 records, and it is ok, but I can't scroll until all the records are loaded. I know there is a way to load the array only if the user is scrolling at the end of the list, but I want to load all the list also if the user don't scroll at the end.
So, there is a way to load the list partially (like send and update the array each 100 records) and in async way (in order to don't freeze the display for bad user experience)?
You are almost certainly running into the issues described and fixed here by Paul Hudson.
SwiftUI is trying to animate all of the changes so if you use his hack around the issue it should work but you will lost all animations between changes of the list.
Apple devs responded to him and Dave DeLong who were discussing it on Twitter, they said that it is definitely an issue on their end that they hope to fix.
tldr of the article:
Add .id(UUID()) to the end of your List's initializer.

SwiftUI holding reference to deleted core data object causing crash

Im finding it impossible to use core data with SwiftUI, because as I pass a core data to a view observed object variable, the navigation link view will hold a reference to the object even after the view has disappeared, so as soon as I delete the object from context the app crashes, with no error messages.
I have confirmed this by wrapping the core data object variable into a view model as an optional, then set the object to nil right after the context delete action and the app works fine, but this is not a solution because I need the core data object to bind to the swift ui views and be the source of truth. How is this suppose to work? I seriously cannot make anything remotely complex with SwiftUI it seems.
I have tried assigning the passed in core data object to a optional #State, but this does not work. I cannot use #Binding because it's a fetched object. And I cannot use a variable, as swiftui controls require bindings. It only makes sense to use a #ObservedObject, but this cannot be an optional, which means when the object assigned to it gets deleted, the app crashes, because i cannot set it to nil.
Here is the core data object, which is an observable object by default:
class Entry: NSManagedObject, Identifiable {
#NSManaged public var date: Date
}
Here is a view that passes a core data entry object to another view.
struct JournalView: View {
#Environment(\.managedObjectContext) private var context
#FetchRequest(
entity: Entry.entity(),
sortDescriptors: [],
predicate: nil,
animation: .default
) var entries: FetchedResults<Entry>
var body: some View {
NavigationView {
List {
ForEach(entries.indices) { index in
NavigationLink(destination: EntryView(entry: self.entries[index])) {
Text("Entry")
}
}.onDelete { indexSet in
for index in indexSet {
self.context.delete(self.entries[index])
}
}
}
}
}
}
Now here is the view that accesses all the attributes from the core data entry object that was passed in. Once, I delete this entry, from any view by the way, it is still referenced here and causes the app to crash immediately. I believe this also has something to do with the Navigation Link initializing all destination view before they are even accessed. Which makes no sense why it would do that. Is this a bug, or is there a better way to achieve this?
I have even tried doing the delete onDisappear with no success. Even if I do the delete from the JournalView, it will still crash as the NavigationLink is still referencing the object. Interesting it will not crash if deleting a NavigationLink that has not yet been clicked on.
struct EntryView: View {
#Environment(\.managedObjectContext) private var context
#Environment(\.presentationMode) private var presentationMode
#ObservedObject var entry: Entry
var body: some View {
Form {
DatePicker(selection: $entry.date) {
Text("Date")
}
Button(action: {
self.context.delete(self.entry)
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Delete")
}
}
}
}
UPDATE
The crash is taking me to the first use of entry in the EntryView and reads Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0).. thats the only message thrown.
The only work around I can think of is to add a property to the core data object "isDeleted" and set it to true instead of trying to delete from context. Then when the app is quit, or on launch, I can clean and delete all entries that isDeleted? Not ideal, and would prefer to figure out what it wrong here, as it appears I'm not doing anything different then the MasterDetailApp sample, which seems to work.
I basically had the same issue. It seems that SwiftUI loads every view immediately, so the view has been loaded with the Properties of the existing CoreData Object. If you delete it within the View where some data is accessed via #ObservedObject, it will crash.
My Workaround:
The Delete Action - postponed, but ended via Notification Center
Button(action: {
//Send Message that the Item should be deleted
NotificationCenter.default.post(name: .didSelectDeleteDItem, object: nil)
//Navigate to a view where the CoreDate Object isn't made available via a property wrapper
self.presentationMode.wrappedValue.dismiss()
})
{Text("Delete Item")}
You need to define a Notification.name, like:
extension Notification.Name {
static var didSelectDeleteItem: Notification.Name {
return Notification.Name("Delete Item")
}
}
On the appropriate View, lookout for the Delete Message
// Receive Message that the Disease should be deleted
.onReceive(NotificationCenter.default.publisher(for: .didSelectDeleteDisease)) {_ in
//1: Dismiss the View (IF It also contains Data from the Item!!)
self.presentationMode.wrappedValue.dismiss()
//2: Start deleting Disease - AFTER view has been dismissed
DispatchQueue.main.asyncAfter(deadline: .now() + TimeInterval(1)) {self.dataStorage.deleteDisease(id: self.diseaseDetail.id)}
}
Be safe on your Views where some CoreData elements are accessed - Check for isFault!
VStack{
//Important: Only display text if the disease item is available!!!!
if !diseaseDetail.isFault {
Text (self.diseaseDetail.text)
} else { EmptyView() }
}
A little bit hacky, but this works for me.
I encountered the same issue and did not really find a solution to the root problem. But now I "protect" the view that uses the referenced data like this:
var body: some View {
if (clip.isFault) {
return AnyView(EmptyView())
} else {
return AnyView(actualClipView)
}
}
var actualClipView: some View {
// …the actual view code accessing various fields in clip
}
That also feelds hacky, but works fine for now. It's less complex than using a notification to "defer" deletion, but still thanks to sTOOs answer for the hint with .isFault!
I have had the same issue for a while, the solution for me was pretty simple:
In the View where the #ObservedObject is stored I simply put this !managedObject.isFault.
I experienced this class only with ManagedObjects with a date property, I don't know if this is the only circumstance the crash verifies.
import SwiftUI
struct Cell: View {
#ObservedObject var managedObject: MyNSManagedObject
var body: some View {
if !managedObject.isFault {
Text("\(managedObject.formattedDate)")
} else {
ProgressView()
}
}
}
After some research online, it's clear to me that this crash can be caused by many things related to optionals. For me, I realized that declaring a non-optional Core Data attribute as an optional in the NSManagedObject subclass was causing the issue.
Specifically, I have a UUID attribute id in Core Data that cannot have a default value, but is not optional. In my subclass, I declared #NSManaged public var id: UUID. Changing this to #NSManaged public var id: UUID? fixed the problem immediately.
I had the same issue recently. Adding an entity property to the view fixed it.
ForEach(entities, id: \.self) { entity in
Button(action: {
}) {
MyCell(entity: entity)
}
}
To
ForEach(entities, id: \.self) { entity in
Button(action: {
}) {
MyCell(entity: entity, property: entity.property)
}
}
I suspect that the nullable Core Data entity is the cause of the issue, where as adding a non-nil property as a var (e.g, var property: String) fixed it
I have tried all previous solutions, none worked for me.
This one, worked.
I had my list like this:
List {
ForEach(filteredItems, id: \.self) { item in
ListItem(item:item)
}
.onDelete(perform: deleteItems)
private func deleteItems(offsets: IndexSet) {
//deleting items
This was crashing.
I modified the code to this one
List {
ForEach(filteredItems, id: \.self) { item in
ListItem(item:item)
}
.onDelete { offsets in
// delete objects
}
This works fine without crashing.
For heaven's sake, Apple!
A view modifier for this (based on conditional view modifiers):
import SwiftUI
import CoreData
extension View {
#ViewBuilder
func `if`<Transform: View>(
_ condition: Bool,
transform: (Self) -> Transform
) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
extension View {
func hidingFaults(_ object: NSManagedObject) -> some View {
self.if(object.isFault) { _ in EmptyView() }
}
}
Having said that, it's worth checking you're performing CoreData operations asynchronously on the main thread, doing it synchronously can be a source of grief (sometimes, but not always).
Apple says this (and it works perfectly) :
The behavior you've reported is the result of a system bug, and should
be fixed in a future release. As a workaround, you can prevent the
race condition by wrapping your deletion logic in
NSManagedObjectContext.perform:
private func deleteItems(offsets: IndexSet) {
withAnimation {
viewContext.perform {
offsets.map { molts[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
viewContext.rollback()
userMessage = "\(error): \(error.localizedDescription)"
displayMessage.toggle()
}
}
}
You can find the full thread here https://developer.apple.com/forums/thread/668299
For me, I got this because of a force-unwrapped binding.
I used a Binding($item.someProperty)! like this: TextField("Description", text: Binding($item.someProperty)!).
This was because item is a Core Data class and hence someProperty is a String? instead of a String. Binding(*)! was a solution proposed in https://stackoverflow.com/a/59004832.
I changed the implementation to use a null coalescing operator for bindings as proposed in https://stackoverflow.com/a/61002589, now it doesn't crash anymore.
Wrap your deletion logic in a withAnimation block to prevent a crash after deleting a Core Data object. No need for isFault, isDeleted, or deferring execution in other complicated ways.
withAnimation {
context.delete(object)
do try catch etc...
}

Resources