Home iOS & Swift Books Catalyst by Tutorials

4
Setting the Scene(s) Written by Nick Bonatsakis

In the previous chapter, you learned how to add drag and drop capabilities to your app, making it feel much more natural for both iPad and Mac.

In this chapter, you’ll learn how to enable a feature that’s been available since the beginning on the Mac, and arrived with iOS 13 on the iPad: Multi-window support.

By the end of this chapter, you’ll have learned:

  • What multi-window support is and why you’d want to enable it for your app.
  • How to enable basic multi-window support in Xcode and in your app.
  • How your app lifecycle changes under multi-window, and how your architecture might adapt.
  • How to add custom support for drag and drop window creation.

Ready to dive into the exciting world of multiple windows? Awesome! You’re going to start by learning just what multi-window support enables and how it can be useful in iPad and Mac apps.

Introducing multiple windows for iPad

In 2007, Apple unveiled the next generation of computing with the introduction of the iPhone. Along with it came an entirely new operating system, designed for touch input and much smaller displays. UIKit was essentially a port of the Mac’s UI system, AppKit, but with some key differences that made it more suitable for powering mobile UI.

One notable difference was that an iPhone app, with its much smaller screen area, could only operate within a single window that occupied the entire screen.

Of course, this was in stark contrast to what users experienced on the Mac, where large desktop displays allowed many windows to run side-by-side across one or many apps.

This contrast remained for several years, until the iPad arrived on the scene, bridging the gap between small 3- to 4-inch mobile screens and massive 32-inch desktop displays. Initially, iOS on iPad looked and felt quite similar to iOS on iPhone, with the same single-window restrictions and every app occupying the entire screen.

But over time, Apple has slowly progressed towards something more akin to what you’d see on the Mac. First, it added the ability to run apps side-by-side. Then it introduced tabs in apps like Safari. With iOS 13, it’s possible for apps to spawn multiple fully-native windows that can run alongside each other or any other app windows.

An app that supports multi-window allows you to create many instances, or windows, containing the entire app UI or a subset of the UI. Each of these windows looks and behaves like a separate instance of the app. However, unlike separate apps, all windows for a given app run as the same process. You’ll learn more about this later.

Why multi-window?

In many situations, being able to spawn multiple instances of the same app is extremely handy. Consider the following use-cases that are only possible with multi-window support:

  • Messages: Carrying on two or more conversation threads without having to switch back and forth.
  • Pages: Working on two documents side-by-side when you want to reference information in the first while working on the second and drag and drop content between the two.
  • Mail: Writing a message in one window and searching previous messages for contacts or other information in the second window.
  • Safari: Researching a topic across two different websites, side-by-side at the same time.

Generally, any app that lets a user view or create many instances of the same type of content is a good candidate for multi-window support.

Mac users, and now iPad users, will expect your app to function just as well, if not better, than the built-in Apple apps. If you want to build a first-class experience, take the time to enhance your app with support for multiple windows.

Multi-window in action

There are many ways to spawn and interact with multiple app windows on iPad. Some come with the system. Others are specific to individual apps. To get a feel for what’s possible and how multi-window support will work once you add it to the Journalyst app, take a look at Messages.

Grab the nearest iPad or iPad Simulator and start by opening the Messages app. If you’ve ever used apps on the iPad in multi-tasking mode, where you run two different apps side-by-side, you’ll know that you can swipe up from the bottom edge to reveal the dock. Go ahead and do so, then hold and drag the Messages icon to the right of the screen and drop it when you see the drop zone.

You now have Messages in a side-by-side split screen. Both windows are fully functional, as if you had two Messages apps running at once.

Notice the separator in the middle of the screen and the handle-looking control. Touch and hold on the handle and drag all the way to the right edge of the screen, seemingly dismissing the second window. But wait! You didn’t dismiss that window, you just detached it into a fully separate window.

Swipe up from the bottom of the screen again to bring up the dock once more, then tap and hold on the Messages app icon until you see a contextual menu pop up. Tap on Show All Windows and you’ll see a view of all the windows for this app, including the one you spawned and then swiped off to the right.

You’ll notice here that there’s also a “+” button you can use to create new windows. Try tapping it to see it in action.

Finally, tap on one of the windows to get back into Messages. Tap and hold on one of the conversations in the sidebar for a moment, drag it to the right edge of the screen, then drop it once you see the drop zone indicator.

You should now see that conversation on the right side of the screen, while you still have a fully functioning Messages app on the left. The other multi-window controls are part of the baseline support, but this one needs some additional work because, as you might expect, the interaction is specific to the content in the Messages app.

If the idea of adding these features to Journalyst excites you (hint: It should), then strap in, because you’re about to do just that!

Enabling multi-window in Xcode

Open the starter project for this chapter in Xcode and head over to the project settings. Click on the Journalyst target and make sure you’re on the General tab. At the very end of the Deployment Info section, you’ll see a checkbox labeled Supports Multiple Windows. Go ahead and check it to, you guessed it, enable multi-window support.

Now open Info.plist and you’ll notice that Xcode has added a new entry, Application Scene Manifest. The dictionary contains only one sub-entry called Enable Multiple Windows and it’s set to YES. You might think this would be enough to add basic multi-window support. But if you were to run the app now, you’d see nothing but a big fat empty screen.

To understand why that is, you’ll need to learn a bit about how the standard app architecture changes in a multi-window environment.

Introducing scenes

In the pre-multi-window world, the entry point to every app was the app delegate. Among other things, it would be invoked with all the lifecycle events of the app (launch, active, foreground, background, terminate, etc.). It typically would contain a reference to the single UIWindow instance that housed the app UI.

In an app that supports multi-window, the app delegate is still the main entry point. However, you can now have many windows, and you need to be able to manage and notify each of them of lifecycle events independently. To do all that, you need to add a new abstraction. That abstraction is called a scene and it has the following components:

  • Scene: Represents a single instance of your app’s UI, including basic information and state changes.
  • Scene Delegate: Responds to state changes and events from a scene.
  • Scene Session: Manages the scene process, including configuration and state restoration.

When you enable your app for multiple windows, the system will continue to invoke app-level lifecycle events such as didFinishLaunching and willTerminate on the app delegate. However, a scene will now handle all lifecycle events and information associated with an instance of your app’s UI.

When you launch your app, the system will now create a scene session and its associated scene to represent the first instance of your app, much like it would create a single UIApplication instance for the entire app.

To respond to UI-instance-level lifecycle events, the system will now invoke a custom scene delegate implementation that you provide, much like your app delegate did previously. With each additional scene your user initiates, the system will create another instance of this stack, allowing your app to manage each of its windows independently.

Finish enabling multi-window

Now that you’ve learned how scenes allow you to effectively manage multiple instances of your app’s UI, it’s time to finish enabling multi-window support for your project.

Back in Xcode, create a new Swift file and name it SceneDelegate.swift, then add the following code:

import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  func scene(_ scene: UIScene, 
    willConnectTo session: UISceneSession, 
    options connectionOptions: UIScene.ConnectionOptions) {
    if let splitViewController =
            window?.rootViewController as? 
            UISplitViewController {
           splitViewController.preferredDisplayMode = 
           .oneBesideSecondary
    }
  }
}

There’s not a lot going on in the above code, just a reference to the scene’s UIWindow instance and the addition of scene(_:willConnectTo:options:). This gets called whenever you create a scene and connect it to the scene session. For now, that’s all you need in the scene delegate. There’s just one more thing you need to add before you’re done with basic multi-window support.

Open Info.plist once more, copy the below snippet to the clipboard, then highlight the Application Scene Manifest entry. Then expand it and paste to add the new sub-entry.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>UIWindowSceneSessionRoleApplication</key>
	<array>
		<dict>
			<key>UISceneConfigurationName</key>
			<string>Default Configuration</string>
			<key>UISceneDelegateClassName</key>
			<string>Journalyst.SceneDelegate</string>
			<key>UISceneStoryboardFile</key>
			<string>Main</string>
		</dict>
	</array>
</dict>
</plist>

If you expand the configuration fully, you’ll see an Application Session Role that consists of an array of items, each containing the configuration for a particular scene type. In this case, there’s only one entry in the list representing the default configuration that you use whenever you create a new scene. However, the app can define roles for specific window configurations. The following describes each key-value:

  • Configuration Name: The identifier for this scene configuration.
  • Delegate Class Name: The class that implements the scene delegate, which you’ll use for an instance of this scene configuration.
  • Storyboard Name: The name of the storyboard you’ll use to create an instance of this scene.

With the above configuration change in place, build and run. Long press on the app icon, show all windows and you’ll find that you can now use the system UI to create new windows.

Things are looking great, but if you start playing with the app across several windows, you might start to notice that a few things are off. In particular, try bringing up the app in two side-by-side windows by revealing the dock and dragging the app icon to the right edge. Once you have both windows in place, go back to the main table view controller and try adding some new entries in the left window. Gasp! The right window’s entry list doesn’t change in the slightest.

Well, that’s certainly a day-wrecker, but no worries! You’re about to learn why this problem occurs and, more importantly, how to fix it.

Improving the standard multi-window experience

Remember that when iOS creates a new scene for your app, it’s instantiating an entirely new and parallel instance of everything required to build your UI. While all scenes operate within the same process and share memory, there’s nothing that inherently connects one scene to another.

Before you learn how to resolve the data issue that you introduced by moving to multi-window, you’ll need to understand how the Journalyst app currently propagates data changes from its data layer to the UI layer when a user adds an entry.

Open MainTableViewController.swift and have a look at the addEntry method:

@IBAction private func addEntry(_ sender: Any) {
  DataService.shared.addEntry(Entry())
  reloadSnapshot(animated: true)
}

The above method gets called whenever you tap the Add button on the entries list sidebar. There’s nothing overly complex going on here; you persist the entry to an in-memory data store via DataService.shared.addEntry and then call reloadSnapshot to refresh the UITableView state.

Now, open DataService.swift and take a peek at its addEntry method:

func addEntry(_ entry: Entry) {
  entries.append(entry)
}

Again, nothing too crazy here. You’re just adding the new entry to the data service’s internal data structure (in this case, an array).

The following diagram shows the data flow within each app scene; it should help clarify where the issue lies.

Here, you see that you’re dealing with two identical, but entirely disconnected stacks. Data flows between the view controller and the view only within each separate scene. Data only flows into the Data Service, but not out. The view controller in the first scene initiates the adding of the entry and triggers the table refresh only in its own instance of MainTableViewController. Meanwhile, over in the second scene, there’s an entirely separate instance of MainTableViewController that’s never notified of the data change, and thus never reflects it.

Now that the data issue is apparent, you’re going to write some code that will fix it. One common way to connect disparate components in an iOS or Mac app is through the use of Notifications and NotificationCenter. Here, you’re going to leverage notifications to ensure that data changes get communicated across all scenes.

Start by opening DataService.swift and adding the following code before the DataService class definition:

extension Notification.Name {
  static var JournalEntriesUpdated 
    = Notification.Name(
    "com.raywenderlich.Journalyst.EntriesUpdated")
}

The above code adds a new notification type by extending Notification.Name and adding the property JournalEntriesUpdated. This notification will be issued every time the data in any scene changes so that other scenes can observe and respond accordingly.

Next, modify addEntry and removeEntry to look like the following:

func addEntry(_ entry: Entry) {
  entries.append(entry)
  //1
  postUpdate()
}

func removeEntry(atIndex index: Int) {
  entries.remove(at: index)
  //2
  postUpdate()
}

And add the postUpdate method below:

private func postUpdate() {
  //3
  NotificationCenter.default.post(
    name: .JournalEntriesUpdated, 
    object: nil)
}

Now, you’re getting into some more meaty code, so take a moment to review what you did:

  1. You call postUpdate to notify observers when the user adds new entries.
  2. Next, you add the same postUpdate call to notify observers when the user removes entries.
  3. Finally, you implement postUpdate by posting the notification you created earlier via NotificationCenter.

Great, now whenever the user adds or removes entries, this code will inform any observers of JournalEntriesUpdated. To close the loop, you’ll need to subscribe to these updates in the appropriate places.

Open MainTableViewController.swift and start by removing the explicit calls to reloadSnapshot from addEntry and tableView(_:trailingSwipeActionsConfigurationForRowAt:) as follows:

@IBAction private func addEntry(_ sender: Any) {
  DataService.shared.addEntry(Entry())
}

override func tableView(
	_ tableView: UITableView,
	trailingSwipeActionsConfigurationForRowAt
	indexPath: IndexPath) -> UISwipeActionsConfiguration? {
  
  let deleteAction = UIContextualAction(
    style: .destructive,
    title: "Delete") {_, _, _ in
    DataService.shared.removeEntry(atIndex: indexPath.row)
  }
	  
  deleteAction.image = UIImage(systemName: "trash")
  return UISwipeActionsConfiguration(actions: [deleteAction])
}

You’re about to drive UI updates by observing the data change notification you added, so you won’t need to issue those updates explicitly anymore.

Next, add this code to the end of viewDidLoad to observe the data update notification.

func viewDidLoad {
  ...
  //1
  NotificationCenter.default.addObserver(
    self,
    selector: #selector(handleEntriesUpdate),
    name: .JournalEntriesUpdated,
    object: nil)
}

Then add handleEntriesUpdate to handle the change as follows:

@objc func handleEntriesUpdate() {
  //2
  reloadSnapshot(animated: false)
}  

The above code adds the MainTableViewController as an observer. Specifically:

  1. First, you subscribe to the JournalEntriesUpdated notification to receive updates whenever data changes.
  2. Then, you call reloadSnapshot(animated:) whenever the notification handler triggers, to update the view.

Build and run, bring up the dock, and drag to create a side-by-side view once again. Go back to main screen. Tap the Add button in either of the two scenes and watch in awe as the list in the other scene magically refreshes.

The app feels great now, so maybe you should go celebrate with a delicious dessert? Marzipan perhaps? Well, not so fast. There’s still one more data issue lurking in your app that you’ll need to address before kicking back with a treat.

With the app still running, enter some text on an entry in the first scene, then select a different entry. You’ll notice that the cell for the entry updates with a preview of the text, but only in Scene 1.

To understand why this bug is happening, start by opening EntryTableViewController.swift. You’ll notice this file includes the following delegate protocol declaration:

protocol EntryTableViewControllerDelegate: class {
  func entryTableViewController(
    _ controller: EntryTableViewController, 
    didUpdateEntry entry: Entry)
}

If you look at viewWillDisappear(_:) in EntryTableViewController, you’ll see that whenever this view controller disappears (e.g., leaves the screen), it notifies its delegate of the data change.

Open MainTableViewController.swift and have a look at the extension that implements the EntryTableViewControllerDelegate interface:

// MARK: EntryTableViewControllerDelegate
extension MainTableViewController: 
  EntryTableViewControllerDelegate {
  func entryTableViewController(
    _ controller: EntryTableViewController,
    didUpdateEntry entry: Entry) {
      reloadSnapshot(animated: false)
  }
}

In short, whenever the user updates an entry, the table view issues a snapshot reload to ensure the list reflects the latest data. But once again, recall that changes specific to a given view controller, or a group of view controller instances in one scene, do not carry over to instances in other scenes.

So how might you fix this issue? Yup, the same way you fixed the first data problem: By using notifications.

First things first, get rid of all references to EntryTableViewControllerDelegate, including the extension and assignments in MainTableViewController and the declaration and usages in EntryTableViewController.swift.

Next, open DataService.swift and change the updateEntry(_:) method to look like this:

func updateEntry(_ entry: Entry) {
  //1
  var hasChanges = false
  entries = entries.map { item -> Entry in
    if item.id == entry.id && item != entry {
      //2
      hasChanges = true
      return entry
    } else {
  return item
    }
  }
//3
  if hasChanges {
    postUpdate()
  }
}

Reviewing the changes you made:

  1. First, you add a flag to track whether the provided entry represents an update or not.
  2. You then track when it detects an entry change by assigning a value to hasChanges.
  3. Finally, if it did detect a data change, you issue a data change notification by calling postUpdate().

Since you are already handling the data change notification in MainTableViewController, that’s all you have to do.

Build and run the app one more time. Go back to main screen on both sides. Try changing an entry in one scene while watching the list in the second scene. As soon as you leave the entry screen on Scene 1, you’ll see the list preview for that entry updated in Scene 2.

Nicely done! Your Journalyst app now has solid support for basic multi-windowing. But you’re not one to settle for “basic”, so you’re going to implement one more feature that will take the scene support in this app to the next level.

Adding custom drag behavior to create a new window

Recall that when you explored multi-window support in the Messages app at the beginning of this chapter, you tried out a custom mechanism for spawning new scenes. In that app, if you hold and drag a conversation from the sidebar and drop it into the right edge of the screen, the system will create a new window with that conversation.

If you thought that interaction was pretty nifty, then you’re in luck, because you’re about to add it to Journalyst. When you’re done, you’ll be able to similarly hold, drag and drop a journal entry from the sidebar to start a new window with that entry’s detail.

Start by opening Entry.swift and adding the following extension:

// MARK: NSUserActivity
extension Entry {
  //1
  static let OpenDetailActivityType 
    = "com.raywenderlich.EntryOpenDetailActivityType"
  static let OpenDetailIdKey = "entryID"
  //2
  var openDetailUserActivity: NSUserActivity {
    //3
    let userActivity 
      = NSUserActivity(activityType: 
      Entry.OpenDetailActivityType)
    //4
    userActivity.userInfo = [Entry.OpenDetailIdKey: id]
    return userActivity
  }
}

The above code adds some functionality to Entry around NSUserActivity:

  1. First, you declare several static properties for various identifiers you’ll use later.
  2. Next, you declare a computed property for an NSUserActivity that represents opening the detail screen for a journal entry.
  3. You initialize the activity with the unique identifier you declared earlier.
  4. Finally, you store the ID for the journal entry you want to show when you spawn a new window via this activity in userInfo.

Next, you need to add the drag behavior that initiates the interaction, so open MainTableViewController.swift and add the following extension:

// MARK: UITableViewDragDelegate
extension MainTableViewController: UITableViewDragDelegate {
  //1
  func tableView(_ tableView: UITableView, 
    itemsForBeginning session: UIDragSession, 
    at indexPath: IndexPath) -> [UIDragItem] {
    //2
    let entry = DataService.shared.allEntries[indexPath.row]
    let userActivity = entry.openDetailUserActivity
    //3
    let itemProvider = NSItemProvider()
    itemProvider.registerObject(userActivity, visibility: .all)
    //4
    let dragItem = UIDragItem(itemProvider: itemProvider)
    return [dragItem]
  }
}

The above code might look a bit unfamiliar, so here’s a breakdown of what it does:

  1. UITableViewDragDelegate gets called when a drag interaction begins and allows you to specify the content involved in the operation.
  2. Here, you fetch the Entry object that the user has long pressed on and use entry.openDetailUserActivity to obtain an NSUserActivity representative of that entry.
  3. Next, you create an instance of NSItemProvider and register the user activity object.
  4. Then you create a drag item with the item provider and return it. This will ultimately expose the user activity to the system when the user drags the entry to the edge of the screen.

Now, you’ll need to declare MainTableViewController as the drag delegate for the table view. Add the following code to the end of viewDidLoad to accomplish this:

tableView.dragDelegate = self

The last thing you need to do to enable your custom drag window interaction is to handle the incoming user activity in the scene delegate. You do this by configuring the new scene based on the provided entry ID.

Open SceneDelegate.swift and replace the entire implementation with the following:

import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  func scene(_ scene: UIScene, 
    willConnectTo session: UISceneSession, 
    options connectionOptions: UIScene.ConnectionOptions) {
    if let splitViewController 
      = window?.rootViewController as? UISplitViewController {
        splitViewController.preferredDisplayMode 
        = .oneBesideSecondary
    }
    //1
    if let userActivity 
      = connectionOptions.userActivities.first {
      //2
      if !configure(window: window, with: userActivity) {
        print("Failed to restore from \(userActivity)")
      }
    }
  }

  func configure(window: UIWindow?, 
    with activity: NSUserActivity) -> Bool {
    //3
    guard activity.activityType == Entry.OpenDetailActivityType,
      let entryID 
        = activity.userInfo?[Entry.OpenDetailIdKey] as? String,
      let entry = DataService.shared.entry(forID: entryID),
      let entryDetailViewController 
        = EntryTableViewController.loadFromStoryboard(),
      let splitViewController 
        = window?.rootViewController 
        as? UISplitViewController else {
        return false
    }

    //4
    entryDetailViewController.entry = entry
    //5
    let navController 
      = UINavigationController(
      rootViewController: entryDetailViewController)
    splitViewController.showDetailViewController(
      navController, sender: self)
    return true
  }
}

That’s quite a lot of code, so walk through it, step by step:

  1. First, you check the connectionOptions for the presence of user activity. In the previous section, you configured the drag interaction to expose an NSUserActivity containing information about the target journal entry; here, you’ll receive this activity.
  2. Next, you call configure, passing the window and the user activity, and log a message in the case of failure.
  3. Inside configure, you start by ensuring that the activity type matches Entry.OpenDetailActivityType. You then obtain the entry object, instantiate an instance of EntryTableViewController for displaying the entry and obtain a reference to the main split view controller.
  4. You now configure the entry view controller to display the obtained entry object.
  5. Lastly, you wrap the entry view controller in a navigation controller and present the navigation controller as the split view controller’s detail content.

Now, when you drag and drop an entry on the edge of the screen, the system will relay your user activity, housed in the drag item, to the scene delegate. There, it will configure and present the entry detail immediately.

Go ahead and build and run to give it a try.

Try it on the Mac

The hard work you put in to make your app support multi-window for iPad has an added bonus: It’ll work seamlessly when you run the app on Mac. Open Xcode, select the My Mac destination and set your team. Then build and run.

Once the app is running, just press Command + N and boom!

In a later chapter, you’ll learn how to add a menu item for spawning a new window, much like you’d find in many Mac apps. But for now, pat yourself on the back and maybe indulge in that tasty dessert you promised yourself earlier.

Key points

  • Multi-window is a powerful way to be more productive on iPad and users expect to see it on Mac.
  • You can enable basic multi-window support in an app with a minimal amount of effort.
  • Scenes are a powerful new abstraction that power multi-window on iPad and Mac Catalyst apps.
  • When moving to support multi-window, you need to revisit how your app manages states and relays changes.
  • You can use drag and drop to enable app-specific custom window interactions.

In this chapter, you learned what multi-window support is, why you might want to incorporate it into your app, and how to do just that. You also learned about some of the issues that you might introduce when adopting scenes and how to resolve them. Finally, you learned how to go beyond the OS-provided multi-window support by adding a custom window interaction using drag and NSUserActivity.

In the next chapter, you’ll learn how to add powerful contextual menus to your app so that it’s more efficient on iPad and feels even more at home on the Mac.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2020 Razeware LLC