Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

@FetchRequest results not updating in SwiftUI when change source is share extension

There are many questions on @FetchRequest with answers that provide workarounds for forcing SwiftUI to redraw the view. I can't find any that address this question.

My app uses a share extension to insert a new item into my CoreData model (sqlite). A framework is shared between the main app and the share extension target. The main SwiftUI ContentView in my app uses a @FetchRequest property to monitor the contents of the database and update the view:

struct ContentView: View {
    @FetchRequest(fetchRequest: MyObj.allFetchRequest()) var allObjs: FetchedResults<MyObj>
    ...
}

my xcdatamodel is extended with:

    public static func allFetchRequest() -> NSFetchRequest<MyObj> {
        let request: NSFetchRequest<MyObj> = MyObj.fetchRequest()
        // update request to sort by name
        request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
        return request
    }

The share extension adds a new item using:

let newObj = MyObj(context: context)
newObj.name = "new name"
try! context.save()

After adding a new item using the share extension, when I return to my app by switching apps on the device, SceneDelegate detects sceneDidBecomeActive. In this function, I can manually run a fetch request and see that the new item has been added. I can even use answers from other SO questions to trick the UI into rebuilding in ContentView (by toggling a Bool used in the builder on receipt of a notification), but in either case, when the UI rebuilds, allObjs does not contain the updated results from the database.

In my sceneDidBecomeActive I have tried numerous combinations of code to attempt to update the context with the latest contents of the database in a way that the @FetchRequest will see it. The data is there, and the my managed context can see this new data because a manual fetch request returns the new item, but the @FetchRequest is always stale preventing a UI rebuild. In both locations I've printed the context and am sure that it is the same context. I've tried:

  • context.reset()
  • context.refreshAllObjects()
  • context.save()

I'm at a loss as to why the @FetchRequest is not updating its values when ContentView redraws.

How can I force @FetchRequest to update its objects and cause a SwiftUI rebuild after my share extension adds a new object to the database?

(Xcode 11.4.1, Swift 5, target: iOS 13.1)

edit:

In my ContentView, I print the allObjs variables, their state, the memory address of the context, etc. I compare this to a new fetch from the context and all values for existing objects are the same, except the new fetch shows the newly added object, whereas allObjs does not contain the new object. It is very clear that the @FetchRequest object is not getting updated.

edit 2:

I tried creating a custom ObservableObject class with a @Published property and an explicit update() method, then using it as an @ObservedObject in ContentView. This works.

class MyObjList: ObservableObject {
    static let shared = MyObjList()
    @Published var allObjs: [MyObj]!

    init() {
        update()
    }

    func update() {
        allObjs = try! DataStore.persistentContainer.viewContext.fetch(MyObj.allFetchRequest())
    }
}

Then in ContentView:

@EnvironmentObject var allObjs: MyObjList

Which helps confirm that the data is there, it just isn't getting updated properly by @FetchRequest. This is a functional workaround.

like image 782
drootang Avatar asked May 08 '20 13:05

drootang


1 Answers

I solved a similar problem using persistent history tracking and remote change notifications. These make Core Data record every change that occurs to an SQLite store and sends you notifications whenever one of these changes occurred remotely (eg from an extension or iCloud).

These features aren't enabled by default. You have to configure them before loading the persistent stores:

persistentStoreDescriptions.forEach {
    $0.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
    $0.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}

Then subscribe to remote change notifications:

NotificationCenter.default.addObserver(
    /* The persistent container/view model/MyObjList/etc that acts as observer */, 
    selector: #selector(coordinatorReceivedRemoteChanges(_:)), 
    name: .NSPersistentStoreRemoteChange, 
    object: persistentStoreCoordinator // don't forget to pass the PSC as the object!
)

Now we will be notified when remote changes occur, but we need to share these changes with the managed object contexts throughout the app. This looks different for every app: sometimes your MOCs don't need to know about the changes at all, sometimes they only need to know about some of them, sometimes all of them. Apple recently published a guide that goes into depth about how to ensure only relevant changes are processed.

In this scenario you might do:

@objc
func coordinatorReceivedRemoteChanges(_ notification: Notification) {
    let currentToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken
    let existingToken = ... // retrieve the previously processed history token so we don't repeat work; you might have saved it to disk using NSKeyedArchiver
    
    // Fetch the history
    let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: existingToken)
    let result = try! context.execute(historyRequest) as! NSPersistentHistoryResult
    let transactions = result.result as! [NSPersistentHistoryTransaction]

    // Filter out the non-relevant changes
    for transaction in transaction {
        // Maybe we only care about changes to MyObjs 
        guard transaction.entityDescription.name == MyObj.entity().name else { continue }
        // Maybe we only care about specific kinds of changes
        guard let changes = transaction.changes, changes.contains(where: { ... }) else { continue }
        // Merge transaction into the view context
        viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
    }

    // Finally, save `currentToken` so it can be used next time and optionally delete old history.
}

Any @FetchRequests using viewContext will now receive all merged changes and refresh their data accordingly.

like image 192
halleygen Avatar answered Nov 20 '22 21:11

halleygen