UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

How to ensure WidgetKit view shows correct results from @FetchRequest?

Forums > SwiftUI

I have an app that uses Core Data with CloudKit. Changes are synced between devices. The main target has Background Modes capability with checked Remote notifications. Main target and widget target both have the same App Group, and both have iCloud capability with Services set to CloudKit and same container in Containers checked.

My goal is to display actual Core Data entries in SwiftUI WidgetKit view.

My widget target file:

import WidgetKit
import SwiftUI
import CoreData

// MARK: For Core Data

public extension URL {
    /// Returns a URL for the given app group and database pointing to the sqlite database.
    static func storeURL(for appGroup: String, databaseName: String) -> URL {
        guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
            fatalError("Shared file container could not be created.")
        }

        return fileContainer.appendingPathComponent("\(databaseName).sqlite")
    }
}

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: "Countdowns")

    let storeURL = URL.storeURL(for: "group.app-group-countdowns", databaseName: "Countdowns")
    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: For Widget

struct Provider: TimelineProvider {
    var moc = managedObjectContext

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

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

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

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

        let currentDate = Date()
        let entryDate = Calendar.current.date(byAdding: .minute, value: 1, 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 CountdownsWidgetEntryView : View {
    var entry: Provider.Entry

    @FetchRequest(entity: Countdown.entity(), sortDescriptors: []) var countdowns: FetchedResults<Countdown>

    var body: some View {
        return (
            VStack {
                ForEach(countdowns, id: \.self) { (memoryItem: Countdown) in
                    Text(memoryItem.title ?? "Default title")
                }.environment(\.managedObjectContext, managedObjectContext)
                Text(entry.date, style: .time)
            }
        )
    }
}

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

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

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

But I have a problem: let's say I have 3 Countdown records in the main app:

At the start widget view shows 3 records as expected in preview (UI for adding a widget). But after I add a widget to the home screen, it does not show Countdown rows, only entry.date, style: .time. When timeline entry changes, rows not visible, too. I made a picture to illustrate this better:

adding a widget

Or:

At the start widget view shows 3 records as expected, but after a minute or so, if I delete or add Countdown records in the main app, widget still shows initial 3 values, but I want it to show the actual number of values (to reflect changes). Timeline entry.date, style .time changes, reflected in the widget, but not entries from request.

Is there any way to ensure my widget shows correct fetch request results? Thanks.

3      

Hi, If I understand your question correctly you want Widgets to update not just on the timelineProvider schedule but also when you make a relevant change in your app.

I add this code in my host app whenever I save something that affects the Widget.

       if self.moc.hasChanges {
          do {
              try self.moc.save()

              WidgetCenter.shared.reloadAllTimelines()

          } catch let error {
              print("Error Save Oppty: \(error.localizedDescription)")
          }
      }

Don't forget to import WidgetKit. This reloads the timelines and gets you what you want. If you are looking for something that will trigger an update to the widget on your iPhone when you update the core data on your mac then you will need to register for remote updates with the NSPersistentStoreRemoteChangeNotificationPostOptionKey in your store description to recieve those updates on each device. You can then call the reloadAllTimelines() from there as well.

Hope this helps. Let me know if I missed the question. I have the small and medium widgets working well with CD but have not figured out how I want to use the large yet.

3      

Hello @lyncht22, thank you for your reply.

I changed my persistentContainer declaration, to this:

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

    let storeURL = URL.storeURL(for: "group.app-group-countdowns", databaseName: "Countdowns")
    let description = NSPersistentStoreDescription(url: storeURL)

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

    if managedObjectContext.hasChanges {
        do {
            try managedObjectContext.save()
            WidgetCenter.shared.reloadAllTimelines()
        } catch let error {
            print("Error Save Oppty: \(error.localizedDescription)")
        }

    }

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

    return container
}()

But now I get a fatal error, when I run widget target on device. Error:

2020-09-23 19:12:49.171978+0300 CountdownsWidgetExtension[2197:724341] [NotificationListener] CloudKit push notifications require the 'remote-notification' background mode in the info plist
CoreData: debug: CoreData+CloudKit: -[PFCloudKitOptionsValidator validateOptions:andStoreOptions:error:](35): Validating options: <NSCloudKitMirroringDelegateOptions: 0x282795500> containerIdentifier:iCloud.container-countdowns databaseScope:Private ckAssetThresholdBytes:<null> operationMemoryThresholdBytes:<null> useEncryptedStorage:NO useDeviceToDeviceEncryption:NO automaticallyDownloadFileBackedFutures:NO automaticallyScheduleImportAndExportOperations:YES skipCloudKitSetup:NO preserveLegacyRecordMetadataBehavior:NO useDaemon:YES apsConnectionMachServiceName:<null> containerProvider:<PFCloudKitContainerProvider: 0x280b98220> storeMonitorProvider:<PFCloudKitStoreMonitorProvider: 0x280b98230> metricsClient:<PFCloudKitMetricsClient: 0x280b98240> metadataPurger:<PFCloudKitMetadataPurger: 0x280b98250> scheduler:<null> notificationListener:<null> containerOptions:<null> defaultOperationConfiguration:<null> progressProvider:<NSPersistentCloudKitContainer: 0x281c93080>
storeOptions: {
    NSInferMappingModelAutomaticallyOption = 1;
    NSMigratePersistentStoresAutomaticallyOption = 1;
    NSPersistentCloudKitContainerOptionsKey = "<NSPersistentCloudKitContainerOptions: 0x2807987e0>";
    NSPersistentHistoryTrackingKey = 1;
    NSPersistentStoreMirroringOptionsKey =     {
        NSPersistentStoreMirroringDelegateOptionKey = "<NSCloudKitMirroringDelegate: 0x283294410>";
    };
}
2020-09-23 19:12:51.179218+0300 CountdownsWidgetExtension[2197:724368] [lifecycle] WARNING: Did not receive handshake message from the host after waiting ~2 seconds. THIS MAY BE A SPURIOUS LAUNCH OF THE PLUGIN due to a message to an XPC endpoint other than the main service endpoint, or the CPU is highly contended and this extension or its host is not getting enough CPU time.
(lldb) 

As far as I understand, I need to enable Remote notifications checkbox in Background Modes capability, but I don't know how to enable Background Modes capability in Widget target... Can it be done? 🧐

3      

My goal is to display updated rows from Core Data. As far I understand — widget views don't observe anything. They're just provided with TimelineEntry data. Which means @FetchRequest will not work here. I feel like I need to re-init persistentContainer periodically, am I correct?

3      

Hi @gh-rusinov, I don't think you need to re-init the container. I understood that you had this working with Core Data in the widget already. I am using CoreData from a swift package so I import into both my app and the widget. Also, I am using the views from the package as well as opposed to building them right there in the widget file.

Ex Code

var body: some View {
    switch family {
    case .systemSmall:
        // I use this in both my app and in widgets
        DashboardOpportunities<Opportunity>(predicate: NSPredicate(value: true), sortDescriptors: [])
            .environment(\.managedObjectContext, MyCoreDataStack.shared.persistentContainer.viewContext)

           // Other family sizes
  }
}

The updates are called and registered from the same stack.

I would recommend:

1) Go back to the container setting you had where it was working in the widget as well.

2) From the main app call WidgetCenter.shared.reloadAllTimelines() whenever you save something in order to make sure the widget updates itself.

Oh, it might help to confirm in your widget that you have AppGroup capability with your group container checked.

// Adding more comments Just looking at your TimelineEntry code. You should update that to include your NSManagedObject. That will force you to update a few more items. I think that will help.

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
    // NSManaged Object
    public let opportunity: Opportunity
}

Thanks

3      

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.