优雅地书写 UIView 动画

原文: Swift: UIView Animation Syntax Sugar
作者: Andyy Hope
译者: kemchenj

闭包成对出现时会恶心到你

Swift 代码里的闭包是很好用的工具, 它们是一等公民, 如果他们在 API 的尾部时还可以变成尾随闭包, 并且现在 Swift 3 里还默认noescape 以避免循环引用.

但每当我们不得不使用那些包含了多个闭包参数的API的时候, 就会让这门优雅的语言变得很丑陋. 是的, 我说的就是你, UIView.

class func animate(withDuration duration: TimeInterval,            
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil)

尾随闭包

UIView.animate(withDuration: 0.3, animations: {
// 动画
}) { finished in
// 回调
}

我们正在混合使用多个闭包和尾随闭包, animation: 还拥有参数标签, 但 completion: 已经丢掉参数标签变成一个尾随闭包了. 在这种情况下, 我觉得尾随闭包已经跟原有的函数产生了割裂感, 但我猜这是因为 API 的右尖括号跟右括号让我感觉这个函数已经结束了:

}) { finished in // 糟透了

如果你不确定什么是尾随闭包, 我有另一篇文章解释它的定义和用法 Swift: Syntax Cheat Codes

缩进之美

另一个就是 animation 的两个闭包是同一层级的, 而它们默认的缩进却不一致. 最近我感受了一下函数式编程的伟大, 写函数式代码的一个很爽的点在于把那些序列的命令一条一条通过点语法罗列出来:

[0, 1, 2, 4, 5, 6]
.sorted { $0 < $1 }
.map { $0 * 2 }
.forEach { print($0) }

那为什么不能把带两个闭包的 API 用同样的方式列出来?

如果你不理解 $0 语法, 我有另一篇文章介绍如何它们的含义和语法 Swift: Syntax Cheat Codes

把丑陋的语法强制变得优雅

UIView.animate(withDuration: 0.3,
animations: {
// 动画
},
completion: { finished in
// 回调
})

我想借鉴一下函数式编程的语法, 强迫自己去手动调整代码格式而不是用 Xcode 默认的自动补齐. 我个人觉得这样子会让代码可读性更加好但这也是一个很机械性的过程. 每次我复制粘贴这段代码的时候, 缩进总是会乱掉, 但我觉得这是 Xcode 的问题而不是 Swift 的.

传递闭包

let animations = {
// 动画
}
let completion = { (finished: Bool) in
// 回调
}
UIView.animate(withDuration: 0.3,
animations: animations,
completion: completion)

这篇文章开头我提到闭包是Swift 的一等公民, 这意味着我们可以把它赋值给一个变量并且传递出去. 我觉得这么写并不比上一个例子更具可读性, 而且别的对象只要想要就可以去接触到这些闭包. 如果一定要我选择的话, 我更乐意使用上一种写法.

解决方案

就像许多程序员一样, 我会强迫自己去思考出一个方式去解决这个很常见的问题, 并且告诉自己, 长此以往我可以节省很多时间.

UIView.Animator(duration: 0.3)
.animations {
// Animations
}
.completion { finished in
// Completion
}
.animate()

就像你看到的, 这种语法和结构从 Swift 函数式的 API 里借鉴了很多. 我们把两个闭包的看作是集合的高等函数, 然后现在代码看起来好很多, 并且在我们换行和复制粘贴的时候, 编译器也会根据我们想要的那样去工作(译者注: 这应该跟 IDE 的 formator 有关, 而不是编译器, 毕竟 Swift 不需要游标卡尺😂)

“长此以往我可以节省很多时间”

Animator

class Animator {
typealias Animations = () -> Void
typealias Completion = (Bool) -> Void
private var animations: Animations
private var completion: Completion?
private let duration: TimeInterval
init(duration: TimeInterval) {
self.animations = {} // 译者注: 把 animation 声明为 ! 的其实就可以省略这一行
self.completion = nil // 这里其实也是可以省略的
self.duration = duration
}
...

这里的 Animator 类很简单, 只有三个成员变量: 一个动画时间和两个闭包, 一个初始化构造器和一些函数, 待会我们会讲一下这些函数的作用. 我们已经用了一些 typealias 提前定义一些闭包的签名, 但这是一个提高代码可读性的好习惯, 并且如果我们在多个地方用到了这些闭包, 需要修改的时候, 只需要修改定义, 编译器就会替我们找出所有需要调整的地方, 而不是由我们自己去把所有实现都给找出来, 这样就可以帮助我们减少出错的几率.

这些闭包变量是可变的(用 var 声明), 所以我们需要把他们保存在某个地方, 并且在实例化之后去修改它, 但同时他们也是 private 私有的, 避免外部修改. completion 是 optional 的, 而 animation 不是, 就像 UIView 的官方 API 那样. 在我们初始化构造器的实现里, 我们给闭包一个默认值避免编译器报错.

func animations(_ animations: @escaping Animations) -> Self {
self.animations = animations
return self
}
func completion(_ completion: @escaping Completion) -> Self {
self.completion = completion
return self
}

闭包集合的实现非常简单, 接受一个闭包的参数, 然后把它赋值给相应的变量就行了.

返回 Self

最棒的一点是, 这些 API 都会把返回自己, 这样我们就可以链式地调用:

let numbers =
[0, 1, 2, 4, 5, 6] // Returns Array
.sorted { $0 < $1 } // Returns Array
.map { $0 * 2 } // Returns Array

然而, 如果链式调用的最后一个函数返回一个对象, 那我们就可以把它赋值给某个变量, 然后继续使用, 在这里我们把结果赋值给了 numbers.

而如果函数返回空值那我们就不必赋值给变量了:

[0, 1, 2, 4, 5, 6]         // Returns Array
.sorted { $0 < $0 } // Returns Array
.map { $0 * 2 } // Returns Array
.forEach { print($0) } // Returns Void

Animating

func animate() {
UIView.animate(withDuration: duration,
animations: animations,
completion: completion)
}

就像函数式一样, 前面所有的调用都是为了最后的结果, 这并不是一件坏事. Swift 允许我们作为思考者, 工匠和程序员去重新想象和构建我们所需要的工具.

扩展 UIView

extension UIView {
class Animator { ...

最后, 我们把 Animator 的放到 UIView 的 extension 里, 主要是因为 Animator 是强依赖于 UIView 的, 并且内部函数需要获取到 UIView 内部的上下文, 我们没有任何必要把它独立成一个类.

Options

UIView.Animator(duration: 0.3, delay: 0, options: [.autoreverse])
UIView.SpringAnimator(duration: 0.3, delay: 0.2, damping: 0.2, velocity: 0.2, options: [.autoreverse, .curveEaseIn])

还有一些参数是我们需要传递给 animation 的 API 里的,查看这里的文档就可以了. 我们还可以继承 Animator 类再创建一个 SpringAnimator 去满足我们日常的绝大部分需求.

就像之前那样, 我提供了一个 playgrounds 在 Github 上, 或者看一下这里的 Gist 也可以, 这样你就不必打开 Xcode 了.

如果你喜欢这篇文章的话, 也可以看一下我别的文章, 或者你想在你的项目里使用这个方法的话, 请在 Twitter 上发个推@我或者关注我, 这都会让我很开心.

译者言

翻译这篇文章的时候, 我很偶然地在简书上看到了 Cyandev 的 Swift 中实现 Promise 模式 (我很喜欢他写的文章), 发现其实可以再优化一下

大家有没有印象 URLRequest 的写法, 典型的写法是这样子的:

let url = URL()
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
// 回调
}
task.resume()

刚接触这个 API 的时候, 我经常忘记书写后面那句 task.resume(), 虽然这么写很 OO, 但是我还是很讨厌这种写法, 因为生活中任务不是一个可命令的对象, 我命令这个任务执行是一件很违反直觉的事情

同样的, 我也不太喜欢原文里最后的那一句 animate, 所以我们可以用 promise 的思路去写:

class Animator {
typealias Animations = () -> Void
typealias Completion = (Bool) -> Void

private let duration: NSTimeInterval

private var animations: Animations! {
didSet {
UIView.animateWithDuration(duration, animations: animations) { success in
self.completion?(success)
self.success = success
}
}
}
private var completion: Completion? {
didSet {
guard let success = success else { return }
completion?(success)
}
}

private var success: Bool?

init(duration: NSTimeInterval) {
self.duration = duration
}

func animations(animations: Animations) -> Self {
self.animations = animations
return self
}

func completion(completion: Completion) -> Self {
self.completion = completion
return self
}
}

我把原有的 animate 函数去掉了, 加了一个 success 变量去保存 completion 回调的参数.

这里会有两种情况: 一种是动画先结束, completion 还没被赋值, 另一种情况是 completion 先被赋值, 动画还没结束. 我的代码可能有一点点绕, 主要是利用了 Optional chaining 的特性, completion 其实只会执行一次.

稍微思考一下或者自己跑一下大概就能理解了, 这里其实我也只是简单的处理了一下时序问题, 并不完美, 还是有极小的概率会出问题, 但鉴于动画类 API 的特性, 两个闭包都会按顺序跑在主线程上, 而且时间不会设的特别短, 所以正常情况是不会出问题

具体调用起来会是这个样子, 这个时候再把这个类命名为 Animator 其实已经不是很适合:

UIView.Animator(duration: 3)
.animations {
// 动画
}
.completion {
// 回调
}

虽然只是少了一句代码, 但是我觉得会比之前更好一点, 借用作者的那句话 “save time in the long run”