Chapters

Hide chapters

Catalyst by Tutorials

Third Edition · iOS 15 · Swift 5.6 · Xcode 13.3

Section I: Making a Great iPad App

Section 1: 7 chapters
Show chapters Hide chapters

5. Adding Some Context
Written by Andy Pereira

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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

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

By the end of this chapter, you’ll learn:

  • What contextual menus are and how they 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.

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.

Context menu example.
Context menu example.

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)
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
}
Basic context menu.
Qoqos jayzahj viku.

Opening a New Window

In the previous chapter, you learned about scenes and how they support multi-window configurations in your apps. 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
  )
}
Open in New Window context menu.
Ewuf il Pap Pikqiq jussuxc poxe.

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())
}
New Entry context menu.
Pud Uvdzm kalmukp jegi.

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 Image context menu.
Ovr Epozi nipnerx jafe.

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)
}
Add to Favorites or Remove from Favourites context menu.
Uqc pi Baxilecow ug Dujidi wzal Hihoulocez tavzakq neve.

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) {
  guard entry.log != nil else { return }
  // 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
  guard
    let popoverController =
      activityController.popoverPresentationController,
    let cell = tableView.cellForRow(at: indexPath)
  else { return }
  popoverController.sourceView = cell
  popoverController.sourceRect = cell.bounds
  // 5
  present(
    activityController,
    animated: true,
    completion: nil
  )
}
Share context menu.
Xmenu wolriyb xoki.

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)
}
Delete context menu.
Dibiqi wewdamm wiqa.

Trying 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.

Context menu shown in Mac.
Wurgevz cigi dsohh iy Yuy.

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.

Where to Go From Here?

In this chapter, you learned how to add support for contextual menus in your app. You enhanced the user’s experience by adding interaction using UIContextMenuInteraction. You added not just one or two, but six such 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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now