I have a one-to-many relationship in core data of plan -> recipe. plan.recipes is of type NSSet?, so I have created custom NSManagedObject classes with computed properties to convert these into arrays, adding an extra property recipesArray:
public var recipesArray: [Recipe] {
let set = recipes as? Set<Recipe> ?? []
return set.sorted {
$0.wrappedName < $1.wrappedName
}
}
I then display this list in a View using a ForEach, using the recipesArray property. A subview of this view calls plan.addToRecipes(recipe: Recipe), to add a new object to the relationship. I then save.
The issue is, the ForEach in the parent view does not react to this addition. If I refresh the view by navigating away, then the new recipe is shown, but the View is not automatically updated when the new recipe is added.
Does anyone know how to do this? Should I be using the original recipes property instead of this custom array one?
You need to make another FetchRequest for Recipes using a predicate that equals a given plan, e.g. something like this:
struct RecipesView: View {
var fetchRequest: FetchRequest<Recipe>
var recipes: FetchedResults<Recipe> { fetchRequest.wrappedValue }
init(plan: Plan) {
let sortDescriptors = ...
let predicate = NSPredicate(format: "plan = %#", plan)
fetchRequest = FetchRequest(sortDescriptors: sortDescriptors, predicate: predicate, animation: .default)
}
var body: some View {
ForEach(recipes) { recipe in
RecipeView(recipe: recipe) // it's important to not access recipe's properties (firing a fault) until inside a sub-view's body.
}
}
}
Note: There is currently a bug in FetchRequest that results in body always invoked even when this View is init with the same plan and thus same FetchRequest. This is because the FetchRequest struct inits a new object inside it every time, causing it and consequently this View to appear as changed to SwiftUI. I reported this bug so hopefully they fix it. You could workaround it in the meantime with a wrapper View that takes the plan as a let so body won't be called.
Related
I'm new-ish to Swift, and trying to better understand Core Data with some (I thought!) simple projects.
I'm trying to use an #FetchRequest property wrapper to list child-items which belong to a given parent. This works for most cases, but not when the to-parent relationship is the only thing which would cause a child item to be displayed after a new child item is added.
The problem I'm facing seems to boil down to any filtering done with UUID values using predicates in FetchRequest decorators not being updated the same way other attributes are. The minimal example I've (with an edit) constructed shows just the behaviour when using a UUID for the predicate, and avoids any relationship complications.
My question is, is there a 'nice' way to make this behaviour (UUID in a predicate driving a FetchRequest which updates views properly) work, or something I'm missing, like registering one of the parts as an observed item, or something I'm doing triggering an update at the wrong time?
I have produced an example which illustrates the puzzle I'm having. Note: I'm using iOS 14.4 and Xcode 12.4 I appreciate these are not current, but my machine does not support an OS past Catalina, so I'm stuck here until I can afford a new one.
My Core Data model is as follows:
The Item entity has two attributes (uuid, of type UUID) and an Int16 attribute (number).
The app has two buttons: + to add a new item with a random number value and a fixed UUID of 254DC... and 5 which adds a item, with a number value of 5 (and the same, fixed, UUID).
The predicate[s] I'm using to drive this list filter Item entities which either:
match the hard-coded UUID value*
or have a number attribute of 5
The problem is that when adding (using the + button) a new entity which should show up in this view just because its uuid property matches, it does not appear. It does appear if you quit the app, and return to it (this proves that the predicate does indeed 'match' the UUID values).
After tapping 5 (now showing two rows with number 5, as expected):
After tapping + twice (unchanged view, should have two new rows showing):
If we exit the app and then reopen it, the added Items (300 and 304 in this case) are only now visible:
Different phrasing of the question: Why do these updates work unreliably on UUID, but perfectly with the number (Int16-typed) attribute?
Working as expected:
Deleting Item entities works as expected, regardless of whether they were in the list because of number == 5 or their UUID match
Notes:
I've used viewContext.perform liberally, as one other glitch with an update not displaying was resolved by using that.
I had already (based on this SO question/answer) already tried wrapping each of the elements of the rows as an ObservedObject. This was also suggested in a comment. Have updated the example code to do that, which has no effect on this behaviour. (The elements are not in the items collection produced by the FetchRequest, so do not get wrapped by this code.)
There is lots of try! all over the place. This is just to keep the example code as small as possible.
View code
import SwiftUI
import CoreData
struct ItemList: View {
#Environment(\.managedObjectContext) private var viewContext
static let filterUUID = "254DC821-F654-4D45-BC94-CEEE12A428CB"
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.number, ascending: true)],
predicate: NSPredicate(format: "number == %# OR uuid == %#", NSNumber(5), filterUUID))
var items : FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in ItemRowView(item: item) }
.onDelete(perform: deleteItems)
}
.navigationTitle(Text(items.isEmpty ? "No items" : "Item count: \(items.count)"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: addRandom) {
Label("Add Item", systemImage: "plus")
}
Button(action: addFive) {
Label("Add Five", systemImage: "5.circle.fill")
}
}
}
}
}
private func addItem(number: Int) {
viewContext.perform {
let newItem = Item(context: viewContext)
newItem.uuid = UUID(uuidString: ItemList.filterUUID)
newItem.number = Int16(number)
try! viewContext.save()
}
}
private func addRandom() { addItem(number: Int.random(in: 10...500)) }
private func addFive() { addItem(number: 5) }
private func deleteItems(offsets: IndexSet) {
viewContext.perform {
offsets.map { items[$0] }.forEach(viewContext.delete)
try! viewContext.save()
}
}
}
struct ItemRowView : View {
#ObservedObject var item : Item
var body : some View {
HStack {
Text("\(item.number)")
.font(.largeTitle)
Text(item.uuid?.uuidString.prefix(8) ?? "None")
}
}
}
What have I tried already?
I've spent the best part of two days trawling documentation, StackOverflow, and various blogs, and while there have been several things which seemed on first look to be exactly the answer I was looking for, the simpler I got my example, the more confusing it became.
This started out as a similar issue related to parent-child fetching, but I've narrowed the actual problem I'm facing down to something smaller, which can be demonstrated with the single entity with an UUID, and no relationships.
Supporting code (App and PersistenceController)
import SwiftUI
import CoreData
#main
struct minimalApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ParentListView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "minimal")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
I believe the weird behaviour is due to your predicate, which is being evaluated in two different ways. When you first run the app, or after closing and restarting, the predicate is parsed and passed to SQLite (as a WHERE clause in a SELECT statement). Thereafter, when you add new items, the predicate is evaluated directly in memory (ie in the NSManagedObjectContext) - no need to involve SQLite.
In your predicate, you are comparing a UUID type attribute with a String value - which fails. Even if the string representation of the UUID attribute is the same as the string you compare it with, the context sees them as different and regards the new object as failing the predicate. Hence the view is not updated.
However, SQLite is much more tolerant of type mismatches. (I'm guessing CoreData's implementation of UUID attributes is to store the string representation - but that's just a guess). So when you quit and restart, the fetch is processed by SQLite which regards the new object as meeting the predicate and accordingly includes it in the results.
To get the correct behaviour, I think you need your predicate to compare the UUID attribute with the UUID which has the correct string representation:
NSPredicate(format: "number == %# OR uuid == %#", NSNumber(5), UUID(uuidString: ItemList.filterUUID))
My first attempt was to set the property wrapper's nsPredicate dynamic property in .onAppear, but if the view gets reinitialized for any reason, the predicate set by .onAppear is lost. So I went back to using the init pattern.
Here is what I thought should work (but doesn't) and something that does work (however mysteriously):
struct ItemEditView : View {
var item: Item
#FetchRequest(fetchRequest: Attribute.fetchRequestAllInOrder(), animation: .default)
var attributes: FetchedResults<Attribute>
init(item: Item) {
self.item = item
// This is how I would have expected to set the dynamic property at View initialization, however
// it crashes on this statement
attributes.nsPredicate = NSPredicate(format: "item == %#", item)
// Not sure why the below works and the above does not.
// It seems to work as desired, however it receives this runtime warning:
// "Context in environment is not connected to a persistent store coordinator"
$attributes.projectedValue.wrappedValue.nsPredicate = NSPredicate(format: "item == %#", item)
}
var body: some View {
List {
ForEach(attributes) { attribute in
Text("Name:\(attribute.name) Order:\(attribute.order)")
}
}
}
}
So, why does the first assignment to nsPredicate crash? And after commenting out that first one, why does the second one work? Is the warning message a real issue? Is there a better way to do this? It seems like there should be a simple way to do this using the new dynamic properties.
It turns out that (re)setting the nsPredicate property of the #FetchRequest in onAppear is really the way to go. However, to make this work, you must make sure that your View's init() method does not get called again after onAppear is called. There are several valuable hints on how to accomplish this in the Demystify SwiftUI session from this year's WWDC (WWDC21-10022).
I am doing a CoreData fetch inside my init() method of the View. I am not using FetchRequest in SwiftUI, as I need a predicate based on a parameter which is send to the View aswell. Using that parameter in the #FetchRequest will cause an error, as the variable has not been initialized.
I am doing following fetch Request inside the init()
//FetchRequest
let fetch : NSFetchRequest<Article>
self.articleRows = [Article]()
fetch = Article.fetchRequest() as! NSFetchRequest<Article>
fetch.predicate = NSPredicate(format: "type == %d", self.type)
do {
self.articleRows = try NSManagedObjectContext.current.fetch(fetch)
} catch
{
}
That is working fine. I am displaying all my data inside a ForEach loop.
ForEach(self.articleRows, id:\.self) { article in
However, when I am deleting an entity from my context I need to refresh the view to display the changes. On delete action I toggle a #State variable, to refresh the view. That works, however the entity is still inside my Array. Thats due to the fact that init() is not called again and the fetch request is not made again. If init() would be called, the deleted entity wouldn't be there anymore.
What is the best approach here? How can I refetch my CoreData entities.
My current solution: I am currently using a Binding inside my Fetch view. If I make changes I toggle that binding and my parent view reloads. The will cause my child view (fetch View) to reload aswell and the fetch request is made again. Is that the best way? Any simpler solution for that?
So, I found a way of solving my problem.
What was the problem?
The View was not updating because I wasn't using FetchRequest property wrapper, because I need a instance variable in that FetchRequest. So I need to do the Fetching inside my Init() method. But what I did was, I just fetched my items once in the init() and that won't be updated unless the parent with is reloaded.
How to solve it without annoying parent update?
Instead of doing a manual fetch only once in the init() I used a FetchRequest and initialized it in the init(), so it still behaves as FetchRequest property wrapper, like an Observable Object.
I declared it like that:
#FetchRequest var articleRows : FetchedResults<Article>
And inside the init()
//Here in my predicate I use self.type variable
var predicate = NSPredicate(format: "type == %d", self.type)
//Intialize the FetchRequest property wrapper
self._articleRows = FetchRequest(entity: Article.entity(), sortDescriptors: [], predicate: predicate)
Assuming you have your managedObjectContext set up as such;
#Environment(\.managedObjectContext) var moc
then I believe this solution might work for you.
func deleteArticle(at index: IndexSet) {
for i in index {
// pull the article from the FetchRequest
let article = articleRows[i]
moc.delete(article)
}
// resave to CoreData
try? moc.save()
}
call that method at the end of your ForEach block with;
.onDelete(perform: deleteArticle)
That said I am not familiar with the way you are doing the fetch request so you made need to do some tweaking.
Dan
I am building a SwiftUI list where I need a dynamic predicate. The approach is discussed here: https://www.hackingwithswift.com/books/ios-swiftui/dynamically-filtering-fetchrequest-with-swiftui
Here is my code so far:
struct SomeView: View {
var collection: Collection
var messages: FetchRequest<Message>
init(collection: Collection) {
let predicate : NSPredicate = NSPredicate(format: "collection = %#", collection)
self.collection = collection
self.messages = FetchRequest<Message>(entity: Message.entity(), sortDescriptors: [NSSortDescriptor(key: "date", ascending: true)], predicate: predicate)
}
var body: some View {
List(messages.wrappedValue, id: \.uniqueIdentifier) { message in
// construct the UI
}
}
}
So far so good.
What I can’t figure out how to do: I need to transform the messages elements based on some other messages in the results (let’s say based on previous message for simplicity). messages[0] should look a particular way. messages[1] look depends on messages[0]. messages[2] depends on messages[1] and so on. I cannot precompute this, since it may vary across time. It should be computed in the context of this specific fetch request/result.
I could express this as some transient computed property on the Message object, which the view code could then use to branch out. I could have a function where I give a particular message and the array of messages, the function looks up the message and other messages and sets the state of a given message based on that. However, SwiftUI limits what I can do in View code, I can’t execute functions this way.
I can run map or flatmap where I access the wrappedValue, but those don’t let me access other elements of the collection to make decisions (I think?).
How would I run this kind of transformation in this context?
If I correctly understood your description (and taking into account that FetchedResults is a RandomAccessCollection) I would go with the following approach
var body: some View {
List(messages.wrappedValue, id: \.uniqueIdentifier) { message in
rowView(for: message, from: messages.wrappedValue)
}
}
func rowView(for message: Message, from result: FetchedResults<Message>) -> some View {
// having .starIndex, .endIndex, .position, etc. do any dependent calculations here
// and return corresponding View
}
I have a Swift app that uses CoreData. I created List entity with class MyAppTarget.List. Everything is configured properly in .xcdatamodeld file. In order to fetch my entities from persistent store, I am using NSFetchedResultsController:
let fetchRequest = NSFetchRequest()
fetchRequest.entity = NSEntityDescription.entityForName("List", inManagedObjectContext: managedObjectContext)
fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "name", ascending: true) ]
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: "ListFetchedResultsControllerCache")
and it works like expected, returning array of MyAppTarget.List objects when fetching.
However, I would like to use it inside another target, for unit testing. I added List class to MyUnitTestTarget, so I can access it inside the unit test target. The problem is that the fetched results controller returns MyAppTarget.List objects, not the MyUnitTestTarget.List objects. In order to make the List entity testable, I have to make it public along with all methods that I need to use and I would like to avoid this.
I tried to change the managedObjectClassName property on NSEntityDescription:
fetchRequest.entity.managedObjectClassName = "MyUnitTestTarget.List"
but it generates exception:
failed: caught "NSInternalInconsistencyException", "Can't modify an immutable model."
The documentation states that
[...] once a description is used (when the managed object model to which it belongs is associated with a persistent store coordinator), it must not (indeed cannot) be changed. [...] If you need to modify a model that is in use, create a copy, modify the copy, and then discard the objects with the old model.
Unfortunately, I don't know how to implement this flow. I wonder if there is a way to change the managed object class name in runtime, before fetching the entities with NSFetchedResultsController?
It occurs that solution to my problem was pretty simple. In order to make it working, I had to create a copy of managedObjectModel, edit its entities and create NSPersistentStoreCoordinator with the new model. Changing the managedObjectClassName property on NSEntityDescription instance is possible only before model to which it belongs is associated with NSPersistentStoreCoordinator.
let testManagedObjectModel = managedObjectModel.copy() as NSManagedObjectModel
for entity in testManagedObjectModel.entities as [NSEntityDescription] {
if entity.name == "List" {
entity.managedObjectClassName = "CheckListsTests.List"
}
}
This also solves my other problem with unit testing CoreData model entities in Swift.
You can dynamically alter the class name of the NSManagedObject subclass with something like:
let managedObjectModel = NSManagedObjectModel.mergedModelFromBundles([NSBundle.mainBundle()])!
// Check if it is within the test environment
let environment = NSProcessInfo.processInfo().environment as! [String : AnyObject]
let isTestEnvironment = (environment["XCInjectBundle"] as? String)?.pathExtension == "xctest"
// Create the module name based on product name
let productName:String = NSBundle.mainBundle().infoDictionary?["CFBundleName"] as! String
let moduleName = (isTestEnvironment) ? productName + "Tests" : productName
let newManagedObjectModel:NSManagedObjectModel = managedObjectModel.copy() as! NSManagedObjectModel
for entity in newManagedObjectModel.entities as! [NSEntityDescription] {
entity.managedObjectClassName = "\(moduleName).\(entity.name!)"
}