swiftswiftuicore-datansfetchrequest

SwiftUI: Best practice to access selection of Core Data entity in fetch requests


Summary

Hi! I’m looking for the best way to get a Core Data entity – selected by the user – everywhere in my SwiftUI app. The app has a fairly complex view structure with multiple tabs, modals and navigation stack levels.

As you can see below, the model is structured in a way that all entities are in some way related to one entity (Home). It’s possible to have multiple Home entities, though. The user can then select which Home they want to see and the whole app reloads it’s content based on that selection. The selection is also saved in User Defaults to be persisted during launches.

So, as I basically need the selected Home in (almost) every view, I don’t want to pass it down as a parameter of every single view that is loaded.

Core Data Structure

enter image description here

Code Structure

Currently I’m using a DataHandler() manager class that (creates) and holds the selected Home as a @Published variable. This variable is an optional which allows me to handle the onboarding if users haven’t created a Home yet.

@main
struct MyApp: App {
    let persistenceController = PersistenceController.shared
    
    // My custom manager class
    @StateObject var dataHandler = DataHandler()

    var body: some Scene {
        WindowGroup {
            ContentView()
                    .environment(\.managedObjectContext, persistenceController.container.viewContext)
                    .environmentObject(dataHandler)
        }
    }
}
class DataHandler: ObservableObject {
    // Variable that holds the user selected Home entity
    @Published var selectedHome: Home?
    
    // User Defaults
    @AppStorage("homeID") private var homeID: UUID?
    
    
    init() {
        // Check if User Default exists
        if homeID != nil {
            // Search for Home with User Default ID
            selectedHome = fetchHomeBy(id: homeID!)
            
            // Check if Home with ID exists
            if selectedHome == nil {
                loadFallbackHome()
            }
        } else {
            // No UserDefault
            loadFallbackHome()
        }
    }


    // more stuff …
}

To access the selected Home in the given view I’m then using an @EnvironmentObject. I then want to filter my other entity types for this very selection in a @FetchRequest or @SectionedFetchRequest. The fetch requests may also filter for additional variables or custom sorting.

struct EventsTabView: View {
    @EnvironmentObject var dataHandler: DataHandler

    @SectionedFetchRequest private var events: SectionedFetchResults<String, Event>
    
    init() {
        // Fetching Events of selected Home that aren’t archived
        _events = SectionedFetchRequest(entity: Event.entity(),
                                        sectionIdentifier: \.startYear,
                                        sortDescriptors: [NSSortDescriptor(keyPath: \Event.startDate, ascending: false)],
                                        predicate: NSPredicate(format: "eventHome == %@, isArchived != true", DataHandler().selectedHome!))
    }
    
    var body: some View {
        // …
    }
}

Problem

The problem I’m currently running into is that I can’t access the manager class in the @FetchRequest. I need this to filter the Meters and Events for the selected Home and additional filter parameters, though!

I’ve learned that @EnvironmentObjects can’t be used in a custom init(). And the way I currently handle the predicate of the fetch request isn’t great either as it always creates a new instance of DataHandler(). 👎

I’ve tried putting the fetch requests in computed variables that returns an array of the entities I need. The problem then is that my UI doesn’t correctly update when adding/deleting/editing the data.

I also thought about using a derived Home.id attribute in every other entity. This way I could only store the selected Home ID in the @Published var. I guess that’s better performance wise? Though that still begs the question how to access this variable in the @FetchRequests then?

So my questions would be:

  1. How can I access an app wide variable in the dynamic fetch requests of my init()?
  2. Is there a better design to handle a user selection like this instead of loading the whole entity into a @Published variable of an @EnvironmentObject class?
  3. Is using computed variables to store the fetch request results worse (performance wise) than doing the fetch request in the views init()?

PS: I’m fairly new to SwiftUI, so I would be so thankful if someone can point me into a direction or can point out a better solution. :)


Solution

  • I’ve ended up now creating a fairly complex setup with multiple handler classes (one for each entity) which do all the Core Data fetching using NSFetchedResultsControllers. The selected home entity is then passed down via a Cancellable. This requires the import of the Combine framework.

    class MetersHandler: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
        let objectContext = PersistenceController.shared.container.viewContext
        
        // Set of cancellable from Combine framework.
        // Used for listening to `selectedHome` changes.
        private var cancellables = Set<AnyCancellable>()
        
        // Fetched Results Controllers
        private let allMetersFetchedResultsController: NSFetchedResultsController<Meter>
        
        // Published Fetched Results
        @Published var allMeters = [Meter]()
    
        override init() {
            // Setup Fetched Results Controllers
            allMetersFetchedResultsController = NSFetchedResultsController(fetchRequest: Meter.allFetchRequest,
                                                                           managedObjectContext: objectContext,
                                                                           sectionNameKeyPath: nil,
                                                                           cacheName: nil)
            
            
            super.init()
    
            allMetersFetchedResultsController.delegate = self
            
            
            // Attaching `selectedHome` as a cancellable to variable.
            // Watches for changes of `selectedHome` until it the wrapper gets cancelled.
            // Requires "Combine" framework to work.
            HomesHandler.shared.$selectedHome
                .sink { home in
                    self.fetchAllMeters(selectedHome: home)
                }
                .store(in: &cancellables)
        }
    
        private func fetchAllMeters(selectedHome: Home?) {
            // do all the fetching
        }
    
        func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
            // update `allMeters` var, if something changes
        }
    }
    

    Thanks to my friend Lukas for helping me with that!