Home iOS & Swift Books Catalyst by Tutorials

5
Adding Some Context Written by Nick Bonatsakis

In the previous chapter, you added multi-window support to your app using scenes.

In this chapter, you’ll learn all about context menus, adding support for long press menus on iPad and how those menus will port to Mac automatically.

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

  • What contextual menus are and how they can enhance your app’s experience.
  • How to add basic support for context interactions.
  • How to implement basic menu items.
  • How to make menu items dynamic.
  • How to implement hierarchical menus.

Ready to experience the exciting world of contextual menus? Great! It’s time to get started.

Introducing context menus

You might want to jump right in and start coding, but before you get started, you’ll need some context around the topic at hand (pun certainly intended). Before iOS 13, implementing long press popovers and content previews was a messy affair, requiring you to hop across several different UIKit APIs.

Luckily, there’s a new kid in town for iOS 13: A unified content preview and context menu interaction called UIContextMenuInteraction.

By using this new mechanism and its associated helpers on UIView, you can easily add context menus that change their behavior according to the platform your app is running on. On iPad, you trigger context menus with a long press gesture. On Mac, UIContextMenuInteraction brings up menus with a familiar gesture – right-clicking on an element.

Have a look at this feature in action in the Shortcuts app for iPad. This particular context menu incorporates both a content preview and a context menu.

Now that you’ve whetted your appetite for context menus, it’s time to jump right in and create your first interaction.

Adding a context interaction

The most sensible place to enable the Journalyst app’s context menus is in the sidebar. Why? Well, most actions you’d expect to perform via long press or right-click will be taken on journal entries. Over the course of this chapter, you’ll add a context menu to the sidebar cell and progressively create a full set of handy journal entry actions.

Open the starter project and go to MainTableViewController.swift. In diaryDataSource(), add the following code right before the return statement:

let contextInteraction 
  = UIContextMenuInteraction(delegate: self)
cell?.addInteraction(contextInteraction)

In the above code, you create a new instance of UIContextMenuInteraction, passing self as the delegate. You’ll implement the delegate method in the next step. Then you associate this interaction with the table view cell by calling addInteraction, which is a method common to all UIView subclasses.

Now, add the following extension to the end of the file:

// MARK: UIContextMenuInteractionDelegate
extension MainTableViewController: 
  UIContextMenuInteractionDelegate {
  func contextMenuInteraction(
    _ interaction: UIContextMenuInteraction, 
    configurationForMenuAtLocation location: CGPoint) 
    -> UIContextMenuConfiguration? {
    //1
    let locationInTableView = 
      interaction.location(in: tableView)
    //2
    guard let indexPath = tableView
      .indexPathForRow(at: locationInTableView) 
      else { return nil }
    //3
    let entry = DataService.shared.allEntries[indexPath.row]
    //4
    return UIContextMenuConfiguration(
      identifier: nil, 
      previewProvider: nil) { _ -> UIMenu? in
      
        //5
        var rootChildren: [UIMenuElement] = []
        //6
        let noOpAction = self.createNoOpAction()
        //7
        rootChildren.append(noOpAction)
        //8
        let menu = UIMenu(title: "", image: nil, 
        identifier: nil, options: [], 
        children: rootChildren)
        return menu
      }
  }
}

There’s a decent amount of code here, so take a look at it, step-by-step:

  1. When the system calls this delegate method, the CGPoint it provides is in the coordinate space of the view that the user interacted with directly – in this case, the UITableViewCell instance. To obtain the index path of the cell, you need the point in terms of the table view’s coordinate space, so the first thing you do is obtain that information via the location(in:) method of the interaction instance.
  2. Next, armed with the target coordinate in the table view’s coordinate space, you attempt to grab the index path for the cell that the user interacted with and bail if it’s not found. Returning nil from this method indicates to the system that it shouldn’t activate a context menu for the given interaction.
  3. Next, you fetch the entry for the given cell by using the index path you obtained above. You’ll need the entry object to perform actions on it in the next parts of this chapter.
  4. Next, you return an instance of UIContextMenuConfiguration. Passing nil as an identifier will cause the system to generate a unique ID automatically. You also pass nil for previewProvider since you are only going to be adding a menu, not a content preview. The closure here returns the UIMenu instance that implements the menu.
  5. Next, you create a mutable array to house the list of top-level menu items. A UIMenu consists of one or more instances of UIMenuElement subclasses.
  6. Then, you call a method that returns UIAction .
  7. Then, you add the action to the list of root elements.
  8. Finally, you create the root UIMenu, passing it an empty title and the root elements list as the children.

Next, add the following method to the same extension:

//1
func createNoOpAction() -> UIAction {
  let noOpAction = UIAction(title: "Do Nothing",image: nil, 
  identifier: nil, discoverabilityTitle: nil,attributes: [], 
  state: .off) { _ in
    // Do nothing
  }
  return noOpAction
}
  1. Here, you create a method returning UIAction, which is a subclass of UIMenuElement. This action has a bare-bones configuration with just a title. The trailing block in the initializer is called when the user activates the action (e.g., the user clicks or taps on the menu item). In this case, it does nothing.

Build and run, then long press on a journal entry in the sidebar. You should see the following:

Sweet! Your first context menu. It’s not much to look at yet, and it doesn’t do anything particularly useful. But you’re about to change that…

Opening a new window

In the previous chapter, you learned all about scenes and how they support multi-window configurations in your apps. Recall that you implemented a very nifty custom drag interaction that allows the user to drag a journal entry to create a new window.

An app with great UX often offers the same action via several paths, so that a user is more likely to discover and learn how to perform that action. Context menus are a great place to offer these alternate paths, as users are familiar with long pressing and right-clicking. Wouldn’t it be neat to add a menu action for opening an entry in a new window? Why, yes; yes, it would.

First, remove the createNoOpAction method that you created. Add a new method as follows:

func addOpenNewWindowAction(entry: Entry) -> UIAction {
  //1
  let openInNewWindowAction = UIAction(
    title: "Open in New Window",
    image: UIImage(systemName: "uiwindow.split.2x1"),
    identifier: nil,
    discoverabilityTitle: nil,
    attributes: [],
    state: .off) { _ in 
    //2
    self.createNewWindow(for: entry) }
  return openInNewWindowAction
}

Back in contextMenuInteraction method, remove the code that creates the noOpAction and adds it to the rootChildren array. Replace it with the following code:

let openInNewWindowAction = self.addOpenNewWindowAction(entry: entry)
//3
rootChildren.append(openInNewWindowAction)

The code is similar to the action you added previously, but there are a few things that are different. Here’s some more detail about what’s going on:

  1. As before, you create a new instance of UIAction. The most notable difference here is that you pass UIImage(systemName: "uiwindow.split.2x1") as the image parameter. This is how you reference an icon from the SF Symbols package that Apple introduced in iOS 13.
  2. Next, instead of an empty block, you call a method, createNewWindow(for:), that will do the work of creating a new window and passing it the entry.
  3. Finally, as before, you add the action to the rootChildren array for inclusion in the root menu.

Next, go ahead and implement createNewWindow(for:) as follows:

func createNewWindow(for entry: Entry) {
  UIApplication.shared.requestSceneSessionActivation(
    nil, userActivity: entry.openDetailUserActivity, 
    options: .none, errorHandler: nil)
}

Just one line of code is all it takes to get this one done. You call requestSceneSessionActivation(_:userActivity:options:errorHandler:) on the shared UIApplication, passing it entry.openDetailUserActivity to preconfigure the new window to display the provided entry.

Build and run to see the fruits of your labor. Try tapping on Open in New Window to see this action work in all its glory.

Creating a new entry

Oftentimes, apps that manage lists of data include a menu action that lets its users create a new instance of a given entity. Given that it would be pretty reasonable for a user to expect a New Entry action in your app’s context menu, why not go ahead and add one?

Start by adding the following method:

func addNewEntryAction(entry: Entry) -> UIAction {
  let newEntryAction = UIAction(
    title: "New Entry",
    image: UIImage(systemName: "square.and.pencil"),
    identifier: nil,
    discoverabilityTitle: nil,
    attributes: [],
    state: .off) { _ in
    self.createEntry()
  }
  return newEntryAction
}

Add the following code to contextMenuInteraction, after you append openInNewWindowAction:

let newEntryAction = self.addNewEntryAction(entry: entry)
rootChildren.append(newEntryAction)

This code is similar to the code you used to add the Open in New Window action. It configures the action, implements a handler, calls a method, createEntry(), to do the work of creating the new entry, then finally adds the action to the list of root menu children.

To finish up, implement createEntry() as follows:

func createEntry() {
  DataService.shared.addEntry(Entry())
}

Nothing too crazy here, just another one-liner, this time creating a new entry via the DataService class. Since the data flow is tied together using NotificationCenter, that’s all you need to do to make a new entry and have it show up in the UI.

Build and run once more, then check out the context menu again. You should now be able to create new entries in a snap.

Adding an image to an entry

Another action that would be pretty useful to have in the context menu for journal entries is the ability to directly add images. Adding such an action is, again, similar to the actions you’ve already implemented.

Start by once again adding the following method.

func addImageAction(entry: Entry, indexPath: IndexPath) -> UIAction {
  let addImageAction = UIAction(
    title: "Add Image",
    image: UIImage(systemName: "photo"),
    identifier: nil,
    discoverabilityTitle: nil,
    attributes: [],
    state: .off) { _ in
    self.addImage(to: entry, indexPath: indexPath)
  }
  return addImageAction
}

Then, add the following code to contextMenuInteraction, after you append newEntryAction:

let addImageAction = self.addImageAction(entry: entry, indexPath: indexPath)
rootChildren.append(addImageAction)

Again, there’s nothing new here compared with the previous few actions, so jump right into implementing addImage(to:indexPath:) as follows:

func addImage(to entry: Entry, indexPath: IndexPath) {
  //1
  let cell = tableView.cellForRow(at: indexPath)
  //2
  photoPicker.present(in: self, 
    sourceView: cell) {image, _ in
    //3
    if let image = image {
      var newEntry = entry
      newEntry.images.append(image)
      DataService.shared.updateEntry(newEntry)
    }
  }
}

The above code is a bit more involved than the previous few action handlers, so reviewing it in detail:

  1. You obtain a reference to the UITableViewCell that activated the interaction.
  2. Next, you call present on PhotoPicker to launch an action sheet that allows the user to select an image from either their library or the camera, if it’s available. You pass UITableViewCell here so that when the app runs on iPad and Mac, the action sheet is anchored to that element.
  3. Finally, if the user selects an image, you handle it by creating a mutable copy of the entry, adding the image to it and then persisting the change to the data service.

Build and run, then activate the menu again and you’ll find that you can now add images to a journal entry directly from the sidebar.

Add an entry to favorites

The next menu action you’ll implement allows a user to add and remove a journal entry as a favorite. At present, there isn’t a way to filter by favorite entries. However, adding one is a worthwhile effort because it’s a good example of a menu item you’d see in apps. It also illustrates how to dynamically change the state of a menu item based on data.

Before you add the menu item, you’ll need to tweak the journal entry model and the UI associated with it to support favorites. First, open Entry.swift and add the following property to the struct:

var isFavorite = false

Then change the implementation of == to incorporate the new property as follows:

static func == (lhs: Entry, rhs: Entry) -> Bool {
  return lhs.dateCreated == rhs.dateCreated &&
    lhs.log ?? "" == rhs.log ?? "" &&
    lhs.images == rhs.images &&
    lhs.isFavorite == rhs.isFavorite
}

Finally, you’ll want to add a visual indicator to the entry cells so your users can see at a glance if an entry is a favorite. Open EntryTableViewCell.swift and add the following code to the didSet block on entry:

accessoryView = entry.isFavorite 
  ? UIImageView(image: UIImage(systemName: "star.fill")) 
  : nil

The above code sets the cell’s accessory view to an SF Symbols star icon if the entry is a favorite; otherwise, it’s set to nil.

You’re now ready to add the new menu action. Jump back to MainTableViewController.swift and add the following method to UIContextMenuInteractionDelegate extension.

func addFavoriteAction(entry: Entry) -> UIAction {
  //1
  let favoriteTitle = entry.isFavorite ? "Remove from Favorites" : "Add to Favorites"
  //2
  let favoriteImageName = entry.isFavorite ? "star.slash" : "star"
  //3
  let favoriteAction = UIAction(
    title: favoriteTitle,
    image: UIImage(systemName: favoriteImageName),
    identifier: nil,
    discoverabilityTitle: nil,
    attributes: [],
    state: .off) { _ in self.toggleFavorite(for: entry)
  }
  return favoriteAction
}

Then, add the following code to contextMenuInteraction, after you append addImageAction:

let favoriteAction = self.addFavoriteAction(entry: entry)
//4
rootChildren.append(favoriteAction)

Here’s what this code is doing:

  1. First, you create a title variable that prompts the user to “Add to Favorites” if the entry is not a favorite, or “Remove from Favorites” if it already is.
  2. You create a variable to represent the icon you want to use: A star if the action is “Add to Favorites” or a star with a slash through it for the “Remove from Favorites” action.
  3. Now, you create the menu action, passing it the variables you defined and calling toggleFavorite in the handler.
  4. Finally, as always, you add the action to the root children array for inclusion in the menu.

To finish up this action, implement toggleFavorite like so:

func toggleFavorite(for entry: Entry) {
  var newEntry = entry
  newEntry.isFavorite.toggle()
  DataService.shared.updateEntry(newEntry)
}

In the above code, you make a mutable copy of the provided entry, toggle the isFavorite property and persist the change to the data service.

Build and run, then bring up the context menu again. Try toggling the favorite state of an entry and you should see both the entry cell and the menu option change.

Sharing an entry

Your journal entry context menu is really starting to take shape now, but you’re not done yet. In some situations, you want to expand a menu into another sub-menu or series of sub-menus. Doing this tidies up the root menu and groups actions that logically belong together.

To learn how to add one of these nested menus, you’re going to add a “Share” menu item that triggers a sub-menu with several share options.

Still inside MainTableViewController.swift, add the following method to UIContextMenuInteractionDelegate extension:

func addShareMenu(entry: Entry, indexPath: IndexPath) -> UIMenu {
  //1
  let copyAction = UIAction(
    title: "Copy",
    image: UIImage(systemName: "doc.on.doc"),
    identifier: nil,
    discoverabilityTitle: nil,
    attributes: [],
    state: .off) { _ in self.copy(contentsOf: entry) }
  //2  
  let moreAction = UIAction(
    title: "More",
    image: UIImage(systemName: "ellipsis"),
    identifier: nil,
    discoverabilityTitle: nil,
    attributes: [],
    state: .off) { _ in self.share(entry, at: indexPath) }
  //3  
  let shareMenu = UIMenu(
    title: "Share",
    image: UIImage(systemName: "square.and.arrow.up"),
    identifier: nil,
    options: [],
    children: [copyAction, moreAction])
  return shareMenu
}

Then, add the following code to contextMenuInteraction, after you append favoriteAction:

let shareMenu = self.addShareMenu(entry: entry, indexPath: indexPath)
//4
rootChildren.append(shareMenu)

This code looks similar to the code for creating a regular menu action. Here’s a detailed breakdown of what it does:

  1. First, you create a menu action to copy the contents of an entry. This is the first menu item for the sub-menu.
  2. Next, you create the second sub-menu action, a menu item that brings up the system share sheet for more options.
  3. You then create the share menu for inclusion in the root menu, passing the two sub-menu actions as children.
  4. Finally, you add the share menu to the root children array so that it gets included in the root menu.

Next, implement the two methods that do the work for the share sub-menu actions like this:

func copy(contentsOf entry: Entry) {
  if entry.log != nil {
    //1
    UIPasteboard.general.string = entry.log
  }
}

func share(_ entry: Entry, at indexPath: IndexPath) {
  //2
  var items: [Any] = []
  if let log = entry.log {
    items.append(log)
  }
  if !entry.images.isEmpty {
    items.append(contentsOf: entry.images)
  }
  //3
  let activityController = UIActivityViewController(
    activityItems: items, 
    applicationActivities: nil)
    //4
    if let popoverController =
      activityController.popoverPresentationController,
      let cell = tableView.cellForRow(at: indexPath) {
        popoverController.sourceView = cell
        popoverController.sourceRect = cell.bounds
        //5
        present(activityController, animated: true, 
                completion: nil)
    }
}

Taking the above code, step by step:

  1. First, you implement copy(contentsOf:) by setting the system paste board’s string to the journal entry text.
  2. You implement share(_:at:) by first declaring an array for the activity items and then populating the array with the entry text and images.
  3. Now, you create a new instance of UIActivityViewController with the activity items.
  4. You then configure the activity view controller’s presentation to anchor on the relevant cell if it’s running on larger-screen platforms.
  5. Finally, you present the share sheet activity as you would any other view controller.

Build and run yet again and you’ll find a new “Share” menu item that should, when tapped on, bring up the sub-menu you see below.

Deleting an entry

There’s just one more action to go before you have a fully-armed and operational context menu. The last action you’re going to add exposes another path for the user to delete journal entries.

Continuing in MainTableViewController.swift, add the following code to UIContextMenuInteractionDelegate extension:

func addDeleteAction(indexPath: IndexPath) -> UIAction {
  let deleteAction = UIAction(
    title: "Delete",
    image: UIImage(systemName: "trash"),
    identifier: nil,
    discoverabilityTitle: nil,
    attributes: .destructive,
    state: .off) { _ in self.removeEntry(at: indexPath) }
  return deleteAction
}

Then, add the following code to contextMenuInteraction, after you append shareMenu:

let deleteAction = self.addDeleteAction(indexPath: indexPath)
rootChildren.append(deleteAction)

This code’s almost identical to all the other menu actions you’ve implemented, but there’s one difference worth noting: You pass .destructive as an attribute. This will give the menu title and icon a red color and clearly indicate to the user that this action is destructive and potentially dangerous.

Now, add the following method to delete the entry when the user activates this menu action:

func removeEntry(at indexPath: IndexPath) {
  DataService.shared.removeEntry(atIndex: indexPath.row)
}

There’s nothing complicated about the above code, it just removes the entry via the data service. Once again, data changes will propagate to the UI via notifications, so this is all that’s needed to implement this action.

Build and run one last time and you’ll see the finished menu, complete with the ability to delete an entry.

Try it on macOS

Now that you’ve gone to the trouble of implementing context menus for iPad, you’ll find that you get the same support for free when running on Mac.

On the Mac, the menus’ look and feel automatically adjust to align with standard Mac menus. Your users will have a familiar experience.

Switch your run location to My Mac and build and run. Right-click on a journal entry in the sidebar and explore the various actions and sub-menus.

Key points

  • Context menus are a powerful way to expose alternate paths for common app actions.
  • iOS offers a unified mechanism for creating context menus that work on iPad and Mac.
  • Context menus can be as simple as singular actions or as complex as multi-level hierarchical menus.

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