3

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()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .minute, 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 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.

Vishal Naik
  • 134
  • 12
Igor R.
  • 399
  • 4
  • 23
  • This might help you: [How to refresh Widget data?](https://stackoverflow.com/questions/63976424/how-to-refresh-widget-data) - the same logic can be applied to your case. – pawello2222 Sep 21 '20 at 14:11
  • @pawello2222 thank you for suggestion, I just tried similar way - [GitHub Gist](https://gist.github.com/gh-rusinov/224bbb1ea417daf9767c35e3ea9e8f61), but no luck, I get wrong value from `func getCountdownsCount()` as time goes. For example: it is right the first time, but after I delete rows in main app — it does not update `count`. – Igor R. Sep 21 '20 at 15:10
  • @pawello2222 I just wish I could call `getCountdownsCount()` every time view is re-rendered (when new timeline entry is displayed). – Igor R. Sep 21 '20 at 15:16
  • @pawello2222 I feel like I'm very close, but doing something wrong with managed object context... – Igor R. Sep 21 '20 at 15:58

1 Answers1

5

Widget views don't observe anything. They're just provided with TimelineEntry data. Which means @FetchRequest, @ObservedObject etc. will not work here.


  1. Enable remote notifications for your container:
let container = NSPersistentContainer(name: "DataModel")
let description = container.persistentStoreDescriptions.first
description?.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
  1. Update your CoreDataManager to observe remote notifications:
class CoreDataManager {
    var itemCount: Int?

    private var observers = [NSObjectProtocol]()

    init() {
        fetchData()
        observers.append(
            NotificationCenter.default.addObserver(forName: .NSPersistentStoreRemoteChange, object: nil, queue: .main) { _ in
                // make sure you don't call this too often - notifications may be posted in very short time frames
                self.fetchData()
            }
        )
    }

    deinit {
        observers.forEach(NotificationCenter.default.removeObserver)
    }

    func fetchData() {
        let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Item")

        do {
            self.itemCount = try CoreDataStack.shared.managedObjectContext.count(for: fetchRequest)
            WidgetCenter.shared.reloadAllTimelines()
        } catch {
            print("Failed to fetch: \(error)")
        }
    }
}
  1. Add another field in the Entry:
struct SimpleEntry: TimelineEntry {
    let date: Date
    let itemCount: Int?
}
  1. Use it all in the Provider:
struct Provider: TimelineProvider {
    let coreDataManager = CoreDataManager()

    ...

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
        let entries = [
            SimpleEntry(date: Date(), itemCount: coreDataManager.itemCount),
        ]

        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}
  1. Now you can display your entry in the view:
struct WidgetExtEntryView: View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text(entry.date, style: .time)
            Text("Count: \(String(describing: entry.itemCount))")
        }
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209