3

I'm building a basic notes app where the main page of my app should display a list of the user's notes. A note is represented with the Note class, a Core Data-generated class. (My ultimate goal is a notes app that syncs with CloudKit via NSPersistentCloudKitContainer.)

So far, when a user loads the app, the List displays the correct notes data. However, when I attempt to create a new note by tapping my newNoteButton, the array of Notes changes, but my UI doesn't change. I have to reload the app to see the new note. What could I be doing wrong? Sorry for the messy code below:

NoteList.swift

struct NoteList: View {

  @EnvironmentObject var userNotes: UserNotes

  var newNoteButton: some View {
    Button(action: {
      self.userNotes.createNewNote()
      self.userNotes.objectWillChange.send()
    }) {
      Image(systemName: "plus")
        .imageScale(.large)
        .accessibility(label: Text("New Note"))
    }
  }

  var body: some View {
    NavigationView {
      List {
        ForEach(self.userNotes.notes) { note in
          NavigationLink(destination: NoteDetail(note: self.$userNotes.notes[self.userNotes.notes.firstIndex(of: note)!])) {
            Text(note.unsecuredContent!)
          }
        }
      }
      .navigationBarTitle(Text("Notes"), displayMode: .inline)
      .navigationBarItems(trailing: newNoteButton)
    }
  }

}

UserNotes.swift

class UserNotes: NSObject, ObservableObject {

  @Published var notes: [Note] = []

  var managedObjectContext: NSManagedObjectContext? = nil

  var fetchedResultsController: NSFetchedResultsController<Note> {
    if _fetchedResultsController != nil {
      return _fetchedResultsController!
    }

    let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()

    // Set the batch size to a suitable number.
    fetchRequest.fetchBatchSize = 20

    // Edit the sort key as appropriate.
    let sortDescriptor = NSSortDescriptor(key: "unsecuredContent", ascending: false)

    fetchRequest.sortDescriptors = [sortDescriptor]

    // Edit the section name key path and cache name if appropriate.
    // nil for section name key path means "no sections".
    let aFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                               managedObjectContext: self.managedObjectContext!,
                                                               sectionNameKeyPath: nil, cacheName: "Master")
    aFetchedResultsController.delegate = self
    _fetchedResultsController = aFetchedResultsController

    do {
      try _fetchedResultsController!.performFetch()
    } 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)")
    }

    return _fetchedResultsController!
  }
  var _fetchedResultsController: NSFetchedResultsController<Note>? = nil

  override init() {
    super.init()
    managedObjectContext = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    notes = fetchedResultsController.sections![0].objects as! [Note]
  }

  func createNewNote() {
    let newNote = Note(context: managedObjectContext!)

    // If appropriate, configure the new managed object.
    newNote.unsecuredContent = "New CloudKit note"

    // Save the context.
    do {
      try managedObjectContext!.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)")
    }
  }

}

extension UserNotes: NSFetchedResultsControllerDelegate {

  func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    notes = controller.sections![0].objects as! [Note]
  }

}

Note.swift (generated by Core Data)

//  This file was automatically generated and should not be edited.
//

import Foundation
import CoreData

@objc(Note)
public class Note: NSManagedObject {

}

Note.swift (extension)

extension Note: Identifiable {}
Eugene
  • 3,417
  • 5
  • 25
  • 49
  • 1
    While unaccepted, my answer here has 4 upvotes. See if this helps: https://stackoverflow.com/questions/57511826/change-to-published-var-in-environmentobject-not-reflected-immediately/57513103#57513103 –  Sep 04 '19 at 18:37
  • That seems to work, thank you @dfd! I don't fully understand why it works though, and how I can declare a `objectWillChange` without the `override` keyword when one already exists. Oh well, something to learn soon! – Eugene Sep 04 '19 at 18:43

2 Answers2

0

With @dfd's help (see here) I was able to resolve this issue by importing Combine to my UserNotes class, adding a objectWillChange, and calling objectWillChange.send():

import Foundation
import UIKit
import CoreData
import Combine

class UserNotes: NSObject, ObservableObject {

  var objectWillChange = PassthroughSubject<Void, Never>()

  @Published var notes: [Note] = [] {
    willSet {
      objectWillChange.send()
    }
  }

  var managedObjectContext: NSManagedObjectContext? = nil

  var fetchedResultsController: NSFetchedResultsController<Note> {
    if _fetchedResultsController != nil {
      return _fetchedResultsController!
    }

    let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()

    // Set the batch size to a suitable number.
    fetchRequest.fetchBatchSize = 20

    // Edit the sort key as appropriate.
    let sortDescriptor = NSSortDescriptor(key: "unsecuredContent", ascending: false)

    fetchRequest.sortDescriptors = [sortDescriptor]

    // Edit the section name key path and cache name if appropriate.
    // nil for section name key path means "no sections".
    let aFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                               managedObjectContext: self.managedObjectContext!,
                                                               sectionNameKeyPath: nil, cacheName: "Master")
    aFetchedResultsController.delegate = self
    _fetchedResultsController = aFetchedResultsController

    do {
      try _fetchedResultsController!.performFetch()
    } 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)")
    }

    return _fetchedResultsController!
  }
  var _fetchedResultsController: NSFetchedResultsController<Note>? = nil

  override init() {
    super.init()
    managedObjectContext = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    notes = fetchedResultsController.sections![0].objects as! [Note]
  }

  func createNewNote() {
    let newNote = Note(context: managedObjectContext!)

    // If appropriate, configure the new managed object.
    newNote.unsecuredContent = UUID().uuidString // Just some random crap

    // Save the context.
    do {
      try managedObjectContext!.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)")
    }
  }

}

extension UserNotes: NSFetchedResultsControllerDelegate {

  func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    notes = controller.sections![0].objects as! [Note]
  }

}

Eugene
  • 3,417
  • 5
  • 25
  • 49
  • 1
    It seems like calling `objectWillChange.send()` is no longer necessary for adding new items to the array but what about updating existing items? The only trick that worked was updating `List`'s `id` property to a new `UUID` so that it refreshes every time the view appears with the new data changes but refreshing every time is a bit much. – Ever Uribe May 08 '20 at 21:16
  • Having the same problem. Updating an item in Core Data won't update the UI (even with the objectWillChange.send() func. Is there a workaround? – cocos2dbeginner May 16 '20 at 19:39
  • How did you solve this? – RopeySim May 30 '23 at 10:32
0

With SwiftUI the fetched results controller goes in the View like this:

@Environment(\.managedObjectContext) var moc
@FetchRequest(entity: Note.entity(), sortDescriptors: []) var notes: FetchedResults<Note>

var body: some View {
    VStack{
        List{
            ForEach(notes, id: \.self) { note in
            ...
            }
        }
    }
}

You can also do Note(self.moc) to create your new notes right in the View e.g. in a button handler, without the need of that helper class.

malhal
  • 26,330
  • 7
  • 115
  • 133