Reputation: 382
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
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
Reputation: 9121
Make sure that the Core Data Model is checked in the Target Membership list in the File inspector.
Upvotes: 1
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
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