目录
译者 | MartinRGB
来源 | MARTIN‘S GRAPHIC NOTES
英文链接 | https://medium.com/@esskeetit/how-uiscrollview-works-e418adc47060#8fc0
Yandex.Metro 在 iOS 跟 Android 端共同使用了用 C++ 编写的 MetroKit 库。其中有一个 SchemeView 用来显示地铁地图。我们在实现这个程序的时候发现我们需要重新实现滚动功能。因此我们采用了 UIScrollView 作为参考,因为我们觉得它的动画表现非常自然和谐。
我们将开发流程分成三块,用来实现 UIScrollView 的三种力学运动行为。第一部分就是衰减。
衰减
第二种就是弹性动画,实现了边缘反弹。
弹性动画
第三种就是橡皮筋效果,实现了边缘外拖动的滚动阻尼。
橡皮筋效果
我将阐述这些动画的力学细节,包括实现他们所用到的数学公式。
测试案例
我们先来看一下用来做测试的 SimpleScrollView,我们将通过这个案例分析 iOS 滚动视图的力学原理、公式,以及改进之处。
跟 UIScrollView 类似,这个自定义类包含 contentView, contentSize 和 contentOffset:
class SimpleScrollView: UIView {
var contentView: UIView?
var contentSize: CGSize
var contentOffset: CGPoint
}
为了手势效果,我们需要处理 UIPanGestureRecognizer 和 handlePanRecognizer 函数。
let panRecognizer = UIPanGestureRecognizer()
override init(frame: CGRect) {
super.init(frame: frame)
addGestureRecognizer(panRecognizer)
panRecognizer.addTarget(self, action: #selector(handlePanRecognizer))
}
SimpleScrollView 有两种状态:
. default 无事发生时
. dragging 滚动时
enum State {
case `default`
case dragging(initialOffset: CGPoint)
}
var state: State = .default
handlePanRecognizer 的实现如下:
@objc func handlePanRecognizer(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
state = .dragging(initialOffset: contentOffset)
case .changed:
let translation = sender.translation(in: self)
if case .dragging(let initialOffset) = state {
contentOffset = clampOffset(initialOffset - translation)
}
case .ended:
state = .default
// Other cases
}
}
. 当 UIPanGestureRecognizer 进入 .began 状态,我们对 .dragging 状态进行设置
. 当进入 .change 状态,我们计算变化量,并根据滚动视图改变 contentOffset。于此同时,我们调用 clampOffset 函数避免越界。
. 当进入 .ended 状态,我们将 SimpleScrollView 恢复为 .default 状态
我们设置附加属性 contentOffsetBounds ,结合当前的 contentSize 进而定义 contentOffset 的边界。同样起到限制的还有 clampOffset 函数,它也利用边界限制了 contentOffset 。
var contentOffsetBounds: CGRect {
let width = contentSize.width - bounds.width
let height = contentSize.height - bounds.height
return CGRect(x: 0, y: 0, width: width, height: height)
}
func clampOffset(_ offset: CGPoint) -> CGPoint {
return offset.clamped(to: contentOffsetBounds)
}
现在我们完成了 SimpleScrollView 的简单实现。
SimpleScrollView
滚动起了作用,然而没有动画,运动缺乏惯性。随着本文的展开,我们将一点一点添加效果来改进 SimpleScrollView
衰减
我们先从衰减开始。
SDK 中没有提到衰减动画的实现方法。UIScrollView 有一个 DecelerationRate 的属性,可以设置为 .normal 或者 .fast ,你可以用这个属性来调整滚动衰减的速率。文档 中提到 DecelerationRate 决定了滚动的衰减率
// A floating-point value that determines the rate of deceleration after the user lifts their finger
var decelerationRate: UIScrollView.DecelerationRate
extension UIScrollView.DecelerationRate {
static let normal: UIScrollView.DecelerationRate // 0.998
static let fast: UIScrollView.DecelerationRate // 0.99
}
UIScrollView | https://developer.apple.com/documentation/uikit/uiscrollview
而滚动衰减终点的计算公式 —— 当你手指抬起,滚动动画执行完最终停止的 衰减动画总变化量,在 WWDC 的 Designing Fluid Interfaces 中有所提及。
// Distance travelled after decelerating to zero velocity at a constant rate.
func project(initialVelocity: Float, decelerationRate: Float) -> Float {
return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate)
}
这个公式只能用来计算变化量,无法用来实现衰减动画,但我们可以用起来作为我们未来公式计算的参考。在上面这段代码中,函数的参数为手势的初始速度和衰减率,通过计算返回了手指抬起滚动停止的滚动变化量。
速度
我们来猜猜 DecelerationRate 的运作机制和含义,文档中是这样描述它的:
当用户抬起手指时,决定衰减率的浮点值。
我们假设,这个比率决定了速度每毫秒的变化量(所有 UIScrollView和速度相关的值都用毫秒表示,UIPanGestureRecognizer则不是如此。
如果手指抬起的瞬间,我们有初速度 v₀,我们选择的衰减率是 DecelerationRate.fast,那么:
. 1 毫秒后速度为 v₀ 的 0.99 倍
. 2 毫秒后速度为 v₀ 的 0.992 倍
. k 秒后速度为 v₀ 的 0. 991000k 倍
衰减运动的速度公式为:
运动方程
速度公式可没有办法实现衰减动画。我们需要找到运动方程:拿到时间决定变化量的方程 x(t)。而速度公式可以帮助我们得到运动方程,我们只需要积分即可:
我们替换速度公式 v(x),积分得到:
终点变化方程
现在我们可以得出滚动终点总变化量方程,然后跟苹果的公式进行比较。首先我们将 t 设置为无穷大,因为 d 小于 1,因此 d1000t趋于 0 ,我们得到:
现在我们将苹果的公式写成同种形式,进行比较:
两个公式的差异之处:
我们的公式 和 苹果的公式
然而,如果我们了解 自然对数分解成 以 1 为邻域的泰勒级数的知识,我们可以发现苹果的公式和我们的公式是近似的。
自然对数 | https://en.wikipedia.org/wiki/Natural_logarithm#Series
如果我们对函数在图形上进行打点绘制,我们可以发现在衰减率接近 1 的时候,图形非常拟合。
我们再会想到 苹果的衰减率 提供的两个参数都非常接近 1,这意味着苹果的做法是比较正确的。
衰减时间
现在我们只需要获取衰减动画时间就能够构建动画。为了寻找衰减整个过程的变化量,我们之前将时间设置为无穷。但是动画时间不可能是无穷的。
如果我们将运动公式函数通过打点绘制出来,我们发现这个函数是无限趋近于终点 X 的。这个动画从起点开始,很快就运动到终点附近,然后无限趋近于终点,这个趋近过程几乎是不可察觉的。
利用微积分思想,我们可以重塑我们的问题: 我们只需要找到某个时刻 t ,整体运行的变化量足够趋近于 最终变化量 X 即可(设误差量为 ε)。按惯例来说,UI 上半像素的误差就不易察觉。
当 T 时刻的变化量和 最终变化量的相差绝对值为 ε 时,我们求 T:
我们替换 x 跟 X ,得到时间衰减动画的方程:
现在我们掌握了实现衰减动画的知识,我们将在实现中使用这些公式,改进 SimpleScrollView
衰减实现
我们首先创造一个 DecelerationTimingParameters 结构体,这个结构体包含了衰减动画所需要的信息:
struct DecelerationTimingParameters {
var initialValue: CGPoint
var initialVelocity: CGPoint
var decelerationRate: CGFloat
var threshold: CGFloat
}
. initialValue 指的是初始的 contentOffset — 抬起手指的位置点。
. initialVelocity 抬起手指的初始速度
. decelerationRate 衰减率
. threshold 确定衰减时间的阈值
根据公式,我们来解出衰减滚动停止的点:
var destination: CGPoint {
let dCoeff = 1000 * log(decelerationRate)
return initialValue - initialVelocity / dCoeff
}
求衰减动画时间
var duration: TimeInterval {
guard initialVelocity.length > 0 else { return 0 }
let dCoeff = 1000 * log(decelerationRate)
return TimeInterval(log(-dCoeff * threshold / initialVelocity.length) / dCoeff)
}
运动方程:
func value(at time: TimeInterval) -> CGPoint {
let dCoeff = 1000 * log(decelerationRate)
return initialValue + (pow(decelerationRate, CGFloat(1000 * time)) - 1) / dCoeff * initialVelocity
}
我们构建一个 TimerAnimation 类,会根据屏幕刷新率每秒执行 60 次回调来做动画(ipad Pro 120 次):
class TimerAnimation {
typealias Animations = (_ progress: Double, _ time: TimeInterval) -> Void
typealias Completion = (_ finished: Bool) -> Void
init(duration: TimeInterval, animations: @escaping Animations,
completion: Completion? = nil)
}
我们使用 animation block 来让运动方程根据时间改变 contentOffset 。TimerAnimation 的实现参考 此仓库
现在我们来改进手势处理函数:
@objc func handlePanRecognizer(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
state = .dragging(initialOffset: contentOffset)
case .changed:
let translation = sender.translation(in: self)
if case .dragging(let initialOffset) = state {
contentOffset = clampOffset(initialOffset - translation)
}
case .ended:
state = .default
// Other cases
}
}
手指抬起时才执行衰减动画,因此,当切换为T .ended 状态,我们将调用 startDeceleration 函数,将手势的速度传递进去。
@objc func handlePanRecognizer(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
state = .dragging(initialOffset: contentOffset)
case .changed:
let translation = sender.translation(in: self)
if case .dragging(let initialOffset) = state {
contentOffset = clampOffset(initialOffset - translation)
}
case .ended:
state = .default
let velocity = sender.velocity(in: self)
startDeceleration(withVelocity: -velocity)
// Other cases
}
}
startDeceleration 的实现:
var contentOffsetAnimation: TimerAnimation?
func startDeceleration(withVelocity velocity: CGPoint) {
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let threshold = 0.5 / UIScreen.main.scale
let parameters = DecelerationTimingParameters(initialValue: contentOffset,
initialVelocity: velocity,
decelerationRate: decelerationRate,
threshold: threshold)
contentOffsetAnimation = TimerAnimation(
duration: parameters.duration,
animations: { [weak self] _, time in
guard let self = self else { return }
self.contentOffset = self.clampOffset(parameters.value(at: time))
})
}
衰减率设置为 DecelerationRate.normal 阈值设置为 0.5
初始化 DecelerationTimingParameters
执行动画,通过 animations block 给运动公式传入动画时间,从而更新视图的contentOffset
结果如下:
衰减
弹性动画
我们用弹性动画来实现边缘的回弹效果。
弹性动画是基于阻尼 弹性振荡 的原理实现的。所有的弹性动画原理都是想通的,无论是 iOS SDK,Android SDK 还是本文要实现的弹性效果。
弹性动画通常有以下参数p>
. 质量 Mass (m)
. 刚度 Stiffness (k)
. 阻尼 Damping (d)
弹性公式的运动公式如下,也是由上面参数构成:
某些情况下,使用阻尼比 dampingRatio (ζ),而不是阻尼 damping。他们的关系为下面的公式:
阻尼比
弹性动画中最有意思的参数就是阻尼比,它决定了动画的感受:
阻尼比:0.1 0.5 1.0
阻尼比越接近于 0 ,产生的往复振荡次数越多。越接近于 1 ,往复振荡次数越少。当等于 1 的时候,没有往复运动,仅仅是振幅的衰减变化。
根据阻尼比,阻尼运动分为三种运动类型:
. 0 < ζ < 1 — 欠阻尼,物体会在停止点附近来回周期振荡,阻尼比越接近于 0,振荡次数越多。
. ζ = 1 — 临界阻尼,物体不在终点来回周期振荡, 不做周期运动,先短暂增大振幅,然后振荡以指数衰减的形式停止。
. ζ > 1 — 过阻尼,以指数衰减的形式运动到停止点,这种运动很罕见,所以不考虑。
运动方程
运动方程为:
如果想用来描述物体运动,我们需要知道 x(t) 的方程的解来定义运动。我们还需要知道振荡时间,以便动画使用。因为阻尼比不同,方程的解也不同,因此我们需要分开考虑:
欠阻尼
阻尼比小于 1 (欠阻尼),运动方程的解为:
. ω’ — 阻尼自然频率
. β — 附加参数
. C₁ 和 C₂ 系数用来做初始化条件的参数输入:初始位置为 x₀,初始速度为 is v₀:
正弦函数跟余弦函数描述了振荡运动的周期,而指数描述了振荡运动的衰减。
曲线上可以看到运动方程看起来形如正弦函数跟余弦函数所提供的振幅随时间指数衰减。
临界阻尼
现在我们来看一下临界阻尼,运动方程为:
. β — 想同的附加参数;
. C₁ 和 C₂ 系数跟欠阻尼不同,但是他们也是给初始化参数用来提供输入的:
函数曲线图表如下:
终点没有周期往复振荡,仅仅是振幅的指数衰减。
震荡时间
现在我们需要知道振荡时间,跟衰减一样,我们不能给动画使用无限的时间,通过下图我们知道,从某一点开始,振荡幅度和周期不断变小,几乎视觉上不可见:
因此我们还是要设置一个阈值 ε 来确定振荡是否已经足够小了 (例如半个像素)。
欠阻尼系统的时间的解为 (0 < ζ < 1):
临界阻尼系统的时间的解为 (ζ = 1):
综上,我们找到了弹性动画实现的数学公式。
看到这里你会产生疑问:既然 iOS SDK 已经提供了弹性动画的实现,为什么我还要知道弹性动画的原理呢?
因为在我的案例中,没法使用 iOS 的 SDK,因为要跨平台,因此我们要了解弹性动画的原理。但是为什么自定义 SimpleScrollView 需要知道这些呢?为什么 iOS 开发这需要知道这些呢?我们下面来分析以下 iOS SDK 中的弹性动画。
iOS SDK 中的弹性动画分析
iOS SDK 中提供了好几种创建弹性动画的方法,最简单的是 UIView.animate.
UIView.animate
extension UIView {
class func animate(withDuration duration: TimeInterval,
delay: TimeInterval,
usingSpringWithDamping dampingRatio: CGFloat,
initialSpringVelocity velocity: CGFloat,
options: UIView.AnimationOptions = [],
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil)
}
UIView.animate 的动画是由 dampingRatio 和 initialSpringVelocity 的参数决定的。但是这个函数的古怪之处在于,我们还需要填一个 时间 duration 参数。我们没办法根据 dampingRatio 和 intialSpringVelocity 计算出弹性动画时间,因为我们不知道 质量 mass 跟 刚度 stiffness,我们也不知道弹性的初始位移。
这个函数主要解决了另外一个问题:我们通过设置阻尼比跟动画时间来决定弹性的动画行为,其余参数在函数的实现中被自动计算出来。
因此, UIView.animate 函数主要用来做给定时间的简单动画,它无需参考坐标系。但这个动画函数无法在滚动视图中使用。
CASpringAnimation
另外一种方式是 CASpringAnimation:
open class CASpringAnimation : CABasicAnimation {
/* The mass of the object attached to the end of the spring. Must be greater
than 0. Defaults to one. */
open var mass: CGFloat
/* The spring stiffness coefficient. Must be greater than 0.
* Defaults to 100. */
open var stiffness: CGFloat
/* The damping coefficient. Must be greater than or equal to 0.
* Defaults to 10. */
open var damping: CGFloat
}
想要对 CALayer 的属性进行动画,就必须使用 CASpringAnimation。CASpringAnimation 的参数有质量 mass, 刚度 stiffness 和阻尼 damping,但是没有 阻尼比 dampingratio。我们前面说过阻尼比决定了弹性的运动。如果我们不想要回弹振荡,我将其设置为 1 ,如果我们想要强烈的振荡,我们可以将其设置为接近 0.但是 CA 动画没有这个参数。
不过我们之前学了那个转换公式,因此我们可以拓展 CASpringAnimation 类,添加一个结构体来获取阻尼比 damping ratio:
extension CASpringAnimation {
convenience init(mass: CGFloat = 1, stiffness: CGFloat = 100,
dampingRatio: CGFloat)
{
self.init()
self.mass = mass
self.stiffness = stiffness
self.damping = 2 * dampingRatio * sqrt(mass * stiffness)
}
}
与此同时你还需要设置阻尼运动时间,跟 UIView.animate 不同的是 CASpringAnimation 提供了一个 settlingDuration 的属性,能根据 CASpringAnimation 的参数返回一个估算的动画时间:
open class CASpringAnimation : CABasicAnimation {
/* The basic duration of the object. Defaults to 0. */
var duration: CFTimeInterval { get set }
/* Returns the estimated duration required for the spring system to be
* considered at rest. The duration is evaluated for the current animation
* parameters. */
open var settlingDuration: CFTimeInterval { get }
}
问题在于 settlingDuration 的计算不考虑弹性值的偏移量,也不考虑起点值跟终点值 - fromValue 和 toValue ,你不管怎么设置 fromValue 和 toValue ,settlingDuration 都是一样的。这样设计的目的是为了灵活性,因为 fromValue 和 toValue 可以用来表示任何事情:可以是坐标值,也可以是颜色值 —— 颜色值的偏移量就很难去计算。
当调用 UIView.animate 进行动画时,你可以传入一个动画曲线的参数:例如 .linear, .easeIn, .easeOut, 或 .easeInOut.
这条动画曲线描述了动画进度是如何随时间从 0 到 1 变化的。
WWDC:Advanced Animations with UIKit
在 iOS SDK 中弹性动画的设计也是如此。弹性方程用来表述一个 从 0 到 1 变化的动画进度曲线。因此弹性动画值的偏移量始终就是 1,因此 fromValue 和 toValue 的值可以忽略。
WWDC:Advanced Animations with UIKit
UISpringTimingParameters
第三种创建弹性动好的方式,是从 iOS 10 开始的 UISpringTimingParameters。有两种创建 UISpringTimingParameters 的方式:
class UISpringTimingParameters : NSObject, UITimingCurveProvider {
init(dampingRatio ratio: CGFloat, initialVelocity velocity: CGVector)
init(mass: CGFloat, stiffness: CGFloat, damping: CGFloat,
initialVelocity velocity: CGVector)
}
最有意思的是,使用的构造方法不同,UISpringTimingParameters 创建出来的动画效果也不同。
如果创建 UISpringTimingParameters 时使用了 mass, stiffness 和 damping 的构造方法,动画时间会被自动计算,我们设置的 duration 被忽略:
let timingParameters = UISpringTimingParameters(mass: 1, stiffness: 100, damping: 10,
initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: 4, timingParameters: timingParameters)
animator.addAnimations { /* animations */ }
animator.startAnimation()
print(animator.duration) // 1.4727003346780927
这个设计就很灵活,既然你可以用 animations block 做任何事,这就证明了函数里面动画的偏移量是 0 到 1。但即便你知道了动画的偏移量,知道如何计算动画时间,你也无法自己手动设置。
如果创建 UISpringTimingParameters 时使用了 dampingRatio,那么动画时间就不会被自动计算,你需要设置以下:
let timingParameters = UISpringTimingParameters(dampingRatio: 0.3,
initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: 4, timingParameters: timingParameters)
print(animator.duration) // 4.0
但这个方法在于我们没有足够的信息去计算,跟 UIView.animate 一样,没有质量 mass,没有刚度 stiffness,没有弹性偏移量。
偏移量为零的弹性动画
iOS 弹性动画还有一个常见问题时,没有 0 偏移量的动画 (fromValue == toValue)。因此,你无法快速的实现下面这样的动画:
偏移量为零的弹性动画
即便设置了 initialSpringVelocity,下面的这段代码也不起作用:
UIView.animate(
withDuration: 5,
delay: 0,
usingSpringWithDamping: 0.1,
initialSpringVelocity: -1000,
animations: {
})
即便你不断更新 frame ,也不会产生改变:
UIView.animate(
withDuration: 5,
delay: 0,
usingSpringWithDamping: 0.1,
initialSpringVelocity: -1000,
animations: {
self.circle.frame = self.circle.frame
})
我们后面会看到,为了实现 SimpleScrollView 的回弹效果,你需要实现 零偏移量的动画。通过分析 iOS SDK 中弹性动画相关,我们了解到 iOS SDK 的函数不适用于我们的案例,因此我们需要自己实现。
弹性动画实现
为了实现弹性动画,我们使用 SpringTimingParameters 结构体:
struct Spring {
var mass: CGFloat
var stiffness: CGFloat
var dampingRatio: CGFloat
}
struct SpringTimingParameters {
var spring: Spring
var displacement: CGPoint
var initialVelocity: CGPoint
var threshold: CGFloat
}
. spring — 弹性参数;
. displacement - 位移;
. initialVelocity - 初始速度;
. threshold — 用来求动画时间的变化量阈值;
利用公式,我们可以得到动画时间和运动方程 (value:at:):
extension SpringTimingParameters {
var duration: TimeInterval
func value(at time: TimeInterval) -> CGPoint
}
More on GitHub
现在我们来看一下回弹的工作原理,以及衰减动画和弹性动画之间过渡的实现:
. 定义滚动视图内容的边界
. 抬起手指时,拿到当前的内容偏移量 contentOffset 和手势速度
. 利用公式得出滚动最终停止的位置 (destination)
. 如果我们发现我们会越界,我们需要根据衰减公式计算,越界之前的动画时间和越界瞬间的速度。
. 因此,我们只需要先执行衰减动画,然后在碰撞边界的瞬间,将速度传递给弹性动画,将弹性动画设置为偏移量为 0 的动画机制,然后执行弹性动画即可。(译者注:跟我安卓的做法一样)
但在我们实现这个算法之前,我们拓展一下 DecelerationTimingParameters ,添加两个辅助函数:
. duration: to: 用来得到越界之前的动画时间
. velocity: at: 来得到越界瞬间的动画速度
extension DecelerationTimingParameters {
func duration(to value: CGPoint) -> TimeInterval? {
guard value.distance(toSegment: (initialValue, destination)) < threshold else { return nil }
let dCoeff = 1000 * log(decelerationRate)
return TimeInterval(log(1.0 + dCoeff * (value - initialValue).length / initialVelocity.length) / dCoeff)
}
func velocity(at time: TimeInterval) -> CGPoint {
return initialVelocity * pow(decelerationRate, CGFloat(1000 * time))
}
}
处理手势的 handlePanRecognizer 函数如下:
@objc func handlePanRecognizer(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
state = .dragging(initialOffset: contentOffset)
case .changed:
let translation = sender.translation(in: self)
if case .dragging(let initialOffset) = state {
contentOffset = clampOffset(initialOffset - translation)
}
case .ended:
state = .default
let velocity = sender.velocity(in: self)
startDeceleration(withVelocity: -velocity)
// Other cases
}
}
现在我们改进一下当手指抬起的时候调用的 startDeceleration 函数:
func startDeceleration(withVelocity velocity: CGPoint) {
let parameters = DecelerationTimingParameters(…)
let destination = parameters.destination
let intersection = getIntersection(rect: contentOffsetBounds, segment: (contentOffset, destination))
let duration: TimeInterval
if let intersection = intersection {
duration = parameters.duration(to: intersection)
} else {
duration = parameters.duration
}
contentOffsetAnimation = TimerAnimation(
duration: duration,
animations: { [weak self] _, time in
self?.contentOffset = parameters.value(at: time)
},
completion: { [weak self] finished in
guard finished && intersection != nil else { return }
let velocity = parameters.velocity(at: duration)
self?.bounce(withVelocity: velocity)
})
}
. 初始化 DecelerationTimingParameters;
. 找到滚动停止时的位置
. 找到与内容边界的碰撞点位置
. 如果发现会越界,那么找到越界之前的动画时间。
. 首先执行衰减动画,根据衰减动画的值,更新contentOffset
. 碰撞的瞬间,计算出瞬时速度,作为调用 bounce 函数的参数.
bounce 函数的实现如下:
func bounce(withVelocity velocity: CGPoint) {
let restOffset = contentOffset.clamped(to: contentOffsetBounds)
let displacement = contentOffset - restOffset
let threshold = 0.5 / UIScreen.main.scale
let spring = Spring(mass: 1, stiffness: 100, dampingRatio: 1)
let parameters = SpringTimingParameters(spring: spring,
displacement: displacement,
initialVelocity: velocity,
threshold: threshold)
contentOffsetAnimation = TimerAnimation(
duration: parameters.duration,
animations: { [weak self] _, time in
self?.contentOffset = restOffset + parameters.value(at: time)
})
}
. 首先,计算弹性动画的终止位置 —— 在此情况下,终止位置也就是内容的边界。
. 计算初始的弹性动画的偏移量 —— 在此情况下,为 0,因为此时此刻弹性动画的起点就在边界。
. 阈值设置为半个像素。
. 阻尼比设置为 1,这样在内容边界附近就不会产生周期振荡的动画效果。
. 初始化 SpringTimingParameters;
. 传入计算好的 动画时间 duration ,执行弹性动画。在 animations block 中,我们调用弹性运动方程,将当前的动画时间带入其中。然后用解出来的数值更新 contentOffset
结果如下:
当我们抬起手指的时候,内容会向边界方向滚动。如果触碰到了边界,则触发弹性动画的执行。因为衰减动画和弹性动画都是基于速度概念的数学公式,因此只要速度衔接得当,那么两个动画的连结会非常流畅。我们不需要分别调整衰减和弹性,就可以接的很好。
橡皮筋效果
现在我们来处理橡皮筋效果。我们前面提到过,这是在边界处拖拽的滚动阻尼效果,而且这个效果不光在滚动中应用,也在我们的缩放手势中😤应用。
因为 iOS 开发文档中没有提到 橡皮筋效果 是如何实现的。因此我们得靠猜。我们首先根据边界手指拖拽的效果打出 contentOffset 的点生成曲线。然后再思考什么公式比较能近似生成此曲线。
首先,我们试着用 弹性公式 去套。但是不管怎么尝试,都无法接近图表中的曲线。
最后,我们决定尝试使用简单的多项式拟合曲线。实现方法是选择几个点,然后找出可能曲线上有这几个点的多项式。你可以自己手动计算,也可以去 WolframAlpha 输入这几个点来生成
quadratic fit {0, 0} {500, 205} {1000, 328} {1500, 409}
生成的多项式如下:
多项式曲线和 iOS 的函数曲线非常拟合:
得亏我们知道这个效果的专有名次叫 橡皮筋效果,不然我们的研究止步于此。
很快我们就在 twitter 上发现了一个公式:
https://twitter.com/chpwn/status/285540192096497664
. 等式右面的 x 指的是手指的位移量:
. c 是某个比率
. d 指的是滚动视图的尺寸
我们用这个函数构建了一个曲线图表,c 比率使用了 0.55 ,发现曲线契合:
我们再来分析一下这个公式的运作原理,我们来用更清晰的方式写出这个公式:
我们使用不同的 c 比率,选择 812(iPhone X 的高度)作为 d 的值,构建曲线图表:
曲线图表说明 d 影响了橡皮筋效果的刚度:d 越小,橡皮筋效果越硬,就需要更强的拖拽才能驱动内容位移 contentOffset.
从函数我们知道,当 x 趋于无穷时,我们的函数的解无限趋近但小于 d:
这个公式非常方便,因为你可以用 d 来设置最大偏移量。通过这个公式,你可以确保滚动视图的内容永远保留在屏幕上。
橡皮筋效果仅使用了这一个公式,因此让我们来改进我们的 SimpleScrollView 类。
橡皮筋效果实现
我们首先来生命一个 rubberBandClamp 函数, 这个函数来源自我们 Twitter 上看到的公式:
func rubberBandClamp(_ x: CGFloat, coeff: CGFloat, dim: CGFloat) -> CGFloat {
return (1.0 - (1.0 / ((x * coeff / dim) + 1.0))) * dim
}
然后为了方便起见,我们给 rubberBandClamp 函数,添加一个限制 limits :
func rubberBandClamp(_ x: CGFloat, coeff: CGFloat, dim: CGFloat,
limits: ClosedRange<CGFloat>) -> CGFloat
{
let clampedX = x.clamped(to: limits)
let diff = abs(x - clampedX)
let sign: CGFloat = clampedX > x ? -1 : 1
return clampedX + sign * rubberBandClamp(diff, coeff: coeff, dim: dim)
}
函数的运作原理如下:
. 如果 x 在限制 limits 之内,保留 x 原来的值
. 只要 x 在 limits 之外,那么执行 rubberBandClamped
当我们越界拖拽的时候,我们只需要 rubberBandClamp 函数即可。
让我们将此函数拓展为二维,然后创建 RubberBand 结构体:
struct RubberBand {
var coeff: CGFloat
var dims: CGSize
var bounds: CGRect
func clamp(_ point: CGPoint) -> CGPoint {
let x = rubberBandClamp(point.x, coeff: coeff, dim: dims.width, limits: bounds.minX...bounds.maxX)
let y = rubberBandClamp(point.y, coeff: coeff, dim: dims.height, limits: bounds.minY...bounds.maxY)
return CGPoint(x: x, y: y)
}
}
. 我们用滚动视图的边界作为 bounds 参数 (contentOffset 边界);
. 至于 dimensions — 滚动。视图的尺寸
. clamp 的运作原理如下:当传入的 point 位置点在 bounds 之内的时候,无事发生,如果超出了边界 bounds 的值,那么产生橡皮筋效果。
手势处理函数 handlePanRecognizer 如下:
@objc func handlePanRecognizer(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
state = .dragging(initialOffset: contentOffset)
case .changed:
let translation = sender.translation(in: self)
if case .dragging(let initialOffset) = state {
contentOffset = clampOffset(initialOffset - translation)
}
case .ended:
state = .default
let velocity = sender.velocity(in: self)
startDeceleration(withVelocity: -velocity)
// Other cases
}
}
clampOffset 函数如下:
func clampOffset(_ offset: CGPoint) -> CGPoint {
return offset.clamped(to: contentOffsetBounds)
}
这个函数会对传入的 contentOffset 进行范围限制,对越界的移动产生抵抗力。
为了产生橡皮筋效果,你需要创建 RubberBand 结构,传入滚动视图的尺寸dimensions,滚动视图的偏移量 contentOffset,边界位置 bounds,然后调用 clamp 函数.
func clampOffset(_ offset: CGPoint) -> CGPoint {
let rubberBand = RubberBand(dims: frame.size, bounds: contentOffsetBounds)
return rubberBand.clamp(offset)
}
但仅仅这样还不够,之前的滚动回弹动画中,当我们抬起手指的时候,我们先衰减再回弹。但是这个动画中,手势驱动的滚动在内容的边界范围之外。因此在边界范围之外的手势滚动不会触发衰减。因此当越界拖拽之后,视图需要归于原位(也就是最近的边界点),我们需要调用 bounce 函数。
我们在 completeGesture 函数中完成上述的动画逻辑,我们只需要在 handlePanRecognizer 函数中的 .ended 状态中写入我们的逻辑:
case .ended:
state = .default
let velocity = sender.velocity(in: self)
completeGesture(withVelocity: -velocity)
// Other cases
这个函数本身非常简单:
func completeGesture(withVelocity velocity: CGPoint) {
if contentOffsetBounds.contains(contentOffset) {
startDeceleration(withVelocity: velocity)
} else {
bounce(withVelocity: velocity)
}
}
. 当拖拽滚动视图产生的 contentOffset 在边界之内时,例如,当我们在边界内抬起手指,那么调用 startDeceleration 函数:
. 当我们在边界之外进行拖拽,松手时则调用 bounce 函数,不触发衰减,直接移动到最近的边界点。
视觉效果如下:
我们重修了这三种效果的力学原理,并用原理实现了这三种滚动效果。Yandex.Metro 的 C++ 代码也采用了同样的原理,但是有一些很小但很重要 的差异:
⚠️ 在 SimpleScrollView 的案例中,我们对 contentOffset 进行了动画,这是为了展示的简便性。但是更正确的做法是 分离各个属性:分开 x, y 以及 scale;
⚠️ 在 SimpleScrollView 的案例中, the bounce 函数被调用了两次,边界内拖拽后松手导致的边缘反弹,以及边界外拖拽松手导致的复位。两个动画的参数完全想通,但我个人建议这两种动画行为采用不同的 刚度stiffness参数 质量 mass 和 阻尼比 damping ratio 也可以分开调节。
案例
实现一整套滚动的需求非常罕见,我们这么做的原因是我们不能用平台的 SDK,这里我来展示一些采用上述函数的案例:
抽屉 - 状态间切换
在 Yandex.Metro 中的上拉抽屉,这种设计模式在一些苹果的应用中也有用到(地图,股市,Find My xxx 等)
这个动画有三种状态:中间态,展开态,收缩态。抽屉不会停在这三种状态之外的状态上。当松开手指的时候,抽屉会滚动到这三个定点中的一个。这里的设计细节在于,当抬起手指的时候,如何判定应该停留在哪个状态的哪个点。
如果按照手指释放的位置跟状态的距离来判定,移动到展开态就很麻烦,因为你需要将手指移动接近屏幕上方的点释放:
定位到最近点
这种实现,用户会感觉抽屉从你手里溜出去了
因此我们需要考虑手势速率。这个算法的实现多种多样,但是最成功的解决方案在 WWDC 的 Designing Fluid Interfaces 中有所展示。采用这个解决方案的思路如下:
. 当手指离开的时候,抽屉的位置和手势的速度是已知的
. 根据上面的两个参数,运用找到抽屉最终会移动到的点的位置。
. 根据这个(符合物理模型、理想的)的点的位置,找到最近的定位点
. 动画滚动到这个定位点。
可以使用衰减动画的公式来寻找定位点。
我们来描述一下这个映射 project 函数,我们传入抽屉的当前位置 (value),手势初始速度 velocity 以及衰减率 decelerationRate。这个函数会返回一个抽屉理想状态下衰减运动应该移动到的位置的值:
func project(value: CGPoint, velocity: CGPoint, decelerationRate: CGFloat) -> CGPoint {
return value - velocity / (1000.0 * log(decelerationRate))
}
整个手势的函数如下:
func completeGesture(velocity: CGPoint) {
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let projection = project(value: self.origin, velocity: velocity,
decelerationRate: decelerationRate)
let anchor = nearestAnchor(to: projection)
UIView.animate(withDuration: 0.25) { [weak self] in
self?.origin = anchor
}
}
. 首先,选择衰减率为 DecelerationRate.normal;
. 找到理想衰减动画的映射函数的值 projection;
. 寻找最近的定位点 anchor;
. 修改抽屉的动画位置
线性曲线动画
但是这个动画还是不够完美。因为使用了固定的动画时间,动画会看起来不自然,因为这个动画没有考虑到手势的速率,以及手势起点和动画终点的距离。因此需要使用弹性动画:
. 当手指抬起的时候,抽屉位置和手势速度已知
. 传入上述两个参数,得出理想状况下抽屉该衰减移动到的位置。
. 找到和这个位置最近的点:
. 创建弹性动画,移动到该位置点,整个动画偏移量是手势点到终点的距离。
按上面的修改后,completeGesture 函数如下:
func completeGesture(velocity: CGPoint) {
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let projection = project(value: self.origin, velocity: velocity,
decelerationRate: decelerationRate)
let anchor = nearestAnchor(to: projection)
let timingParameters = SpringTimingParameters(
spring: Spring(mass: 1, stiffness: 200, dampingRatio: 1),
displacement: self.origin - anchor,
initialVelocity: velocity,
threshold: 0.5 / UIScreen.main.scale)
originAnimation = TimerAnimation(
duration: timingParameters.duration,
animations: { [weak self] _, time in
self?.origin = anchor + timingParameters.value(at: time)
})
}
效果如下:
因为阻尼比设置为 1,因此定位点附近的动画不产生周期振荡。如果阻尼比设置为接近于 0,效果如下:
除了一维的变化,这个方法还可以应用到二维的案例中
PiP | Example on GitHub
这是模拟 FaceTime 或 Skype 的画中画的案例,这个算法在 WWDC 中有所阐述。我的这个案例考虑到了动画过渡中手势的作用,修正了状态。
你还可以利用映射方法来实现非同寻常的翻页效果:例如新 App Store 中的下面这个效果
iOS 13 应用商店
这里的翻页效果应用了阻尼振荡。在标准的 UIScrollView 翻页中,所能翻的页面尺寸是一样的,翻页动画行为也不能控制。
抽屉 - 橡皮筋效果
除此之外,这个抽屉效果还可以加上一个橡皮筋效果。问题来了:为啥我要给抽屉加这种效果?在 iOS 中,任何能够滚动的都行,都有橡皮筋效果。如果滚动被突然阻断,那么 APP 看起来像卡住了。添加这个效果,会给用户的手势操作提供更多的反馈。
但是这个应用中的橡皮筋效果实现略有不同:
. 对 x,我们取了抽屉当前状态的点为边界点。
. 这里使用了抽屉到屏幕上边缘的距离作为 dimension,这保证了抽屉不会滚动出屏幕。
收缩态也是同样如此:
【动图帧数过多无法上传,请查看原文】
. 对 x,我们取了抽屉当前状态的点为边界点。
. 使用了抽屉到屏幕下边缘的距离作为 dimension,这保证了抽屉不会滚动出屏幕。
地铁地图缩放
除了位移,橡皮筋效果可以用来施加给任何属性:二维位移、缩放、颜色、旋转。在 Metro 中,我们给手势缩放也添加了这个效果:
【动图帧数过多无法上传,请查看原文】
双指缩放的橡皮筋效果
总结
如果想在状态之间做平滑的动画过度,而状态导致的属性变化不局限于位置变化(例如颜色、旋转、不透明度),那么可以使用弹性动画和属性映射方法。
如果想要状态的边缘临界感平滑,可以考虑使用橡皮筋效果
了解动画效果的力学原理非常重要。比如说橡皮筋效果,我们找到了一个和我们期望公式在图形上非常近似的多项式,然而在意义上却大不相同:
原公式和近似公式的区别在于,原公式又一个 c 作为比率,用来可预测修改橡皮筋效果的刚度。在近似公式中,估计只能尝试通过调节 x 来控制刚度。
原公式中还有一个 d 参数,用来控制最大偏移量,而近似公式无法控制
当实现回弹效果时,我们先执行衰减动画,再执行弹性动画。因为我们这两种动画的实现都基于速度,所以不用调节衰减率,也不用调节弹性参数,就可以让两个动画无缝结合。所以我们可以随意调节衰减行为和回弹行为,这两个动画行为互不干扰,不管怎么调结合点都是平滑的,看起来像一段动画。
如果你想了解一些力学效果是如何运作,使用什么公式实现,我建议去看原公式,理解原理会简化实现难度。
📚 所有代码案例,包含 PiP 跟 TimerAnimation,可以在这个 仓库 https://github.com/super-ultra/ScrollMechanics 里找到
🚀 抽屉拉动案例已经加入了 CocoaPods ,可以看下这个 仓库 https://github.com/super-ultra/UltraDrawerView 。
👨💻 我还推荐看一下 Designing Fluid Interfaces 和 Advanced Animations with UIKit