Chapter 13 Gold Challenge (Platinum?)

This challenge invokes a new feature introduced with iOS 13, so there is not much chatter about it yet. I have no idea if this is right, but I noted that when I brought up two instances on the iPad simulator, that they both loaded from the same sandbox. So to keep data integrity I not only saved data when I was notified that the App entered the background, but I added an observer for .willEnterForegroundNotification and reloaded the data and tableView when I got that notification. It seems to work on the simulator, but it isn’t easy to demonstrate satisfactorily. I would appreciate anyone’s thoughts on this.

I had gotten up to this gold challenge a couple days ago. Started reading up on multiple window support & then got sidetracked by other things & hadn’t gotten back to it yet. I was looking at SceneDelegate and the sceneWillEnterForeground/sceneDidEnterBackground methods & thinking I would need to trigger some data loading & storing from those methods. But using the existing observer in ItemStore & changing the notification it looks for would be easier; I just hadn’t gotten as far as finding the proper notifications.

What’s the timing on scenes entering the foreground & background? One thing I was concerned about was making sure that the scene entering the foreground didn’t try to read data before the scene entering the background had finished writing it.

It is hard to tell about the timing, working with the simulator. I added a function: @objc func reloadItems() throws , to parallel the saveChanges function. I tried to test it on the simulator with two windows. I populated one and then when I opened the other it loaded the same items from the plist. I then added some and looked to see if they would transfer. They didn’t seem to but if I went the other way it removed items. I was hoping someone would try this idea on a real iPad. It was very difficult to get two windows on the simulator and keep them open. All ideas welcome.

By the way, you do not need to replace the notification, you can add an additional observer to the existing notificationCenter.

Well, so far nothing is doing anything when I switch between the two windows running side by side. No foreground/background notifications, no activate/deactivate notifications. Nor are the corresponding functions in SceneDelegate getting called. The only time I see any of those things is when I hit the home button to exit the app, or select the app icon to re-display it.

I’m thinking this is the wrong approach entirely, so I’m going to try this instead: when an item is added/moved/deleted, the ItemsViewController will tell the itemStore to write the updated data, then send out a notification to tell the other scenes to reread the newly saved data & reload the table view. Each scene will need some kind of unique serial number (like the items have) that I can send with the notification to make sure the scene sending the notification doesn’t get triggered by itself.

If I get that working, I might get more fancy & try actually sending individual notifications for adding, moving, and deleting so I don’t have to write & read the entire itemStore each time there’s a change. But I worry a bit about the two itemStores getting desynchronized with that approach.

(I double checked, and I do have multiple window support enabled in Deployment Info.)

OK, so I got that partly implemented, and even without the unique serial number part it works - you add or move or delete an item, and the display in the other window immediately updates.

But that got me thinking - why should these two scenes each have their own itemStores? Seems like I should be able to create the itemStore in AppDelegate instead of SceneDelegate, and then as each scene is created tell it where the itemStore is. Then all windows are using the exact same itemStore and you don’t have to read & write the itemStore at all except at startup & shutdown, which it already does. So to get that working all I have to figure out is how to get the AppDelegate to tell the SceneDelegate where the itemStore is located each time a new scene is created.

That is interesting. I used the print(#function) statement to track state changes. I also printed out when the data were saved and loaded. Those seemed to work as expected. The odd thing on the simulator is that the second instance loads with the same items with which I populated the first instance, but adding additional items wasn’t updated in the first instance. I guess the data is loaded into two instances of ItemStore or something like that. I will be interested to see how your approach works out.

Before the latest versions of iOS, the ItemStore would have been declared in AppDelegate.Swift: applicationDidFinishLaunching WithOptions. I wonder if you can just go back to that practice.

That’s what I had in mind. But when the sceneDelegate is created it has to be told about the itemStore so it can in turn pass that down to its ItemViewController. That’s the part I’m stuck on at the moment, I can’t find a way for SceneDelegate to get anything from AppDelegate.

Anyway, here’s how I implemented the notifications:

In SceneDelegate.swift I added a couple new lines to scene(:willConnectTo:options):

let uniqueSceneIdentifier = UUID().uuidString.components(separatedBy: "-").first!
itemsController.uniqueSceneIdentifier = uniqueSceneIdentifier

Then in ItemsViewController.swift, I added a couple new properties and functions:

    let myNotificationKey = "com.jonault.LootLogger"
    
    var uniqueSceneIdentifier: String!

    func sendNotification()
    {
        let notificationCenter = NotificationCenter.default
        notificationCenter.post(name: Notification.Name(rawValue: myNotificationKey), 
                                object: self, 
                                userInfo: ["identifier": uniqueSceneIdentifier!])
    }
    
    func catchNotification(notification:Notification) -> Void {
        guard let sceneIdentifier = notification.userInfo!["identifier"] else { return }
        
        let senderSceneIdentifier = "\(sceneIdentifier)"
        if (senderSceneIdentifier != uniqueSceneIdentifier!)
        {
            itemStore.loadChanges()
            tableView.reloadData()
        }
    }

Next I updated the editing functions in ItemsViewController to write itemStore and send the notification after each edit:

@IBAction func addNewItem(_ sender: UIBarButtonItem)
{
    let newItem = itemStore.createItem()
        
    if let index = itemStore.allItems.firstIndex(of: newItem)
    {
        let indexPath = IndexPath(row: index, section: 0)
        tableView.insertRows(at: [indexPath], with: .automatic)
    }
        
    itemStore.saveChanges()
    sendNotification()
}

override func tableView(_ tableView: UITableView,
                        commit editingStyle: UITableViewCell.EditingStyle,
                        forRowAt indexPath: IndexPath)
{
    if editingStyle == .delete
    {
        let item = itemStore.allItems[indexPath.row]
        itemStore.removeItem(item)
        tableView.deleteRows(at: [indexPath], with: .automatic)
            
        itemStore.saveChanges()
        sendNotification()
    }
}
    
override func tableView(_ tableView: UITableView,
                        moveRowAt sourceIndexPath: IndexPath,
                        to destinationIndexPath: IndexPath)
{
    itemStore.moveItem(from: sourceIndexPath.row, to: destinationIndexPath.row)
        
    itemStore.saveChanges()
    sendNotification()
}

ItemStore.loadChanges is the code that the book had us adding to ItemStore.init():

@objc func loadChanges()
{
    do
    {
        let data = try Data(contentsOf: itemArchiveURL)
        let unarchiver = PropertyListDecoder()
        let items = try unarchiver.decode([Item].self, from: data)
        allItems = items
    }
    catch
    {
        print("Error reading in saved items: \(error)")
    }
}

Edited to add: Whoops! Forgot this change. In ItemsViewController.viewDidLoad(), add an observer for the new notification:

let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(forName: NSNotification.Name(rawValue: myNotificationKey),
                               object: nil,
                               queue: nil,
                               using: catchNotification)

Now, when the data is edited in one scene, that scene will write out its itemStore and send out a notification. Both scenes will receive that notification, but the one that sent it won’t do anything (it doesn’t need to, it’s already up to date). The other scene will reload its data and refresh the table view.

Well, turns out that doesn’t cover everything. If you select an item to edit it, it doesn’t update in the other window. When DetailViewController.viewWillDisappear() runs and it updates the item being edited, that needs to cause itemStore to be written and a notification to be sent.

Fixed that by adding a bit of code to ItemsViewController.viewWillAppear():

if sendNotificationOnAppear
{
    sendNotificationOnAppear = false
    itemStore.saveChanges()
    sendNotification()
}

sendNotificationOnAppear is a new property in ItemsViewController. It’s initially false, and is set true in prepare(for:sender:) when the seque to the DetailViewController is initiated. This property is necessary to prevent viewWillAppear from sending out the notification when the app first starts up.

OK, after a bit more googling I found out how to access AppDelegate from SceneDelegate:

let myAppDelegate = UIApplication.shared.delegate as! AppDelegate

So, in AppDelegate, create your itemStore:

class AppDelegate: UIResponder, UIApplicationDelegate {

    var itemStore: ItemStore!

    func application(_ application: UIApplication, 
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.

        // Create an ItemStore
        itemStore = ItemStore()
        
        return true
    }

Then in SceneDelegate, get itemStore from the AppDelegate & pass it on down to ItemsViewController:

    func scene(_ scene: UIScene, 
               willConnectTo session: UISceneSession, 
               options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let _ = (scene as? UIWindowScene) else { return }
                
        // Access theItemsViewController and set its item store
        let navController = window!.rootViewController as! UINavigationController
        let itemsController = navController.topViewController as! ItemsViewController
        let myAppDelegate = UIApplication.shared.delegate as! AppDelegate
        itemsController.itemStore = myAppDelegate.itemStore

        let uniqueSceneIdentifier = UUID().uuidString.components(separatedBy: "-").first!
        itemsController.uniqueSceneIdentifier = uniqueSceneIdentifier
    }

Then in ItemsViewController, you can get rid of all the calls to itemStore.saveChanges() before sending the notifications, and you don’t have to call itemStore.loadChanges() when processing the notification.

1 Like

This is getting a bit strange. I used your code in both App and Scene Delegate, but wanted to try it without the unique scene identifier. When i switched windows in the simulator I got all the proper notifications when the app went in the background or active and the data was being loaded and saved at the proper times. My test was to add or delete data in one window and then open the other to see if the changes were there. The first time I opened the second window the changes were not there, but if I closed it and reopened it the changes appeared. I take this to mean that the common ItemStore is working. Because I don’t have a real iPad, I am not certain whether I am looking at a real artifact or one related to the simulator. I am not sure that the unique scene identifier is necessary.?

The unique scene identifier isn’t necessary, it’s just an optimization. When the notification is sent out it gets received by all the scenes, including the scene that sent it. Since the sender’s view is already up to date, it doesn’t need to reload its table view again; the unique scene identifier is there so the scene can identify that it sent that notification and can safely ignore it.

As for the window switching stuff you’re seeing, I don’t know how you’re doing that. I did all my testing running the two windows side by side so both were always active (Split View); I didn’t know there was a way to put one in the background so I didn’t take that into account. Is that Slide Over? I think the scene that’s in the background would stop receiving notifications, so you would need to have it reload its table view when it comes back to the foreground so it can display any updates to the itemStore that happened while it was in the background.

I’m not doing my testing on a real iPad either, I’m using the simulator.

I think I understand. I am running the simulator on a MacBook Pro 13 inch, which means the windows are very small. I guess I didn’t realize that they were both active. My solution saves when it goes inactive and loads when it goes active on the theory that you can only update one window at a time. When I opened the window the first time it didn’t change because it was still active. Only when it cycled through the states did it update.

OK, I managed to get another LootLogger running outside the Split View (so now I have three total).


It seems to be working correctly - when I make a change in one window, the other window updates, but only after I switch to it & it takes over the iPad screen. If I make a change - lets say I delete an item in the Split View - and then bring up the Dock, press the LootLogger icon and select “Show All Windows”, the other window (on the left) is still showing the deleted item.

It only updates that window when I select it & bring it to full screen. I think that’s inherent to iOS and isn’t anything your app can control. iOS is basically taking a picture of the way that window looked when you switched away from it and in “Show All Windows” it’s showing you the picture, not a live view of the app (similar to the launch images the book mentioned in chapter 1). But each scene is still receiving the notification even when its window isn’t onscreen - when I set a breakpoint in catchNotification, it hits it three times after each edit.

Edit, No, apparently I was wrong about the picture stuff. It is possible to get the windows not currently displayed to update, but it takes some extra work. Check out this page, scroll down to the “Keeping Scenes up to Date” section. He uses notifications to keep the displayed scenes in sync, similar to what i did, but then he goes on to talk about how to update the scenes that are off-screen. The stuff he’s doing here depends on things he did higher up on the page, so it looks like it would take some work to port it over to LootLogger.

I agree. His app structure is substantially different and probably not what our authors intended as a challenge. I appreciate you sending the link, it would be useful for an app that made more than occasional use of multiple windows.

Turned out a lot of that stuff just wasn’t necessary. I added these two lines of code at the end of sendNotification() and it did the job:

let scenes = UIApplication.shared.connectedScenes
        
scenes.forEach { scene in
    UIApplication.shared.requestSceneSessionRefresh(scene.session) }

This doesn’t take the place of sending out the notification, though. You still need to do that.

The unique scene identifier isn’t necessary, it’s just an optimization.

I tinkered with using a unique identifier. One option is to use session.persistentIdentifier:

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
    guard let _ = (scene as? UIWindowScene) else { return }
    
    // Access the ItemsViewController and set its item store
    //let itemsController = window!.rootViewController as! ItemsViewController
    let navController = window!.rootViewController as! UINavigationController
    let itemsController = navController.topViewController as! ItemsViewController
    
    itemsController.itemStore = appDelegate.itemStore
    
    //show unique ID in navigation item to make scenes easier to track
    let id = String(session.persistentIdentifier.suffix(4))
    let title = itemsController.navigationItem.title
    itemsController.navigationItem.title = title != nil ? title! + " " + id : id
}

That is interesting. I used the print(#function) statement to track state changes. I also printed out when the data were saved and loaded.