談談iOS中粘性動畫以及果凍效果的實現
在最近做個一個自訂PageControl——KYAnimatedPageControl中,我實現了CALayer的形變動畫以及CALayer的彈性動畫,效果先過目:
先做個提綱:
第一個分享的主題是“如何讓CALayer發生形變”,這個技術在我之前一個項目 ———— KYCuteView 中有涉及,也寫了篇簡短的實現原理博文。今天再舉一個例子。
之前我也做過類似果凍效果的彈性動畫,比如這個項目—— KYGooeyMenu。用到的核心技術是CAKeyframeAnimation,然後設定幾個不同狀態的主要畫面格,就能初步達到這種彈性效果。但是,畢竟只有幾個主要畫面格,而且是需要手動計算,不精確不說,動畫也不夠細膩,畢竟你不可能手動建立60個主要畫面格。所以,今天的第二個主題是 —— “如何用阻尼震動函數建立出60個主要畫面格”,從而實現CALayer產生類似[UIView animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion] 的彈性動畫。
本文。
如何讓CALayer發生形變?
關鍵技術很簡單:你需要用多條貝茲路徑 “拼” 出這個Layer。之所以這樣做的原因不言而喻,因為這樣方便我們發生形變。
比如 KYAnimatedPageControl 中的這個小球,其實它是這麼被畫出來的:
小球是由弧AB、弧BC、弧CD、弧DA 四段組成,其中每段弧都綁定兩個控制點:弧AB 綁定的是 C1 、 C2;弧BC 綁定的是 C3 、 C4 .....
如何表達各個點?
首先,A、B、C、D是四個動點,控制他們動的變數是ScrollView的contentOffset.x。我們可以在-(void)scrollViewDidScroll:(UIScrollView *)scrollView中即時擷取這個變數,並把它轉換成一個控制在 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。那麼結合這個0~1的係數,我們可以得出A、B、C、D的真實變化距離 extra 為:extra = (self.width * 2 / 5) * factor。當factor == 1時,達到最大形變狀態,此時四個點的變化距離均為(self.width * 2 / 5)。
注意:根據滑動方向,我們還要根據是B點移動還是D點移動。
- 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);
然後是控制點:
關鍵是要知道中A-C1 、B-C2、B-C3、C-C4....這些水平和垂直虛線的長度,命名為offSet。經過多次嘗試,我得出的結論是:
當offSet設定為 直徑除以3.6 的時候,弧線能完美地貼合成圓弧。我隱約感覺這個 3.6 是必然,貌似和360度有某種關係,或許通過演算能得出 3.6 這個值的必然性,但我沒有嘗試。
因此,各個控制點的座標:
- 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);
有了終點和控制點,就可以用UIBezierPath 中提供的方法 - (void)addCurveToPoint:(CGPoint)endPoint controlPoint1:(CGPoint)controlPoint1 controlPoint2:(CGPoint)controlPoint2; 畫線段了。
重載CALayer的- (void)drawInContext:(CGContextRef)ctx;方法,在裡面畫圖案:
- - (void)drawInContext:(CGContextRef)ctx{
- ....//在這裡計算每個點的座標
- 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);
- }
現在,當你滑動ScrollView的時候,小球就會形變了。
如何用阻尼震動函數建立出60個主要畫面格?
上面的例子中,有個很重要的因素,就是ScrollView中的contentOffset.x這個變數,沒有這個輸入,那接下來什麼都不會發生。但想要獲得這個變數,是需要使用者觸摸、滑動去互動產生的。在某個動畫中使用者是沒有直接的互動輸入的,比如當手指離開之後,要讓這個小球以果凍效果彈回初始狀態,這個過程手指已經離開螢幕,也就沒有了輸入,那麼用上面的方法肯定行不通,所以,我們可以用CAAnimation.
我們知道,iOS7中蘋果在 UIView(UIViewAnimationWithBlocks) 加入了一個新的製作彈性動畫的Factory 方法:
- + (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);
但是沒有直接的關於彈性的 CAAnimation 子類,類似CABasicAnimation或CAKeyframeAnimation 來直接給CALayer添加動畫。好訊息是iOS9中添加了公開的 CASpringAnimation。但是出於相容低版本以及對知識探求的角度,我們可以瞭解一下如何手動給CALayer建立一個彈性動畫。
在開始之前需要複習一下高中物理知識 ———— 阻尼震動,你可以點擊高亮字型的連結稍微複習一下。
根據維基百科,我們可以得到如下震動函數通式:
當然這隻是一個通式,我們需要讓 映像過(0,0),並且最後衰減到1 。我們可以讓原映像先繞X軸翻轉180度,也就是加一個負號。然後沿y軸向上平移一個單位。所以稍加變形可以得到如下函數:
想看函數的映像?沒問題,推薦一個線上查看函數圖象的網站 —— Desmos ,把這段公式 1-\left(e^{-5x}\cdot \cos (30x)\right) 複製粘帖進去就可以看到映像。
改進後的函數映像是這樣的:
完美滿足了我們 圖形過0,0),震蕩衰減到1 的要求。其中式子中的 5 相當於阻尼係數,數值越小幅度越大;式子中的 30 相當于震蕩頻率 ,數值越大震蕩次數越多。
接下來就需要轉換成代碼。
總體思路是建立60幀主要畫面格因為螢幕的最高重新整理頻率就是60FPS),然後把這60幀資料賦值給 CAKeyframeAnimation 的 values 屬性。
用以下代碼產生60幀後儲存到一個數組並返回它,其中//1就是利用剛才的公式建立60個數值:
- +(NSMutableArray *) animationValues:(id)fromValue toValue:(id)toValue usingSpringWithDamping:(CGFloat)damping initialSpringVelocity:(CGFloat)velocity duration:(CGFloat)duration{
- //60個主要畫面格
- 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;
- }
接下來建立一個對外的類方法,並返回一個 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;
- }
另一個關鍵
以上,我們建立了 CAKeyframeAnimation 。但是這些values到底是對誰起作用的呢?如果你熟悉CoreAnimation的話,沒錯,是對傳入的keypath起作用。而這些keypath其實就是CALayer中的屬性@property。比如,之所以當傳入的keypath為transform.rotation.x時CAKeyframeAnimation會讓layer發生旋轉,就是因為CAKeyframeAnimation發現CALayer中有這麼個屬性叫transform,於是動畫就發生了。現在我們需要改變的是主題一中的那個factor變數,所以,很自然地想到,我們可以給CALayer補充一個屬性名稱為factor就行了,這樣CAKeyframeAnimation加到layer上時發現layer有這個factor屬性,就會把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即時地去改變了我們想要的factor,但我們還得通知螢幕重新整理,這樣才能看到動畫。
- +(BOOL)needsDisplayForKey:(NSString *)key{
- if ([key isEqual:@"factor"]) {
- return YES;
- }
- return [super needsDisplayForKey:key];
- }
上面的代碼通知螢幕當factor發生變化時,即時重新整理螢幕。
最後的最後,你需要重載CALayer中的-(id)initWithLayer:(GooeyCircle *)layer方法,為了保證動畫能連貫起來,你需要拷貝前一個狀態的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;
- }
總結:
做自訂的動畫最關鍵的就是要有變數,要有輸入。像滑動ScrollView的時候,滑動的距離就是動畫的輸入,可以作為動畫的變數;當沒有互動的時候,可以用CAAnimation。其實CAAnimation底層就有個定時器,而定時器的作用就是可以產生變數,時間就是變數,就可以產生變化的輸入,就能看到變化的狀態,連起來就是動畫了。