優雅地書寫 UIView 動畫

四娘發表於2016-12-13

原文: 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”

相關文章