UIKit框架(三十二) —— 替换Peek and Pop交互的基于iOS13的Context Menus(一)

版本记录

版本号 时间
V1.0 2019.12.28 星期六

前言

iOS中有关视图控件用户能看到的都在UIKit框架里面,用户交互也是通过UIKit进行的。感兴趣的参考上面几篇文章。
1. UIKit框架(一) —— UIKit动力学和移动效果(一)
2. UIKit框架(二) —— UIKit动力学和移动效果(二)
3. UIKit框架(三) —— UICollectionViewCell的扩张效果的实现(一)
4. UIKit框架(四) —— UICollectionViewCell的扩张效果的实现(二)
5. UIKit框架(五) —— 自定义控件:可重复使用的滑块(一)
6. UIKit框架(六) —— 自定义控件:可重复使用的滑块(二)
7. UIKit框架(七) —— 动态尺寸UITableViewCell的实现(一)
8. UIKit框架(八) —— 动态尺寸UITableViewCell的实现(二)
9. UIKit框架(九) —— UICollectionView的数据异步预加载(一)
10. UIKit框架(十) —— UICollectionView的数据异步预加载(二)
11. UIKit框架(十一) —— UICollectionView的重用、选择和重排序(一)
12. UIKit框架(十二) —— UICollectionView的重用、选择和重排序(二)
13. UIKit框架(十三) —— 如何创建自己的侧滑式面板导航(一)
14. UIKit框架(十四) —— 如何创建自己的侧滑式面板导航(二)
15. UIKit框架(十五) —— 基于自定义UICollectionViewLayout布局的简单示例(一)
16. UIKit框架(十六) —— 基于自定义UICollectionViewLayout布局的简单示例(二)
17. UIKit框架(十七) —— 基于自定义UICollectionViewLayout布局的简单示例(三)
18. UIKit框架(十八) —— 基于CALayer属性的一种3D边栏动画的实现(一)
19. UIKit框架(十九) —— 基于CALayer属性的一种3D边栏动画的实现(二)
20. UIKit框架(二十) —— 基于UILabel跑马灯类似效果的实现(一)
21. UIKit框架(二十一) —— UIStackView的使用(一)
22. UIKit框架(二十二) —— 基于UIPresentationController的自定义viewController的转场和展示(一)
23. UIKit框架(二十三) —— 基于UIPresentationController的自定义viewController的转场和展示(二)
24. UIKit框架(二十四) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (一)
25. UIKit框架(二十五) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (二)
26. UIKit框架(二十六) —— UICollectionView的自定义布局 (一)
27. UIKit框架(二十七) —— UICollectionView的自定义布局 (二)
28. UIKit框架(二十八) —— 一个UISplitViewController的简单实用示例 (一)
29. UIKit框架(二十九) —— 一个UISplitViewController的简单实用示例 (二)
30. UIKit框架(三十) —— 基于UICollectionViewCompositionalLayout API的UICollectionViews布局的简单示例(一)
31. UIKit框架(三十一) —— 基于UICollectionViewCompositionalLayout API的UICollectionViews布局的简单示例(二)

开始

首先看下主要内容

通过上下文菜单(context menus)学习增强您的应用程序,包括配置操作,添加图像,嵌套子菜单,添加自定义预览等。

下面看下写作环境

Swift 5, iOS 13, Xcode 11

接着就是正文了。

随着iOS 13的正式发布,我们获得了一个新的,简单,功能强大且简洁的用户界面范例-上下文菜单(context menus)。 上下文菜单取代了iOS 12之前使用的标准Peek and Pop交互,并将其进一步发展。 当您点击并按住受支持的视图时,上下文菜单会提供一些内容的预览以及操作列表。 它们在整个iOS中得到了广泛使用,例如在Photos应用中。 点按并按住照片会显示一个上下文菜单,如下所示:

动作列表和预览都是可自定义的。 点击预览将打开照片。 您可以自定义在上下文菜单中点击预览时发生的情况。

在本教程中,您将构建上下文菜单,并通过以下方法将其推至极限:

  • 配置动作。
  • 使用新的SF Symbols集合为操作设置图像。
  • 带有嵌套和嵌入式子菜单的简化菜单。
  • 使用相关信息构建更好的自定义预览。
  • 将上下文菜单添加到表视图中的每个项目。

Exploring Vacation Spots

上下文菜单旨在使现有内容引人注目且易于访问。 您将菜单添加到现有应用程序Vacation Spots中。

在Xcode中打开准备好的项目,运行应用程序并开始计划下一个假期。

打开应用程序时,您会看到一个表格视图,其中包含不同的目的地。

轻按一个度假胜地可显示有关目的地的重要信息。 您还可以添加景点评级,在地图上查看或进入其Wikipedia页面。


Your First Context Menu

查看度假胜地时,点击Submit Rating按钮。

该应用程序显示代表1-5星的几个不同按钮。 点击您的选择,然后Submit Your Rating。 现在,Submit Rating按钮以及您选择的分数将变为Update Rating。 再次点击它可以更改或查看您的评分。

使用我们的应用程序,对于某些急切的世界旅行者而言,这可能会成为一个繁琐的过程。 这是您第一个上下文菜单的理想选择。

返回Xcode,打开SpotInfoViewController.swift,在其中添加上下文菜单。

在文件底部,添加以下扩展:

// MARK: - UIContextMenuInteractionDelegate
extension SpotInfoViewController: UIContextMenuInteractionDelegate {
  func contextMenuInteraction(
    _ interaction: UIContextMenuInteraction,
    configurationForMenuAtLocation location: CGPoint)
      -> UIContextMenuConfiguration? {
    return UIContextMenuConfiguration(
      identifier: nil,
      previewProvider: nil,
      actionProvider: { _ in
        let children: [UIMenuElement] = []
        return UIMenu(title: "", children: children)
    })
  }
}

UIContextMenuInteractionDelegate协议是构建上下文菜单的关键。它带有一个必需required的方法-contextMenuInteraction(_:configurationForMenuAtLocation :),您刚刚通过创建并返回一个新的UIContextMenuConfiguration对象来实现该方法。

有很多事情要做,但是一旦完成,您将了解iOS中上下文菜单的基础。 UIContextMenuConfiguration初始化程序采用三个参数:

  • 1) identifier - 标识符:使用标识符来跟踪多个上下文菜单。
  • 2) PreviewProvider:返回UIViewController的闭包。如果将其设置为nil,则菜单的默认预览将显示,这只是您点击的视图。稍后您将使用它来显示更吸引人的预览。
  • 3) actionProvider:上下文菜单中的每个项目都是一个动作。此闭包实际上是您构建菜单的地方。您可以使用UIActions和嵌套的UIMenus构建UIMenu。该闭包采用UIKit提供的建议操作数组作为参数。这次,您将忽略它,因为您的菜单将具有您自己的自定义项目。

注意:上下文菜单使用现代的Swift界面,其闭包比UIKit的闭包要多得多。 您将在本教程中编写的大多数代码都大量使用了闭包。 这也是一种正常的Swift风格,即使用尾随闭包语法(trailing closure syntax),而忽略了调用中的最终参数名称。 在本教程中,您将看到其余的调用。

您可能已经注意到,您从未直接创建上下文菜单。 相反,您将始终创建一个UIContextMenuConfiguration对象,系统将使用该对象来配置菜单中的项目。

通常,用于创建上下文菜单的UIMenu不需要标题,因此您可以为其提供空白字符串。 但是,到目前为止,这将创建一个空菜单。 如果菜单中包含一些操作,它将更加有用。

contextMenuInteraction(_:configurationForMenuAtLocation :)下添加此方法:

func makeRemoveRatingAction() -> UIAction {
  // 1
  var removeRatingAttributes = UIMenuElement.Attributes.destructive
  
  // 2
  if currentUserRating == 0 {
    removeRatingAttributes.insert(.disabled)
  }
  
  // 3
  let deleteImage = UIImage(systemName: "delete.left")
  
  // 4
  return UIAction(
    title: "Remove rating",
    image: deleteImage,
    identifier: nil,
    attributes: removeRatingAttributes) { _ in 
      self.currentUserRating = 0 
    }
}

makeRemoveRatingAction()创建一个UIAction来删除用户的评分。 稍后,您将其添加为上下文菜单中的第一项。 您的代码将逐步执行以下操作:

  • 1) 动作action可以具有一组影响其外观和行为的属性。 因为这是一个删除操作,所以可以使用destructive菜单元素属性。
  • 2) 如果currentUserRating0,则表示用户没有评级。 没有要删除的内容,因此您添加了disable属性以禁用菜单项。
  • 3) UIAction可以具有图像,并且iOS 13SF符号看起来特别好,因此您可以将UIImage(systemName :)初始化程序与新 SF Symbols应用程序中的符号名称一起使用。
  • 4) 创建并返回一个UIAction。 它不需要标识符,因为以后不需要引用它。 当用户点击此菜单项时,将触发handler闭包。

返回contextMenuInteraction(_:configurationForMenuAtLocation :),将声明children变量的行替换为:

let removeRating = self.makeRemoveRatingAction()
let children = [removeRating]

这将创建删除评级操作并将其放置在children数组中。

太好了,您的上下文菜单现在可以执行有用的操作!

接下来,在viewDidLoad()的末尾添加以下内容:

let interaction = UIContextMenuInteraction(delegate: self)
submitRatingButton.addInteraction(interaction)

要在点击并按住视图时显示上下文菜单,可以使用addInteraction方法向该视图添加UIContextMenuInteraction。 这将创建一个交互,并将其添加到submitRatingButton

您终于可以看到运行中的上下文菜单了!

生成并运行该应用程序。 点击并按住Update Rating。 如果您已经添加了评分,则可以将其删除。 如果不是,则禁用Remove rating菜单项。

这是一个开始,但是这个不起眼的上下文菜单还有很长的路要走。 您已经了解了上下文菜单中最重要的概念:

  • UIContextMenuInteraction:将上下文菜单添加到视图。
  • UIContextMenuConfiguration:使用操作构建UIMenu并配置其行为。
  • UIContextMenuInteractionDelegate:管理上下文菜单的生命周期,例如构建UIContextMenuConfiguration

但是,对于更多的美学问题,例如自定义菜单的外观呢?


Adding Submenus

子菜单是保持上下文菜单整洁有序的好方法。 使用它们对相关动作进行分组。

将以下内容添加到SpotInfoViewController.swift底部的UIContextMenuInteractionDelegate扩展中:

func updateRating(from action: UIAction) {
  guard let number = Int(action.identifier.rawValue) else {
    return
  }
  currentUserRating = number
}

此方法使用UIAction的标识符来更新用户的评分。

UIContextMenuConfiguration一样,UIAction可以具有标识符。 updateRating(from :)尝试将操作的标识符转换为Int并相应地设置currentUserRating

updateRating(from :)下面添加以下方法:

func makeRateMenu() -> UIMenu {
  let ratingButtonTitles = ["Boring", "Meh", "It's OK", "Like It", "Fantastic!"]
  
  let rateActions = ratingButtonTitles
    .enumerated()
    .map { index, title in
      return UIAction(
        title: title,
        identifier: UIAction.Identifier("\(index + 1)"),
        handler: updateRating)
    }
  
  return UIMenu(
    title: "Rate...",
    image: UIImage(systemName: "star.circle"),
    children: rateActions)
}

此方法创建的UIAction具有与每个用户等级匹配的标识符:1到5。 请记住,UIAction的处理程序是一个闭包项,在点击该项目时会触发它。 设置您之前写为每个操作处理程序的updateRating(from :)。 然后,它返回一个带有所有操作的UIMenu作为菜单的子级。

如果您查看UIActionUIMenu的声明,它们都是UIMenuElement的子类。 children参数的类型为[UIMenuElement]。 这意味着在配置上下文菜单时,可以同时添加操作或整个子菜单。

回到contextMenuInteraction(_:configurationForMenuAtLocation :),找到声明子项的行,并用以下内容替换:

let rateMenu = self.makeRateMenu()
let children = [rateMenu, removeRating]

不是为每个可能的评分添加五个新项目,而是将rateMenu添加为子菜单。

构建并运行该应用程序,并通过设置用户等级来测试您的上下文菜单。

1. Inline Menus

嵌套的子菜单可以清除内容,但这意味着用户需要额外点击才能执行操作。

为了使操作更简单,您可以内联显示菜单。 displayInline菜单选项将显示根菜单中的所有项目,但用分隔线分隔。

为此,用以下命令替换创建并返回UIMenumakeRateMenu()的末尾:

return UIMenu(
  title: "Rate...",
  image: UIImage(systemName: "star.circle"),
  options: .displayInline,
  children: rateActions)

除了添加.displayInline菜单选项外,其他操作与以前相同。

构建并运行该应用程序以查看结果:


Custom Previews

上下文菜单通常显示内容的预览。 现在,点击并按住Submit Rating按钮将显示Submit Rating按钮本身,或者其Update Rating更改自我,如上一个屏幕截图所示。 这并不完全吸引人。

接下来,您将设置自己的预览。 在UIContextMenuInteractionDelegate扩展的底部添加以下方法:

func makeRatePreview() -> UIViewController {
  let viewController = UIViewController()
  
  // 1
  let imageView = UIImageView(image: UIImage(named: "rating_star"))
  viewController.view = imageView
  
  // 2
  imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
  imageView.translatesAutoresizingMaskIntoConstraints = false
  
  // 3
  viewController.preferredContentSize = imageView.frame.size
  
  return viewController
}

这使一个简单的UIViewController可以显示一个星级。

这是逐步发生的事情:

  • 1) 该应用程序已有一个rating_star图像,用于该应用程序中的星星。 使用该图像创建一个UIImageView并将其设置为空白UIViewController的视图。
  • 2) 设置图像的frame以指定其尺寸。 设置frame就足够了,您不需要为您设置任何“自动布局”约束。 将translatesAutoresizingMaskIntoConstraints设置为false
  • 3) 您需要指定preferredContentSize,以将视图控制器显示为预览。 如果您不这样做,它将占用所有可用空间。

返回contextMenuInteraction(_:configurationForMenuAtLocation :),找到UIContextMenuConfiguration初始化程序的PreviewProvider参数。 将以下行替换为将makeRatePreview作为预览提供程序传递:

previewProvider: makeRatePreview) { _ in

构建并运行。 点击并按住Submit Rating按钮后,您应该会看到评分星标的预览:

做得好! 这样就为该上下文菜单包装了所有内容。 现在,您可以为完全不同的事情做准备了。


Context Menus in Table Views

如果在主表视图中点击一个度假区会显示一个常见操作列表,这是否有用? 查看度假胜地时,View Map按钮可打开度假胜地所在位置的地图视图:

通过在度假胜地列表上的上下文菜单中添加View Map操作,用户可以在打开景点信息视图控制器之前先打开地图。 您还将添加一个操作,可轻松与朋友分享您最喜欢的度假胜地。 到目前为止,您已经了解了向视图添加上下文菜单的必要步骤:

  • 1) 将UIContextMenuInteraction添加到视图。
  • 2) 实现contextMenuInteraction(_:configurationForMenuAtLocation :),这是UIContextMenuInteractionDelegate的一种required方法。
  • 3) 使用所有菜单项构建一个UIContextMenuConfiguration

度假胜地列表以表格视图的形式存在于SpotsViewController中。 表格视图中的每一行都是视图本身,或更具体地说,是UITableViewCell

要向每行添加上下文菜单,您可以像以前一样进行操作,但是有一种更简单的方法。

打开SpotsViewController.swift并在类的底部添加以下代码:

// MARK: - UITableViewDelegate

override func tableView(
  _ tableView: UITableView,
  contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint)
    -> UIContextMenuConfiguration? {
  // 1
  let index = indexPath.row
  let vacationSpot = vacationSpots[index]
  
  // 2
  let identifier = "\(index)" as NSString
  
  return UIContextMenuConfiguration(
    identifier: identifier, 
    previewProvider: nil) { _ in
      // 3
      let mapAction = UIAction(
        title: "View map",
        image: UIImage(systemName: "map")) { _ in
          self.showMap(vacationSpot: vacationSpot)
      }
      
      // 4
      let shareAction = UIAction(
        title: "Share",
        image: UIImage(systemName: "square.and.arrow.up")) { _ in
          VacationSharer.share(vacationSpot: vacationSpot, in: self)
      }
      
      // 5
      return UIMenu(title: "", image: nil, children: [mapAction, shareAction])
  }
}

使用表视图,将UIContextMenu添加到每一行就像在UITableViewDelegate上实现此方法一样容易。

点击并按住任意行将调用tableView(_:contextMenuConfigurationForRowAt:point :),从而允许它为特定行提供上下文菜单。

您正在为表格视图的每一行构建一个有用的功能菜单,因此需要进行很多工作。在上面的代码中,您:

  • 1) 获取当前行的休假地点。
  • 2) 在上下文菜单中添加一个标识符,您将立即使用它。您必须将其转换为NSString,因为标识符需要符合NSCopying
  • 3) 菜单中的第一个动作是针对地图的。轻触此项目将调用showMap(vacationSpot :),它将打开该景点的地图视图。
  • 4) 添加其他动作以共享该地点。 VacationSharer.share(vacationSpot:in :)是用于打开共享表的帮助器方法。
  • 5) 最后,构造并返回包含两个项目的UIMenu

就这样。构建并运行该应用程序,然后点击并按住一个度假区以尝试新的上下文菜单。

1. Custom Previews in Table Views

再次,默认预览留有一些改进的余地。 上下文菜单仅将整个表格视图单元格用作预览。

在第一个上下文菜单中,您使用了UIContextMenuConfigurationpreviewProvider参数来显示自定义预览。 PreviewProvider使您可以创建一个全新的UIViewController作为预览。 但是,还有另一种方式。

添加以下UITableViewDelegate方法:

override func tableView(_ tableView: UITableView,
  previewForHighlightingContextMenuWithConfiguration 
  configuration: UIContextMenuConfiguration)
    -> UITargetedPreview? {
  guard
    // 1
    let identifier = configuration.identifier as? String,
    let index = Int(identifier),
    // 2
    let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0))
      as? VacationSpotCell
    else {
      return nil
  }
  
  // 3
  return UITargetedPreview(view: cell.thumbnailImageView)
}

这不是创建一个新的UIViewController,而是使用UITargetedPreview指定一个现有视图。 这是逐步发生的事情:

  • 1) 在前面的方法中,您为UIContextMenuConfiguration提供了一个标识符。 现在,您可以使用它来获取分接索引。
  • 2) 获取该索引处的单元格。
  • 3) 创建一个UITargetedPreview,并传递单元格的图像视图。

这告诉上下文菜单将单元格的图像视图用作预览,而不是整个单元格。

构建并运行以查看新的预览:

看起来好多了,不是吗? 现在,点击预览。

度假胜地列表再次在屏幕上显示动画。 好吧,预览的目的是预览某些内容。 在这种情况下,预览休假地点的地点信息视图控制器将是有意义的。 接下来,您将解决这个问题。

2. Handling Preview Actions

SpotsViewController中添加此最终的UITableViewDelegate方法:

override func tableView(
  _ tableView: UITableView, willPerformPreviewActionForMenuWith
  configuration: UIContextMenuConfiguration,
  animator: UIContextMenuInteractionCommitAnimating) {
  // 1
  guard 
    let identifier = configuration.identifier as? String,
    let index = Int(identifier) 
    else {
      return
  }
  
  // 2
  let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0))
  
  // 3
  animator.addCompletion {
    self.performSegue(
      withIdentifier: "showSpotInfoViewController",
      sender: cell)
  }
}

点击上下文菜单的预览时,将触发此UITableViewDelegate方法。 轻按预览可关闭上下文菜单,而tableView(_:willPerformPreviewActionForMenuWith:animator :)使您有机会在动画完成时运行代码。 下面就是要做的事情:

  • 1) 和以前一样,使用标识符查找上下文菜单所属的行的索引。
  • 2) 获取用户点击的单元格。
  • 3) 动画(animator)对象处理释放动画。 在这里,您添加了一个完成处理程序,该处理程序通过segue显示了现场信息视图控制器。

构建并运行,查看点击预览时会发生什么。 在学习过程中,使用新的上下文菜单会很有趣,因为您已经完成了本教程。 恭喜你!

上下文菜单还可以与drag and drop无缝地交互。 您可以通过观看WWDC 2019的iOS 13的UI现代化视频Modernizing Your UI for iOS 13了解更多信息。

后记

本篇主要讲述了替换旧的Peek and Pop交互的基于iOS13的Context Menus,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,607评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,239评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,960评论 0 355
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,750评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,764评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,604评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,347评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,253评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,702评论 1 315
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,893评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,015评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,734评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,352评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,934评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,052评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,216评论 3 371
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,969评论 2 355

推荐阅读更多精彩内容