1. 程式人生 > >在 iOS 10 中使用 Swift 3 和 UIViewPropertyAnimator 編寫動畫

在 iOS 10 中使用 Swift 3 和 UIViewPropertyAnimator 編寫動畫

作者:Jason Newell,原文連結,原文日期:2016-06-28
譯者:冬瓜;校對:numbbbbb;定稿:numbbbbb

這是一個 iOS 10 系列教程,會介紹 iOS 10、Swift 和 XCode 8 的新特性。

UIKit in iOS 10 now has “new object-based, fully interactive and interruptible animation support that lets you retain control of your animations and link them with gesture-based interactions” through a family of new objects and protocols.

iOS 10 的 UIKit 使用一系列新物件和 protocol 來控制使用者互動和中斷動畫,支援用手勢操作。

簡言之,iOS 10 可以讓開發者更加自由寬鬆地控制動畫計時。你可以細粒度控制自己製作的動畫,易於抹除、逆向、暫停和重啟動畫,並重構動畫幀使之平滑流暢。這些功能也可以用於控制器的轉場動畫。

我希望通過此文介紹一些關於新特性的基本用法,並記錄一些在文件中的關鍵點。

構建基礎應用

我們會使用一些 UIViewPropertyAnimator 的新特性。首先需要一些素材。

建立一個 single-view application,然後在 ViewController.swift 中新增如下程式碼:

import UIKit

class ViewController: UIViewController {
// 記錄拖動時的圓形檢視 center
var circleCenter: CGPoint!

override func viewDidLoad() {
super.viewDidLoad()

// 新增可拖動檢視
let circle = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0))
circle.center = self.view.center
circle.layer.cornerRadius = 50.0

circle.backgroundColor = UIColor.green()

// 新增拖動手勢
circle.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.dragCircle)))

self.view.addSubview(circle)
}

func dragCircle(gesture: UIPanGestureRecognizer) {
let target = gesture.view!

switch gesture.state {
case .began, .ended:
circleCenter = target.center
case .changed:
let translation = gesture.translation(in: self.view)
target.center = CGPoint(x: circleCenter!.x + translation.x, y: circleCenter!.y + translation.y)
default: break
}
}
}

這並不複雜。在 viewDidLoad 方法中,建立一個綠色背景的圓形檢視並放置在當前檢視中央。然後新增 UIPanGestureRecongnizer例項來感知拖動圓形檢視的手勢並呼叫 dragCircle 方法。你應該已經猜到執行效果了:

UIViewPropertyAnimator 介紹

UIViewPropertyAnimator 是修改動畫屬性的核心類,它提供了中斷動畫、修改動畫中間過程的功能。UIViewpropertyAnimator 維護了一個動畫集合,可以無縫連線新動畫和原有動畫。

注意:UIViewPropertyAnimator 有些拗口,我在下文將使用animator 來代替。

如果兩個或多個動畫需要同時改變相同的屬性,則會遵循“後者優先”原則。有趣的是,這將導致卡頓,因為需要組合新舊動畫。在舊動畫淡出的同時會隱約看見新動畫。

後者優先:UIViewPropertyAnimator 例項中靠後新增的動畫或者執行時間更晚的動畫會覆蓋之前的效果。

暫停和恢復動畫

我們繼續擴充套件上面的動畫,增加一個動畫效果:原型檢視被拖動時會擴大到原尺寸的兩倍,停止拖動時該檢視會縮小到原尺寸。

首先給 animator 新增一個屬性和一個持續時間。

// ...
class ViewController: UIViewController {
// 我們將在拖拽響應事件上附加不同的動畫
var circleAnimator: UIViewPropertyAnimator!
let animationDuration = 4.0
// ...

在 viewDidLoad: 中對 animator 初始化:

// ...
// 可選動畫引數
circleAnimator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut, animations: {
[unowned circle] in
// 放大涼別
circle.transform = CGAffineTransform(scaleX: 2.0, y: 2.0)
})
// self.view.addSubview(circle) here
// ...

初始化 circleAnimator 時, 我們需要傳入持續時間和動畫曲線。 curve 引數可設定為四種簡單的曲線之一。在本例中使用的是easeInOut。其他三種是 easeIneaseOut 和 linear。我們使用一個閉包來實現圓形檢視變大動畫。

現在需要一個方法來觸發動畫。修改一下 dragCircle:。在這段程式碼中,通過拖動檢視來觸發動畫,通過circleAnimator.isReversed 來判斷動畫縮放狀態。

func dragCircle(gesture: UIPanGestureRecognizer) {
let target = gesture.view!

switch gesture.state {
case .began, .ended:
circleCenter = target.center

if (circleAnimator.isRunning) {
circleAnimator.pauseAnimation()
circleAnimator.isReversed = gesture.state == .ended
}
circleAnimator.startAnimation()

// animator 三個重要屬性
print("Animator isRunning, isReversed, state: \(circleAnimator.isRunning), \(circleAnimator.isReversed), \(circleAnimator.state)")
case .changed:
let translation = gesture.translation(in: self.view)
target.center = CGPoint(x: circleCenter!.x + translation.x, y: circleCenter!.y + translation.y)
default: break
}
}

執行程式碼,長按,感受一下新動畫。

注意

動畫結束時,如下圖所示:

圓形檢視不再縮放,停止在放大後的尺寸。

到底發生了什麼?簡單來說,動畫結束後,其引用自動釋放了。

animator 有三種狀態:

  • inactive(休眠):初始狀態, 以及動畫完成後的狀態(可以過渡到 active
  • active(啟用):動畫正在執行(可以過渡到 stopped 或者 inactive
  • stopped(停止):呼叫 animator 的 stopAnimation: 方法(過渡到 inactive

來看下圖示:


(譯者注:為了方便大家閱讀,附加了對應的中文翻譯。)

只要過渡到 inactive 狀態,就會導致 animator 清除所有動畫(並執行 animator 的完成回撥函式)

我們已經介紹了 startAnimation 方法,下面介紹另外兩個狀態。

要修復本節的問題,需要修改 circleAnimator 的初始化方法:

expansionAnimator = UIViewPropertyAnimator(duration: expansionDuration, curve: .easeInOut)

譯者注:這裡原作者寫錯了 duration 引數名稱,expansionDuration 應該改成 animationDuration。

修改 dragCircle 方法:

// ...
// dragCircle:
case .began, .ended:
circleCenter = target.center

if circleAnimator.state == .active {
// 使animator為inactive狀態
circleAnimator.stopAnimation(true)
}

if (gesture.state == .began) {
circleAnimator.addAnimations({
target.transform = CGAffineTransform(scaleX: 2.0, y: 2.0)
})
} else {
circleAnimator.addAnimations({
target.transform = CGAffineTransform.identity
})
}

case .changed:
// ...

無論使用者開始還是停止拖拽,我們都讓 animator 停止並完成(只要它處於active狀態)。animator 會清除關聯動畫並返回到 inactive狀態。然後,我們新增一個新動畫,讓圓形檢視回到正確狀態。

使用 transform 的好處是,你可以直接把 transform 屬性設定為 CGAffineTransform.identity 來還原檢視,無需記錄初值。

circleAnimator.stopAnimation(true) 相當於這兩行程式碼:

circleAnimator.stopAnimation(false) // 不要結束(保持在 stop 狀態)
circleAnimator.finishAnimation(at: .current) // 設定檢視動畫屬性

finishAnimationAt: 方法接受一個 UIViewAnimatingPosition 引數。如果我們已經到達 start 和 end,原型檢視的動畫變形狀態將會改變。

動畫時間

我們的程式碼還有一個小問題。每次終止並開始一個新動畫時,新動畫都會持續4.0秒,哪怕當前狀態已經很接近終止狀態。

修改一下程式碼:

// dragCircle:
// ...
case .began, .ended:
circleCenter = target.center

let durationFactor = circleAnimator.fractionComplete // 記錄完成進度
// 在原始進度上增加新動畫
circleAnimator.stopAnimation(false)
circleAnimator.finishAnimation(at: .current)

if (gesture.state == .began) {
circleAnimator.addAnimations({
target.backgroundColor = UIColor.green()
target.transform = CGAffineTransform(scaleX: 2.0, y: 2.0)
})
} else {
circleAnimator.addAnimations({
target.backgroundColor = UIColor.green()
target.transform = CGAffineTransform.identity
})
}

circleAnimator.startAnimation()
circleAnimator.pauseAnimation()
// 剩餘時間完成新動畫
circleAnimator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor)
case .changed:
// ...

我們主動停止動畫,在原動畫末尾加入新動畫並啟動,通過 continueAnimationWithTimingParameters: durationFactor: 來確定第一個動畫的剩餘時間。這樣就解決了剛才的固定時間問題。 continueAnimationWithTimingParameters: durationFactor: 這個方法也能動態修改動畫的持續時間。

譯者注:fractionComplete: 這個屬性在 NSProgress 中也有涉及,不過 NSProgress 的屬性為 fractionCompleted。其含義與此處類似,都是使用 0.0-1.0 之間的浮點數來表示一段連續動作的完成比例。

* 當你變化後的動畫(相比於之前的動畫)擁有不同的時間曲線函式,動畫在過渡時會插入到舊時間繼續執行。例如,從一個彈性時間曲線過渡到線性時間曲線,動畫線上性變化之前會有一段彈性時間部分。

時間曲線函式

新的時間曲線函式時間曲線函式要比原來的更加合理。

Swift 3 相容了舊的 UIViewAnimationCurve(例如在本文示例中使用的 easeInOut 這類靜態曲線函式),還新增了兩個新的時間曲線函式物件:UISpringTimingParameters 和UICubicTimingParameters

UISpringTimingParameters

UISpringTimingParameters 的例項需要設定阻尼係數(damping)質量引數(mass)剛性係數(stiffness)初始速度(initial velocity)。這些引數會帶入給定公式,讓動畫更加真實。雖然不需要使用,但是初始化動畫時必須傳入持續時間引數,UISpringTimingParameters 會忽略它。這個引數可以相容舊的時間曲線函式。

下面來看一個例項,使用彈簧動畫把圓形檢視約束在螢幕中心:

ViewController.swift

import UIKit

class ViewController: UIViewController {
// 記錄拖動時的圓形檢視中心
var circleCenter: CGPoint!
// 後面會在拖拽響應事件上附加不同的動畫
var circleAnimator: UIViewPropertyAnimator?

override func viewDidLoad() {
super.viewDidLoad()

// 新增一個可拖動檢視
let circle = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0))
circle.center = self.view.center
circle.layer.cornerRadius = 50.0
circle.backgroundColor = UIColor.green()

// 新增拖動手勢
circle.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.dragCircle)))

self.view.addSubview(circle)
}

func dragCircle(gesture: UIPanGestureRecognizer) {
let target = gesture.view!

switch gesture.state {
case .began:
if circleAnimator != nil && circleAnimator!.isRunning {
circleAnimator!.stopAnimation(false)
}
circleCenter = target.center
case .changed:
let translation = gesture.translation(in: self.view)
target.center = CGPoint(x: circleCenter!.x + translation.x, y: circleCenter!.y + translation.y)
case .ended:
let v = gesture.velocity(in: target)
// 500 這個隨機值看起來比較合適,你也可以基於裝置寬度動態設定
// y 軸的速度分量通常被忽略,不過操作檢視中心時需要使用
let velocity = CGVector(dx: v.x / 500, dy: v.y / 500)
let springParameters = UISpringTimingParameters(mass: 2.5, stiffness: 70, damping: 55, initialVelocity: velocity)
circleAnimator = UIViewPropertyAnimator(duration: 0.0, timingParameters: springParameters)

circleAnimator!.addAnimations({
target.center = self.view.center
})
circleAnimator!.startAnimation()
default: break
}
}
}

拖動圓形檢視,讓動畫執行起來。這裡我們把向量速度(velocity)傳入 initialVelocity: 並用彈性時間曲線函式(Sprint Timing)作為 parameters: 引數,這樣圓形檢視不僅會返回起始點,釋放時還會保持原有動量。

// dragCircle: .ended:
// ...
let velocity = CGVector(dx: v.x / 500, dy: v.y / 500)
let springParameters = UISpringTimingParameters(mass: 2.5, stiffness: 70, damping: 55, initialVelocity: velocity)
circleAnimator = UIViewPropertyAnimator(duration: 0.0, timingParameters: springParameters)
// ...

為了跟蹤動畫路徑,我繪製了一些圓點。原本筆直的路徑稍顯彎曲,因為釋放圓形檢視,中心點會以彈簧效果的形式向中心“拉拽”,到達終點後動量仍舊保留。

UICubicTimingParameters

UICubicTimingParameters 允許通過多個控制點(control point)來定義三階貝塞爾曲線。需要注意的是,在 0.0~1.0 範圍外的點會修正到範圍內。

// 為 y 設定對應的值
let curveProvider = UICubicTimingParameters(controlPoint1: CGPoint(x: 0.2, y: -0.48), controlPoint2: CGPoint(x: 0.79, y: 1.41))
expansionAnimator = UIViewPropertyAnimator(duration: expansionDuration, timingParameters: curveProvider)

如果你仍不滿足這些時間曲線函式,可以通過 UITimingCurveProvider 協議來實現更加合適的曲線函式。

動畫抹除

你可以給 animator 的 fractionComplete 傳入一個 0.0-1.0 的浮點數,使其在對應位置暫停。如果傳入 0.5,無論時間曲線函式是什麼,動畫都會停止在一半狀態的位置。需要注意的是,當動畫重新開始時,其銜接位置將會對映到給定的時間曲線函式曲線上,所以fractionComplete = 0.5 並不代表已經運行了一半時間。

我們來做個實驗。首先在 viewDidLoad 尾部初始化 animator 並傳入兩個動畫:

// viewDidLoad:
// ...
circleAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear, animations: {
circle.transform = CGAffineTransform(scaleX: 3.0, y: 3.0)
})

circleAnimator?.addAnimations({
circle.backgroundColor = UIColor.blue()
}, delayFactor: 0.75)
// ...

這次不呼叫 startAnimation 方法。圓圈會隨著動畫而逐漸變大,檢視背景在 75% 處開始變成藍色。

重寫一下 dragCircle: 方法:

func dragCircle(gesture: UIPanGestureRecognizer) {
let target = gesture.view!

switch gesture.state {
case .began:
circleCenter = target.center
case .changed:
let translation = gesture.translation(in: self.view)
target.center = CGPoint(x: circleCenter!.x + translation.x, y: circleCenter!.y + translation.y)

circleAnimator?.fractionComplete = target.center.y / self.view.frame.height
default: break
}
}

現在拖拽圓形檢視時會更新 animator 的 fractionComplete 屬性,從而達到不同的效果:

這裡我使用的是線性曲線函式,你也可以基於這個例子實現其他函式。這個改變顏色的動畫遵循一個壓縮過的時間曲線。

自定義 animator 的動畫程序需要使用 0.0-1.0 範圍內的浮點數表示,如果超出範圍,則會取該資料臨近的端界值(即 0 或 1)。

擴充套件性

最後我想強調一點:Don’t like something? Change it! (譯者注:“看著不爽?自己動手!”)你可以根據動畫需要來實現各種時間曲線函式。

此外,這會進一步解耦協議和類,很多原始碼中的協議都能做到這一點。這會使開發更加便捷,我也希望能有更多開發者去深入探索。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 http://swift.gg