Today extension: syncing data with container app

1.6k views Asked by At

Context

I've been playing around with Today Extensions using this example project.

The app is quite simple:

  • In the containing app, you have a list of todo items, which you can mark completed
  • In the Today widget, you see the same list, but you can switch between completed, and incomplete items using a segmented control.

My goal is the following: whenever there is a data change, either in the container app, or the widget, I want both to reflect the changes:

  • If I mark an item as completed in the container app, then pull down the Notification Center, the widget should be updated
  • When I do the same in the widget, then return to the app, the app's state should be updated

The implementation

I understand, that the container app, and the extension run in their separate processes, which means two constraints:

  • NSUserDefaultsDidChangeNotification is useless.
  • Managing the model instances in memory is useless.

I also know, that in order to access a shared container, both targets must opt-in to the App Groups entitlements under the same group Id.

The data access is managed by an embedded framework, TodoKit. Instead of keeping properties in memory, it goes straight to NSUserDefaults for the appropriate values:

public struct ShoppingItemStore: ShoppingStoreType {

    private let defaultItems = [
        ShoppingItem(name: "Coffee"),
        ShoppingItem(name: "Banana"),
    ]

    private let defaults = NSUserDefaults(suiteName: appGroupId)

    public init() {}

    public func items() -> [ShoppingItem] {
        if let loaded = loadItems() {
            return loaded
        } else {
            return defaultItems
        }
    }

    public func toggleItem(item: ShoppingItem) {

        let initial = items()

        let updated = initial.map { original -> ShoppingItem in
            return original == item ?
                ShoppingItem(name: original.name, status: !original.status) : original
        }

        saveItems(updated)
    }

    private func saveItems(items: [ShoppingItem]) {

        let boxedItems = items.map { item -> [String : Bool] in
            return [item.name : item.status]
        }

        defaults?.setValue(boxedItems, forKey: savedDataKey)
        defaults?.synchronize()
    }

    private func loadItems() -> [ShoppingItem]? {

        if let loaded = defaults?.valueForKey(savedDataKey) as? [[String : Bool]] {

            let unboxed = loaded.map { dict -> ShoppingItem in

                return ShoppingItem(name: dict.keys.first!, status: dict.values.first!)
            }

            return unboxed
        }

        return nil
    }
}

The problem

Here's what works:

  • When I modify the list in my main app, then stop the simulator, and then launch the Today target from Xcode, it reflects the correct state. This is true vice-versa.

This verifies, that my app group is set up correctly.

However, when I change something in the main app, then pull down the Notification Center, it is completely out of sync. And this is the part, which I don't understand.

My views get their data straight from the shared container. Whenever a change happens, I immediately update the data in the shared container.

What am I missing? How can I sync up these two properly? My data access class is not managint any state, yet I don't understand why it doesn't behave correctly.

Additional info

  • I know about MMWormhole. Unfortunately this is not an option for me, since I need to reach proper functionality without including any third party solutions.

  • This terrific article, covers the topic, and it might be possible, that I need to employ NSFilePresenter, although it seems cumbersome, and I don't completely understand the mechanism yet. I really hope, there is an easier solution, than this one.

1

There are 1 answers

0
József Vesza On BEST ANSWER

Well, I have learned two things here:

First of all, Always double check your entitlements, mine somehow got messed up, and that's why the shared container behaved so awkwardly.

Second: Although viewWillAppear(_:) is not called, when you dismiss the notification center, it's still possible to trigger an update from your app delegate:

func applicationDidBecomeActive(application: UIApplication) {
    NSNotificationCenter.defaultCenter().postNotificationName(updateDataNotification, object: nil)
}

Then in your view controller:

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)

    NSNotificationCenter.defaultCenter().addObserverForName(updateDataNotification, object: nil, queue: NSOperationQueue.mainQueue()) { (_) -> Void in
        self.tableView.reloadData()
    }
}

override func viewDidDisappear(animated: Bool) {
    super.viewDidDisappear(animated)
    NSNotificationCenter.defaultCenter().removeObserver(self)
}

Updating your Today widget is simple: each time the notification center is pulled down, viewWillAppear(:_) is called, so you can query for new data there.

I'll update the example project on GitHub shortly.