'How can I observe an array of CoreData objects in SwiftUI? [closed]

I am relatively new to SwiftUI and am having troubles finding a solution to a problem I have with my UI not updating when a change is made to a Core Data object. I have been working on this bug for the past three days and have scoured through StackOverflow as well as other sources and I haven't found anything that solves my problem.

Essentially, I have a Core Data entity "Book" that has relationships to other entities (i.e. "Author", "Editor", "Genre", etc) which in turn inherit from an abstract entity ("AbstractName"):

My Core Data entities

The Book entity's relationships

I created a view in SwiftUI that displays the book data without a problem. I use a global view model to keep track of the selected book (global, so that I can enable and disable menubar items on macOS based on whether a book is selected or not) and inject it into the view as an environment object. That all works just fine and when I edit the book, the view also correctly updates everything except for the relationships.

The reason it doesn't work with the relationships is because I use a subview to display them in a consistent manner which SwiftUI doesn't update when the data changes. I have to select another book, then go back to the one I just edited for the data to be updated. I understand this doesn't work because an array of Core Data objects doesn't conform to ObservableObject and so you can't observe it, but I have no idea how to go about fixing it.

And now finally some code. I've simplified the code a bit to only contain the necessary parts, but my project is also on GitHub (https://github.com/eiskalteschatten/BookJournalSwift), so I'll post the link to the full file below the code snippets. The subview that I pass the relationships to is called WrappingSmallChipsWithName.

The view showing the book data:

import SwiftUI

struct MacBookView: View {
    @EnvironmentObject private var globalViewModel: GlobalViewModel
    
    var body: some View {
        if let book = globalViewModel.selectedBook {
            let offset = 100.0
            let maxWidth = 800.0
            let textWithLabelSpacing = 50.0
            let groupBoxSpacing = 20.0
            let groupBoxWidth = (maxWidth / 2) - (groupBoxSpacing / 2)

            ScrollView {
                    LazyVStack(spacing: groupBoxSpacing) {
                        if book.authors != nil && book.sortedAuthors.count > 0 {
                            WrappingSmallChipsWithName<Author>(data: book.sortedAuthors, chipColor: AUTHOR_COLOR)
                        }
                        
                        Group {
                            HStack(alignment: .top, spacing: groupBoxSpacing) {
                                MacBookViewGroupBox(title: "Editors", icon: "person.2.wave.2", width: groupBoxWidth) {
                                    if book.editors != nil && book.sortedEditors.count > 0 {
                                        WrappingSmallChipsWithName<Editor>(data: book.sortedEditors, chipColor: EDITOR_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No editors selected")
                                    }
                                }
                                
                                MacBookViewGroupBox(title: "Genres", icon: "text.book.closed", width: groupBoxWidth) {
                                    if book.genres != nil && book.sortedGenres.count > 0 {
                                        WrappingSmallChipsWithName<Genre>(data: book.sortedGenres, chipColor: GENRE_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No genres selected")
                                    }
                                }
                            }
                            
                            HStack(alignment: .top, spacing: groupBoxSpacing) {
                                MacBookViewGroupBox(title: "Lists", icon: "list.bullet.rectangle", width: groupBoxWidth) {
                                    if book.lists != nil && book.sortedLists.count > 0 {
                                        WrappingSmallChipsWithName<ListOfBooks>(data: book.sortedLists, chipColor: LIST_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No lists selected")
                                    }
                                }
                                
                                MacBookViewGroupBox(title: "Tags", icon: "tag", width: groupBoxWidth) {
                                    if let unwrappedTags = book.tags {
                                        if unwrappedTags.allObjects.count > 0 {
                                            WrappingSmallChipsWithName<Tag>(data: unwrappedTags.allObjects as! [Tag], chipColor: TAG_COLOR, alignment: .leading)
                                        }
                                        else {
                                            Text("No tags selected")
                                        }
                                    }
                                }
                            }
                            
                            HStack(alignment: .top, spacing: groupBoxSpacing) {
                                MacBookViewGroupBox(title: "Translators", icon: "person.2", width: groupBoxWidth) {
                                    if book.translators != nil && book.sortedTranslators.count > 0 {
                                        WrappingSmallChipsWithName<Translator>(data: book.sortedTranslators, chipColor: TRANSLATOR_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No translators selected")
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Books/BookView/macOS/MacBookView.swift

The WrappingSmallChipsWithName subview that displays the relationship data:

import SwiftUI
import WrappingHStack

struct WrappingSmallChipsWithName<T: AbstractName>: View {
    var title: String?
    var data: [T]
    var chipColor: Color = .gray
    var alignment: HorizontalAlignment = .center
    
    var body: some View {
        VStack(alignment: alignment, spacing: 1) {
            if title != nil {
                Text(title!)
            }
            
            if data.count > 0 {
                Spacer()
                
                WrappingHStack(data, id: \.self, alignment: alignment) { item in
                    SmallChip(background: chipColor) {
                        HStack(alignment: .center, spacing: 4) {
                            if let name = item.name {
                                Text(name)
                            }
                        }
                    }
                    .padding(.horizontal, 1)
                    .padding(.vertical, 3)
                    .contextMenu {
                        let copyButtonLabel = item.name != nil ? "Copy \"\(item.name!)\"" : "Copy"
                        Button(copyButtonLabel) {
                            if let name = item.name {
                                copyTextToClipboard(name)
                            }
                        }
                        .disabled(item.name == nil)
                    }
                }
                .frame(minWidth: 250)
            }
        }
    }
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Elements/NamedElements/WrappingSmallChipsWithName.swift

The global view model:

import SwiftUI
import CoreData

final class GlobalViewModel: ObservableObject {
    private let defaults = UserDefaults.standard
    private var viewContext: NSManagedObjectContext?
    private let selectedBookURLKey = "GlobalViewModel.selectedBookURL"
    
    @Published var selectedBook: Book? {
        didSet {
            // Use user defaults instead of @SceneStorage so it can be initialized in the constructor
            defaults.set(selectedBook?.objectID.uriRepresentation(), forKey: selectedBookURLKey)
        }
    }
    
    #if os(iOS)
    @Published var globalError: String?
    @Published var globalErrorSubtext: String?
    @Published var showGlobalErrorAlert: Bool = false {
        didSet {
            if !showGlobalErrorAlert {
                globalError = nil
                globalErrorSubtext = nil
            }
        }
    }
    #endif
    
    static let shared: GlobalViewModel = GlobalViewModel()
    
    private init() {
        let persistenceController = PersistenceController.shared
        viewContext = persistenceController.container.viewContext
        
        if let url = defaults.url(forKey: selectedBookURLKey),
           let objectID = viewContext!.persistentStoreCoordinator!.managedObjectID(forURIRepresentation: url),
           let book = try? viewContext!.existingObject(with: objectID) as? Book {
                selectedBook = book
        }
    }
    
    #if os(macOS)
    func promptToDeleteBook() {
        let alert = NSAlert()
        alert.messageText = "Are you sure you want to delete this book?"
        alert.informativeText = "This is permanent."
        alert.addButton(withTitle: "No")
        alert.addButton(withTitle: "Yes")
        alert.alertStyle = .warning
        
        let delete = alert.runModal() == NSApplication.ModalResponse.alertSecondButtonReturn
        
        if delete {
            deleteBook()
        }
    }
    
    func deleteBook() {
        withAnimation {
            if let unwrappedBook = selectedBook {
                selectedBook = nil
                viewContext!.delete(unwrappedBook)
                
                do {
                    try viewContext!.save()
                } catch {
                    handleCoreDataError(error as NSError)
                }
            }
        }
    }
    #endif
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Models/GlobalViewModel.swift

The extension that defines "sortedAuthors", "sortedEditors", etc:

import SwiftUI

extension Book {
    public var sortedAuthors: [Author] {
        let set = authors as? Set<Author> ?? []
        return set.sorted {
            $0.wrappedName < $1.wrappedName
        }
    }
    
    public var sortedEditors: [Editor] {
        let set = editors as? Set<Editor> ?? []
        return set.sorted {
            $0.wrappedName < $1.wrappedName
        }
    }
    
    public var sortedTranslators: [Translator] {
        let set = translators as? Set<Translator> ?? []
        return set.sorted {
            $0.wrappedName < $1.wrappedName
        }
    }
    
    public var sortedLists: [ListOfBooks] {
        let set = lists as? Set<ListOfBooks> ?? []
        return set.sorted {
            $0.wrappedName < $1.wrappedName
        }
    }
    
    public var sortedGenres: [Genre] {
        let set = genres as? Set<Genre> ?? []
        return set.sorted {
            $0.wrappedName < $1.wrappedName
        }
    }
}

extension AbstractName {
    public var wrappedName: String {
        name ?? "Unnamed"
    }
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Extensions/CoreData.swift

I only posted the code for the view I use for macOS. I also have a similar one I use for iOS, but it uses the same subview (WrappingSmallChipsWithName) and has the same problem.

I know I somehow need to pass an object or view model that can be observed to the subview, but since the subview is generic and needs to access different types of entities (which all inherit from the same abstract entity), I'm just not sure how to go about doing that.

Does anyone have any suggestions here? I'm starting to pull my hair out with this seemingly small issue.



Solution 1:[1]

We use FetchRequest (or @FetchRequest) to observe managed object arrays. To observe the array of related objects supply a NSPredicate, e.g. to observe the array of books for an author use: "author = %@", author. E.g.

private var fetchRequest: FetchRequest<Book>
private var books: FetchedResults<Book> {
    fetchRequest.wrappedValue
}

init(author: Author) {
    let sortDescriptors = [SortDescriptor(\Book.timestamp, order: sortAscending ? .forward : .reverse)]
    fetchRequest = FetchRequest(sortDescriptors: sortDescriptors, predicate: NSPredicate(format: "author = %@", author), animation: .default)

FetchRequest is a DynamicProperty struct which gives it some special abilities compared to a normal struct. Before SwiftUI calls body it calls update on all the dynamic properties, and in the case of FetchRequest it reads the managed object context out of the @Environment.

When using Core Data we can generate an NSManagedObject subclass or extension to add additional functionality, e.g. class Book: NSManagedObject. In the model editor choose generate model subclass from the menu. Move everything from BookModel into the Book extension. These managed objects do conform to ObservableObject which allows you to use @ObservedObject which again makes the View struct value type behave like a view model object because when it detects a change, body will be called.

In SwiftUI we try not to use objects for view state because SwiftUI's use of value types is designed to eliminate the bugs typical of objects, so I would suggest removing the GlobalViewModel class and instead using SwiftUI features @State and @AppStorage which makes the View struct value type have view model object semantics.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1