自定義Drawable實現靈動的紅鯉魚動畫(上篇)

此篇中的小魚動畫是模仿國外一個大牛做的flash動畫,第一眼就愛上它了,簡約靈動又不失美學,于是抽空試著嘗試了一下,如下是我用Android實現的效果圖:

小魚兒

由于整個繪制分析過程比較繁瑣所以靈動的紅鯉魚準備做成上下兩篇,本篇是小魚兒繪制的實現篇,第二篇是小魚兒游動控制篇下篇傳送門。本篇實現如下效果:

原地擺尾版

繪制實現篇用到如下主要的技術:

1)、自定義Drawable動畫
2)、Android的坐標及角度
3)、Canvas中layer的使用
4)、正余弦函數的使用以及角度角和弧度角的轉換

下圖是我實現小魚兒的分解圖紙:


部件分解圖

一、動畫拆解

拿到動畫需求或者模仿一個動畫首先需要分析動畫主體如何繪制部件如何活動,就此動畫外觀分析如下:
1)、小魚的身體各個部件都是簡單的半透明幾何圖形
2)、各個部件都可以活動
3)、從頭到尾方向的部件擺動幅度越來越大、頻率越來越高

二、技術分析

小魚擺動是周期運動,三角函數正好有此特性,角度問題也需要和坐標掛鉤,所以我們先來明確一下兩個最重要也是最基本的問題:坐標和角度。與平面直角坐標系不同的是Android的坐標系中Y軸正方向是朝下的,但是角度卻和平面直角坐標系的計算方法一樣,即原點指向X軸正方向為0°,正角度是逆時針旋轉,負角度是順時針旋轉那么問題就來了:坐標系不同,角度轉動方式卻一樣,為了讓java中的Math函數計算出來的角度跟Android的坐標習慣一致我們需要將與Y軸相關的角度都減去180°,這樣解決了既用Android的坐標又用自然角度的問題,即下圖所示的角度和坐標系關系
  

Android坐標系下的自然角度

  
  統一完角度問題,接下來我們就看看魚的各部件是怎么關聯在一起的。需要先了解三個重要參數

1)、魚的重心

因為最終我們要實現魚兒根據手指點擊的位置而移動的效果,必須確保能讓點擊點成為唯一確定魚兒位置的點,所以我們必須找到一個讓魚兒的各個部件都相對此點繪制的點。參考點可以任意選,但是考慮到轉彎的時候或者身體擺動的時候不會往某一邊偏,于是將參考點選在魚的中軸線上,本來選在中軸線和魚兒頭頂橡膠的點但是最后轉彎的時候就跟秋名山老司機漂移一樣,那叫一個飄逸,最后將參考點選在了魚的腹部重心處。

2)、魚頭半徑

比例示意圖

此案例中魚的各個部件都是以魚頭半徑R為單位衡量的,比如魚的身子第一節長度是3.2R,依次確定好身體的各個部件相對于魚頭半徑的尺寸就能確定整條魚的總長度為6.79R,繼而確定控件的總尺寸。如下圖,經過計算控件最小尺寸為8.36R,這樣就保證魚兒轉動任意角度都在控件之內

打轉圖

3)、魚身角度

此處的魚身角度是指重心到魚頭圓心的連線和X軸正方向的夾角角度,即魚兒前進方向的角度。此方向是確定各個部件方向及位置的的基礎方向,部件的定位、魚身角度以及尾部的擺動角度都是在此角度基礎上通過加減角度來控制左右搖擺。
 下邊我將演示一下如何通過這三個因素來確定頭部以及魚鰭的點坐標(其他部位原理相同)
 先假設魚身角度為0°,即頭朝向X軸正方向。通過重心點以及第一節身長的一半的長度,以及角度即可計算出頭部的圓心坐標,然后再以頭部圓心坐標和0.9R的長度,順時針旋轉80°確定右邊魚鰭的坐標點
 

魚鰭定位過程

魚鰭繪制原理相似,通過上文的右鰭坐標可以計算出右鰭的另一端坐標,魚鰭弧度是通過二階貝塞爾曲線繪制的

魚尾張合分析。魚尾是內外兩個三角形疊加而成的,三角形頂點和三角形底邊中點連線的角度和最后一節身體的角度一直,三角形底邊左右兩點通過底邊的中點以及動態計算出來的長度確定的
    
  最后用放出骨架系統:黑線為各個部件的主軸,圓圈為各個部件邊界的定位點或貝塞爾曲線的控制點,是不是很酷,像不像電影里的動作捕捉
  

骨架系統

三、代碼實現

文章只貼出主要代碼,完整代碼文末提供鏈接

0)自定義Drawable

自定義View可能大家都知道,但是自定義Drawable卻并不是很常見。我們知道Drawable在Android里常常和ImageView配合使用,或者作為某個View的background,它不能通過標簽的方式在xml里定義,所以嚴格意義上來說它不是一個可以獨立展示的控件,需要依附在其他控件中。在attrs.xml里自定義屬性也和它無緣,measure測量也可以省略,這么一看Drawabe好像就只是專著繪制,沒錯,這就是它比View和ViewGroup繪圖的優勢 —— 輕量。
既然說到不用Measure,那么它的大小怎么確定呢?
  當ImageView使用我們自定義Drawable的時候,如果設置的是wrap_content,那么content的內容寬高從哪里來?Drawable提供了兩個函數 getIntrinsicHeight()getIntrinsicWidth(),從名字上看是獲得固有寬高,所以我們就可以在這里控制我們的Drawable本來的寬高。如果ImageView的寬高是具體值的話,具體值超過Drawable的固有寬高,那么Drawable就會被拉伸(具體拉伸方案是依據ImageView的scaleType類型),如果不想讓自己的內容因拉伸而導致不清晰的話可以在draw()函數里通過canvas.getHeight()和canvas.getWidth()來獲取ImageView的大小。也可以通過getBounds方法獲取到一個Rect邊界來獲取尺寸。
  
本例中的固有寬高就是可以容納小魚360°旋轉的尺寸8.38R

    @Override
    public int getIntrinsicHeight() {
        return (int) (8.38f * HEAD_RADIUS);
    }

    @Override
    public int getIntrinsicWidth() {
        return (int) (8.38f * HEAD_RADIUS);
    }

其次自定義Drawable只需復寫必要的四個函數,比較簡單具體作用見注釋

@Override
    public void draw(Canvas canvas) {
        //和自定義View中的onDraw()異曲同工
    }

    @Override
    public void setAlpha(int alpha) {
        //設置Drawable的透明度,一般情況下將此alpha值設置給Paint
    }

    @Override
    public void setColorFilter(ColorFilter colorFilter) {
        //設置顏色濾鏡,一般情況下將此值設置給Paint
    }

    @Override
    public int getOpacity() {
        //決定繪制的部分是否遮住Drawable下邊的東西,有點抽象,有幾種模式
        //PixelFormat.UNKNOWN
        //PixelFormat.TRANSLUCENT 只有繪制的地方才蓋住下邊
        //PixelFormat.TRANSPARENT 透明,不顯示繪制內容
        //PixelFormat.OPAQUE 完全蓋住下邊內容
        return PixelFormat.TRANSLUCENT;
    }

主要是復寫draw()方法,利用canvas繪制各種想要的東西。

1)坐標部分

最最最主要的坐標計算代碼,小魚兒所有部件都是通過此方法計算出坐標的 ,功能是計算一個點的坐標,可以理解為一個長度為length的線繞起點startPoint旋轉angle角度后線段另一端的坐標

  
    /**
     *  輸入起點、長度、旋轉角度計算終點
     * @param startPoint 起點
     * @param length 長度
     * @param angle 旋轉角度
     * @return 計算結果點
     */
    private static PointF calculatPoint(PointF startPoint, float length, float angle) {
        float deltaX = (float) Math.cos(Math.toRadians(angle)) * length;
        //符合Android坐標的y軸朝下的標準
        float deltaY = (float) Math.sin(Math.toRadians(angle-180)) * length;
        return new PointF(startPoint.x + deltaX, startPoint.y + deltaY);
    }

這里要特別說明一下Math.sin()、Math.cos()、Math.toRadians()這三個函數,其中sin\cos的參數是弧度制角度。說到弧度制可能大家都忘得差不多了,帶大家回顧一下中學數學。角的度量可以用弧度制也可以用角度制表示。其中弧度和角度轉換的橋梁就是圓周率π

1角度=(π/180)弧度

比如說想計算30°的正弦值,用Java代碼需要先將角度制的30°轉為弧度值即通過Math.toRadians(30)得到30°對應的弧度,完整代碼如下:

double sin30 = Math.sin( Math.toRadians(30) );

打印結果是

0.49999999999999994

如果非要得到0.5的話就強轉成float型就行了,可能是由于double的精度問題。

2)、第一節身體

第一節身體包括頭部和身體的第一段,代碼如下(虛線部分是身體其他部分的生成方法,暫時不管)

頭身
private void makeBody(Canvas canvas, float headRadius) {

    float angle = mainAngle + (float) Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence)) * 2;
    headPoint = calculatPoint(middlePoint, BODY_LENGHT / 2,mainAngle);
    //畫頭
    canvas.drawCircle(headPoint.x, headPoint.y, HEAD_RADIUS, mPaint);
        ........
        .......
    PointF point1, point2, point3, point4, contralLeft, contralRight;
    //point1和4的初始角度決定發髻線的高低值越大越低
    point1 = calculatPoint(headPoint, headRadius,  angle-80);
    point2 = calculatPoint(endPoint, headRadius * 0.7f, angle-90);
    point3 = calculatPoint(endPoint, headRadius * 0.7f, angle +90);
    point4 = calculatPoint(headPoint, headRadius, angle +80);
    //決定胖瘦
    contralLeft = calculatPoint(headPoint, BODY_LENGHT * 0.56f, angle -130);
    contralRight = calculatPoint(headPoint, BODY_LENGHT * 0.56f, angle +130);
    mPath.reset();
    mPath.moveTo(point1.x, point1.y);
    mPath.quadTo(contralLeft.x, contralLeft.y, point2.x, point2.y);
    mPath.lineTo(point3.x, point3.y);
    mPath.quadTo(contralRight.x, contralRight.y, point4.x, point4.y);
    mPath.lineTo(point1.x, point1.y);

    mPaint.setColor(Color.argb(BODY_ALPHA, 244, 92, 71));
    //畫身子
    canvas.drawPath(mPath, mPaint);
}

其中最難理解的是角度的計算這句話:

    float angle = mainAngle + (float) Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence)) * 2;//中心軸線和X軸順時針方向夾角

這里Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence))是控制第一節身體擺動的核心方法,變量currentValue是ValueAnimator動畫的過程數值,1.2是用來控制身體擺動的固有頻率,waveFrequence是全局頻率,用于控制魚兒運動時的擺動頻率,因為sin函數是周期函數,且值域為[-1,1],計算結果乘2之后這句話就可以生成一個[-2,2]的變化范圍,用這個值加上mainAngle(身體前進方向和X軸正方向夾角)就可以讓魚的第一節身體在身體主軸左右搖擺2°了。上邊的代碼生成了頭的圓心坐標,第一節身體的四個頂角以及身體兩側的貝塞爾曲線控制點,通過這幾個點,就可以畫出魚的頭和第一節身體了,并且可以根據動畫控制器的數值左右擺動身體

第二節第三節身體思想和第一節身體一致,不過腰線沒有用貝塞爾曲線,而是直接用直線代替,所以二三節身體是梯形,需要注意的是在計算第二三節身體角度的時候擺動核心方法要正余弦相互交替,否則就順拐了

3)、魚鰭

魚鰭的畫法也不難,麻煩的地方在于要判斷魚鰭是左邊的還是右邊的,因為魚鰭的弧線是貝塞爾曲線生成的,而曲線的控制點要分左右。其中fatherAngle是魚身主軸方向和X軸的的夾角,finsAngle是魚鰭向內擺動時的偏移角度

    private void makeFins(Canvas canvas, PointF startPoint, int type, float fatherAngle) {
        //魚鰭控制點相對于魚主軸方向的角度
        float contralAngle = 115;
        mPath.reset();
        mPath.moveTo(startPoint.x, startPoint.y);
        //魚鰭的另一端
        PointF endPoint = calculatPoint(startPoint, FINS_LENGTH, type == FINS_RIGHT ? fatherAngle - finsAngle-180 : fatherAngle + finsAngle+180);
        //曲線的控制點
        PointF contralPoint = calculatPoint(startPoint, FINS_LENGTH * 1.8f, type == FINS_RIGHT ?
                fatherAngle - contralAngle - finsAngle : fatherAngle + contralAngle + finsAngle);
        mPath.quadTo(contralPoint.x, contralPoint.y, endPoint.x, endPoint.y);
        mPath.lineTo(startPoint.x, startPoint.y);
        mPaint.setColor(Color.argb(FINS_ALPHA, 244, 92, 71));
        canvas.drawPath(mPath, mPaint);
        mPaint.setColor(Color.argb(OTHER_ALPHA, 244, 92, 71));

    }

魚鰭定位過程

4)、魚尾

魚尾是大小兩個等腰三角形疊加而成的,三角形的頂點重合。繪制原理是根據三角形底邊中點來確定底邊的兩個點,其中角度和魚尾主方向垂直。其中newWith變量的是根據當前動畫的過程值動態生成的

private void makeTail(Canvas canvas, PointF mainPoint, float length, float maxWidth, float angle) {
        float newWidth = (float) Math.abs(Math.sin(Math.toRadians(currentValue * 1.7 * waveFrequence)) * maxWidth + HEAD_RADIUS/5*3);
        //endPoint為三角形底邊中點
        PointF endPoint = calculatPoint(mainPoint, length, angle-180);
        PointF endPoint2 = calculatPoint(mainPoint, length - 10, angle-180);
        PointF point1, point2, point3, point4;
        point1 = calculatPoint(endPoint, newWidth, angle-90);
        point2 = calculatPoint(endPoint, newWidth, angle +90);
        point3 = calculatPoint(endPoint2, newWidth - 20, angle-90);
        point4 = calculatPoint(endPoint2, newWidth - 20, angle +90);
        //內
        mPath.reset();
        mPath.moveTo(mainPoint.x, mainPoint.y);
        mPath.lineTo(point3.x, point3.y);
        mPath.lineTo(point4.x, point4.y);
        mPath.lineTo(mainPoint.x, mainPoint.y);
        canvas.drawPath(mPath, mPaint);
        //外
        mPath.reset();
        mPath.moveTo(mainPoint.x, mainPoint.y);
        mPath.lineTo(point1.x, point1.y);
        mPath.lineTo(point2.x, point2.y);
        mPath.lineTo(mainPoint.x, mainPoint.y);
        canvas.drawPath(mPath, mPaint);

    }

5)、動畫引擎

接下來就是激動人心的引擎“發動”時間了,看過上篇文章Android仿百度貼吧客戶端Loading小球的朋友就知道引擎部分是一個ValueAnimator,此篇也是。 動畫周期180秒,數值變化從0到54000,無限循環往復運行,將過程值賦值給currentValue然后刷新Drawable

//引擎部分
ValueAnimator valueAnimator = ValueAnimator.ofInt(0, 54000);
valueAnimator.setDuration(180 * 1000);
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.setRepeatMode(ValueAnimator.REVERSE);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        currentValue = (int) (animation.getAnimatedValue());
        invalidateSelf();
    }
});

運行結果:

感謝女朋友的默默支持

四、結語

動畫的分析和實現是一個枯燥又費腦筋的過程,時不時還要復習一下還給老師的數學知識,不過當引擎發動的時候看到繪制的東西動起來了你會覺得所有的努力都是值得的。下一篇將分析如何讓魚兒游動起來,希望大家繼續關注。
繪制部分源碼:靈動的紅鯉魚Github源碼
CSDN同步分析文章鏈接: 自定義Drawable實現靈動的紅鯉魚動畫(上篇)
下篇鏈接: 自定義Drawable實現靈動的紅鯉魚動畫(下篇)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,791評論 6 545
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,795評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,943評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,057評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,773評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,106評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,082評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,282評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,793評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,507評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,741評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,220評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,929評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,325評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,661評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,482評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,702評論 2 380

推薦閱讀更多精彩內容