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.

let contextInteraction 
  = UIContextMenuInteraction(delegate: self)
cell?.addInteraction(contextInteraction)
// 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
      }
  }
}
//1
func createNoOpAction() -> UIAction {
  let noOpAction = UIAction(title: "Do Nothing",image: nil, 
  identifier: nil, discoverabilityTitle: nil,attributes: [], 
  state: .off) { _ in
    // Do nothing
  }
  return noOpAction
}

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.

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
}
let openInNewWindowAction = self.addOpenNewWindowAction(entry: entry)
//3
rootChildren.append(openInNewWindowAction)
func createNewWindow(for entry: Entry) {
  UIApplication.shared.requestSceneSessionActivation(
    nil, userActivity: entry.openDetailUserActivity, 
    options: .none, errorHandler: nil)
}

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?

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
}
let newEntryAction = self.addNewEntryAction(entry: entry)
rootChildren.append(newEntryAction)
func createEntry() {
  DataService.shared.addEntry(Entry())
}

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.

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
}
let addImageAction = self.addImageAction(entry: entry, indexPath: indexPath)
rootChildren.append(addImageAction)
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)
    }
  }
}

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.

var isFavorite = false
static func == (lhs: Entry, rhs: Entry) -> Bool {
  return lhs.dateCreated == rhs.dateCreated &&
    lhs.log ?? "" == rhs.log ?? "" &&
    lhs.images == rhs.images &&
    lhs.isFavorite == rhs.isFavorite
}
accessoryView = entry.isFavorite 
  ? UIImageView(image: UIImage(systemName: "star.fill")) 
  : nil
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
}
let favoriteAction = self.addFavoriteAction(entry: entry)
//4
rootChildren.append(favoriteAction)
func toggleFavorite(for entry: Entry) {
  var newEntry = entry
  newEntry.isFavorite.toggle()
  DataService.shared.updateEntry(newEntry)
}

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.

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
}
let shareMenu = self.addShareMenu(entry: entry, indexPath: indexPath)
//4
rootChildren.append(shareMenu)
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)
    }
}

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.

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
}
let deleteAction = self.addDeleteAction(indexPath: indexPath)
rootChildren.append(deleteAction)
func removeEntry(at indexPath: IndexPath) {
  DataService.shared.removeEntry(atIndex: indexPath.row)
}

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.

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

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.