iOS 10 还有许多新特性,集中放到最后一章来说下吧,主要分三个主题
本章我们来完成一个 EmojiRater 应用,它是一个 collection view 来显示各种 emoji 表情
我们将利用新的 API 对 collection view 预取数据过程提速,然后就能在预览交互部分对 emojis 表情评分。最后当用户滚动到顶部时能感到视觉反馈。
在本节,你可以为 EmojiRater 添加数据源预取功能。
数据源预取是指:在需要显示之前准备其内容数据的机制。可以想象下应用的 cell 包含需要从网络下载的图片,我们可以通过提前预取(新开个下载线程)操作来减少延迟
下面有请新的 data source protocol 登场 UICollectionViewDataSourcePrefetching ,由此协议负责数据的预取操作,它只定义了两个方法:
对于那些很大、需要消耗时间处理的数据源,实现此协议可以极大地改善用户体验,当然内部并没什么复杂的原理,它只是靠猜测用户下一刻的动作来决定预取的动作。如果用户滚动地非常快,或者资源受限,预取请求将变慢或者停止。
collection view 会在用户滚动时调用此方法,提供下一刻将要显示的单元索引。你可以根据这些索引来提前进行数据的准备工作。但是数据的提取过程必须是异步的,并且将结果交给数据源 collectionView(_:cellForItemAt:)
方法 如果你使用的是 TableView,那也没关系,可以去看看 UITableViewDataSourcePrefetching 协议
我们先将注意力集中在 EmojiCollectionViewController.swift 这个文件上,它其实是 Collection ViewController 用来显示 EmojiCollectionViewCell 对象的,这些 cells 当前只显示一个 emoji 表情。
我们在 collectionView(_:willDisplay:forItemAt:)
方法中配置这些 cell,数据源来自于 loadingOperations 字典,它是由索引和 DataLoadOperation (载入数据操作)组成的键值对。 DataLoadOperation 属于 Operation 的子类,主要负责载入 emoji 表情内容。
当 collectionView(_:willDisplay:forItemAt:)
触发时,DataLoadOperation 根据其提供的索引值(indexPath)将相关对象拉入载入队列。
运行一下,向下滚动,能看到很多小菊花转啊转的载入数据~
其实这里只是通过随机睡眠几秒的方法,模拟了从网络加载数据等耗时的操作。不过这种体验还是太糟糕,我们来改进一下。
打开 EmojiCollectionViewController.swift,在底部添加:
extension EmojiCollectionViewController: UICollectionViewDataSourcePrefetching { func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { print("Prefetch: /(indexPaths)") } }
这里实现了 UICollectionViewDataSourcePrefetching 协议的 collectionView(_:prefetchItemsAt:)
方法,当 collection view 预感将要载入某些 cells 时,就会将对应的索引传回来,在此我们只打印了这些索引。
回到 viewDidLoad()
在顶部指定协议的实现者
collectionView?.prefetchDataSource = self
先运行一下什么操作都不做,观察下终端的输出
Prefetch: [[0, 8], [0, 9], [0, 10], [0, 11], [0, 12], [0, 13]]
collection view 非常聪明,它已经知道此时 view 的位置停留在顶部,因此只可能向下滑动,所以期望预取的结果就是从第九条 cell 开始(iPhone 6s 一屏显示 8 条)。随便滑动几下并观察终端输出,你会发现滚动的速度越快就需要请求预取更多的 cells,而且滚动的方向和所处的位置都会决定 cell 如何预取。
因为 cell 还未呈现在屏幕上,所以还不能设置它的界面部分。但可以提前准备显示内容,具体过程就是在后台队列中执行 DataLoadOperation 操作,下面来实现下,还是在 collectionView(_:prefetchItemsAt:)
方法中(替换之前的打印方法):
// 1 indexPaths 是按优先级排序的数组,越紧急的顺序越靠前 for indexPath in indexPaths { // 2 如果已经提供索引对应的 operation 已经存在了就继续 if let _ = loadingOperations[indexPath] { continue } // 3 根据索引创建 DataLoadOperation 对象,并放到队列和字典中 if let dataLoader = dataStore.loadEmojiRating(at: indexPath.item) { loadingQueue.addOperation(dataLoader) loadingOperations[indexPath] = dataLoader } }
运行,等待一下给应用留点时间载入数据,再缓慢的滚动,这次你会发现 cells 的载入速度有了显著提高:
这套工作机制在用户规律地滚动 collection view 时很管用,但如果用户突然改变滚动方向,很可能预取的那些 cell 就不需要了,因此我们要能取消不必要的预取请求, UICollectionViewDataSourcePrefetching 协议也提供了取消方法:
func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { for indexPath in indexPaths { if let dataLoader = loadingOperations[indexPath] { dataLoader.cancel() loadingOperations.removeValue(forKey: indexPath) } } }
取消判断也很简单,如果一个载入操作(loading operation)已经存在,那么我们就取消载入操作,然后从 loadingOperations 字典中移除
再次运行,随便滚动几下,你不会发现任何异样,但不必要的载入操作已经被取消了。
不过并不 100% 保证所有操作都被取消,毕竟这涉及到苹果的私有算法。
iOS 9 带来了全新的 3D Touch 交互操作(Peek and Pop),你可以使用 Peek and Pop 来控制显示内容和快速执行一些动作。但你不能自定义过渡动画和用户的交互方式
iOS 10 推出了全新的 UIPreviewInteraction API,它让我们可以创建自定义的预览交互操作(类似于 Peek 和 Pop 的概念),即通过不断对屏幕施压,然后触发 3D Touch 的两种界面状态,同时也会伴随着独特的触觉反馈(振动马达)
不同于 Peek 和 Pop,这些交互限于导航。
下面的例子展示了这几种状态
在 Preview 状态渐渐隐去背景,将焦点集中在选择的 cell 上,然后把投票的控件放到 emoji 上。在 commit 状态,上下移动你的手指按压进行评价。松开手指评分会出现在 cell 上。
为了实现该功能,我们需要 UIPreviewInteractionDelegate ,它负责监听整个交互过程,并接收相关进度消息。下面是几个关键节点:
previewInteractionShouldBegin(_:)
当 3D Touch 开始一个 preview 进程时,在这里我们可以进行动画对象的设置 previewInteraction(_:didUpdatePreviewTransition:ended:)
执行 preview 过程,系统给我们传达两个参数:1.执行进程 2.完成情况 previewInteractionDidCancel(_:)
当 preview 进程被取消时被调用 previewInteraction(_:didUpdateCommitTransition:ended:)
执行 commit 过程,与执行 preview 过程类似,同样接收两个参数。 本节 Demo 需要在支持 3D Touch 的机器上运行,即至少是 iPhone 6s 级别的手机。
我们现在开始来实现 UIPreviewInteractionDelegate 协议,打开 EmojiCollectionViewController.swift 文件,添加下面的属性
var previewInteraction: UIPreviewInteraction?
UIPreviewInteraction对象带一个 view 属性(能配置)可以进行 3D Touch 交互,稍后来创建它。
找到 viewDidLoad() 中创建 ratingOverlayView 的地方,RatingOverlayView 可以看做是一个与屏幕大小相等的蒙版,稍后要由它来负责一系列的动画和交互效果,即创建一个背景模糊动画效果然后焦聚在某个 cell 上,然后在此之上覆盖一个评分控件。
创建完 ratingOverlayView 后,我们来创建一个用 collectionView 做交互区域的 UIPreviewInteraction 对象,并且它的 delegate 方法交给 self 来实现。
if let collectionView = collectionView { previewInteraction = UIPreviewInteraction(view: collectionView) previewInteraction?.delegate = self }
实现所谓的 UIPreviewInteractionDelegate,当前只是进行了打印操作
extension EmojiCollectionViewController: UIPreviewInteractionDelegate { func previewInteraction(_ previewInteraction: UIPreviewInteraction, didUpdatePreviewTransition transitionProgress: CGFloat, ended: Bool) { print("Preview: /(transitionProgress), ended: /(ended)") } func previewInteractionDidCancel(_ previewInteraction: UIPreviewInteraction) { print("Canceled") } }
在真机上运行一下,对某个 cell 进行 3D Touch 按压操作,感受马达的振动反馈,并观察终端输出:
Preview: 0.0, ended: false Preview: 0.0970873786407767, ended: false Preview: 0.184466019417476, ended: false Preview: 0.271844660194175, ended: false Preview: 0.330097087378641, ended: false Preview: 0.378640776699029, ended: false Preview: 0.466019417475728, ended: false Preview: 0.543689320388349, ended: false Preview: 0.631067961165048, ended: false Preview: 0.747572815533981, ended: false Preview: 1.0, ended: true Canceled
UIPreviewInteractionDelegate 还有两个可选的方法
func previewInteractionShouldBegin(_ previewInteraction: UIPreviewInteraction) -> Bool { print("Preview should begin") return true } func previewInteraction(_ previewInteraction: UIPreviewInteraction, didUpdateCommitTransition transitionProgress: CGFloat, ended: Bool) { print("Commit: /(transitionProgress), ended: /(ended)") }
前者在 preview 开始时触发并打印日志,这里返回 true 是让 preview 进程开始;后者方法类似于监听 preview 过程
再次运行执行 3D Touch 操作,观察终端输出的完整的生命周期日志
Preview should begin Preview: 0.0, ended: false Preview: 0.567567567567568, ended: false Preview: 1.0, ended: true Commit: 0.0, ended: false Commit: 0.252564102564103, ended: false Commit: 0.340009067814572, ended: false Commit: 0.487818348221377, ended: false Commit: 0.541819501609486, ended: false Commit: 0.703165992497785, ended: false Commit: 0.902372307312938, ended: false Commit: 1.0, ended: true
理清了这些 delegate 调用流程,现在就能更好地设置自定义交互了,首先来构建一个 helper 方法,即传入一个 UIPreviewInteraction 参数,返回一个 Cell;其实就是当 3D Touch 事件发生时找到所按压的 cell
func cellFor(previewInteraction: UIPreviewInteraction) -> UICollectionViewCell? { if let indexPath = collectionView? .indexPathForItem(at: previewInteraction .location(in: collectionView!)), let cell = collectionView?.cellForItem(at: indexPath) { return cell } else { return nil } }
我们通过 UIPreviewInteraction 对象的 location(in:)
方法找到了按压位置,进而找出对应的 cell
现在该来完善之前仅仅打印日志的 deleage 交互方法了,找到 previewInteractionShouldBegin(_:)
更新为如下代码:
// 1 先保证找出所按压的 cell guard let cell = cellFor(previewInteraction: previewInteraction) else { return false } // 2 开始 3D Touch 交互动画,并禁止 scroll 滚动 ratingOverlayView?.beginPreview(forView: cell) collectionView?.isScrollEnabled = false return true
第二步,我们在 ratingOverlayView 上手动实现了一个类似 3D Touch 背景模糊并焦聚的动画,通过 beginPreview
方法来触发
接下来在 previewInteraction(_:didUpdatePreviewTransition:ended:) 方法中,我们让手动实现的 ratingOverlayView 动画进程与 preview 进程同步(基于系统提供的 transitionProgress 来控制动画)
func previewInteraction(_ previewInteraction: UIPreviewInteraction, didUpdatePreviewTransition transitionProgress: CGFloat, ended: Bool) { ratingOverlayView?.updateAppearance(forPreviewProgress: transitionProgress) }
运行一下,缓慢地按压某个 cell,背景开始变得模糊,评分控件最后也加上去了
但是手指挪开,整个动画就冻结了,这是因为交互完成后,我们并没有清理动画
我们在 previewInteractionDidCancel(_:) 中结束交互并恢复滚动
ratingOverlayView?.endInteraction() collectionView?.isScrollEnabled = true
在 endInteraction() 方法的实现中,我们反转动画到开始前的状态。再次运行,按压---移开手指,这次一切正常了
当我们继续重压,触发第二级感应反馈时来实现评分操作。更新 previewInteraction(_:didUpdateCommitTransition:ended:)
方法
let hitPoint = previewInteraction.location(in: ratingOverlayView!) if ended { // TODO commit new rating } else { ratingOverlayView?.updateAppearance(forCommitProgress: transitionProgress, touchLocation: hitPoint) }
updateAppearance(forCommitProgress:touchLocation:)
方法根据按压的位置和进程,来选择 评分控件 触发高亮动画进程。
再次运行,持续按压通过一级反馈后,进入评分环境,上下移动手指选择『点赞』或『踩踩』
当点评操作完成后,即 ended 参数为 true,我们就要将所选的评分手势图片覆盖到 emoji 表情上,这里我们创建一个独立的 helper 方法来完成
func commitInteraction(_ previewInteraction: UIPreviewInteraction, hitPoint: CGPoint) { // 1 根据点击区域判断所选评分手势图片 let updatedRating = ratingOverlayView? .completeCommit(at: hitPoint) // 2 找出对应的 cell 和 cell 上的 emoji guard let cell = cellFor(previewInteraction: previewInteraction) as? EmojiCollectionViewCell, let oldEmojiRating = cell.emojiRating else { return } // 3 创建一个新的 EmojiRating 对象,传入旧的 emoji 和评分 // 最后更新 cell 并开启滚动 let newEmojiRating = EmojiRating(emoji: oldEmojiRating.emoji, rating: updatedRating!) dataStore.update(emojiRating: newEmojiRating) cell.updateAppearanceFor(newEmojiRating) collectionView?.isScrollEnabled = true }
回到 previewInteraction(_:didUpdateCommitTransition:ended:)
方法,替换 //TODO
为
commitInteraction(previewInteraction, hitPoint: hitPoint)
最后运行,选择某个 cell 缓慢按压,当你感受到一级振动反馈后,上下移动手指选择评分手势图片继续重压,直到感受到二级振动反馈后确认结果。
本节要介绍的 Haptic 其实是指 iPhone 7 和 iPhone 7 Plus 新的线性震动马达所带来的多级触觉反馈,苹果 iOS 10 的 API 允许开发者在他们的应用程序中使用 Taptic 引擎,新的 Taptic 带来了多级触觉反馈,这意味着开发者可以对应地采取一些行动。
UIFeedbackGenerator 类是所有反馈的抽象类,它有三个子类
使用这些发生器也非常简单:
let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) feedbackGenerator.impactOccurred()
这里创建了一个 heavy 风格的 UIImpactFeedbackGenerator 发生器,触发它也很简单,调用 impactOccurred()
方法即可
我们可以让 EmojiRater 应用的 collectionView 滚动到顶部时触发 UIImpactFeedbackGenerator 振动反馈。打开 EmojiCollectionViewController.swift 在 UICollectionViewDelegate 标记下添加一个方法
override func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) feedbackGenerator.impactOccurred() }
表示滚动到顶部时触发一个振动反馈,运行一下,先滚动到下面再单击顶部区域让 view 回滚到顶部,感受下马达振动的感觉。
UIImpactFeedbackGenerator 适用于用户 UI 交互,你可以试一下另外两种类型的反馈,分别替换 scrollViewDidScrollToTop(_:)
方法为通知类型的反馈
let feedbackGenerator = UINotificationFeedbackGenerator() feedbackGenerator.notificationOccurred(.success)
以及选择类型的反馈
let feedbackGenerator = UISelectionFeedbackGenerator() feedbackGenerator.selectionChanged()
用心去感受不同的类型的振动反馈吧,当然你得要有个 iPhone 7 或 iPhone 7 plus 才行,手动滑稽
终于写完啦,全书完,继续看前端去了~