Search code examples
swiftswiftuicore-data

Check if Core Data Entity is empty from outside a view


I currently have a ContentView with a FetchRequest which I store as a variable. I want to disable a menu item Button (this is on macOS) if the Core Data entity from the FetchRequest is Empty. Here is the relative code in my ContentView:

struct ContentView: View {

@Environment(\.managedObjectContext) private var viewContext
@SectionedFetchRequest(
    sectionIdentifier: \.startDateRelative,
    sortDescriptors: [NSSortDescriptor(keyPath: \MyEntity.startDate, ascending: false)],
    animation: .default)
    var items: SectionedFetchResults<String, MyEntity>
}

In "MyApp" I have:

@main
struct MyApp: App {
    let persistenceController = PersistenceController.shared
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
        .commands {
            CommandMenu("Database") {
                Button("Delete All") {
                    stuffToDo()
                }
                .disabled() // This is the problem, what do I put in the ()?
            }
        }
    }
}

The problem is, if I try to add .disabled(contentView.items.isEmpty) then I get the error Accessing StateObject's object without being installed on a View. This will create a new instance each time. and it doesn't work.

I've been successful in checking if the entity is empty once, but I need this to be updated anytime the entity changes, otherwise it won't function properly.

Any ideas?

EDIT: Persistence Controller:

    import CoreData

struct PersistenceController {
    static let shared = PersistenceController()

    static var preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
        for _ in 0..<10 {
            let newItem = MyEntity(context: viewContext)
            newItem.startTime = Date()
        }
        do {
            try viewContext.save()
        } catch {
            let nsError = error as NSError
            print("Unresolved error during save \(nsError), \(nsError.userInfo)")
        }
        return result
    }()

    let container: NSPersistentCloudKitContainer

    init(inMemory: Bool = false) {
        container = NSPersistentCloudKitContainer(name: "MyApp")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                print("Unresolved data error \(error), \(error.userInfo)")
            }
        })
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}

Solution

  • You don't have a CoreData problem, you have an inter app communication problem. You need to change state in your App struct to enable and disable the CommandMenu. In the simplest world, you could create a State var in your App file and bind that to and @Binding in your ContentView and simply change that binding as needed based on whether your fetch returned anything. However, I doubt you have two views, so the other route which is only a bit more complex would be allowing that value to be changed through the environment. You could then trigger that change from anywhere (with the standard limitations) and have that disable or enable the CommandMenu at will.

    I first created an ObservableObject, which would be overkill if you only need to pass a simple binding between views, and use that to disable or enable the CommandMenu:

    import SwiftUI
    
    class CommandMenuDisabled: ObservableObject {
        
        @Published var value = false
        
        func toggle() {
            value.toggle()
        }
    }
    
    // This creates the EnvironmentKey that you will inject into the environment.
    struct CommandMenuDisabledKey: EnvironmentKey {
      static var defaultValue = CommandMenuDisabled()
    }
    
    // This tells the environment that the EnvironmentKey exists and how to handle it.
    extension EnvironmentValues {
      var commandMenuDisabled: CommandMenuDisabled {
        get { self[CommandMenuDisabledKey.self] }
        set { self[CommandMenuDisabledKey.self] = newValue }
      }
    }
    

    Next, I added it to the App file:

    @main
    struct MyApp: App {
        let persistenceController = PersistenceController.shared
        @StateObject var disabled = CommandMenuDisabled()
    
        
        var body: some Scene {
            WindowGroup {
                ContentView(commandMenuDisabled: $disabled.value) // If you are using an @Binding, send it here.
                    .environment(\.managedObjectContext, persistenceController.container.viewContext)
                    .environment(\.commandMenuDisabled, disabled)
    
            }
            .commands {
                CommandMenu("Database") {
                    Button("Delete All") {
                        stuffToDo()
                    }
                    .disabled(disabled.value) // Use the StateObject here
                }
            }
        }
    }
    

    And, a simple demonstration ContentView:

    struct ContentView: View {
        @Environment(\.commandMenuDisabled) var disabled // Receive it here
        
        @Binding var commandMenuDisabled: Bool // or receive it here for @Binding
        
        var body: some View {
            VStack {
                // This button uses the @Environment value
                Button {
                    disabled.toggle() // calls the toggle func in the ObservableObject
                } label: {
                    Text("@Environment Toggle Command Menu")
                }
    
                // This button uses the @Binding value
                Button {
                    commandMenuDisabled.toggle() // toggles the @Binding value
                } label: {
                    Text("@Binding Toggle Command Menu")
                }
            }
            .padding()
        }
    }
    

    By passing the ObservableObject in the environment, you get to control it, but updates won't affect any other views directly, other than the App struct.

    edit:

    CoreData entities can be observed in the view with an .onChange():

    .onChange(of: items.count) { newValue in
        commandMenuDisabled.toggle()
    }
    

    You will probably need to start off with the CommandMenu disabled, and then when count > 0, enable it.