動畫實作

利用 UIViewPropertyAnimator 製作進階動畫 為 App 增強使用者體驗!

動畫可以幫助開發者吸引使用者,替 App 添加一些樂趣。此文將教大家利用 UIViewPropertyAnimator 為視圖製作動畫,有了這個類別,開發者可以構建自定義計時、交互方式和中斷動畫,更可以在動畫運行時作出變動。
利用 UIViewPropertyAnimator 製作進階動畫 為 App 增強使用者體驗!
利用 UIViewPropertyAnimator 製作進階動畫 為 App 增強使用者體驗!
In: 動畫實作, iOS App 程式開發, Swift 程式語言, UI, Xcode

動畫很酷,它們是 iOS Human Interface Guidelines(介面設計指南)的重要組成部分。動畫可以幫助開發者吸引使用者注意重要的事情,或者替 App 添加一些樂趣。

在 iOS 中,有幾個方法可以實作動畫,最常見到的可能就是 UIView.animate(withDuration:animations:)。你亦可以使用 CABasicAnimation 為視圖的圖層設置動畫。此外,UIKit 允許你使用UIViewControllerTransitioningDelegate,來自定義視圖控制器呈現的動畫。

在本教程中,我想與讀者討論另一種為視圖製作動畫的方式,就是 UIViewPropertyAnimator。這個類別比它的前身 UIView.animate 提供更多控制方法。有了它,開發者可以構建自定義計時、交互方式和中斷動畫,更可以在動畫運行時作出變動。

很困惑嗎?別擔心,你很快就會明白的了。

從 UIViewPropertyAnimator 開始

UIViewPropertyAnimator 是在 iOS 10 推出的,它允許你以物件導向方式創建動畫。讓我們看看使用 UIViewPropertyAnimator 創建的範例動畫。

img

這是你用 UIView 實作的方法。

UIView.animate(withDuration: 0.3) {
    view.frame = view.frame.offsetBy(dx: 100, dy: 0)
}

而這是使用 UIViewPropertyAnimator 實作的方法:

 let animator = UIViewPropertyAnimator(duration:0.3, curve: .linear) {
     view.frame = view.frame.offsetBy(dx:100, dy:0)
 }
 animator.startAnimation()

如果要測試動畫,只需創建一個 Playground 專案並運行下列程式碼,兩段程式碼都會產生相同的動畫。

img

你可能會認為兩者沒甚麼區別,那為甚麼要介紹另一種創建動畫的新方法呢?但當你想要創建交互式動畫時,UIViewPropertyAnimator 就大派用場了。

交互式 (Interactive) 和可中斷 (Interruptible) 的動畫

你還記得經典的 “Slide to Unlock(滑動解鎖)” 手勢嗎?或是打開控制中心的 “Swipe from bottom(底部上滑)” 手勢?這些都是交互式和可中斷動畫的完美範例,你可以用手指開始移動視圖,然後釋放它,讓視圖返回其原始位置。或者,你可以在動畫期間捕捉視圖,並繼續用手指拖動它。

但是,UIView 動畫就無法讓我們完全控制動畫。你不能在動畫循環中間暫停,並在中斷後繼續執行動畫。

這就是 UIViewPropertyAnimator 的優勢。一起看看我們如何透過幾個步驟,構建一個完整的交互式、可中斷、可倒轉動畫吧!

準備起始專案

首先,請下載起始專案開始作業。解壓縮文件後,你會找到 CityGuide App,它可幫助使用者計劃假期。使用者可以在城市列表中滑動,然後選擇喜歡的城市,打開卡片去閱讀詳細信息。

讓我們在創建動畫前稍微探索一下專案程式碼。你可以在 Xcode 專案中找到以下內容:

  1. ViewController.swift:App 的主視圖控制器帶有一個 UICollectionView,用來顯示 City 物件的陣列。
  2. CityCollectionViewCell.swift:用於顯示 City 的 Cell。實際上,在本教程中,大多數改動都將應用於此類別。你可能會注意到,我們在類別中有定義 descriptionLabelcloseButton。但是,如果運行 App 卻不會看到這兩個物件。別擔心,我們稍後會將它們顯示出來。在這類別中,我們還有 collectionViewindex 屬性。同樣地,我們稍後會將它們用於動畫之中。
  3. CityCollectionViewFlowLayout.swift:這個類別負責水平滾動,但我們不會接觸到它。
  4. City.swift:App 的主要模型。這裡還有一個工廠方法,我們將會在 ViewController 中用到它。
  5. Main.storyboard:你可以在此找到 ViewControllerCityCollectionViewCell 的 UI。

嘗試構建程式碼並執行範例 App,你應該會看到以下畫面。

cityguideapp-iphone8

實作展開和折疊動畫

啟動 App 後,它會顯示一個城市列表,但是使用者無法與 Cell 互動。現在,我們希望在使用者點擊其中一個 Cell 時顯示該城市的資訊。先看看最後應該要呈現的結果,這是我們想要構建的動畫:

這個動畫看起來不錯吧?但這裡沒什麼特別的,只是一些基本的 UIViewPropertyAnimator 邏輯。 讓我們看看如何實作這種類型的動畫吧!插入以下程式碼片段到 ViewController 檔案的末尾,以創建 collectionView(_:didSelectItemAt)方法:

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    let selectedCell = collectionView.cellForItem(at: indexPath)! as! CityCollectionViewCell
    selectedCell.toggle()
}

現在我們需要實作 toggle 方法。讓我們切換到 CityCollectionViewCell.swift 並實作這個方法。

首先,在CityCollectionViewCell的類別宣告前,將 State enum 添加到檔案的頂部,這個 enum 允許我們追蹤 Cell 的狀態:

private enum State {
    case expanded
    case collapsed

    var change: State {
        switch self {
        case .expanded: return .collapsed
        case .collapsed: return .expanded
        }
    }
}

接下來,讓我們為 CityCollectionViewCell 類別添加一些用於控制動畫的屬性:

private var initialFrame: CGRect?
private var state: State = .collapsed
private lazy var animator: UIViewPropertyAnimator = {
    return UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut)
}()

initialFrame 變數是用於在動畫之前儲存 Cell 的框架 (frame);state 是用於追蹤 Cell 為展開或折疊;而 animator 變數是用於驅動和控制動畫。

現在添加 toggle 方法,並從 close 這個函式中調用它,像是這樣:

@IBAction func close(_ sender: Any) {
    toggle()
}

func toggle() {
    switch state {
    case .expanded:
        collapse()
    case .collapsed:
        expand()
    }
}

這裡再添加了兩個方法:expand() 以及 collapse()。讓我們繼續實作它們,首先,從 expansion d() 方法開始:

private func expand() {
    guard let collectionView = self.collectionView, let index = self.index else { return }

    animator.addAnimations {
        self.initialFrame = self.frame

        self.descriptionLabel.alpha = 1
        self.closeButton.alpha = 1

        self.layer.cornerRadius = 0
        self.frame = CGRect(x: collectionView.contentOffset.x, y:0 , width: collectionView.frame.width, height: collectionView.frame.height)

        if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
            leftCell.center.x -= 50
        }

        if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
            rightCell.center.x += 50
        }

        self.layoutIfNeeded()
    }

    animator.addCompletion { position in
        switch position {
        case .end:
            self.state = self.state.change
            collectionView.isScrollEnabled = false
            collectionView.allowsSelection = false
        default:
            ()
        }
    }

    animator.startAnimation()
}

嘩!這裡程式碼很多,讓我一步一步向你解釋:

  1. 首先,我們檢查 collectionViewindex 不是 nil。否則,我們將無法運行動畫。
  2. 下一步,我們呼叫 animator.addAnimations 來開始創建動畫。
  3. 接著,我們儲存當前的框架,這是用於在折疊動畫上恢復它。
  4. 然後,我們設置 descriptionLabelcloseButton 的 alpha 值使其可見。
  5. 接著,我們刪除圓角並為 Cell 設置新框架。Cell 將以全屏顯示。
  6. 然後,我們移動相鄰的 Cell。
  7. 最後,我們呼叫 animator.addComplete() 方法來禁止 collectionView 的交互,這可以預防使用者在 Cell 擴展時滾動它。我們也改變了 Cell 的當前狀態,這一點很重要,因為我們只在動畫完成時作出變動。

現在我們要添加折疊動畫。簡單來說,我們只是將 Cell 恢復到之前的狀態:

private func collapse() {
    guard let collectionView = self.collectionView, let index = self.index else { return }

    animator.addAnimations {
        self.descriptionLabel.alpha = 0
        self.closeButton.alpha = 0

        self.layer.cornerRadius = self.cornerRadius
        self.frame = self.initialFrame!

        if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
            leftCell.center.x += 50
        }

        if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
            rightCell.center.x -= 50
        }

        self.layoutIfNeeded()
    }

    animator.addCompletion { position in
        switch position {
        case .end:
            self.state = self.state.change
            collectionView.isScrollEnabled = true
            collectionView.allowsSelection = true
        default:
            ()
        }
    }

    animator.startAnimation()
}

現在,是時候運行我們的 App 了。你可以試試點擊 Cell,應該會看到動畫。要關閉視圖,請點擊右上角的十字圖標。

添加拖曳手勢 (Pan Gesture)

你可能會說,我們明明可以使用 UIView.animate 來獲得相同的結果,為甚麼要使用 UIViewPropertyAnimator 呢?

好,是時候讓動畫變為互動式了!我們將添加一個 UIPanGestureRecognizer 和一個名為 popupOffset 的新屬性,以追蹤我們可拖曳的 Cell 數量。讓我們在 CityCollectionViewCell 類別中宣告這些變數:

private let popupOffset: CGFloat = (UIScreen.main.bounds.height - cellSize.height)/2.0
private lazy var panRecognizer: UIPanGestureRecognizer = {
    let recognizer = UIPanGestureRecognizer()
    recognizer.addTarget(self, action: #selector(popupViewPanned(recognizer:)))

    return recognizer

}()

接下來,添加下列方法來註冊拖曳手勢:

override func awakeFromNib() {
    self.addGestureRecognizer(panRecognizer)
}

然後,我們需要添加 popupViewPanned 方法來追蹤拖曳手勢。在 CityCollectionViewCell 中插入以下程式碼:

@objc func popupViewPanned(recognizer: UIPanGestureRecognizer) {
    switch recognizer.state {
    case .began:
        toggle()
        animator.pauseAnimation()

    case .changed:
        let translation = recognizer.translation(in: collectionView)
        var fraction = -translation.y / popupOffset
        if state == .expanded { fraction *= -1 }
        animator.fractionComplete = fraction

    case .ended:
        animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)

    default:
        ()
    }
}

我們這裡有三個狀態。在手勢開始時,我們使用 toggle 方法初始化 animator,並立即暫停它。當使用者拖動 Cell 時,我們透過設置 animatorfractionComplete 屬性來更新動畫;這就是 animator 的魔法所在,讓我們可以控制 animator。最後,當使用者釋放手指時,我們呼叫 animator 的 continueAnimation 方法繼續執行動畫,然後 Cell 將進入目標位置。

運行這個 App,你將可以向上拖動 Cell 來展開它,然後向下拖動展開的 Cell 來折疊它。

現在這個動畫看起來很不錯,但還不能在中間中斷動畫。因此,為了使動畫完全交互,我們必須添加一個功能:interruption。使用者可以像往常一樣啟動展開/折疊動畫,但是一旦使用者在動畫運行期間點擊 Cell,動畫就應該會立即暫停。

為了實作它,我們必須儲存動畫運行的進度,然後依據該值來計算動畫的完成百分比。

首先,我們向 CityCollectionViewCell 宣告一個新屬性:

private var animationProgress: CGFloat = 0

接下來,使用以下程式碼更新 popupViewPanned 方法的 .began 來記住進度:

animationProgress = animator.fractionComplete

針對 .changed case,你需要更新以下行程式碼以正確計算完成百分比:

animator.fractionComplete = fraction + animationProgress

現在我們已經準備好,可以測試這個 App 了。請運行專案看看成果如何,如果你有正確遵循每個步驟,動畫看起來應該像這樣:

interactive-animation-uiviewpropertyanimator-1

倒轉動畫

你可能發現目前實作成果還有一個的缺點:當你將 Cell 向上拖動一點後再將其恢復到原始位置時,即使你釋放手指,Cell 仍然繼續展開。我們接下來將會解決這個問題,讓交互式動畫變得更好。

請參考下面程式碼,更新 popupViewPanned 方法的 .end case:

let velocity = recognizer.velocity(in: self)
let shouldComplete = velocity.y > 0

if velocity.y == 0 {
    animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
    break
}

switch state {
case .expanded:
    if !shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
    if shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
case .collapsed:
    if shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
    if !shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
}
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)

現在我們會根據拖曳手勢的速度來決定是否應該倒轉動畫。

最後,在 .changedcase 中再插入一行程式碼,正確計算 animator.fractionComplete

if animator.isReversed { fraction *= -1 }

讓我們再次運行 App,現在一切都應該順利進行。

interactive-animation-uiviewpropertyanimator-2

修復拖曳手勢

還有一件事!我們用 UIViewPropertyAnimator 完成了動畫的實作,但還有一個討厭的錯誤。也許你在測試 App 時已經看過它,就是我們無法水平滾動Cell。試試在 Cell 上向左/向右滑動,你就會看到問題所在。

根本原因是由於我們創建了 UIPanGestureRecognizer,它也捕獲與 UICollectionView 的內置手勢識別器 (gesture recogniser) 有衝突的滑動手勢。

雖然使用者仍然可以滑動 Cell 的上下部分、或是 Cell 之間的空間,以水平滾動來瀏覽城市,但我不喜歡這種糟糕的使用者體驗,所以接下來會修復這個問題。

要解決這些衝突,我們需要實作一個名為 gestureRecognizerShouldBegin(_ :) 的委託方法 (delegate method),該方法會控制手勢識別器是否應該繼續處理觸控事件。如果在方法中返回 false,則手勢識別器將忽略觸控事件。所以,我們要做的就是讓 pan recognizer 忽略水平滑動。

為此,讓我們設置 pan recognizer 的 delegate。在 panRecognizer 的初始化中插入以下程式碼,你可以在 return recognizer 之前放置這行程式碼 。

recognizer.delegate = self

接下來,如此實作 gestureRecognizerShouldBegin(_:) 方法:

override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    return abs((panRecognizer.velocity(in: panRecognizer.view)).y) > abs((panRecognizer.velocity(in: panRecognizer.view)).x)
}

如果其垂直速度大於其水平對應物, 我們將開始打開/關閉拖曳手勢 。

完成了!讓我們再次測試 App,你現在應該可以透過向左/向右滑動 Cell 來瀏覽城市記錄。

interactive-animation-uiviewpropertyanimator-3

Bonus : 自定義計時功能

在我們結束本教程之前,讓我們談談自定義時間函式,還記得上一次設計師要求你為動畫實作自定義計時功能的情況嗎?

通常你必須將 UIView.animation 改為 CABasicAnimation ,或將它包裝在 CATransaction 中。使用UIViewPropertyAnimator,你就可以輕鬆實作自定義計時功能。

用這個自定義計時功能替換 animator 初始化(嘗試繪製你自己的 cubic-bezier 曲線) ,像這樣 :

private lazy var animator: UIViewPropertyAnimator = {
    let cubicTiming = UICubicTimingParameters(controlPoint1: CGPoint(x: 0.17, y: 0.67), controlPoint2: CGPoint(x: 0.76, y: 1.0))

    return UIViewPropertyAnimator(duration: 0.3, timingParameters: cubicTiming)
}()

或者,你也可以使用下面的 springTiming,來取代 cubicTiming 參數 :

let springTiming = UISpringTimingParameters(mass: 1.0, stiffness: 2.0, damping: 0.2, initialVelocity: .zero)

嘗試再次運行專案,看看成果如何。

總結

藉由 UIViewPropertyAnimator,你可以使用交互式動畫增強使用者體驗。

我知道你迫不及待想把學到的東西實作到自己的專案中,若你在專案中應用這個技術,歡迎在下面留言與我分享。

以供參考,你可以在這裡下載最終完整專案

相關連結參考

譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015 年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校 ALPHA Camp 畢業後,積極投入 iOS 程式開發,目前任職於國內電商公司。聯絡方式:電郵 [email protected]

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

原文Advanced Animations with UIViewPropertyAnimator
作者
Eugene Trapeznikov
美國貨運業 TruckerPath 平台的 iOS程式開發員,持續學習關於手機和網絡的新知識並加以實踐運用。過去四年發表了超過10隻Apps,當中兩隻更被App Store選為精選推介。請到 LinkedIn 和 GitHub 與 Eugene 聯絡。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。