A Core Data model with entity Node
having name
, createdAt
, to-many relationship children
and to-one relationship parent
(both optional). Using CodeGen Class Definition.
Using a @FetchRequest
with a predicate of parent == nil
, it's possible to grab the root nodes and subsequently walk the tree using the relationships.
Root nodes CRUD refreshes the view fine, but any modifications to child nodes don't display until restart although changes are saved in Core Data.
Simplest possible example in the code below illustrates the problem with child node deletion. The deletion works in Core Data but the view does not refresh if the deletion is on a child. The view refresh works fine if on a root node.
I'm new to Swift, so my apologies if this is a rather elementary question, but how can the view be refreshed upon changes to the child nodes?
import SwiftUI
import CoreData
extension Node {
class func count() -> Int {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
let fetchRequest: NSFetchRequest<Node> = Node.fetchRequest()
do {
let count = try context.count(for: fetchRequest)
print("found nodes: \(count)")
return count
} catch let error as NSError {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
}
struct ContentView: View {
@Environment(\.managedObjectContext) var managedObjectContext
@FetchRequest(entity: Node.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: true)], predicate: NSPredicate(format: "parent == nil"))
var nodes: FetchedResults<Node>
var body: some View {
NavigationView {
List {
NodeWalkerView(nodes: Array(nodes.map { $0 as Node }) )
}
.navigationBarItems(trailing: EditButton())
}
.onAppear(perform: { self.loadData() } )
}
func loadData() {
if Node.count() == 0 {
for i in 0...3 {
let node = Node(context: self.managedObjectContext)
node.name = "Node \(i)"
for j in 0...2 {
let child = Node(context: self.managedObjectContext)
child.name = "Child \(i).\(j)"
node.addToChildren(child)
for k in 0...2 {
let subchild = Node(context: self.managedObjectContext)
subchild.name = "Subchild \(i).\(j).\(k)"
child.addToChildren(subchild)
}
}
}
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
}
}
struct NodeWalkerView: View {
@Environment(\.managedObjectContext) var managedObjectContext
var nodes: [Node]
var body: some View {
ForEach( self.nodes, id: \.self ) { node in
NodeListWalkerCellView(node: node)
}
.onDelete { (indexSet) in
let nodeToDelete = self.nodes[indexSet.first!]
self.managedObjectContext.delete(nodeToDelete)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
}
}
struct NodeListWalkerCellView: View {
@ObservedObject var node: Node
var body: some View {
Section {
Text("\(node.name ?? "")")
if node.children!.count > 0 {
NodeWalkerView(nodes: node.children?.allObjects as! [Node] )
.padding(.leading, 30)
}
}
}
}
EDIT:
A trivial but unsatisfying solution is to make NodeListWakerCellView
retrieve the children using another @FetchRequest
but this feels wrong since the object is already available. Why run another query? But perhaps this is currently the only way to attach the publishing features?
I am wondering if there's another way to use a Combine
publisher directly to the children, perhaps within the .map
?
struct NodeListWalkerCellView: View {
@ObservedObject var node: Node
@FetchRequest var children: FetchedResults<Node>
init( node: Node ) {
self.node = node
self._children = FetchRequest(
entity: Node.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: false)],
predicate: NSPredicate(format: "%K == %@", #keyPath(Node.parent), node)
)
}
var body: some View {
Section {
Text("\(node.name ?? "")")
if node.children!.count > 0 {
NodeWalkerView(nodes: children.map({ $0 as Node }) )
.padding(.leading, 30)
}
}
}
}
You can easily observe all the changes by observing the NSManagedObjectContextObjectsDidChange
Notification
and refreshing the View
.
In the code below you can replicate the issue as follows.
Node
entity with the following attributes and relationships
2. Paste the code below into the project
Run the NodeContentView
on simulator
Select one of the nodes in the first screen
Edit the node's name
Click on the "Back" button
Notice the name of the selected variable didn't change.
How to "solve"
Uncomment //NotificationCenter.default.addObserver(self, selector: #selector(refreshView), name: Notification.Name.NSManagedObjectContextObjectsDidChange, object: nil)
that is located in the init
of CoreDataPersistence
Follow steps 3-6
Notice that the node name was updated this time.
import SwiftUI
import CoreData
extension Node {
public override func awakeFromInsert() {
super.awakeFromInsert()
self.createdAt = Date()
}
}
///Notice the superclass the code is below
class NodePersistence: CoreDataPersistence{
func loadSampleData() {
if NodeCount() == 0 {
for i in 0...3 {
let node: Node = create()
node.name = "Node \(i)"
for j in 0...2 {
let child: Node = create()
child.name = "Child \(i).\(j)"
node.addToChildren(child)
for k in 0...2 {
let subchild: Node = create()
subchild.name = "Subchild \(i).\(j).\(k)"
child.addToChildren(subchild)
}
}
}
save()
}
}
func NodeCount() -> Int {
let fetchRequest: NSFetchRequest<Node> = Node.fetchRequest()
do {
let count = try context.count(for: fetchRequest)
print("found nodes: \(count)")
return count
} catch let error as NSError {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
}
struct NodeContentView: View {
//Create the class that sets the appropriate context
@StateObject var nodePers: NodePersistence = .init()
var body: some View{
NodeListView()
//Pass the modified context
.environment(\.managedObjectContext, nodePers.context)
.environmentObject(nodePers)
}
}
struct NodeListView: View {
@EnvironmentObject var nodePers: NodePersistence
@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: true)], predicate: NSPredicate(format: "parent == nil"))
var nodes: FetchedResults<Node>
var body: some View {
NavigationView {
List {
NodeWalkerView(nodes: Array(nodes))
}
.navigationBarItems(trailing: EditButton())
.navigationTitle("select a node")
}
.onAppear(perform: { nodePers.loadSampleData()} )
}
}
struct NodeWalkerView: View {
@EnvironmentObject var nodePers: NodePersistence
//This breaks observation, it has no SwiftUI wrapper
var nodes: [Node]
var body: some View {
Text(nodes.count.description)
ForEach(nodes, id: \.objectID ) { node in
NavigationLink(node.name.bound, destination: {
NodeListWalkerCellView(node: node)
})
}
.onDelete { (indexSet) in
for idx in indexSet{
nodePers.delete(nodes[idx])
}
}
}
}
struct NodeListWalkerCellView: View {
@EnvironmentObject var nodePers: NodePersistence
@ObservedObject var node: Node
var body: some View {
Section {
//added
TextField("name",text: $node.name.bound) //<---Edit HERE
.textFieldStyle(.roundedBorder)
if node.children?.allObjects.count ?? -1 > 0{
NavigationLink(node.name.bound, destination: {
NodeWalkerView(nodes: node.children?.allObjects.typeArray() ?? [])
.padding(.leading, 30)
})
}else{
Text("empty has no children")
}
}.navigationTitle("Edit name on this screen")
}
}
extension Array where Element: Any{
func typeArray<T: Any>() -> [T]{
self as? [T] ?? []
}
}
struct NodeContentView_Previews: PreviewProvider {
static var previews: some View {
NodeContentView()
}
}
extension Optional where Wrapped == String {
var _bound: String? {
get {
return self
}
set {
self = newValue
}
}
var bound: String {
get {
return _bound ?? ""
}
set {
_bound = newValue
}
}
}
///Generic CoreData Helper not needed just to make stuff easy.
class CoreDataPersistence: ObservableObject{
//Use preview context in canvas/preview
//The setup for this is in XCode when you create a new project
let context = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" ? PersistenceController.preview.container.viewContext : PersistenceController.shared.container.viewContext
init(){
//Observe all the changes in the context, then refresh the View that observes this using @StateObject, @ObservedObject or @EnvironmentObject
//There are other options, like NSPersistentStoreCoordinatorStoresDidChange for the coordinator
//https://developer.apple.com/documentation/foundation/nsnotification/name/1506884-nsmanagedobjectcontextobjectsdid
//NotificationCenter.default.addObserver(self, selector: #selector(refreshView), name: Notification.Name.NSManagedObjectContextObjectsDidChange, object: nil)
}
///Creates an NSManagedObject of any type
func create<T: NSManagedObject>() -> T{
T(context: context)
//Can set any defaults in awakeFromInsert() in an extension for the Entity
//or override this method using the specific type
}
///Updates an NSManagedObject of any type
func update<T: NSManagedObject>(_ obj: T){
//Make any changes like a last modified variable
save()
}
///Creates a sample
func addSample<T: NSManagedObject>() -> T{
return create()
}
///Deletes an NSManagedObject of any type
func delete(_ obj: NSManagedObject){
context.delete(obj)
save()
}
func resetStore(){
context.rollback()
save()
}
internal func save(){
do{
try context.save()
}catch{
print(error)
}
}
@objc
func refreshView(){
objectWillChange.send()
}
}
CoreDataPersistence
is a generic class
that can be used with any entity. Just copy it into you project and you can use it as a superclass
for your own CoreData ViewModels or use it as is if you don't have anything to override or add.
The key part of the solution is the line that is uncommented, and the
selector
that tells theView
to reload. Everything else is extra
The code seems like a lot because this is what was provided by the OP but the solution is contained in CoreDataPersistence
. Notice the NodeContentView
too the context
should match the @FetchRequest
with theCoreDataPersistence
Option 2
For this specific use case (children are of the same type as the parent) you can use List
with children
in the init
it simplifies a lot of the setup and updating issues are greatly reduced.
extension Node {
public override func awakeFromInsert() {
super.awakeFromInsert()
self.createdAt = Date()
}
@objc
var typedChildren: [Node]?{
self.children?.allObjects.typeArray()
}
}
struct NodeListView: View {
@EnvironmentObject var nodePers: NodePersistence
@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: true)], predicate: NSPredicate(format: "parent == nil"))
var nodes: FetchedResults<Node>
var body: some View {
NavigationView {
List(Array(nodes) as [Node], children: \.typedChildren){node in
NodeListWalkerCellView(node: node)
}
.navigationBarItems(trailing: EditButton())
.navigationTitle("select a node")
}
.onAppear(perform: { nodePers.loadSampleData()} )
}
}
struct NodeListWalkerCellView: View {
@EnvironmentObject var nodePers: NodePersistence
@ObservedObject var node: Node
var body: some View {
HStack {
//added
TextField("name",text: $node.name.bound)
.textFieldStyle(.roundedBorder)
Button("delete", role: .destructive, action: {
nodePers.delete(node)
})
}.navigationTitle("Edit name on this screen")
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With