phiredrop
phiredrop

Reputation: 382

Fetch CoreData in a Widget

I'm trying to fetch Entities and Attributes from my Core Data to be displayed in a Widget and really struggling. Currently I can fetch the ItemCount of an Entity, but that's not what I'm trying to display. I'd like to be able to display the strings and images that I have saved in Core Data. I've looked at tons of sites, including this post, but haven't had success.

My current setup has a working App Group for the Core Data which is shared to the main app and the widget.

Here's the Widget.swift code that works to display the Entity's ItemCount:

import WidgetKit
import SwiftUI
import CoreData


private struct Provider: TimelineProvider {
    
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
        let midnight = Calendar.current.startOfDay(for: Date())
        let nextMidnight = Calendar.current.date(byAdding: .day, value: 1, to: midnight)!
        let entries = [SimpleEntry(date: midnight)]
        let timeline = Timeline(entries: entries, policy: .after(nextMidnight))
        completion(timeline)
    }
}

private struct SimpleEntry: TimelineEntry {
    let date: Date
}

private struct MyAppWidgetEntryView: View {
    var entry: Provider.Entry
    let moc = PersistenceController.shared.container.viewContext
    let predicate = NSPredicate(format: "favorite == true")
    let request = NSFetchRequest<Project>(entityName: "Project")
    
    
    var body: some View {
        // let result = try moc.fetch(request)
        
        VStack {
            Image("icon")
                .resizable()
                .aspectRatio(contentMode: .fit)
            Text("Item Count: \(itemsCount)")
                .padding(.bottom)
                .foregroundColor(.secondary)
        }
    }

    var itemsCount: Int {
        let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Project")
        do {
            return try PersistenceController.shared.container.viewContext.count(for: request)

        } catch {
            print(error.localizedDescription)
            return 0
        }
    }
}
@main
struct MyAppWidget: Widget {
    let kind: String = "MyAppWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyAppWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("MyApp Widget")
        .description("Track your project count")
        .supportedFamilies([.systemSmall])
    }
}

But again, what I'd really like to be able to do is access the attribute bodyText or image1 from the Entity, like how my main app accesses it. E.g.,

VStack {
        Image(uiImage: UIImage(data: project.image1 ?? self.projectImage1)!)
             .resizable()
             .aspectRatio(contentMode: .fill)
 
        Text("\(project.bodyText!)")
    }

Here's the Widget.swift code that seems closer to what I'm trying to accomplish, but it fails on getting project.bodyText with the error Value of type 'FetchedResults<Project>' has no member 'bodyText' -- I can't figure out why it's not seeing the attributes for my entity Project

// MARK: Core Data
var managedObjectContext: NSManagedObjectContext {
    return persistentContainer.viewContext
}

var workingContext: NSManagedObjectContext {
    let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
    context.parent = managedObjectContext
    return context
}

var persistentContainer: NSPersistentCloudKitContainer = {
    let container = NSPersistentCloudKitContainer(name: "MyAppApp")

    let storeURL = URL.storeURL(for: "group.com.me.MyAppapp", databaseName: "MyAppApp")
    let description = NSPersistentStoreDescription(url: storeURL)

    container.loadPersistentStores(completionHandler: { storeDescription, error in
        if let error = error as NSError? {
            print(error)
        }
    })

    container.viewContext.automaticallyMergesChangesFromParent = true
    container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy

    return container
}()


// MARK: WIDGET
struct Provider: TimelineProvider {

// CORE DATA
var moc = managedObjectContext

    init(context : NSManagedObjectContext) {
        self.moc = context
    }

// WIDGET
func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date())
}

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    let entry = SimpleEntry(date: Date())
    completion(entry)
}

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []

    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    let currentDate = Date()
    for hourOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = SimpleEntry(date: entryDate)
        entries.append(entry)
    }

    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
}
}

struct SimpleEntry: TimelineEntry {
let date: Date

}

struct MyAppWidgetEntryView : View {
var entry: Provider.Entry

@FetchRequest(entity: Project.entity(), sortDescriptors: []) var project: FetchedResults<Project>

var body: some View {

        Text("\(project.bodyText)").  <--------------------- ERROR HAPPENS HERE

}
}

@main
struct MyAppWidget: Widget {
let kind: String = "MyAppWidget"

var body: some WidgetConfiguration {
    StaticConfiguration(kind: kind, provider: Provider(context: managedObjectContext)) { entry in
        MyAppWidgetEntryView(entry: entry)
            .environment(\.managedObjectContext, managedObjectContext)
    }
    .configurationDisplayName("My Widget")
    .description("This is an example widget.")
}
}

struct MyAppWidget_Previews: PreviewProvider {
static var previews: some View {
    MyAppWidgetEntryView(entry: SimpleEntry(date: Date()))
        .previewContext(WidgetPreviewContext(family: .systemSmall))
}
}

Am I close? Have you happened to get something similar working? Thanks so much!

Upvotes: 1

Views: 1852

Answers (4)

Dary
Dary

Reputation: 31

@NSFetchRequest of course works in widget. You can get the connection to your Core Data but can't fetch the request, is mostly because that the entity you're fetching contains transformable type, and for some reason this error occurred: Cannot decode object of class, try fix this.

Upvotes: 0

Ely
Ely

Reputation: 9121

Make sure that the Core Data Model is checked in the Target Membership list in the File inspector.

Upvotes: 1

Volker88
Volker88

Reputation: 654

As far as I did read in the internet @NSFetchRequest is not working in Widgets.

I created a CoreDataManager class for the Widget which will fetch the data from "getTimeline"

My Code is a little bit more complex as my Widget is configurable but maybe it helps you to understand how to get it work.

First the DataController which is shared between the main app and the widget.

    class DataController: ObservableObject {
    let container: NSPersistentCloudKitContainer

    init(inMemory: Bool = false) {
        container = NSPersistentCloudKitContainer(name: "Main", managedObjectModel: Self.model)
        let storeURL = URL.storeURL(for: "MyGroup", databaseName: "Main")
        let storeDescription = NSPersistentStoreDescription(url: storeURL)
        storeDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "Identifier"
        )
        container.persistentStoreDescriptions = [storeDescription]

        guard let description = container.persistentStoreDescriptions.first else {
            Log.shared.add(.coreData, .error, "###\(#function): Failed to retrieve a persistent store description.")
            fatalError("###\(#function): Failed to retrieve a persistent store description.")
        }

        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
        }

        container.loadPersistentStores { (_, error) in
            if let error = error {
                Log.shared.add(.cloudKit, .fault, "Fatal error loading store \(error)")
                fatalError("Fatal error loading store \(error.localizedDescription)")
            }
        }

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    static let model: NSManagedObjectModel = {
        guard let url = Bundle.main.url(forResource: "Main", withExtension: "momd") else {
            fatalError("Failed to locate model file.")
        }

        guard let managedObjectModel = NSManagedObjectModel(contentsOf: url) else {
            fatalError("Failed to load model file.")
        }

        return managedObjectModel
    }()
}

CoreDataManager for the Widget

class CoreDataManager {
    var vehicleArray = [Vehicle]()
    let dataController: DataController

    private var observers = [NSObjectProtocol]()

    init(_ dataController: DataController) {
        self.dataController = dataController
        fetchVehicles()

        /// Add Observer to observe CoreData changes and reload data
        observers.append(
            NotificationCenter.default.addObserver(forName: .NSPersistentStoreRemoteChange, object: nil, queue: .main) { _ in //swiftlint:disable:this line_length discarded_notification_center_observer
                self.fetchVehicles()
            }
        )
    }

    deinit {
        /// Remove Observer when CoreDataManager is de-initialized
        observers.forEach(NotificationCenter.default.removeObserver)
    }

    /// Fetches all Vehicles from CoreData
    func fetchVehicles() {
        defer {
            WidgetCenter.shared.reloadAllTimelines()
        }

        dataController.container.viewContext.refreshAllObjects()
        let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Vehicle")

        do {
            guard let vehicleArray = try dataController.container.viewContext.fetch(fetchRequest) as? [Vehicle] else {
                return
            }
            self.vehicleArray = vehicleArray
        } catch {
            print("Failed to fetch: \(error)")
        }
    }
}

I did add my CoreData Model "Vehicle" to the WidgetEntry

struct WidgetEntry: TimelineEntry {
    let date: Date
    let configuration: SelectedVehicleIntent
    var vehicle: Vehicle?
}

Timeline Provider is fetching all CoreData when the timeline is reloaded and injecting my Core data Model into the WidgetEntry

struct Provider: IntentTimelineProvider {

    /// Access to CoreDataManger
    let dataController = DataController()
    let coreDataManager: CoreDataManager

    init() {
        coreDataManager = CoreDataManager(dataController)
    }

    /// Placeholder for Widget
    func placeholder(in context: Context) -> WidgetEntry {
        WidgetEntry(date: Date(), configuration: SelectedVehicleIntent(), vehicle: Vehicle.example)
    }

    /// Provides a timeline entry representing the current time and state of a widget.
    func getSnapshot(for configuration: SelectedVehicleIntent, in context: Context, completion: @escaping (WidgetEntry) -> Void) { //swiftlint:disable:this line_length
        vehicleForWidget(for: configuration) { selectedVehicle in
            let entry = WidgetEntry(
                date: Date(),
                configuration: configuration,
                vehicle: selectedVehicle
            )
            completion(entry)
        }
    }

    /// Provides an array of timeline entries for the current time and, optionally, any future times to update a widget.
    func getTimeline(for configuration: SelectedVehicleIntent, in context: Context, completion: @escaping (Timeline<WidgetEntry>) -> Void) { //swiftlint:disable:this line_length
        coreDataManager.fetchVehicles()

        /// Fetches the vehicle selected in the configuration
        vehicleForWidget(for: configuration) { selectedVehicle in
            let currentDate = Date()
            var entries: [WidgetEntry] = []

            // Create a date that's 60 minutes in the future.
            let nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 60, to: currentDate)!

            // Generate an Entry
            let entry = WidgetEntry(date: currentDate, configuration: configuration, vehicle: selectedVehicle)
            entries.append(entry)

            let timeline = Timeline(entries: entries, policy: .after(nextUpdateDate))
            completion(timeline)
        }
    }

    /// Fetches the Vehicle defined in the Configuration
    /// - Parameters:
    ///   - configuration: Intent Configuration
    ///   - completion: completion handler returning the selected Vehicle
    func vehicleForWidget(for configuration: SelectedVehicleIntent, completion: @escaping (Vehicle?) -> Void) {
        var selectedVehicle: Vehicle?
        defer {
            completion(selectedVehicle)
        }

        for vehicle in coreDataManager.vehicleArray where vehicle.uuid?.uuidString == configuration.favoriteVehicle?.identifier { //swiftlint:disable:this line_length
            selectedVehicle = vehicle
        }
    }
}

And finally the WidgetView

struct WidgetView: View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text("CoreData: \(entry.vehicle?.fuel?.count ?? 99999)")
            Text("Name: \(entry.vehicle?.brand ?? "No Vehicle found")")
            Divider()
            Text("Refreshed: \(entry.date, style: .relative)")
                .font(.caption)
        }
        .padding(.all)
    }
}

Upvotes: 2

Stamenkovski
Stamenkovski

Reputation: 1044

You can remove those lines

let storeURL = URL.storeURL(for: "group.com.me.MyAppapp", databaseName: "MyAppApp")
let description = NSPersistentStoreDescription(url: storeURL)

and instead override defaultDirectoryURL() like this:

override public class func defaultDirectoryURL() -> URL {
    let storeURL = FileManager.default.containerURL(
        forSecurityApplicationGroupIdentifier: "group.com.me.MyAppapp"
    )
    return storeURL!
}

Edit:
Just saw that you don't have a subclass of NSPersistentCloudKitContainer, you should have one where you can override defaultDirectoryURL() and provide the default location of the persistent store.

Also it is a good practice to have a single/shared instance of NSPersistentCloudKitContainer so you don't load the store every time you use the NSPersistentCloudKitContainer variable.

Upvotes: 1

Related Questions