Multiple Windows on iPad for iOS 13: Part I

AUGUST 4, 2019   github   contact



In the keynote of WWDC 2019, one announcement that got a little overshadowed by all of the other amazing announcements was the differentiation between the operating system that runs on the iPhone and the one that runs on the iPad. Starting this year, there is now an iPadOS. While there have always been iPad specific capabilities (split-screen multitasking and pencil support come to mind), now that the operating system for the phone and the tablet are no longer so closely tied, the promise is that more advanced, tablet-specific features can be created.

Toward those ends, one of the first major capabilities added to iPadOS is the ability to have the same app running in multiple windows. Today, we'll look at how to enable multi-window support in an iPad app and how that changes the requirements for our app's architecture. To do that we will start with the standard "Master-Detail App" template and modify it to allow multiple windows. Later, we'll come back and look at how we can improve the multi-window experience.

To start you'll need Xcode 11 installed and an iPad running iOS 13 (you can always just use the simulator if you don't have an iPad) both of which, at the time of this writing, are in beta and can be downloaded from developer.apple.com.

Screenshot of project creation.

Creating a new "Master-Detail App" in Xcode



Start by opening Xcode, create a new project, select "Master-Detail App" from the template picker. Create a name for the project; I called it "Windows." I also left "Use Core Data" unchecked.

With that, you can build and run. Putting the device in landscape, you can see a table view in the master view controller and a label in the detail view controller. Tapping the + button adds timestamps to the table view. Selecting a timestamp displays the same timestamp in the detail's label. You can also delete timestamps from the list. While this app certainly won't win an Apple Design Award, it is complex enough to show some of the things to consider when building an app with multiple windows.

So let's get started. The first thing to do is go to the project settings. In the "General" tab, under the "Deployment Info" section, check the "Supports Multiple Windows" checkbox.

Screenshot of project settings.

Check the "Supports Multiple Windows" checkbox



If you build and run now, you will see a black screen. That's because simply checking the multiple windows checkbox is not enough to enable multi-window support. To handle multiple windows on iPad, Apple introduced the idea of UIWindowScene and UISceneSession. I recommend watching the WWDC session Introducing Multiple Windows on iPad for the exact role these two classes play in enabling multi-window support on iPad. But, a high-level overview is these two classes manage the lifecycle of a specific window within an iPad app. The classes allow for multiple instantiations of application UI and also the purging of that UI if the system is constrained, all while maintaining application state.

By checking the "Supports Multiple Windows" checkbox, we've asked the system to use UIWindowScene and UISceneSession to manage placing our app's UI on screen, but we haven't instructed the system about how that UI should be created. This is because the former ways of doing this – either in code in the AppDelegate or setting the "Main Interface" in project settings – will be ignored on OSs that support multiple windows. To fix this, we can add:

            
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        ...
    }
            
        



to our AppDelegate if we want to do all of our UISceneSession routing in code. Or, we can click the "Configure" button next to the "Supports Multiple Windows" checkbox and add to our "Application Scene Manifest" in Info.plist. For simplicity, we'll configure our scenes in our app's Info.plist.

Screenshot of updated Info.plist.

Add these key-value pairs to "Application Scene Manifest"



For the long and short of what each of these key-value pairs mean, you can read the documentation article Specifying the Scenes Your App Supports. But, the gist is we're specifying what window scenes our app supports and where the corresponding UI is for each of those scenes. The "LaunchStoryboard Name" and "Storyboard Name" of the configuration are pretty straight forward, they're just the interface files for the scene and the launch placeholder. "Configuration Name" is an internal name for the configuration. Finally, the "Delegate Class Name" is the name of the class that will act as the UIWindowSceneDelegate for this configuration. For us, it will be Windows.SceneDelegate – be sure to include the module namespace for your project.

Which leads to our next step, create a new swift file named "SceneDelegate." In it, we'll add our very basic conformance to UIWindowSceneDelegate.

            
    import UIKit

    final class SceneDelegate: NSObject, UIWindowSceneDelegate {
        var window: UIWindow?
    }
            
        



With all of that done, you should now be able to build and run. You should see the master-detail interface as before. To activate multiple windows of the app, swipe up from the bottom of the screen to reveal the dock. From the dock, tap and hold the app icon for our project, once the icon lifts up drag it to the top right corner, and 🎉, you now have your app running in two seperate windows at the same time. However, before we can deploy this to the App Store and call it a day. We might want to take a moment and play around with it.

Screenshot of app running in two windows.

Something's not quite right



When I run the app in two windows and add timestamps to one instance, those timestamps do not appear in the other window. As a user, I would expect that adding timestamps into the app would cause them to appear in all of my windows. Because our timestamps are held in an array on MasterViewController, they are specific to that instance of MasterViewController. That was fine for our app when there was only ever one window. One window implied one MasterViewController, which meant only one array of dates. Now we have multiple windows, which means multiple instances of MasterViewController, but we still really only want one array of dates but displayed in all of the instances of MasterViewController.

To achieve this, Apple recommends having a data controller that is updated and then notifies other objects about underlying data changes. This should sound familiar if you've used Core Data, Realm, or other persistence frameworks where the general pattern is you write data changes to a store and then wait for a callback to update UI. We can do a similified version of the same thing. We'll create a DatesController which will hold an array of dates and notify any object that wants to be notified when that array is modified. While we do this, we'll lean on some of the changes announced for UI data sources, namely automatic diffing, so we don't lose the insert and delete animations found in the template.

With our plan in place, let's get started. The first thing we can do is add a new swift file named "DatesController." DatesController will be a small class to manage an array of dates and also call an array of closures whenever that array is modified. Also, because our app only needs one array of dates we'll create a singleton that will be shared by all of our instances of MasterViewController.

            
    import Foundation

    // Data controller that manages array of dates and informs observers when changes occur
    final class DatesController: NSObject {
        
        // Singleton instance
        static let shared = DatesController()
        
        // Array of dates
        var dates = [Date]() {
            didSet {
                // On modification call each of our observers
                onUpdates.forEach { onUpdate in
                    onUpdate()
                }
            }
        }
        
        // Private array that holds each of our observers
        private var onUpdates = [() -> Void]()
        
        // Function to add an onUpdate observer
        func addOnUpdate(_ onUpdate: @escaping () -> Void) {
            onUpdates.append(onUpdate)
        }
    }
            
        



Now that we have our DatesController, we can modify MasterViewController. First thing to do is delete var detailViewController: DetailViewController? = nil and var objects = [Any](). While the template shows how to access the detail view controller it's not really necessary, and, in my opinion, it's better encapsulation to not hold a reference to the detail view controller. To completely disentangle MasterViewController from DetailViewController go ahead an also delete the following from viewDidLoad:

            
    if let split = splitViewController {
        let controllers = split.viewControllers
        detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController
    }
            
        



Also, we're replacing the objects array with DatesController.shared.dates, so it's no longer necessary. You're likely now getting some errors that say: Use of unresolved identifier 'objects'. That's okay, because we'll deal with each of these in turn.

Let's start but adjusting our data source. Because we want to preserve the animations that are found in the project template, we're going to use the brand new UITableViewDiffableDataSource. Thus, we no longer need to act as the data source for our table view. As such, we can delete all of the overrides that relate to section numbers, row number, and cell configuration. Please delete the following from MasterViewController.

            
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return objects.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

        let object = objects[indexPath.row] as! NSDate
        cell.textLabel!.text = object.description
        return cell
    }
            
        



Now we need to instantiate our data source and create a snapshot for our data source to render. For more information about how UITableViewDiffableDataSource works I recommend watching the Advances in UI Data Sources of WWDC 2019. Add the following code to the top of MasterViewController.

            
    // The sections for our table view we only have one so we can just call it main
    enum Section {
        case main
    }
    
    // The data source for a table view. With sections of object type Section and rows with object type Date
    lazy var dataSource = UITableViewDiffableDataSource(tableView: self.tableView) { (tableView, indexPath, date) -> UITableViewCell? in

        // Dequeue a cell
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        
        // Populate the text label with the date description
        cell.textLabel?.text = date.description
        
        // Return the cell
        return cell
    }
    
    lazy var currentSnapshot: NSDiffableDataSourceSnapshot =  {
        
        // Create a new snapshot
        let currentSnapshot = NSDiffableDataSourceSnapshot()
        
        // Add the only section to the snapshot
        currentSnapshot.appendSections([.main])
        
        // Add all of the dates from DatesController to that  section
        currentSnapshot.appendItems(DatesController.shared.dates, toSection: .main)
        
        // Return the snapshot
        return currentSnapshot
    }()
            
        



This creates a new data source for our table view and creates the initial snapshot of our data that can then me render in our table view. The next step is to modify our override of viewDidLoad. There we'll add ourselves as an observer of DatesController's array of dates, implement how we'll react to changes to the dates array, and set our newly instantiated data source as the dataSource of our table view.

            
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        // Add the edit button to the left bar button item
        navigationItem.leftBarButtonItem = editButtonItem

        // Init the add button and add to the right bar button item
        let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(insertNewObject(_:)))
        navigationItem.rightBarButtonItem = addButton
        
        // Add MasterViewController as an observer to the DatesController dates array and perform the following whenever there's a change
        DatesController.shared.addOnUpdate { [weak self] in
            
            // Use weak self to prevent retain cycles and check if self is still around for this update
            guard let strongSelf = self else { return }
            
            // Create a new snapshot for the updated array
            strongSelf.currentSnapshot = NSDiffableDataSourceSnapshot()
            
            // Still only have the one section
            strongSelf.currentSnapshot.appendSections([.main])
            
            // Add the updated array of dates to the snapshot
            strongSelf.currentSnapshot.appendItems(DatesController.shared.dates, toSection: .main)
            
            // Apply the new snapshot, animating the changes
            strongSelf.dataSource.apply(strongSelf.currentSnapshot, animatingDifferences: true)
        }
        
        // Use whatever animation the system deems best for changes
        dataSource.defaultRowAnimation = .automatic
        
        // Assign our UITableViewDiffableDataSource as the dataSource of our table view
        tableView.dataSource = dataSource
    }
            
        



Next, we'll update func insertNewObject(_ sender: Any). Whereas before, the template updated the objects array and then added a row to the table view, now we'll simply update the DatesController.shared.dates array and wait for the onUpdate callback. Change func insertNewObject(_ sender: Any) to:

            
    @objc
    func insertNewObject(_ sender: Any) {
        DatesController.shared.dates.insert(Date(), at: 0)
    }
            
        



Lastly, we need to change the objects.remove(at: indexPath.row) in commitEditingStyle function to DatesController.shared.dates.remove(at: indexPath.row). And with that, you should now be able to build and run. When the app launches go ahead and put two windows of the app side-by-side and add a few dates to one of the windows master view controllers. After doing that, you may be thinking 🍾, we did it and feeling like:



But, if you put the table view in edit mode and try to delete a timestamp nothing happens. And as we want to recreate the template but with multiple window support, there's one last thing to fix.

Screenshot of app running in two windows, but delete not working.

One last thing to fix



Replace:

            
    override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        // Return false if you do not want the specified item to be editable.
        return true
    }

    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            objects.remove(at: indexPath.row)
            tableView.deleteRows(at: [indexPath], with: .fade)
        } else if editingStyle == .insert {
            // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view.
        }
    }
            
        



With:

            
    // The template uses a deprecated way of deleting replace with trailingSwipeActionsConfigurationForRowAt
    override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        
        // Create a delete action
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { (_, _, completion) in
            DatesController.shared.dates.remove(at: indexPath.row)
        }
        
        // Return a configuration with the delete action
        return UISwipeActionsConfiguration(actions: [deleteAction])
    }
            
        



With that, you should have an app that allows you to insert and delete dates from the MasterViewController and displays those dates in all the open windows on the iPad.

The final product.



In a future post we'll look at creating new window scenes for specific UI and how to programmatically instantiate new windows.

You can find the complete source code on github. Feel free to send me feedback.


←Back