How to create a NSManagedObject in a modal view using SwiftUI? - core-data

How does one create a new ManagedObject (MO) in a modal view using SwiftUI?
Running into a strange bug where Xcode consumes GBs of memory and fills up the hard drive on the mac through swap files.
When one creates the Modal view in the .sheet modifier there appears to be some kind of infinite loop created that fills the memory with duplicates of the ManagedObject that is injected into that Modal view.
This sample project illustrates at least part of the issue. If you run it you see the method called in the .sheet modifier fires over and over again. A theory is that the screen underneath that displays a list of ManagedObjects is causing some kind of loop between the two screens.
https://github.com/sphericalwave/ChildContextTest
Was hoping to use a childContext in the modal screen so any unsaved changes would be discarded if the modal view was dismissed without saving the childContext. But need to clear this hurdle first and there is some challenge involved in sharing ManagedObject across contexts.
import CoreData
import SwiftUI
struct CrtFdsUI: View
{
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: CrtFd.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \CrtFd.scale, ascending: true)])
var crtFds: FetchedResults<CrtFd>
#State var showModal = false
#ObservedObject var absFd: AbsFd
func crtFdModal() -> CrtFdUI {
print("func crtFdModal() -> CrtFdUI")
let cF = CrtFd(scale: 1.0, absFd: absFd, moc: moc)
return CrtFdUI(crtFd: cF)
}
var body: some View {
NavigationView {
VStack {
List {
ForEach(self.crtFds, id: \.objectID) {
CrtFdCell(crtFd: $0)
}
}
.navigationBarTitle("CrtFdsUI")
.navigationBarItems(trailing: PlusBtn(showModal: $showModal))
}
.sheet(isPresented: $showModal) { self.crtFdModal() } //FIXME: Called in endless loop?
}
}
}
Here's the list of managedObjects.
import CoreData
import SwiftUI
struct CrtFdsUI: View
{
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: CrtFd.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \CrtFd.scale, ascending: true)])
var crtFds: FetchedResults<CrtFd>
#State var showModal = false
#ObservedObject var absFd: AbsFd
func crtFdModal() -> CrtFdUI {
print("func crtFdModal() -> CrtFdUI")
let cF = CrtFd(scale: 1.0, absFd: absFd, moc: moc)
return CrtFdUI(crtFd: cF)
}
var body: some View {
NavigationView {
VStack {
List {
ForEach(self.crtFds, id: \.objectID) {
CrtFdCell(crtFd: $0)
}
}
.navigationBarTitle("CrtFdsUI")
.navigationBarItems(trailing: PlusBtn(showModal: $showModal))
}
.sheet(isPresented: $showModal) { self.crtFdModal() }
}
}
}

Right now the code creates a new instance of CrtFd every time the view hierarchy is recalculated. That's probably not a good idea since the hierarchy might be recalculated unexpectedly for reasons you don't directly control, so that even without an infinite loop of creation you'd still probably end up with more new managed objects than you want.
I downloaded your project and I'm not sure what the two Core Data entities represent, but I did notice that when CrtFdsUI first appears, its absFd already has a value for the crtFd property, and that the crtFd property is to-one rather than to-many. That means that when you create a new instance in this code, you're replacing one CrtFd with another that's an exact duplicate.
I'm going to guess that you don't really want to replace one instance with an identical duplicate, so one way of avoiding the problem, then, is to change your crtFdModal() to use the one that already exists, and only create a new copy if there isn't one already:
func crtFdModal() -> CrtFdUI {
print("func crtFdModal() -> CrtFdUI \(showModal)")
let cF = absFd.crtFd ?? CrtFd(scale: 1.0, absFd: absFd, moc: moc)
return CrtFdUI(crtFd: cF)
}
That avoids the problem you describe. It's hard to tell if it's exactly what you need, but since your code creates apparently-unnecessary duplicates it seems likely that it is.

Related

Save updates to Core Data object

I have a Core Data object that is created in one tab and located in another via UUID. It can be updated in the second tab by clicking on the "Save" button.
Currently, when the "Save" button is clicked, the changes to the object are shown across all sheets, but when I close the app and open it again the object has gone back to how it was before.
The "Save" button currently has a similar logic to in this post. I think what could be missing is to add try viewContext.save to the "Save" button, but I'm not sure how to do this without creating a new object (as opposed to updating the existing one.
Any ideas? Thank you! (and please let me know if I need to add more context, I think this is the right amount but wasn't totally sure).
import SwiftUI
import CoreData
struct ModifySheet: View {
#Environment(\.managedObjectContext) private var viewContext
var fetchRequest: FetchRequest<Items>
var items: FetchedResults<Items> {
fetchRequest.wrappedValue
}
#State var newCost: Double = 0
var body: some View {
VStack {
Form {
Slider(value: $newCost, in: 0...100)
}
Button(action: {
items[0].cost = newCost
})
{Text("Save")}
}
}
init(filter: UUID) {
fetchRequest = FetchRequest<Item>(entity: Item.entity(), sortDescriptors: [], predicate: NSPredicate(format: "id == %#", filter as CVarArg))
}
}

Isolated managed object context for FetchRequest in SwiftUI

In a SwiftUI view, by default FetchRequest fetches from the managedObjectContext environment value. If instead the view wants to fetch into an isolated context, for example to make discardable edits without polluting the context of other views, how can it change the context that FetchRequest uses?
One option is to wrap the view in an outer view that creates the isolated context and then calls the wrapped view using it:
var body: some View {
WrappedView().environment(\.managedObjectContext, isolatedContext)
}
This is tedious, however. You have to create two views and pass all the wrapped views' arguments through the wrapper. Is there a better way to tell FetchRequest which context to use?
If you use the standard PersistentController that Apple gives as a startup you could try using
.environment(\.managedObjectContext, privateContext)
Your View would need this property to make it work. #State shouldn't be necessary since the changes are being done in the background by other means such as notifications.
let privateContext = PersistenceController.shared.container.newBackgroundContext()
Invoking newBackgroundContext() method causes the persistent container to create and return a new NSManagedObjectContext with the concurrencyType set to NSManagedObjectContextConcurrencyType.privateQueueConcurrencyType. This new context will be associated with the NSPersistentStoreCoordinator directly and is set to consume NSManagedObjectContextDidSave broadcasts automatically.
Then to test it out using most of the sample code from Apple.
struct SampleSharedCloudKitApp: App {
let privateContext = PersistenceController.shared.container.newBackgroundContext()
var body: some Scene {
WindowGroup {
VStack{
Text(privateContext.description) //Added this to match with ContentView
ContentView()
.environment(\.managedObjectContext, privateContext)
//Once you pass the privateContext here everything below it will have the privateContext
//You don't need to connect it with #FetchRequest by any other means
}
}
}
}
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 {
List {
Text((items.first!.managedObjectContext!.concurrencyType == NSManagedObjectContextConcurrencyType.privateQueueConcurrencyType).description) //This shows true
Text(items.first!.managedObjectContext!.description)// This description matches the parent view
Text(viewContext.description)// This description matches the parent view
Also, something to note is that you have to set
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump
In order for the main context to show the changes done after saving the privateContext. I put it in the PersistenceController init right after the loadPersistentStores closure.

SwiftUI and FetchRequest : delay the reordering of a list

I have written an application displaying a list of students. I use a List of NavigationLink for that purpose. The students are ordered by one of their properties questionAskedClass which is an integer. All this information is stored within CoreData.
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(entity: Student.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Student.questionAskedClass,
ascending: true)])
var students: FetchedResults<Student>
var body: some View {
NavigationView {
List {
ForEach(students) {student in
NavigationLink(destination: StudentView(student: student)) {
HStack {
VStack(alignment: .leading) {
Text("\(student.firstName)").font(.headline).foregroundColor(Color.black)
Text("\(student.lastName)").font(.headline).foregroundColor(Color.gray)
}
}
}
}
}
}
}
When I press the name of the students, I switch to a new view called StudentView where I can get more information about the student and where I can update the property questionAskedClass
struct StudentView: View {
#Environment(\.managedObjectContext) var managedObjectContext
func askQuestion() {
self.managedObjectContext.performAndWait {
self.student.questionAskedClass += 1
try? self.managedObjectContext.save()
}
}
}
Unfortunately, when I change that property, the ordering of the initial list is changed and I am taken away from the StudentView. The framework seems to get the feeling that the list needs to be reordered. But I just want this list to be reordered when I go back to the list. Not immediately when I change the value of questionAskedClass.
What can I do to mitigate this problem?
Thanks for your help.
You can try creating a simple NSFetchRequest<Student> and use the result of this fetch to update your students list.
#State var students: [Student] = []
fun refresh() {
let fetchRequest = NSFetchRequest<Student>(entityName: "Student")
students = try? managedObjectContext.fetch(fetchRequest) ?? []
}
You can trigger this refresh in onAppear so the list will be updated every time the View appears:
NavigationView {
...
}.onAppear {
self.refresh()
}
Alternatively you can save your context in onAppear of the main view instead of StudentView:
struct StudentView: View {
func askQuestion() {
self.student.questionAskedClass += 1
}
}
NavigationView {
...
}.onAppear {
try? self.managedObjectContext.save()
}
If you want your data to persist when the app is terminated, add this function in AppDelegate (assuming your persistentContainer is declared there:
func applicationWillTerminate(_ application: UIApplication) {
try? persistentContainer.viewContext.save()
}

SwiftUI #FetchRequest after screen lock not fetching data and returning nil properties

I have usual SwiftUI view like this
struct MyView: View {
#FetchRequest var users: FetchedResults<User>
init() {
self._users = FetchRequest(
entity: User.entity(),
sortDescriptors: [
],
predicate: NSPredicate(format: "company.id == %#", companyId)
)
}
var body: some View {
List {
ForEach(Array(self.users.enumerated()), id: \.1.objectID) { (i, user) in
Text("\(user.name)")
}
}
}
But after locking screen/using home button and returning to app. Initially this view wake ups with empty NSManagedObjects, objects seems to be available there is correct users.count value, each object has its appropriate objectID. But other managed object properties are nil. Then sometimes I experience that in subsequent view refreshes it can (I think "fault" this object properties) fetch this properties and displays ok, or can stay with nil values and I have empty results on list or crash depending user.name is forced unwrap or not
user.name! or user.name ?? ""

Using SwiftUI, Core Data, and one-to-many relationships, why does the list not update when adding a row on the Many side

I have a SwiftUI project with Core Data. The data model is a simple one-to-many and two primary views which each have a textfield at the top and a button to add a new item to the list view below. The first view is for the One side of the relation and the second for the Many. So, the NavigationLink in the first opens the second and passes the One object. Pretty standard stuff, it would seem. The methodology for creating the One works and the list below gets updated immediately when the managed object context saves the new item. But, the same type of methodology doesn't refresh the list for the Many side when viewing on a device, although it does work fine in the simulator and the preview window. The data is definitely saved because if you navigate back to the One side then re-select it to re-load the Many view, it shows the new item in the list.
I've looked through lots of tutorials, other questions, etc. and haven't found a reason for this. Am I doing something wrong in how I am going to the Many side of the relation, or is there something else I have to do to refresh the view only on the Many side? Thanks!!!
Full project available at https://github.com/fahrsoft/OneToManyTest
From the ContentView, showing the One side (note: OneView is a simple view that takes the object and shows the text. Same for ManyView.):
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: One.entity(), sortDescriptors: []) var ones: FetchedResults<One>
#State private var newName = ""
#State var isNavTitleHidden = true
var body: some View {
NavigationView {
VStack {
HStack {
TextField("New One", text: self.$newName)
Spacer()
Button(action: {
let newOne = One(context: self.moc)
newOne.name = self.newName
self.newName = ""
try? self.moc.save()
}) {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
.frame(width: 32, height: 32, alignment: .center)
}
}
.padding(.top)
.padding(.horizontal)
List {
Section(header: Text("Ones")) {
ForEach(self.ones, id:\.self) { (one:One) in
NavigationLink(destination: OneDetailView(one: one, isNavTitleHidden: self.$isNavTitleHidden).environment(\.managedObjectContext, self.moc)) {
OneView(one: one).environment(\.managedObjectContext, self.moc)
}
}
.onDelete { indexSet in
let deleteOne = self.ones[indexSet.first!]
self.moc.delete(deleteOne)
do {
try self.moc.save()
} catch {
print(error)
}
}
}
}
}
.navigationBarTitle(Text("Ones List"))
.navigationBarHidden(self.isNavTitleHidden)
.onAppear {
self.isNavTitleHidden = true
}
}
}}
From the OneDetailView showing the Many side:
struct OneDetailView: View {
#Environment(\.managedObjectContext) var moc
#ObservedObject var one: One
#State private var newManyAttribute = ""
#Binding var isNavTitleHidden: Bool
var body: some View {
VStack {
HStack {
TextField("New Many", text: self.$newManyAttribute)
Spacer()
Button(action: {
let newMany = Many(context: self.moc)
newMany.attribute = self.newManyAttribute
self.newManyAttribute = ""
self.one.addToMany(newMany)
try? self.moc.save()
}) {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
.frame(width: 32, height: 32, alignment: .center)
}
}
.padding(.top)
.padding(.horizontal)
List {
Section(header: Text("Manys")) {
ForEach(self.one.manyArray, id: \.self) { many in
ManyView(many: many).environment(\.managedObjectContext, self.moc)
}
}
}
}
.navigationBarTitle("\(self.one.wrappedName) Details")
.onAppear {
self.isNavTitleHidden = false
}
}}
If you create a separate View for the child objects, and bind them to a FetchRequest inside that view, it will work.
e.g. say you have a list of Restaurant NSManagedObjects, each with a bunch of associated MenuItem NSManaged objects. Assuming the MenuItems have a 1:1 relationship with a Restaurant object, you can do this:
public struct RestaurantList: View {
#FetchRequest private var restaurants: FetchedResults<Restaurant>
#Environment(\.managedObjectContext) private var viewContext
public init() {
_restaurants = FetchRequest(fetchRequest: Restaurant.fetchRequest().then {
$0.predicate = NSPredicate(format: "city = %#", argumentArray: ["Tokyo"])
$0.sortDescriptors = [NSSortDescriptor(keyPath: \Restaurant.title, ascending: false)]
})
}
public var body: some View {
VStack {
Text("All restaurants in Tokyo")
ForEach(restaurants) { restaurant in
MenuItemsView(restaurant)
}
}
}
}
public struct MenuItemsView: View {
#FetchRequest private var items: FetchedResults<MenuItem>
#ObservedObject var restaurant: Restaurant
#Environment(\.managedObjectContext) private var viewContext
public init(restaurant: Restaurant) {
self.restaurant = restaurant
_items = FetchRequest(fetchRequest: MenuItem.fetchRequest().then {
$0.predicate = NSPredicate(format: "restaurant = %#", argumentArray: [restaurant])
$0.sortDescriptors = [NSSortDescriptor(keyPath: \MenuItem.order, ascending: true)]
})
}
public var body: some View {
VStack {
Text("Menu for ")
Text(restaurant.title)
ForEach(items) { menuItem in
MenuItemDetailView(menuItem)
}
}
}
}
public struct MenuItemDetailView: View {
#ObservedObject var menuItem: MenuItem
#Environment(\.managedObjectContext) private var viewContext
public init(_ menuItem: MenuItem) {
self.menuItem = menuItem
}
public var body: some View {
Text("info about your menuItem here")
}
}
Now whenever a MenuItem changes, the main screen (RestaurantList) will automatically update.
Many-to-many relationships
If the relationship between Restaurants and MenuItems is n:n, all you have to do is change the predicate from
$0.predicate = NSPredicate(format: "restaurant = %#", argumentArray: [restaurant])
to
$0.predicate = NSPredicate(format: "ANY restaurant = %#", argumentArray: [restaurant])
Identifiable
If your NSManagedObjects have a unique string ID, use that for Identifiable. If you don't want to add a unique ID right away, objectID.debugDescription is a good placeholder.
#objc(Restaurant)
public class Restaurant: NSManagedObject, Identifiable {
public var id: String {
get {
return your_unique_field_from_core_data_here
// or this in a pinch
// return objectID.debugDescription
}
}
}
The only thing I could come up with as a way to make it work decently well was to create a new FetchRequest for the Many items using the selected One in a predicate. Adding the FetchRequest and an init to the beginning of the OneDetailView allows for the list to update.
struct OneDetailView: View {
#Environment(\.managedObjectContext) var moc
#ObservedObject var one: One
#State private var newManyAttribute = ""
#Binding var isNavTitleHidden: Bool
#FetchRequest var manys: FetchedResults<Many>
init(one: One, isNavTitleHidden: Binding<Bool>) {
self.one = one
self._isNavTitleHidden = isNavTitleHidden
var predicate: NSPredicate?
predicate = NSPredicate(format: "one = %#", one)
self._manys = FetchRequest(
entity: Many.entity(),
sortDescriptors: [],
predicate: predicate
)
}
var body: some View {
VStack {
HStack {
TextField("New Many", text: self.$newManyAttribute)
Spacer()
Button(action: {
let newMany = Many(context: self.moc)
newMany.attribute = self.newManyAttribute
self.newManyAttribute = ""
self.one.addToMany(newMany)
try? self.moc.save()
}) {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
.frame(width: 32, height: 32, alignment: .center)
}
}
.padding(.top)
.padding(.horizontal)
List {
Section(header: Text("Manys")) {
ForEach(self.manys, id: \.self) { many in
ManyView(many: many).environment(\.managedObjectContext, self.moc)
}
}
}
}
.navigationBarTitle("\(self.one.wrappedName) Details")
.onAppear {
self.isNavTitleHidden = false
}
}}
After more research, ObservableObject has a built-in publisher by default that can notify any views that the object will change. Simply call
objectWillChange.send()
on an ObservableObject before changes occur to have any UI refresh that is observing that object.
For example, to fix my issue where changes to Core Data relationships weren't updating the UI, I've added this call before saving the context.
if workContext.hasChanges {
objectWillChange.send()
do {
try self.workContext.save()
} catch {
fatalError(error.localizedDescription)
}
}
No need to implement a custom #Published property or other workaround.
I've found a fix/workaround that's literally just a few lines of code and seems to work great.
What's happening as you've seen is CoreData isn't announcing anything has changed when a relationship changes (or for that matter a relation to a relation). So your view struct isn't getting reinstantiated and it's not querying those computed properties on your core data object. I've been learning SwiftUI and trying to rewrite a UI to use a Model that uses a few different relationships, some nested.
My initial thought was to use subviews with #FetchRequests and pass in parameters to those views. But I've got a lot of subviews that need to make use of relationships - that's a ton of code, and for me could potentially be tens if not hundreds of fetchrequests for some layouts. I'm no expert, but that way lies madness!
Instead I've found a way that seems hackish, but uses very little code and feels kind of elegant for a cheat.
I have a ModelController class that handles all Core Data code on a background context and I use that context to 'kick' the ui to tell it to refresh itself when it saves (any time something changes). To do the kicking, I added a #Published kicker property to the class which any views can use to be notified when they need to be torn down and rebuilt. Any time the background context saves, the kicker toggles and that kick is pushed out into the environment.
Here's the ModelController:
public class ModelController: ObservableObject {
// MARK: - Properties
let stack: ModelStack
public let viewContext: NSManagedObjectContext
public let workContext: NSManagedObjectContext
#Published public var uiKicker = true
// MARK: - Public init
public init(stack: ModelStack) {
self.stack = stack
viewContext = stack.persistentContainer.viewContext
viewContext.automaticallyMergesChangesFromParent = true
workContext = stack.persistentContainer.newBackgroundContext()
workContext.automaticallyMergesChangesFromParent = true
}
// Logic logic...
public func save() {
workContext.performAndWait {
if workContext.hasChanges {
do {
try self.workContext.save()
} catch {
fatalError(error.localizedDescription)
}
}
}
uiKicker.toggle()
}
}
I currently instantiate ModelController in #main and inject it into the environment to do my bidding:
#main
struct MyApp: App {
let modelController = ModelController(stack: ModelStack())
var body: some Scene {
WindowGroup {
MainView()
.environment(\.managedObjectContext, modelController.viewContext)
.environmentObject(modelController)
}
}
}
Now take a view that isn't responding... Here's one now! We can use the uiKicker property to force the stubborn view to refresh. To do that you need to actually use the kicker's value somewhere in your view. It doesn't apparently need to actually change something, just be used - so for example in this view you'll see at the very end I'm setting the opacity of the view based on the uiKicker. It just happens the opacity is set to the same value whether it's true or false so this isn't a noticeable change for the user, other than the fact that the 'sticky' value (in this case list.openItemsCount) gets refreshed.
You can use the kicker anywhere in the UI and it should work (I've got it on the enclosing VStack but it could be anywhere in there).
struct CardView: View {
#ObservedObject var list: Model.List
#EnvironmentObject var modelController: ModelController
var body: some View {
VStack {
HStack {
Image(systemName: "gear")
Spacer()
Label(String(list.openItemsCount), systemImage: "cart")
}
Spacer()
Text(list.name ?? "Well crap I don't know.")
Spacer()
HStack {
Image(systemName: "trash")
.onTapGesture {
modelController.delete(list.objectID)
}
Spacer()
Label("2", systemImage: "person.crop.circle.badge.plus")
}
}
.padding()
.background(Color.gray)
.cornerRadius(30)
.opacity(modelController.uiKicker ? 100 : 100)
}
}
And there you have it. Use the uiKicker anywhere things aren't refreshing properly. Literally a few lines of code and stale relationships are a thing of the past!
As I learn more about SwiftUI I have to say I'm loving it!
EDIT TO ADD:
Poking around some more I've found that this only works if the observed object is injected using .environmentObject, it doesn't work if you use custom environment keys and inject using .environment(\.modelController). I have no idea why but it's true as of iOS 14.3/XCode 12.3.

Resources