自訂緩衝函數(緩衝 10.2),自訂緩衝函數10.2
自訂緩衝函數
在第八章中,我們給時鐘項目添加了動畫。看起來很贊,但是如果有合適的緩衝函數就更好了。在顯示世界中,鐘錶指標轉動的時候,通常起步很慢,然後迅速啪地一聲,最後緩衝到終點。但是標準的緩衝函數在這裡每一個適合它,那該如何建立一個新的呢?
除了+functionWithName:
之外,CAMediaTimingFunction
同樣有另一個建構函式,一個有四個浮點參數的+functionWithControlPoints::::
(注意這裡奇怪的文法,並沒有包含具體每個參數的名稱,這在objective-C中是合法的,但是卻違反了蘋果對方法命名的指導方針,而且看起來是一個奇怪的設計)。
使用這個方法,我們可以建立一個自訂的緩衝函數,來匹配我們的時鐘動畫,為了理解如何使用這個方法,我們要瞭解一些CAMediaTimingFunction
是如何工作的。
三次貝茲路徑
CAMediaTimingFunction
函數的主要原則在於它把輸入的時間轉換成起點和終點之間成比例的改變。我們可以用一個簡單的表徵圖來解釋,橫軸代表時間,縱軸代表改變的量,於是線性緩衝就是一條從起點開始的簡單的斜線(圖10.1)。
圖10.1 線性緩衝函數的映像
這條曲線的斜率代表了速度,斜率的改變代表了加速度,原則上來說,任何加速的曲線都可以用這種映像來表示,但是CAMediaTimingFunction
使用了一個叫做三次貝茲路徑的函數,它只可以產出指定緩衝函數的子集(我們之前在第八章中建立CAKeyframeAnimation
路徑的時候提到過三次貝茲路徑)。
你或許會回想起,一個三次貝茲路徑通過四個點來定義,第一個和最後一個點代表了曲線的起點和終點,剩下中間兩個點叫做控制點,因為它們控制了曲線的形狀,貝茲路徑的控制點其實是位於曲線之外的點,也就是說曲線並不一定要穿過它們。你可以把它們想象成吸引經過它們曲線的磁鐵。
圖10.2展示了一個三次貝塞爾緩衝函數的例子
圖10.2 三次貝塞爾緩衝函數
實際上它是一個很奇怪的函數,先加速,然後減速,最後快到達終點的時候又加速,那麼標準的緩衝函數又該如何用映像來表示呢?
CAMediaTimingFunction
有一個叫做-getControlPointAtIndex:values:
的方法,可以用來檢索曲線的點,這個方法的設計的確有點奇怪(或許也就只有蘋果能回答為什麼不簡單返回一個CGPoint
),但是使用它我們可以找到標準緩衝函數的點,然後用UIBezierPath
和CAShapeLayer
來把它畫出來。
曲線的起始和終點始終是{0, 0}和{1, 1},於是我們只需要檢索曲線的第二個和第三個點(控制點)。具體代碼見清單10.4。所有的標準緩衝函數的映像見圖10.3。
清單10.4 使用UIBezierPath
繪製CAMediaTimingFunction
1 @interface ViewController () 2 3 @property (nonatomic, weak) IBOutlet UIView *layerView; 4 5 @end 6 7 @implementation ViewController 8 9 - (void)viewDidLoad10 {11 [super viewDidLoad];12 //create timing function13 CAMediaTimingFunction *function = [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut];14 //get control points15 CGPoint controlPoint1, controlPoint2;16 [function getControlPointAtIndex:1 values:(float *)&controlPoint1];17 [function getControlPointAtIndex:2 values:(float *)&controlPoint2];18 //create curve19 UIBezierPath *path = [[UIBezierPath alloc] init];20 [path moveToPoint:CGPointZero];21 [path addCurveToPoint:CGPointMake(1, 1)22 controlPoint1:controlPoint1 controlPoint2:controlPoint2];23 //scale the path up to a reasonable size for display24 [path applyTransform:CGAffineTransformMakeScale(200, 200)];25 //create shape layer26 CAShapeLayer *shapeLayer = [CAShapeLayer layer];27 shapeLayer.strokeColor = [UIColor redColor].CGColor;28 shapeLayer.fillColor = [UIColor clearColor].CGColor;29 shapeLayer.lineWidth = 4.0f;30 shapeLayer.path = path.CGPath;31 [self.layerView.layer addSublayer:shapeLayer];32 //flip geometry so that 0,0 is in the bottom-left33 self.layerView.layer.geometryFlipped = YES;34 }35 36 @end
View Code
圖10.3 標準CAMediaTimingFunction
緩衝曲線
那麼對於我們自訂時鐘指標的緩衝函數來說,我們需要初始微弱,然後迅速上升,最後緩衝到終點的曲線,通過一些實驗之後,最終結果如下:
[CAMediaTimingFunction functionWithControlPoints:1 :0 :0.75 :1];
如果把它轉換成緩衝函數的映像,最後10.4所示,如果把它添加到時鐘的程式,就形成了之前一直期待的非常贊的效果(見代清單10.5)。
圖10.4 自訂適合時鐘的緩衝函數
清單10.5 添加了自訂緩衝函數的時鐘程式
1 - (void)setAngle:(CGFloat)angle forHand:(UIView *)handView animated:(BOOL)animated 2 { 3 //generate transform 4 CATransform3D transform = CATransform3DMakeRotation(angle, 0, 0, 1); 5 if (animated) { 6 //create transform animation 7 CABasicAnimation *animation = [CABasicAnimation animation]; 8 animation.keyPath = @"transform"; 9 animation.fromValue = [handView.layer.presentationLayer valueForKey:@"transform"];10 animation.toValue = [NSValue valueWithCATransform3D:transform];11 animation.duration = 0.5;12 animation.delegate = self;13 animation.timingFunction = [CAMediaTimingFunction functionWithControlPoints:1 :0 :0.75 :1];14 //apply animation15 handView.layer.transform = transform;16 [handView.layer addAnimation:animation forKey:nil];17 } else {18 //set transform directly19 handView.layer.transform = transform;20 }21 }
圖10.5 一個沒法用三次貝茲路徑描述的反彈的動畫
這種效果沒法用一個簡單的三次貝茲路徑表示,於是不能用CAMediaTimingFunction
來完成。但如果想要實現這樣的效果,可以用如下幾種方法:
- 用
CAKeyframeAnimation
建立一個動畫,然後分割成幾個步驟,每個小步驟使用自己的計時函數(具體下節介紹)。
- 使用定時器逐幀更新實現動畫(見第11章,“基於定時器的動畫”)。
基於主要畫面格的緩衝
為了使用主要畫面格實現反彈動畫,我們需要在緩衝曲線中對每一個顯著的點建立一個主要畫面格(在這個情況下,關鍵點也就是每次反彈的峰值),然後應用緩衝函數把每段曲線串連起來。同時,我們也需要通過keyTimes
來指定每個主要畫面格的時間位移,由於每次反彈的時間都會減少,於是主要畫面格並不會均勻分布。
清單10.6展示了實現反彈球動畫的代碼(見圖10.6)
清單10.6 使用主要畫面格實現反彈球的動畫
@interface ViewController ()@property (nonatomic, weak) IBOutlet UIView *containerView;@property (nonatomic, strong) UIImageView *ballView;@end@implementation ViewController- (void)viewDidLoad{ [super viewDidLoad]; //add ball image view UIImage *ballImage = [UIImage imageNamed:@"Ball.png"]; self.ballView = [[UIImageView alloc] initWithImage:ballImage]; [self.containerView addSubview:self.ballView]; //animate [self animate];}- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ //replay animation on tap [self animate];}- (void)animate{ //reset ball to top of screen self.ballView.center = CGPointMake(150, 32); //create keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"position"; animation.duration = 1.0; animation.delegate = self; animation.values = @[ [NSValue valueWithCGPoint:CGPointMake(150, 32)], [NSValue valueWithCGPoint:CGPointMake(150, 268)], [NSValue valueWithCGPoint:CGPointMake(150, 140)], [NSValue valueWithCGPoint:CGPointMake(150, 268)], [NSValue valueWithCGPoint:CGPointMake(150, 220)], [NSValue valueWithCGPoint:CGPointMake(150, 268)], [NSValue valueWithCGPoint:CGPointMake(150, 250)], [NSValue valueWithCGPoint:CGPointMake(150, 268)] ]; animation.timingFunctions = @[ [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn] ]; animation.keyTimes = @[@0.0, @0.3, @0.5, @0.7, @0.8, @0.9, @0.95, @1.0]; //apply animation self.ballView.layer.position = CGPointMake(150, 268); [self.ballView.layer addAnimation:animation forKey:nil];}@end
View Code
圖10.6 使用主要畫面格實現的反彈球動畫
這種方式還算不錯,但是實現起來略顯笨重(因為要不停地嘗試計算各種主要畫面格和時間位移)並且和動畫強綁定了(因為如果要改變動畫的一個屬性,那就意味著要重新計算所有的主要畫面格)。那該如何寫一個方法,用緩衝函數來把任何簡單的屬性動畫轉換成主要畫面格動畫呢,下面我們來實現它。
流程自動化
在清單10.6中,我們把動畫分割成相當大的幾塊,然後用Core Animation的緩衝進入和緩衝退出函數來大約形成我們想要的曲線。但如果我們把動畫分割成更小的幾部分,那麼我們就可以用直線來拼接這些曲線(也就是線性緩衝)。為了實現自動化,我們需要知道如何做如下兩件事情:
- 自動把任意屬性動畫分割成多個主要畫面格
- 用一個數學函數表示彈性動畫,使得可以對幀做便宜
為瞭解決第一個問題,我們需要複製Core Animation的插值機制。這是一個傳入起點和終點,然後在這兩個點之間指定時間點產出一個新點的機制。對於簡單的浮點起始值,公式如下(假設時間從0到1):
1 value = (endValue – startValue) × time + startValue;
那麼如果要插入一個類似於CGPoint
,CGColorRef
或者CATransform3D
這種更加複雜類型的值,我們可以簡單地對每個獨立的元素應用這個方法(也就CGPoint
中的x和y值,CGColorRef
中的紅,藍,綠,透明值,或者是CATransform3D
中獨立矩陣的座標)。我們同樣需要一些邏輯在插值之前對對象拆解值,然後在插值之後在重新封裝成對象,也就是說需要即時地檢查類型。
一旦我們可以用代碼擷取屬性動畫的起始值之間的任意插值,我們就可以把動畫分割成許多獨立的主要畫面格,然後產出一個線性主要畫面格動畫。清單10.7展示了相關代碼。
注意到我們用了60 x 動畫時間(秒做單位)作為主要畫面格的個數,這時因為Core Animation按照每秒60幀去渲染螢幕更新,所以如果我們每秒產生60個主要畫面格,就可以保證動畫足夠的平滑(儘管實際上很可能用更少的幀率就可以達到很好的效果)。
我們在樣本中僅僅引入了對CGPoint
類型的插值代碼。但是,從代碼中很清楚能看出如何擴充成支援別的類型。作為不能識別類型的備選方案,我們僅僅在前一半返回了fromValue
,在後一半返回了toValue
。
清單10.7 使用插入的值建立一個主要畫面格動畫
1 float interpolate(float from, float to, float time) 2 { 3 return (to - from) * time + from; 4 } 5 6 - (id)interpolateFromValue:(id)fromValue toValue:(id)toValue time:(float)time 7 { 8 if ([fromValue isKindOfClass:[NSValue class]]) { 9 //get type10 const char *type = [fromValue objCType];11 if (strcmp(type, @encode(CGPoint)) == 0) {12 CGPoint from = [fromValue CGPointValue];13 CGPoint to = [toValue CGPointValue];14 CGPoint result = CGPointMake(interpolate(from.x, to.x, time), interpolate(from.y, to.y, time));15 return [NSValue valueWithCGPoint:result];16 }17 }18 //provide safe default implementation19 return (time < 0.5)? fromValue: toValue;20 }21 22 - (void)animate23 {24 //reset ball to top of screen25 self.ballView.center = CGPointMake(150, 32);26 //set up animation parameters27 NSValue *fromValue = [NSValue valueWithCGPoint:CGPointMake(150, 32)];28 NSValue *toValue = [NSValue valueWithCGPoint:CGPointMake(150, 268)];29 CFTimeInterval duration = 1.0;30 //generate keyframes31 NSInteger numFrames = duration * 60;32 NSMutableArray *frames = [NSMutableArray array];33 for (int i = 0; i < numFrames; i++) {34 float time = 1 / (float)numFrames * i;35 [frames addObject:[self interpolateFromValue:fromValue toValue:toValue time:time]];36 }37 //create keyframe animation38 CAKeyframeAnimation *animation = [CAKeyframeAnimation animation];39 animation.keyPath = @"position";40 animation.duration = 1.0;41 animation.delegate = self;42 animation.values = frames;43 //apply animation44 [self.ballView.layer addAnimation:animation forKey:nil];45 }
View Code
這可以起到作用,但效果並不是很好,到目前為止我們所完成的只是一個非常複雜的方式來使用線性緩衝複製CABasicAnimation
的行為。這種方式的好處在於我們可以更加精確地控制緩衝,這也意味著我們可以應用一個完全定製的緩衝函數。那麼該如何做呢?
緩衝背後的數學並不很簡單,但是幸運的是我們不需要一一實現它。羅伯特·彭納有一個網頁關於緩衝函數(http://www.robertpenner.com/easing),包含了大多數普遍的緩衝函數的多種程式設計語言的實現的連結,包括C。這裡是一個緩衝進入緩衝退出函數的樣本(實際上有很多不同的方式去實現它)。
1 float quadraticEaseInOut(float t) 2 {3 return (t < 0.5)? (2 * t * t): (-2 * t * t) + (4 * t) - 1; 4 }
View Code
對我們的彈性球來說,我們可以使用bounceEaseOut
函數:
1 float bounceEaseOut(float t) 2 { 3 if (t < 4/11.0) { 4 return (121 * t * t)/16.0; 5 } else if (t < 8/11.0) { 6 return (363/40.0 * t * t) - (99/10.0 * t) + 17/5.0; 7 } else if (t < 9/10.0) { 8 return (4356/361.0 * t * t) - (35442/1805.0 * t) + 16061/1805.0; 9 }10 return (54/5.0 * t * t) - (513/25.0 * t) + 268/25.0;11 }
如果修改清單10.7的代碼來引入bounceEaseOut
方法,我們的任務就是僅僅交換緩衝函數,現在就可以選擇任意的緩衝類型建立動畫了(見清單10.8)。
清單10.8 用主要畫面格實現自訂的緩衝函數
1 - (void)animate 2 { 3 //reset ball to top of screen 4 self.ballView.center = CGPointMake(150, 32); 5 //set up animation parameters 6 NSValue *fromValue = [NSValue valueWithCGPoint:CGPointMake(150, 32)]; 7 NSValue *toValue = [NSValue valueWithCGPoint:CGPointMake(150, 268)]; 8 CFTimeInterval duration = 1.0; 9 //generate keyframes10 NSInteger numFrames = duration * 60;11 NSMutableArray *frames = [NSMutableArray array];12 for (int i = 0; i < numFrames; i++) {13 float time = 1/(float)numFrames * i;14 //apply easing15 time = bounceEaseOut(time);16 //add keyframe17 [frames addObject:[self interpolateFromValue:fromValue toValue:toValue time:time]];18 }19 //create keyframe animation20 CAKeyframeAnimation *animation = [CAKeyframeAnimation animation];21 animation.keyPath = @"position";22 animation.duration = 1.0;23 animation.delegate = self;24 animation.values = frames;25 //apply animation26 [self.ballView.layer addAnimation:animation forKey:nil];27 }