- 原文地址:Building Fluid Interfaces: How to create natural gestures and animations on iOS
- 原文做者:Nathan Gitter
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:RydenSun
- 校对者:atuooo
在 WWDC 2018 上,苹果设计师进行了一次题为 “设计流畅的交互界面” 的演讲,解释了 iPhone X 手势交互体系背后的设计理念。前端
苹果 WWDC18 演讲 “设计流畅的交互界面”android
这是我最喜欢的 WWDC 分享 —— 我十分推荐它ios
此次分享提供了一些技术性指导,这对一个设计演讲来讲是很特殊的,但它只是一些伪代码,留下了太多的未知。git
演讲中一些看起来像 Swift 的代码。github
若是你想尝试实现这些想法,你可能会发现想法和实现是有差距的。spring
个人目的就是经过提供每一个主要话题的可行的代码例子,来减小差距。swift
咱们会建立 8 个界面。 按钮,弹簧动画,自定义界面和更多!后端
这是咱们今天会讲到的内容概览:数组
一个流畅交互界面也能够被描述为“快”,“顺滑”,“天然”或是“奇妙”。它是一种光滑的,无摩擦的体验,让你只会感受到它是对的。bash
WWDC 演讲认为流畅的交互界面是“你思想的延伸”或是“天然世界的延伸”。当一个界面是按照人们的想法作事,而不是按照机器的想法时,他就是流畅的。
流畅的交互界面是响应式的,可中断的,而且是可重定向的。这是一个 iPhone X 滑动返回首页的手势案例:
应用在启动动画中是能够被关闭的。
交互界面即时响应用户的输入,能够在任何进程中中止,甚至能够中途改变更画方向。
这篇文章剩下的部分,我会为大家展现怎样来构建 WWDC 演讲中提到的 8 个主要的界面。
图标表明了咱们要构建的 8 个交互界面。
这个按钮模仿了 iOS 计算器应用中按钮的表现行为。
咱们但愿按钮感受是即时响应的,让用户知道它们是有功能的。 另外,咱们但愿操做是能够被取消的,若是用户在按下按钮时决定撤销操做。这容许用户更快的作决定,由于他们能够在考虑的同时进行操做。
WWDC 演讲上的幻灯片,展现了手势是如何与想法同时进行的,以此让操做更迅速。
第一步是建立一个按钮,继承自 UIControl
,不是继承自 UIButton
。UIButton
也能够正常工做,但咱们既然要自定义交互,那咱们就不须要它的任何功能了。
CalculatorButton: UIControl {
public var value: Int = 0 {
didSet { label.text = “\(value)” }
}
private lazy var label: UILabel = { ... }()
}
复制代码
下一步,咱们会使用 UIControlEvents
来为各类点击交互事件分配响应的功能。
addTarget(self, action: #selector(touchDown), for: [.touchDown, .touchDragEnter])
addTarget(self, action: #selector(touchUp), for: [.touchUpInside, .touchDragExit, .touchCancel])
复制代码
咱们将 touchDown
和 touchDragEnter
组合到一个单独的事件,叫作 touchDown
,而且咱们将 touchUpInside
,touchDragExit
和 touchCancel
组合一个单独的事件,叫作 touchUp
。
(查看 这个文档 来获取全部可用的 UIControlEvents
的描述。)
这让咱们有两个方法来处理动画。
private var animator = UIViewPropertyAnimator()
@objc private func touchDown() {
animator.stopAnimation(true)
backgroundColor = highlightedColor
}
@objc private func touchUp() {
animator = UIViewPropertyAnimator(duration: 0.5, curve: .easeOut, animations: {
self.backgroundColor = self.normalColor
})
animator.startAnimation()
}
复制代码
在 touchDown
,咱们根据须要取消存在的动画,而后立刻将颜色设置成高亮颜色(在这里是浅灰色)。
在 touchUp
,咱们建立了一个新的 animator 而且将动画启动。使用 UIViewPropertyAnimator
,能够轻松地取消高亮动画。
(幻灯片笔记:这不是严谨的 iOS 计算器应用中按钮的表现,它容许手势从别的按钮移动到这个按钮来启动点击事件。大多数状况下,我在这里建立的按钮就是 iOS 按钮的预期行为)
这个交互展现了弹簧动画是如何能够经过指定一个“阻尼”(反弹)和“响应”(速度)来建立的。
弹簧是一个很好的动画模型,由于它的速度和天然的外观表现。一个弹簧动画能够及其迅速的开始,用其大多数的时间来慢慢接近最终状态。 这对建立一个响应式的交互界面来讲是完美的。
设计弹簧动画时的几个额外的提醒:
在 UIKit 中,咱们能够用 UIViewPropertyAnimator
和一个 UISpringTimingParameters
对象来构建一个弹簧动画。不幸的是,它没有一个只接受“阻尼”和“响应”的初始化构造器。咱们能获得的最接近的初始化构造器是 UISpringTimingParameters
,它须要质量,硬度,阻尼和初始加速度这几个参数。
UISpringTimingParameters(mass: CGFloat, stiffness: CGFloat, damping: CGFloat, initialVelocity: CGVector)
复制代码
咱们但愿建立一个简便的初始化构造器,只使用阻尼和响应这两个参数,而且将它们映射至须要的质量,硬度和阻尼。
使用一点物理知识,咱们能够导出咱们须要的公示:
弹簧动画的常量和阻尼系数的解决方案。
有了这个结果,咱们正好可使用咱们想要的参数来建立咱们本身的 UISpringTimingParameters
。
extension UISpringTimingParameters {
convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) {
let stiffness = pow(2 * .pi / response, 2)
let damp = 4 * .pi * damping / response
self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity)
}
}
复制代码
这就是咱们如何能够指定弹簧动画到全部其余的交互界面。
想深刻研究弹簧动画?看看 Christian Schnorr 发的这篇极好的文章:Demystifying UIKit Spring Animations。
读了他的文章以后,我最终理解了弹簧动画。对 Christian 大大的致敬,由于它帮助我理解了这些动画背后的数学理论,并且教我如何解二阶微分方程。
又是一个按钮,但又不一样的表现形式。它模仿了 iPhone X 锁屏上的手电筒按钮。
苹果但愿建立一个按钮,它能够轻易地而且快速地被接触到,可是并不会被不当心触发。须要强压来启动手电筒是一个很棒的选择,可是缺乏了功能的可见性和反馈性。
为了解决这个问题,这个按钮是有弹性的,而且会随着用户按压的力度来变大。除此以外,有两个单独的触觉震动反馈:一个是在达到要求的力度按压时,另外一个是按压结束按钮被触发时。这些触觉模拟了物理按钮的表现形式。
为了衡量按压按钮的力度,咱们可使用 touch 事件提供的 UITouch
对象。
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
guard let touch = touches.first else { return }
let force = touch.force / touch.maximumPossibleForce
let scale = 1 + (maxWidth / minWidth - 1) * force
transform = CGAffineTransform(scaleX: scale, y: scale)
}
复制代码
咱们基于用户按压力度计算了缩放比例,这样可让按钮随着用户按压力度变大。
既然按钮能够被按压但不会启动,咱们须要持续追踪按钮的实时状态。
enum ForceState {
case reset, activated, confirmed
}
private let resetForce: CGFloat = 0.4
private let activationForce: CGFloat = 0.5
private let confirmationForce: CGFloat = 0.49
复制代码
经过将确认压力设置到稍小于启动压力,防止用户经过快速的超过压力阈值来频繁的启动和取消启动按钮。
对于触觉反馈,咱们可使用 UIKit
的反馈生成器。
private let activationFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
private let confirmationFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
复制代码
最后,对于反弹动画,咱们可使用 UIViewPropertyAnimator
而且配合咱们前面构建的 UISpringTimingParameters
初始化构造器。
let params = UISpringTimingParameters(damping: 0.4, response: 0.2)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)
animator.addAnimations {
self.transform = CGAffineTransform(scaleX: 1, y: 1)
self.backgroundColor = self.isOn ? self.onColor : self.offColor
}
animator.startAnimation()
复制代码
橡皮筋动画发生在视图抗拒移动时。一个例子就是当滚动视图滑到最底部时。
橡皮筋动画是一种很好的方式来沟通无效的操做,它仍然会给用户一种掌控感。它温柔的告诉你这是一个边界,将它们拉回到有效的状态。
幸运的是,橡皮筋动画实现起来很直接。
offset = pow(offset, 0.7)
复制代码
经过使用 0 到 1 之间的一个指数,视图会随着远离原始位置,移动愈来愈少。要移动的少就用一个大的指数,移动的多就使用一个小的指数。
再详细一点,这段代码通常是在触摸移动时,在 UIPanGestureRecognizer
回调中实现的。
var offset = touchPoint.y - originalTouchPoint.y
offset = offset > 0 ? pow(offset, 0.7) : -pow(-offset, 0.7)
view.transform = CGAffineTransform(translationX: 0, y: offset)
复制代码
注意:这并非苹果如何使用像 scroll view 这些元素来实现橡皮筋动画。我喜欢这个方法,是由于它简单,但对不一样的表现,还有不少更复杂的方法。
为了看 iPhone X 上的应用切换,用户须要从屏幕底部向上滑,而且在中途中止。这个交互界面就是为了建立这个表现形式。
流畅的交互界面应该是快速的。计时器产生的延迟,即使很短,也会让界面感到卡顿。
这个交互十分酷,由于它的反应时间是根据用户手势运动的。若是他们很快中止,界面会很快响应。若是他们慢慢中止,界面就慢慢响应。
为了衡量加速度,咱们能够追踪最新的拖拽手势的速度值。
private var velocities = [CGFloat]()
private func track(velocity: CGFloat) {
if velocities.count < numberOfVelocities {
velocities.append(velocity)
} else {
velocities = Array(velocities.dropFirst())
velocities.append(velocity)
}
}
复制代码
这段代码更新了 velocities
数组,这样能够一直持有最新的 7 个速度值,这些能够被用来计算加速度值。
为了判断加速度是否足够大,咱们能够计算数组中第一个速度值和目前速度值的差。
if abs(velocity) > 100 || abs(offset) < 50 { return }
let ratio = abs(firstRecordedVelocity - velocity) / abs(firstRecordedVelocity)
if ratio > 0.9 {
pauseLabel.alpha = 1
feedbackGenerator.impactOccurred()
hasPaused = true
}
复制代码
咱们也要确保手势移动有一个最小位移和速度。若是手势已经慢下来超过 90%,咱们会考虑将它中止。
个人实现并不完美。在个人测试里,它看起来工做的不错,但还有机会深刻探索加速度的计算方法。
一个抽屉动画,有打开和关闭状态,他们会根据手势的速度有一些反弹。
抽屉动画展现了这个交互界面的理念。当用户有必定速度的滑动某个视图,将动画附带一些反弹会更使人满意。这样交互界面感受像活得,也更有趣。
当抽屉被点击时,它的动画是没有反弹的,这感受起来是对的,由于点击时没有任何明确方向动量的。
当设计自定义的交互界面时,要谨记界面对于不一样的交互是有不一样的动画的。
为了简化点击与拖拽手势的逻辑,咱们可使用一个自定义的手势识别器的子类,在点击的一瞬间进入 began
状态。
class InstantPanGestureRecognizer: UIPanGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
self.state = .began
}
}
复制代码
这可让用户在抽屉运动时,点击抽屉来中止它,这就像点击一个正在滚动的滚动视图。为了处理这些点击,咱们能够检查当手势中止时,速度是否为 0 并继续动画。
if yVelocity == 0 {
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
}
复制代码
为了处理带有速度的手势,咱们首先须要计算它相对于剩下的总距离的速度。
let fractionRemaining = 1 - animator.fractionComplete
let distanceRemaining = fractionRemaining * closedTransform.ty
if distanceRemaining == 0 {
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
break
}
let relativeVelocity = abs(yVelocity) / distanceRemaining
复制代码
当咱们可使用这个相对速度时,配合计时变量来继续这个包含一点反弹的动画。
let timingParameters = UISpringTimingParameters(damping: 0.8, response: 0.3, initialVelocity: CGVector(dx: relativeVelocity, dy: relativeVelocity))
let newDuration = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters).duration
let durationFactor = CGFloat(newDuration / animator.duration)
animator.continueAnimation(withTimingParameters: timingParameters, durationFactor: durationFactor)
复制代码
这里咱们建立有一个新的 UIViewPropertyAnimator
来计算动画须要的时间,这样咱们能够在继续动画时提供正确的 durationFactor
。
关于动画的回转,会更复杂,我这里就不介绍了。若是你想知道的哦更多,我写了一个关于这部分的完整的教程:构建更好的 iOS APP 动画。
从新创造 iOS FaceTime 应用中的 picture-in-picture(下文中简称 Pip)UI。
UIScrollView
的减速速率。咱们最终的目的是写一些这样的代码。
let params = UISpringTimingParameters(damping: 1, response: 0.4, initialVelocity: relativeInitialVelocity)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)
animator.addAnimations {
self.pipView.center = nearestCornerPosition
}
animator.startAnimation()
复制代码
咱们但愿建立一个带有初始速度的动画,而且与拖拽手势的速度相匹配。而且进行 pip 动画到最近的角落。
首先,咱们须要计算初始速度。
为了能作到这个,咱们须要计算基于目前速度,目前为止和目标为止的相对速度。
let relativeInitialVelocity = CGVector(
dx: relativeVelocity(forVelocity: velocity.x, from: pipView.center.x, to: nearestCornerPosition.x),
dy: relativeVelocity(forVelocity: velocity.y, from: pipView.center.y, to: nearestCornerPosition.y)
)
func relativeVelocity(forVelocity velocity: CGFloat, from currentValue: CGFloat, to targetValue: CGFloat) -> CGFloat {
guard currentValue - targetValue != 0 else { return 0 }
return velocity / (targetValue - currentValue)
}
复制代码
咱们能够将速度分解为 x 和 y 两部分,而且决定它们各自的相对速度。
下一步,咱们为 PiP 动画计算各个角落。
为了让咱们的交互界面感受天然而且轻量,咱们要基于它如今的移动来投影 PiP 的最终位置。若是 PiP 能够滑动而且中止,它最终停在哪里?
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let velocity = recognizer.velocity(in: view)
let projectedPosition = CGPoint(
x: pipView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),
y: pipView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate)
)
let nearestCornerPosition = nearestCorner(to: projectedPosition)
复制代码
咱们可使用 UIScrollView
的减速速率来计算剩下的位置。这很重要,由于它与用户滑动的肌肉记忆相关。若是一个用户知道一个视图须要滚动多远,他们可使用以前的知识直觉地猜想 PiP 到最终目标须要多大力。
这个减速速率也是很宽泛的,让交互感到轻量——只须要一个小小的推进就能够送 PiP 飞到屏幕的另外一端。
咱们可使用“设计流畅的交互界面”演讲中的投影方法来计算最终的投影位置。
/// Distance traveled after decelerating to zero velocity at a constant rate.
func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)
}
复制代码
最后缺失的一块就是基于投影位置找到最近的角落的逻辑。咱们能够循环全部角落的位置而且找到一个和投影位置距离最小的角落。
func nearestCorner(to point: CGPoint) -> CGPoint {
var minDistance = CGFloat.greatestFiniteMagnitude
var closestPosition = CGPoint.zero
for position in pipPositions {
let distance = point.distance(to: position)
if distance < minDistance {
closestPosition = position
minDistance = distance
}
}
return closestPosition
}
复制代码
总结最终的实现:咱们使用了 UIScrollView
的减速速率来投影 pip 的运动到它最终的位置,而且计算了相对速度传入了 UISpringTimingParameters
。
将 PiP 的原理应用到一个旋转动画。
这里的代码和前面的 PiP 很像。 咱们会使用一样的构造回调,除了将 nearestCorner
方法换成 closestAngle
。
func project(...) { ... }
func relativeVelocity(...) { ... }
func closestAngle(...) { ... }
复制代码
当最终是时候建立一个 UISpringTimingParameters
,针对初始速度,咱们是须要使用一个 CGVector
,即便咱们的旋转只有一个维度。任何状况下,若是动画属性只有一个维度,将 dx
值设为指望的速度,将 dy
值设为 0。
let timingParameters = UISpringTimingParameters(
damping: 0.8,
response: 0.4,
initialVelocity: CGVector(dx: relativeInitialVelocity, dy: 0)
)
复制代码
在内部,动画会忽略 dy
的值而使用 dx
的值来建立时间曲线。
这些交互在真机上更有趣。要本身玩一下这些交互的,这个是 demo 应用,能够在 GitHub 上获取到。
流畅的交互界面 demo 应用,能够在 GitHub 上获取!
若是你喜欢这篇文章,请留下一些鼓掌。 👏👏👏
你能够点击鼓掌 50 次! 因此赶快点啊! 😉
请将这篇文章在社交媒体上分享给你的 iOS 设计师/iOS 开发工程师朋友。
若是你喜欢这种内容,你应该在 Twitter 上关注我。我只发高质量的内容。twitter.com/nathangitte…
感谢 David Okun 校对。
感谢 Christian Schnorr 和 David Okun。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。