天天看點

iOS CoreAnimation專題——實戰篇(三)CADisplayLink進階應用:讓視圖的四條邊振動起來思路與詳細設計代碼實作*阻尼振動的運動力學方程

  • 思路與詳細設計
    • 分解思路
      • 1、動畫整體效果是四個邊從直線變形成二階貝塞爾曲線。
      • 2、動畫過程中實際上是貝賽爾曲線的控制點在垂直于邊的方向上來回移動。
      • 3、控制點的移動效果是具有彈性效果的。
      • 4、在控制點移動的過程中根據新的控制點位置每幀重繪邊的形狀。
    • 詳細設計
  • 代碼實作
  • *阻尼振動的運動力學方程

這次讓我們來實作一個非常有意思的彈性視圖效果,如圖

iOS CoreAnimation專題——實戰篇(三)CADisplayLink進階應用:讓視圖的四條邊振動起來思路與詳細設計代碼實作*阻尼振動的運動力學方程

這個例子主要是讓大家對CADisplayLink有一個更深的了解,即CADisplayLink的“同步螢幕重新整理”這個功能除了用來實作自定義的幀動畫外,它更精妙的一些用法,幫助大家拓寬思路。

在我們這個例子中使用的一個拓展思路是:CADisplayLink可以用來監聽系統動畫效果的每一幀。有些動畫效果我們自己去寫的話可能難以實作,而系統也沒有現成的動畫可以使用,但是系統實作了一些和我們要的效果有些關聯的動畫,我們就可以使用CADisplayLink來監聽系統的這些動畫在每一幀都幹了什麼,再使用這些資訊來輔助實作我們要的動畫效果。

這段描述現在看起來可能會造成困惑,沒關系,我們先實作這個例子,這樣大家就明白我要表達什麼意思了。

思路與詳細設計

首先要說明的是,整個實踐篇的目的在于“如何用已學會的姿勢實作各種各樣的效果”,是以我會在這裡說明大量和思路相關的内容,讓大家了解如何把你在原理篇和技巧篇中學到的零星的姿勢點彙聚到一起,變成得力的工具,這樣才能舉一反三,使得大家今後遇到除此之外的效果,也能有實作的方向去思考,而不是看完這篇部落格後,隻會實作這篇部落格介紹的效果。

是以我很可能不會像其他的部落格那樣,直接講【實作的過程】,我會花大量的篇幅講【思考的過程】。當然如果您覺得這樣看起來比較沒有體驗(因為“顯而易見的廢話”可能确實會比較多,而我要確定每一步大家都能看懂并能想透),那可以直接跳過思路到詳細設計,跳過思路的這部分對于您直接參考詳細設計和實作部分的内容沒有太大的影響。

那麼就讓我們開始吧(不知道為什麼,看英文文章然後用中文做筆記多了以後,自己寫部落格也有一股子硬翻譯的味道,但這确實是我的原創部落格并不是翻譯老外的(捂臉)!

拿到這個效果,首先仍然是想法對動畫效果進行分解。在考慮思路的時候通常是由抽象到具體進行思考,我們通常通過直覺的觀察對效果有一個最基本的認識,然後再具體根據分解的效果來拆分更細的技術。推薦大家在思路不清晰的時候把思考的過程通過文字記錄下來,這種操作有一個術語叫talk to your dog,原意是把你的問題【說出來】描述給你的狗聽,雖然你的狗并不能聽懂,但是你在描述問題的過程中往往能更好的了解問題,這是你在組織語言時收到來自大腦的回報,對找到解決方案有極大幫助,大家可以試一試。

是以首先直覺描述的話,這個動畫效果就是給視圖的4條邊添加彈性動畫的效果。

然後我們再更具體一點的描述,可以這樣拆分效果:

1、邊從直線變形成曲線

2、變形的過程是具有彈性的效果

這樣我們又進一步對上面的1進行分析:實際上邊是從直線變形成二階貝塞爾曲線。

那麼既然是要變形成為二階貝賽爾曲線(隻有一個控制點),根據觀察,貝賽爾曲線的起點和終點都固定不變(視圖四個角上的點固定沒有随着動畫移動),肯定在變形的過程中隻有控制點在(垂直于邊的方向上)移動,當控制點移動的時候,每一幀根據新的控制點的位置重繪貝賽爾曲線就會有類似的動畫效果了。根據2,控制點仿佛是黏在一個彈簧上跟着彈簧一起來回擺動,有一個彈性動畫的效果。

分析到這一步,可能一些讀者腦子裡已經有了完整的對于這個效果的解決方案了。如果還沒有,沒關系,我們繼續分析。

先總結一下我們已經分析出來的思路:

1、動畫整體效果是四個邊從直線變形成二階貝塞爾曲線。

2、動畫過程中實際上是貝賽爾曲線的控制點在垂直于邊的方向上來回移動。

3、控制點的移動效果是具有彈性效果的。

4、在控制點移動的過程中根據新的控制點位置每幀重繪邊的形狀。

分解思路

我們隻要能實作以上四點,這個動畫就能實作了。我們一個一個來看。

1、動畫整體效果是四個邊從直線變形成二階貝塞爾曲線。

首先是1,動畫整體效果是四個邊從直線變形成二階貝塞爾曲線。那麼至少我們首先要想辦法能繪制出二階貝塞爾曲線,在看過我技巧篇中貝賽爾曲線那一篇部落格後,應該很容易想到,使用CAShapeLayer+UIBezierPath就能輕松繪制了。不過值得一想的是,怎樣讓CAShapeLayer+UIBezierPath【成為】一個普通UIView的四條邊呢?換一種說法的話會更清晰:也就是這個UIView的形狀是由這個CAShapeLayer決定的。這樣一說,看過我技巧篇講解蒙版那一篇部落格的朋友應該一下就能想到了吧:使用這個CAShapeLayer作為UIView的蒙版!也就是:

這樣,我們的1就基本解決了,還差一步:“變形”。變形的問題在接下來的思路2中來解決。

2、動畫過程中實際上是貝賽爾曲線的控制點在垂直于邊的方向上來回移動。

好的我們馬上來看2,動畫過程中實際上是貝賽爾曲線的控制點在垂直于邊的方向上來回移動。注意大家在分解思路考慮每一步時,不要去想其他步驟上的内容,比如這裡的2,我們隻關心控制點是這樣垂直移動的,不要去考慮3的問題“在移動過程中怎麼每幀重繪貝塞爾曲線啊”,我們隻在這裡考慮“如何移動控制點”,剩下的問題等到了那一步再考慮。

是以這裡我們一下子在腦子裡想到的肯定是考慮怎麼給這個【控制點】加動畫。因為控制點實際上是一個抽象的東西,在我們眼裡它就是一個CGPoint,它不像一個視圖那樣看得見摸得着,這種抽象的虛無的玩意,一個CGPoint,它咋個加動畫嘛。遇到這種問題,我們從結果入手,總之不管過程怎樣,我們終究得想辦法【加動畫】對吧,那現在就像你抱怨的那樣,我們直接調用系統原生動畫API隻能給一個看得見的玩意,比如CALayer啊,UIView啊加動畫,好吧,既然我們無法改變環境,那就适應環境吧!我們既然隻能給一個UIView加動畫,而又必須要加動畫,那就…沒錯,那就把一個UIView當做控制點就行了呀。但是控制點是看不見的呀,你弄一個UIView在那動,到時候效果出來不是很怪麼,那就讓這個UIView的背景色為透明就行了呗。但是我們的控制點就是一個CGPoint呀,那就取這個UIView的center給控制點指派就OK咯。

這就是talk to your dog,把這些該死的問題用實在的語言描述出來而不是在腦子裡空想,很容易就能簡化問題見招拆招。

那總結一下咯,2如何解決呢,我們用比較小的UIView來模拟這些個二階貝塞爾曲線的控制點,給UIView加動畫就相當于控制點在移動了,而這些UIView的背景色是透明的,在貝賽爾曲線扭來扭去的時候使用者根本就看不見控制點其實也在那擺來擺去,隻有我們開發者知道,這是我們的小秘密!然後呢在這個控制點擺來擺去的時候我們可以取它的center作為那個真正的、抽象的控制點來使用。

3、控制點的移動效果是具有彈性效果的。

我們已經解決一半了!并且現在我們的大腦已經熱身完畢,正在飛速運轉,趕緊趁現在一鼓作氣搞定這些該死的問題!

看到彈性效果這幾個字,作為老司機的筆者(當然讀者您也有可能是一名老司機),一下子就想到了系統自帶的彈性效果動畫。沒錯我們的UIKit有自帶的spring動畫效果,想到這裡似乎又小小的興奮了一下呢,趕緊寫一個簡單的效果出來:

- (void)viewDidLoad {
    [super viewDidLoad];

    UIView * controlPointView = [[UIView alloc] initWithFrame:CGRectMake(, , , )];
    controlPointView.backgroundColor = [UIColor blackColor];
    [self.view addSubview:controlPointView];

    [UIView animateWithDuration: delay: usingSpringWithDamping: initialSpringVelocity: options: animations:^{
        // 彈性地向下偏移20個像素
        controlPointView.frame = CGRectOffset(controlPointView.frame, , );
    } completion:^(BOOL finished) {

    }];
}
           

效果是這樣的:

iOS CoreAnimation專題——實戰篇(三)CADisplayLink進階應用:讓視圖的四條邊振動起來思路與詳細設計代碼實作*阻尼振動的運動力學方程

關于這個方法的兩個參數:damping和initial velocity,damping表示阻尼系數,initial velocity表示初速度,想象一個物體在彈簧上振動,如果沒有初速度的話肯定是動不起來的。

我會在這篇文章末尾通過推導阻尼振動的運動力學方程來更加詳細的說明這幾個參數在動畫中的意義,以加深各位對這幾個參數的了解,感興趣的朋友可以前往觀看(包含的數學姿勢有微分學基礎、微分方程基礎、牛頓第二定律,不掌握這些姿勢也能看懂大概)。

回到思路上來,當我們看到上圖的這個動畫效果,再結合思路2,是不是我們的實作思路又更進一步了呢?思路2中說明了,我們需要使用一個UIView來模拟貝賽爾曲線的控制點,通過給這個UIView添加動畫然後取其center的值來作為控制點的值。而要添加的動畫是一個彈性效果的動畫,而由思路3,我們知道了要給2中的用來模拟控制點的UIView添加的動畫就是系統的這個自帶的spring動畫。

4、在控制點移動的過程中根據新的控制點位置每幀重繪邊的形狀。

看到這個“每幀重繪”,首先要明白的是,動畫本身就是“每幀重繪”的,是以你要實作某個“每幀重繪”的效果,先考慮系統自帶的動畫能不能達到這個效果。在我們這個情景中,我們要每幀重繪的是邊的形狀,更具體的說,每幀重繪CAShapeLayer的UIBezierPath。是以我們首先看看“每幀重繪CAShapeLayer的UIBezierPath”可不可以有什麼系統自帶的動畫可以實作的,答案是有的,因為CAShapeLayer的path屬性本身就是Animatable的,參考技巧篇中的UIBezierPath這一篇部落格,裡面還實作了一個使用CABasicAnimation來每幀重繪path的動畫效果。

OK,既然我們可以直接給path添加動畫,那麼是不是可以直接使用CABasicAnimation來解決3呢?“在控制點移動的過程中根據新的控制點位置”這個條件明确的回答了我們:NO!因為CABasicAnimation的動畫是要求我們傳入插值的條件:ease函數+from+to+duration,然後系統自己來計算每一幀如何繪制,而在“在控制點移動的過程中根據新的控制點位置”這個條件下我們隻能自己計算每一幀如何繪制,CABasicAnimation幫不了我們任何忙了。

好吧,那我們自己計算每一幀如何繪制,我咋個繪制呢?我們的主角到這裡就要閃亮登場了,登登登登,CADisplayLink了解一下,當然如果你之前閱讀了我的技巧篇的第一篇文章講解CADisplayLink和線性插值以及基于緩沖函數的非線性插值,那麼到這裡應該能非常熟練的使用了。

CADisplayLink可以讓我們同步螢幕的重新整理,每當螢幕重新整理的時候,系統會回調一個我們提供的回調方法,在這個回調方法中我們就可以想辦法實作“每幀重繪”了,因為這個回調方法就是“每幀調用”的,是以隻要在方法中實作重繪就好了。

現在我們解決了如何每幀重繪,這裡就隻剩最後一個問題了:根據新的控制點位置每幀重繪邊的形狀。

那麼這個問題也可以分解,其實就是兩步:1、每幀擷取控制點目前的位置;2、根據這個控制點的位置重繪邊的形狀。所謂重繪邊的形狀,其實就是生成一個新的貝賽爾曲線重新指派給作為蒙版的CAShapeLayer。

那麼更進一步,我們可以在回調方法中嘗試寫一下僞代碼(注意一下presentationLayer的用法):

// 這個方法是我們向CADisplayLink提供的回調方法
- (void)onDisplayLink
{
    // 這裡是每幀重繪的地方
    // 擷取新的控制點,一共有四個:

    // 注意我們這是在彈性動畫進行的過程中去擷取點的值,還記得原理篇裡面講CALayer的模型層和展示層嗎,動畫過程中實際上隻有presentationLayer在重繪,modelLayer也就是我們直接通過layer去取的值已經在終點等着了,是以為了取到動畫過程中的layer的屬性的實時的值,這裡隻能去取它presentationLayer的值
    CGPoint control1 = self.topControlPointView.layer.presentationLayer.position;
    CGPoint control2 = ...
    CGPoint control3 = ...
    CGPoint control4 = ...
    // 根據新的控制點指派

    UIBezierPath * topPath = [UIBezierPath bezierPath];

    // 從四邊形的左上角拉一條二階貝塞爾曲線到右上角
    [topPath moveToPoint:leftTopPoint];
    [topPath addQuadCurveToPoint:rightTopPoint controlPoint:control1];

    // 把剩下三條線像這樣畫好,然後進行指派
    UIBezierPath * leftPath = ...
    UIBezierPath * bottomPath = ...
    UIBezierPath * rightPath = ...

    UIBezierPath * path = [UIBezierPath bezierPath];
    [path appendPath:topPath];
    [path appendPath:leftPath];
    [path appendPath:bottomPath];
    [path appendPath:rightPath];

    self.maskLayer.path = path.CGPath;

    self.maskLayer.path = [self pathForMaskLayer];
}
           

我們這個效果中最核心的代碼就實作好了。最後,我們來把上面1234四條思路結合起來,重新整理一下,然後簡單的架構一下,就可以開始着手實作代碼了。

詳細設計

我們冗長的思路分析終于結束了,通過這樣的分析,我們找到了一條實作這個效果的路,并且關鍵的地方的代碼都能寫的出來,接下來我們就可以開始進行編碼的詳細設計,也就是在開始編寫一些子產品之前,在腦中或者在文檔中把具體的類、屬性、方法、方法之間的調用、調用流程等邏輯理一下,然後再動手編碼。

首先根據我們的思路,考慮一下,我們需要一個UIView作為動畫的主體,一個CAShapeLayer作為這個UIView的蒙版,然後需要4個UIView模拟四條邊的控制點,這些是我們在思路上面已經能直接想到的。除此之外,我們其實還需要指定一些更深的細節:阻尼振動的振幅,這個是因為主體視圖本身的frame肯定要比蒙版圍成的矩形要大的,如果一樣大,那在四條邊振動的時候,凸出去的那部分内容就是空的了,具體看我的靈魂畫闆的解釋。

iOS CoreAnimation專題——實戰篇(三)CADisplayLink進階應用:讓視圖的四條邊振動起來思路與詳細設計代碼實作*阻尼振動的運動力學方程

如圖,黑色的框是靜止時我們所看到的内容,紅色的框是動畫過程中可能達到的最大振幅,是以我們的主體視圖至少要能顯示到最大振幅的地方,也就是藍色框所表示的範圍。

這樣我們主體視圖的frame就會設為藍色框的部分,而黑色框部分的frame需要另外進行計算。如何計算呢?肯定需要先确定振幅了,這樣我們可以先聲明出所需的所有屬性:

@interface ViewController ()
@property (nonatomic, strong) CAShapeLayer * maskLayer;
@property (nonatomic, strong) CADisplayLink * displayLink;

// 四條邊的四個控制點
@property (nonatomic, strong) UIView * topControlPointView;
@property (nonatomic, strong) UIView * leftControlPointView;
@property (nonatomic, strong) UIView * bottomControlPointView;
@property (nonatomic, strong) UIView * rightControlPointView;

// 振幅
@property (nonatomic, assign) CGFloat amplitude;

// 視圖靜止時的frame(相對于父視圖)
@property (nonatomic, assign) CGRect contentsFrame;

// 動畫主體視圖,因為存在互動事件,這裡簡單的聲明為UIControl
@property (nonatomic, strong) UIControl * animationView;


@end
           

其中animationView和displayLink需要事先提供好回調方法:

#pragma mark - callback
- (void)touchDown
{
}


- (void)touchUp
{
}

- (void)onDisplayLink
{
}
           

這裡我們将采用面向過程程式設計的思維來進行代碼的編寫,面向過程程式設計實際上就是函數之間的互相調用,把整個子產品的業務邏輯拆分的各個小的功能和邏輯,把這些小的塊用函數來實作,然後在主流程中疊代調用各個函數,這樣子產品的整體功能就實作了。說白一點,在你的主業務邏輯的函數中(比如你們在學習c語言的時候的main函數,當然這裡我直接寫到一個UIViewController裡面的話就是viewDidLoad:方法以及上面聲明的這三個負責處理互動的方法,因為對于一個UIViewController,它要做的就是處理顯示和互動,顯示的邏輯寫到viewDidLoad:裡面,互動的邏輯寫到上面這三個方法裡面,在這四個主邏輯方法中調用其他小的方法,整個主要業務邏輯就能完全實作了)把你要做的事情全部用函數調用來代替。

舉個例子,我們在viewDidLoad中要幹嘛呢?肯定是要初始化所有要用到的變量和屬性,并且把該繪制的繪制好,那麼大概就是兩步,是以我們直接在viewDidLoad中寫:

- (void)viewDidLoad {
    [super viewDidLoad];
    // 初始化所需的所有資料和屬性
    [self initializeDataSource];
    // 繪制初始界面
    [self initializeAppearance];   
}
           

而至于-initializeDataSource和-initializeAppearance兩個方法中幹了什麼,可以暫時不用管,我們在面向過程程式設計時采用自頂向下的思維方式,先考慮“架構”,再考慮實作,也就是優先考慮你需要聲明和調用哪些方法和函數,(把所有需要的函數和方法都聲明好了以後)最後再考慮這些函數裡面該如何實作。當然你應該先把它們聲明好,免得編譯器報錯,聲明可以直接寫到類的extension裡面,因為這兩個很明顯是私有方法,也可以不用聲明直接寫一個空的實作出來:

- (void)initializeAppearance {}
- (void)initializeDataSource {}
           

在面向過程程式設計中,這樣的“自頂向下”的思維是很重要的,就像上級下達指令一下,下級一層一層的,把該自己做的做了,不該自己做的,繼續往下下達指令,疊代完成後,最上級的指令就實施完畢了。每一級隻需考慮自己要做什麼,以及要找下一級做什麼。

這樣viewDidLoad就實作好了(你不需要再管viewDidLoad了,隻要你把initializeAppearance和initializeDataSource實作了,就相當于viewDidLoad實作完畢了,這樣的思維其實就是面向過程程式設計的核心思想)。

接下來我們來看負責互動的三個主邏輯方法,我們同樣按照面向過程的思維,考慮它們應該調用什麼樣的函數來實作它們所有的功能。我們的效果是,在動畫的視圖上按下,四條邊開始“膨脹”,松開後,則彈性動畫開始,而彈性動畫實際上是控制點的彈性動畫,真正改變邊形狀的地方是在displayLink裡面。于是這三個方法裡面要做的事情就顯而易見了:

#pragma mark - callback
- (void)touchDown
{
    // 按下,執行控制點的膨脹動畫,也就是把控制點移到振幅的位置
    // 同時開啟displayLink,因為按下的一瞬間就應該開始監聽控制點的位置改變了
    [self startDisplayLink];
    [self prepareForBounceAnimation];
}


- (void)touchUp
{
    // 放開,執行控制點的彈性動畫
    [self bounceWithAnimation];
}

- (void)onDisplayLink
{
    // 無論是按下還是放開,隻要改變了控制點的位置,就應該根據最新的控制點重繪四條邊的形狀。
    // 調用pathForMaskLayer來計算最新的path并指派給蒙版layer
    self.maskLayer.path = [self pathForMaskLayer];
}
           

接下來當然就是想辦法實作-startDisplayLink、- prepareForBounceAnimation、- bounceWithAnimation和- pathForMaskLayer這幾個方法了。

像這樣逐漸逐漸的疊代調用下去,我們整個功能就能實作完畢了。接下來我再把重要的- bounceWithAnimation和- pathForMaskLayer這兩個方法講一下,剩下的就隻有一些細節處理了,交給大家自己嘗試去實作。

首先是bounceWithAnimation,這個方法是我們的控制點在振幅位置(因為之前調用了prepareForBounceAnimation,把控制點移到了振幅位置)振動回原來的位置。是以隻需要給四個控制點加動畫即可,然後在動畫結束後停止CADisplayLink的監聽,這裡要注意控制點視圖的父視圖就是我們的animationView,animationView的frame參考上面我用靈魂畫闆畫出來的東西,是以這四個控制點視圖在初始位置的center應該是這樣寫:

- (void)bounceWithAnimation
{
    [UIView animateWithDuration: delay: usingSpringWithDamping: initialSpringVelocity: options: animations:^{
        [self positionControlPoints];
    } completion:^(BOOL finished) {
        [self stopDisplayLink];
    }];
}

// 這個方法可以複用,因為在viewDidLoad裡面也需要初始化這四個控制點的位置,在bounceWithAnimation裡面的動畫也可以調用這個方法來讓四個控制點回到初始的位置。
- (void)positionControlPoints
{
    self.topControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), self.amplitude);

    self.leftControlPointView.center = CGPointMake(self.amplitude, CGRectGetMidY(self.animationView.bounds));

    self.bottomControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), CGRectGetHeight(self.animationView.bounds) - self.amplitude);

    self.rightControlPointView.center = CGPointMake(CGRectGetWidth(self.animationView.bounds) - self.amplitude, CGRectGetMidY(self.animationView.bounds));
}
           

接下來是pathForMaskLayer,這個方法裡面根據目前四個控制點視圖的center構造四條二階貝賽爾曲線并合成一條曲線,然後根據這條曲線傳回一個CGPathRef:

- (CGPathRef)pathForMaskLayer
{
    // 視圖可見部分的寬和高
    CGFloat width = CGRectGetWidth(self.contentsFrame);
    CGFloat height = CGRectGetHeight(self.contentsFrame);

    // 擷取四個控制點,這裡通過四個控制點視圖的presentationLayer來擷取(參考原理篇講解CALayer的模型與展示)
    CGPoint topControlPoint = CGPointMake(width/, [self.topControlPointView.layer.presentationLayer position].y - self.amplitude);
    CGPoint rightControlPoint = CGPointMake([self.rightControlPointView.layer.presentationLayer position].x - self.amplitude, height/);
    CGPoint bottomControlPoint = CGPointMake(width/, [self.bottomControlPointView.layer.presentationLayer position].y - self.amplitude);
    CGPoint leftControlPoint = CGPointMake([self.leftControlPointView.layer.presentationLayer position].x - self.amplitude, height/);

    // 為一個UIBezierPath對象添加四條二階貝塞爾曲線,不熟悉的話可以參考技巧篇講解貝塞爾曲線的内容
    UIBezierPath * bezierPath = [UIBezierPath bezierPath];
    [bezierPath moveToPoint:CGPointZero];
    [bezierPath addQuadCurveToPoint:CGPointMake(width, ) controlPoint:topControlPoint];
    [bezierPath addQuadCurveToPoint:CGPointMake(width, height) controlPoint:rightControlPoint];
    [bezierPath addQuadCurveToPoint:CGPointMake(, height) controlPoint:bottomControlPoint];
    [bezierPath addQuadCurveToPoint:CGPointZero controlPoint:leftControlPoint];

    return bezierPath.CGPath;
}
           

代碼實作

詳細設計結束後,我們就可以直接把具體代碼填進去了,注意一些細節即可。

#import "ViewController.h"

@interface ViewController ()
{
    // contentsFrame基于animationView坐标系的frame
    CGRect _privateContentsFrame;
}

@property (nonatomic, strong) CAShapeLayer * maskLayer;
@property (nonatomic, strong) CADisplayLink * displayLink;

@property (nonatomic, strong) UIView * topControlPointView;
@property (nonatomic, strong) UIView * leftControlPointView;
@property (nonatomic, strong) UIView * bottomControlPointView;
@property (nonatomic, strong) UIView * rightControlPointView;

// 振幅
@property (nonatomic, assign) CGFloat amplitude;

//
@property (nonatomic, assign) CGRect contentsFrame;

// 用來做動畫效果測試的視圖
@property (nonatomic, strong) UIControl * animationView;


@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self initializeDataSource];
    [self initializeAppearance];

}

#pragma mark - callback
- (void)touchDown
{
    // 按下,執行控制點的膨脹動畫,也就是把控制點移到振幅的位置
    // 同時開啟displayLink,因為按下的一瞬間就應該開始監聽控制點的位置改變了
    if (!self.displayLink.paused) {
        return;
    }
    [self startDisplayLink];
    [self prepareForBounceAnimation];
}


- (void)touchUp
{
    // 放開,執行控制點的彈性動畫
    [self bounceWithAnimation];
}

- (void)onDisplayLink
{
    // 無論是按下還是放開,隻要改變了控制點的位置,就應該根據最新的控制點重繪四條邊的形狀。
    // 調用pathForMaskLayer來計算最新的path并指派給蒙版layer
    self.maskLayer.path = [self pathForMaskLayer];
}

#pragma mark - private methods

- (void)prepareForBounceAnimation
{
    [UIView animateWithDuration: delay: usingSpringWithDamping: initialSpringVelocity: options: animations:^{

        self.topControlPointView.frame = CGRectOffset(self.topControlPointView.frame, , -self.amplitude);
        self.leftControlPointView.frame = CGRectOffset(self.leftControlPointView.frame, -self.amplitude, );
        self.bottomControlPointView.frame = CGRectOffset(self.bottomControlPointView.frame, , self.amplitude);
        self.rightControlPointView.frame = CGRectOffset(self.rightControlPointView.frame, self.amplitude, );

    } completion:^(BOOL finished) {

    }];
}

- (void)bounceWithAnimation
{
    [UIView animateWithDuration: delay: usingSpringWithDamping: initialSpringVelocity: options: animations:^{
        [self positionControlPoints];
    } completion:^(BOOL finished) {
        [self stopDisplayLink];
    }];
}

- (void)startDisplayLink
{
    self.displayLink.paused = NO;
}

- (void)stopDisplayLink
{
    self.displayLink.paused = YES;
}

- (CGPathRef)pathForMaskLayer
{
    // 視圖可見部分的寬和高
    CGFloat width = CGRectGetWidth(self.contentsFrame);
    CGFloat height = CGRectGetHeight(self.contentsFrame);

    // 擷取四個控制點,這裡通過四個控制點視圖的presentationLayer來擷取(參考原理篇講解CALayer的模型與展示)
    CGPoint topControlPoint = CGPointMake(width/, [self.topControlPointView.layer.presentationLayer position].y - self.amplitude);
    CGPoint rightControlPoint = CGPointMake([self.rightControlPointView.layer.presentationLayer position].x - self.amplitude, height/);
    CGPoint bottomControlPoint = CGPointMake(width/, [self.bottomControlPointView.layer.presentationLayer position].y - self.amplitude);
    CGPoint leftControlPoint = CGPointMake([self.leftControlPointView.layer.presentationLayer position].x - self.amplitude, height/);

    // 為一個UIBezierPath對象添加四條二階貝塞爾曲線,不熟悉的話可以參考技巧篇講解貝塞爾曲線的内容
    UIBezierPath * bezierPath = [UIBezierPath bezierPath];
    [bezierPath moveToPoint:CGPointZero];
    [bezierPath addQuadCurveToPoint:CGPointMake(width, ) controlPoint:topControlPoint];
    [bezierPath addQuadCurveToPoint:CGPointMake(width, height) controlPoint:rightControlPoint];
    [bezierPath addQuadCurveToPoint:CGPointMake(, height) controlPoint:bottomControlPoint];
    [bezierPath addQuadCurveToPoint:CGPointZero controlPoint:leftControlPoint];

    return bezierPath.CGPath;
}

/**
 *  通過contentsFrame和interval确定自己的frame
 */
- (void)updateFrame
{
    CGFloat x = self.contentsFrame.origin.x - self.amplitude;
    CGFloat y = self.contentsFrame.origin.y - self.amplitude;
    CGFloat width = self.contentsFrame.size.width +  * self.amplitude;
    CGFloat height = self.contentsFrame.size.height +  * self.amplitude;
    self.animationView.frame = CGRectMake(x, y, width, height);

    _privateContentsFrame = CGRectMake(self.amplitude, self.amplitude, CGRectGetWidth(self.contentsFrame), CGRectGetHeight(self.contentsFrame));

    self.maskLayer.frame = _privateContentsFrame;
}

- (void)initializeDataSource
{
    self.contentsFrame = CGRectMake(, , , );
    self.amplitude = ;
}

- (void)initializeAppearance
{

    [self updateFrame];
    [self.view addSubview:self.animationView];
    for (UIView * view in @[self.topControlPointView, self.leftControlPointView, self.bottomControlPointView, self.rightControlPointView]) {
//        view.backgroundColor = [UIColor yellowColor];
        view.frame = CGRectMake(, , , );
        [self.animationView addSubview:view];
    }
    [self positionControlPoints];

    self.animationView.layer.mask = self.maskLayer;

}

/**
 *  把四個控制點還原到起始位置(在初始化的時候也要調用,讓它們一開始就在起始位置)
 */
- (void)positionControlPoints
{
    self.topControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), self.amplitude);

    self.leftControlPointView.center = CGPointMake(self.amplitude, CGRectGetMidY(self.animationView.bounds));

    self.bottomControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), CGRectGetHeight(self.animationView.bounds) - self.amplitude);

    self.rightControlPointView.center = CGPointMake(CGRectGetWidth(self.animationView.bounds) - self.amplitude, CGRectGetMidY(self.animationView.bounds));
}


#pragma mark - getter
- (UIView *)topControlPointView
{
    if (!_topControlPointView) {
        _topControlPointView = [[UIView alloc] init];
    }
    return _topControlPointView;
}


- (UIView *)leftControlPointView
{
    if (!_leftControlPointView) {
        _leftControlPointView = [[UIView alloc] init];
    }
    return _leftControlPointView;
}

- (UIView *)bottomControlPointView
{
    if (!_bottomControlPointView) {
        _bottomControlPointView = [[UIView alloc] init];
    }
    return _bottomControlPointView;
}

- (UIView *)rightControlPointView
{
    if (!_rightControlPointView) {
        _rightControlPointView = [[UIView alloc] init];
    }
    return _rightControlPointView;
}

- (UIControl *)animationView
{
    if (!_animationView) {
        _animationView = ({

            UIControl * view = [[UIControl alloc] initWithFrame:CGRectMake(, , , )];
            view.backgroundColor = [UIColor blackColor];
            [view addTarget:self action:@selector(touchDown) forControlEvents:UIControlEventTouchDown];
            [view addTarget:self action:@selector(touchUp) forControlEvents:UIControlEventTouchUpInside];
            // 使用者按下後發生手勢響應中斷的情況,比如按下後還沒放開呢,突然來了個電話。
            [view addTarget:self action:@selector(touchUp) forControlEvents:UIControlEventTouchCancel];
            // 使用者按下後,保持按住的狀态把手指移到視圖外部再放開的情況
            [view addTarget:self action:@selector(touchUp) forControlEvents:UIControlEventTouchUpOutside];
            view;

        });
    }
    return _animationView;
}

- (CAShapeLayer *)maskLayer
{
    if (!_maskLayer) {
        _maskLayer = ({

            CAShapeLayer * layer = [CAShapeLayer layer];
            layer.fillColor = [UIColor redColor].CGColor;
            layer.backgroundColor = [UIColor clearColor].CGColor;
            layer.strokeColor = [UIColor clearColor].CGColor;
            layer.frame = _privateContentsFrame;
            layer.path = [UIBezierPath bezierPathWithRect:layer.bounds].CGPath;

            layer;

        });
    }
    return _maskLayer;
}

- (CADisplayLink *)displayLink
{
    if (!_displayLink) {
        _displayLink = ({

            CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink)];
            displayLink.paused = YES;
            [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
            [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode];
            displayLink;

        });
    }
    return _displayLink;
}


@end
           

我把這個效果進行了封裝,大家可以把效果添加到任意的視圖上,代碼位址在這個git倉庫裡:

DHBounceEffect

代碼是我很早就寫好的了,是以這個封裝版本的代碼可能和部落格裡面的有些許不太一樣,就是一些設計上的不同而已,主要的代碼是一模一樣的。

*阻尼振動的運動力學方程

這裡簡單對阻尼振動的動力學方程做一個介紹,了解微分方程和經典實體學的朋友可以用來當做了解UIKit的spring動畫方法中的damping、initial velocity這兩個參數的參考,不感興趣的同學當然可以略過。

首先考慮一個物體在彈簧上做阻尼振動,該物體受到的合力由兩個力疊加:一是彈簧本身的彈力 f彈=−kx f 彈 = − k x ,一是阻尼力(空氣阻力或者滑動摩擦力或者液體阻力等等),阻尼力與物體振動的速度成正比,也就是物體振動的過程中速度越快,受到的阻尼力就越大: f=−Cv f = − C v ,其中k為彈性系數,C為阻尼力系數。

那麼根據牛頓第二定律有:

ma=f彈+f=−kx−Cv m a = f 彈 + f = − k x − C v

由于阻尼振動整個過程的速度和加速度都在無時無刻變化着,是以速度和加速度我們可以用微分來表示,也就是

v=dxdt,a=dvdt=d2xdt2 v = d x d t , a = d v d t = d 2 x d t 2

那麼帶入牛頓第二定律的方程就得到

md2xdt2=−kx−Cdxdt m d 2 x d t 2 = − k x − C d x d t

因為我們想要得到物體振動的運動力學方程,也就是物體的振動的位移和時間的關系函數: x(t) x ( t ) ,而這裡恰好函數的一階導數為: x′(t)=dxdt x ′ ( t ) = d x d t ,二階導數為 x″(t)=d2xdt2 x ″ ( t ) = d 2 x d t 2 ,是以上述方程就是一個微分方程了:

mx″(t)+Cx′(t)+kx=0 m x ″ ( t ) + C x ′ ( t ) + k x = 0

這個微分方程的解就是我們要得到的 x(t) x ( t ) 的函數表達式。

這是一個二階線性常系數齊次方程,解微分方程的過程這裡就不贅述了,我們可以通過這個微分方程的通解得到彈簧形變量與時間的函數:

x(t)=Ae−δtcos(ωt+φ) x ( t ) = A e − δ t cos ⁡ ( ω t + φ )

其中

δ=C2m,ω=ω20−δ2‾‾‾‾‾‾‾√,ω0=km‾‾‾√ δ = C 2 m , ω = ω 0 2 − δ 2 , ω 0 = k m

這就是動力學方程了,其中 A和φ A 和 φ 為待定常數,δ就是方法要傳入的第一個參數damping,以及另一個變量ω,隻要我們确定了這四個系數,那麼整個彈簧阻尼振動的運動就可以用這個方程來描述了。現在我們來看如何确定 A、φ和ω A 、 φ 和 ω

首先由 x=Ae−δtcos(ωt+φ) x = A e − δ t cos ⁡ ( ω t + φ ) 計算x對t求導得到速度和時間的函數

v(t)=dxdt=−Aδe−δtcos(ωt−φ)−Aωe−δtsin(ωt−φ) v ( t ) = d x d t = − A δ e − δ t cos ⁡ ( ω t − φ ) − A ω e − δ t sin ⁡ ( ω t − φ )

令 x(0)=x0,v(0)=v0 x ( 0 ) = x 0 , v ( 0 ) = v 0 ,也就是在我們的動畫過程中,考慮動畫開始的那一刻,也就是t=0的時候,初速度為 v0 v 0 ,彈簧形變量為 x0 x 0 。

那麼可以計算出

x0=x(0)=Acosφ x 0 = x ( 0 ) = A cos ⁡ φ

v0=v(0)=−Aδcosφ+Aωsinφ v 0 = v ( 0 ) = − A δ cos ⁡ φ + A ω sin ⁡ φ

而初速度 v0 v 0 就是我們方法傳入的第二個參數,可以作為已知量來使用,那麼我們使用 v0 v 0 反解出 A A 和φφ:首先 v0x0=−δ+ωtanφ v 0 x 0 = − δ + ω tan ⁡ φ 消除A得到

φ=arctan(v0+δx0ωx0) φ = arctan ⁡ ( v 0 + δ x 0 ω x 0 )

然後 cosφ=ωx0(ωx0)2+(v0+δx0)2√ cos ⁡ φ = ω x 0 ( ω x 0 ) 2 + ( v 0 + δ x 0 ) 2 帶入x(0)的方程得到

A=x0cosφ=(ωx0)2+(v0+δx0)2‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾√ω A = x 0 cos ⁡ φ = ( ω x 0 ) 2 + ( v 0 + δ x 0 ) 2 ω

然後把 A A 和φφ帶入x(t)得到運動力學方程為

x(t)=(ωx0)2+(v0+δx0)2‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾√ωe−δtcos(ωt−arctan(v0+δx0ωx0)) x ( t ) = ( ω x 0 ) 2 + ( v 0 + δ x 0 ) 2 ω e − δ t cos ⁡ ( ω t − arctan ⁡ ( v 0 + δ x 0 ω x 0 ) )

這樣我們就知道動畫的物體在每個時刻的x位置。這裡的x指的是彈簧的形變量,因為最終物體靜止後的位置彈性勢能肯定為0,也就是彈簧形變量為0的位置,是以x指的就是物體目前位置與物體最終停止的位置的位移。

在這個函數的表達式中存在除了自變量t以外的其他系數,這些系數通過spring動畫的方法參數–duration(持續時間)、damping(阻尼系數)、initial velocity(初速度)來确定。

δ就是damping, v0 v 0 就是初速度, x0 x 0 是運動物體的初位移,也就是彈簧的初形變量,比如你要讓一個視圖從80開始阻尼振動到100結束, x0 x 0 就是100-80=20,因為100的位置是彈簧原長的位置,80就是彈簧形變量為20的位置,ω由δ和duration确定。

這樣我們的運動力學最終的方程就能确定下來了。

順便一提,阻尼振動停止的時刻是物體機械能為0的時刻,我們可以先求出動能和勢能:

Ek=12mv2 E k = 1 2 m v 2

Ep=12kx2 E p = 1 2 k x 2

機械能 E=Ek+Ep E = E k + E p ,帶入v和x的表達式就可以求出來機械能和時間的函數表達式,這裡就不再贅述了。最後根據 t=duration t = d u r a t i o n 時, E=0 E = 0 也就是根據方程 E(duration)=0 E ( d u r a t i o n ) = 0 反解出ω即可。

繼續閱讀