談?wù)刬OS中粘性動(dòng)畫(huà)以及果凍效果的實(shí)現
在最近做個(gè)一個(gè)自定義PageControl——KYAnimatedPageControl中,我實(shí)現了CALayer的形變動(dòng)畫(huà)以及CALayer的彈性動(dòng)畫(huà),效果先過(guò)目:
本文引用地址:http://dyxdggzs.com/article/201609/303407.htm
先做個(gè)提綱:
第一個(gè)分享的主題是“如何讓CALayer發(fā)生形變”,這個(gè)技術(shù)在我之前一個(gè)項目 ———— KYCuteView 中有涉及,也寫(xiě)了篇簡(jiǎn)短的實(shí)現原理博文。今天再舉一個(gè)例子。
之前我也做過(guò)類(lèi)似果凍效果的彈性動(dòng)畫(huà),比如這個(gè)項目—— KYGooeyMenu。用到的核心技術(shù)是CAKeyframeAnimation,然后設置幾個(gè)不同狀態(tài)的關(guān)鍵幀,就能初步達到這種彈性效果。但是,畢竟只有幾個(gè)關(guān)鍵幀,而且是需要手動(dòng)計算,不精確不說(shuō),動(dòng)畫(huà)也不夠細膩,畢竟你不可能手動(dòng)創(chuàng )建60個(gè)關(guān)鍵幀。所以,今天的第二個(gè)主題是 —— “如何用阻尼振動(dòng)函數創(chuàng )建出60個(gè)關(guān)鍵幀”,從而實(shí)現CALayer產(chǎn)生類(lèi)似[UIView animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion] 的彈性動(dòng)畫(huà)。
正文。
如何讓CALayer發(fā)生形變?
關(guān)鍵技術(shù)很簡(jiǎn)單:你需要用多條貝塞爾曲線(xiàn) “拼” 出這個(gè)Layer。之所以這樣做的原因不言而喻,因為這樣方便我們發(fā)生形變。
比如 KYAnimatedPageControl 中的這個(gè)小球,其實(shí)它是這么被畫(huà)出來(lái)的:

小球是由弧AB、弧BC、弧CD、弧DA 四段組成,其中每段弧都綁定兩個(gè)控制點(diǎn):弧AB 綁定的是 C1 、 C2;弧BC 綁定的是 C3 、 C4 .....
如何表達各個(gè)點(diǎn)?
首先,A、B、C、D是四個(gè)動(dòng)點(diǎn),控制他們動(dòng)的變量是ScrollView的contentOffset.x。我們可以在-(void)scrollViewDidScroll:(UIScrollView *)scrollView中實(shí)時(shí)獲取這個(gè)變量,并把它轉換成一個(gè)控制在 0~1 的系數,取名為factor。
1
_factor = MIN(1, MAX(0, (ABS(scrollView.contentOffset.x - self.lastContentOffset) / scrollView.frame.size.width)));
假設A、B、C、D的最大變化距離為小球直徑的2/5。那么結合這個(gè)0~1的系數,我們可以得出A、B、C、D的真實(shí)變化距離 extra 為:extra = (self.width * 2 / 5) * factor。當factor == 1時(shí),達到最大形變狀態(tài),此時(shí)四個(gè)點(diǎn)的變化距離均為(self.width * 2 / 5)。
注意:根據滑動(dòng)方向,我們還要根據是B點(diǎn)移動(dòng)還是D點(diǎn)移動(dòng)。
CGPoint pointA = CGPointMake(rectCenter.x ,self.currentRect.origin.y + extra); CGPoint pointB = CGPointMake(self.scrollDirection == ScrollDirectionLeft ? rectCenter.x + self.currentRect.size.width/2 : rectCenter.x + self.currentRect.size.width/2 + extra*2 ,rectCenter.y); CGPoint pointC = CGPointMake(rectCenter.x ,rectCenter.y + self.currentRect.size.height/2 - extra); CGPoint pointD = CGPointMake(self.scrollDirection == ScrollDirectionLeft ? self.currentRect.origin.x - extra*2 : self.currentRect.origin.x, rectCenter.y);
然后是控制點(diǎn):
關(guān)鍵是要知道上圖中A-C1 、B-C2、B-C3、C-C4....這些水平和垂直虛線(xiàn)的長(cháng)度,命名為offSet。經(jīng)過(guò)多次嘗試,我得出的結論是:
當offSet設置為 直徑除以3.6 的時(shí)候,弧線(xiàn)能完美地貼合成圓弧。我隱約感覺(jué)這個(gè) 3.6 是必然,貌似和360度有某種關(guān)系,或許通過(guò)演算能得出 3.6 這個(gè)值的必然性,但我沒(méi)有嘗試。
因此,各個(gè)控制點(diǎn)的坐標:
CGPoint c1 = CGPointMake(pointA.x + offset, pointA.y); CGPoint c2 = CGPointMake(pointB.x, pointB.y - offset); CGPoint c3 = CGPointMake(pointB.x, pointB.y + offset); CGPoint c4 = CGPointMake(pointC.x + offset, pointC.y); CGPoint c5 = CGPointMake(pointC.x - offset, pointC.y); CGPoint c6 = CGPointMake(pointD.x, pointD.y + offset); CGPoint c7 = CGPointMake(pointD.x, pointD.y - offset); CGPoint c8 = CGPointMake(pointA.x - offset, pointA.y);
有了終點(diǎn)和控制點(diǎn),就可以用UIBezierPath 中提供的方法 - (void)addCurveToPoint:(CGPoint)endPoint controlPoint1:(CGPoint)controlPoint1 controlPoint2:(CGPoint)controlPoint2; 畫(huà)線(xiàn)段了。
重載CALayer的- (void)drawInContext:(CGContextRef)ctx;方法,在里面畫(huà)圖案:
- (void)drawInContext:(CGContextRef)ctx{ ....//在這里計算每個(gè)點(diǎn)的坐標 UIBezierPath* ovalPath = [UIBezierPath bezierPath]; [ovalPath moveToPoint: pointA]; [ovalPath addCurveToPoint:pointB controlPoint1:c1 controlPoint2:c2]; [ovalPath addCurveToPoint:pointC controlPoint1:c3 controlPoint2:c4]; [ovalPath addCurveToPoint:pointD controlPoint1:c5 controlPoint2:c6]; [ovalPath addCurveToPoint:pointA controlPoint1:c7 controlPoint2:c8]; [ovalPath closePath]; CGContextAddPath(ctx, ovalPath.CGPath); CGContextSetFillColorWithColor(ctx, self.indicatorColor.CGColor); CGContextFillPath(ctx); }
現在,當你滑動(dòng)ScrollView的時(shí)候,小球就會(huì )形變了。
如何用阻尼振動(dòng)函數創(chuàng )建出60個(gè)關(guān)鍵幀?
上面的例子中,有個(gè)很重要的因素,就是ScrollView中的contentOffset.x這個(gè)變量,沒(méi)有這個(gè)輸入,那接下來(lái)什么都不會(huì )發(fā)生。但想要獲得這個(gè)變量,是需要用戶(hù)觸摸、滑動(dòng)去交互產(chǎn)生的。在某個(gè)動(dòng)畫(huà)中用戶(hù)是沒(méi)有直接的交互輸入的,比如當手指離開(kāi)之后,要讓這個(gè)小球以果凍效果彈回初始狀態(tài),這個(gè)過(guò)程手指已經(jīng)離開(kāi)屏幕,也就沒(méi)有了輸入,那么用上面的方法肯定行不通,所以,我們可以用CAAnimation.
我們知道,iOS7中蘋(píng)果在 UIView(UIViewAnimationWithBlocks) 加入了一個(gè)新的制作彈性動(dòng)畫(huà)的工廠(chǎng)方法:
+ (void)animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay usingSpringWithDamping:(CGFloat)dampingRatio initialSpringVelocity:(CGFloat)velocity options:(UIViewAnimationOptions)options animations:(void (^)(void))animations completion:(void (^)(BOOL finished))completion NS_AVAILABLE_IOS(7_0);
但是沒(méi)有直接的關(guān)于彈性的 CAAnimation 子類(lèi),類(lèi)似CABasicAnimation或CAKeyframeAnimation 來(lái)直接給CALayer添加動(dòng)畫(huà)。好消息是iOS9中添加了公開(kāi)的 CASpringAnimation。但是出于兼容低版本以及對知識探求的角度,我們可以了解一下如何手動(dòng)給CALayer創(chuàng )建一個(gè)彈性動(dòng)畫(huà)。
在開(kāi)始之前需要復習一下高中物理知識 ———— 阻尼振動(dòng),你可以點(diǎn)擊高亮字體的鏈接稍微復習一下。


根據維基百科,我們可以得到如下振動(dòng)函數通式:

當然這只是一個(gè)通式,我們需要讓 圖像過(guò)(0,0),并且最后衰減到1 。我們可以讓原圖像先繞X軸翻轉180度,也就是加一個(gè)負號。然后沿y軸向上平移一個(gè)單位。所以稍加變形可以得到如下函數:

想看函數的圖像?沒(méi)問(wèn)題,推薦一個(gè)在線(xiàn)查看函數圖象的網(wǎng)站 —— Desmos ,把這段公式 1-left(e^{-5x}cdot cos (30x)right) 復制粘帖進(jìn)去就可以看到圖像。
改進(jìn)后的函數圖像是這樣的:

完美滿(mǎn)足了我們 圖形過(guò)(0,0),震蕩衰減到1 的要求。其中式子中的 5 相當于阻尼系數,數值越小幅度越大;式子中的 30 相當于震蕩頻率 ,數值越大震蕩次數越多。
接下來(lái)就需要轉換成代碼。
總體思路是創(chuàng )建60幀關(guān)鍵幀(因為屏幕的最高刷新頻率就是60FPS),然后把這60幀數據賦值給 CAKeyframeAnimation 的 values 屬性。
用以下代碼生成60幀后保存到一個(gè)數組并返回它,其中//1就是利用剛才的公式創(chuàng )建60個(gè)數值:
+(NSMutableArray *) animationValues:(id)fromValue toValue:(id)toValue usingSpringWithDamping:(CGFloat)damping initialSpringVelocity:(CGFloat)velocity duration:(CGFloat)duration{ //60個(gè)關(guān)鍵幀 NSInteger numOfPoints = duration * 60; NSMutableArray *values = [NSMutableArray arrayWithCapacity:numOfPoints]; for (NSInteger i = 0; i numOfPoints; i++) { [values addObject:@(0.0)]; } //差值 CGFloat d_value = [toValue floatValue] - [fromValue floatValue]; for (NSInteger point = 0; point CGFloat x = (CGFloat)point / (CGFloat)numOfPoints; CGFloat value = [toValue floatValue] - d_value * (pow(M_E, -damping * x) * cos(velocity * x)); //1 y = 1-e^{-5x} * cos(30x) values[point] = @(value); } return values; }
接下來(lái)創(chuàng )建一個(gè)對外的類(lèi)方法,并返回一個(gè) CAKeyframeAnimation :
+(CAKeyframeAnimation *)createSpring:(NSString *)keypath duration:(CFTimeInterval)duration usingSpringWithDamping:(CGFloat)damping initialSpringVelocity:(CGFloat)velocity fromValue:(id)fromValue toValue:(id)toValue{ CAKeyframeAnimation *anim = [CAKeyframeAnimation animationWithKeyPath:keypath]; NSMutableArray *values = [KYSpringLayerAnimation animationValues:fromValue toValue:toValue usingSpringWithDamping:damping * dampingFactor initialSpringVelocity:velocity * velocityFactor duration:duration]; anim.values = values; anim.duration = duration; return anim; }
另一個(gè)關(guān)鍵
以上,我們創(chuàng )建了 CAKeyframeAnimation 。但是這些values到底是對誰(shuí)起作用的呢?如果你熟悉CoreAnimation的話(huà),沒(méi)錯,是對傳入的keypath起作用。而這些keypath其實(shí)就是CALayer中的屬性@property。比如,之所以當傳入的keypath為transform.rotation.x時(shí)CAKeyframeAnimation會(huì )讓layer發(fā)生旋轉,就是因為CAKeyframeAnimation發(fā)現CALayer中有這么個(gè)屬性叫transform,于是動(dòng)畫(huà)就發(fā)生了?,F在我們需要改變的是主題一中的那個(gè)factor變量,所以,很自然地想到,我們可以給CALayer補充一個(gè)屬性名為factor就行了,這樣CAKeyframeAnimation加到layer上時(shí)發(fā)現layer有這個(gè)factor屬性,就會(huì )把60幀不同的values賦值給factor。當然我們要把fromValue和toValue控制在0~1:
CAKeyframeAnimation *anim = [KYSpringLayerAnimation createSpring:@factor duration:0.8 usingSpringWithDamping:0.5 initialSpringVelocity:3 fromValue:@(1) toValue:@(0)]; self.factor = 0; [self addAnimation:anim forKey:@restoreAnimation];
最后一步,雖然CAKeyframeAnimation實(shí)時(shí)地去改變了我們想要的factor,但我們還得通知屏幕刷新,這樣才能看到動(dòng)畫(huà)。
+(BOOL)needsDisplayForKey:(NSString *)key{ if ([key isEqual:@factor]) { return YES; } return [super needsDisplayForKey:key]; }
上面的代碼通知屏幕當factor發(fā)生變化時(shí),實(shí)時(shí)刷新屏幕。
最后的最后,你需要重載CALayer中的-(id)initWithLayer:(GooeyCircle *)layer方法,為了保證動(dòng)畫(huà)能連貫起來(lái),你需要拷貝前一個(gè)狀態(tài)的layer及其所有屬性。
-(id)initWithLayer:(GooeyCircle *)layer{ self = [super initWithLayer:layer]; if (self) { self.indicatorSize = layer.indicatorSize; self.indicatorColor = layer.indicatorColor; self.currentRect = layer.currentRect; self.lastContentOffset = layer.lastContentOffset; self.scrollDirection = layer.scrollDirection; self.factor = layer.factor; } return self; }
總結:
做自定義的動(dòng)畫(huà)最關(guān)鍵的就是要有變量,要有輸入。像滑動(dòng)ScrollView的時(shí)候,滑動(dòng)的距離就是動(dòng)畫(huà)的輸入,可以作為動(dòng)畫(huà)的變量;當沒(méi)有交互的時(shí)候,可以用CAAnimation。其實(shí)CAAnimation底層就有個(gè)定時(shí)器,而定時(shí)器的作用就是可以產(chǎn)生變量,時(shí)間就是變量,就可以產(chǎn)生變化的輸入,就能看到變化的狀態(tài),連起來(lái)就是動(dòng)畫(huà)了。
評論