自定義Drawable實(shí)現(xiàn)靈動(dòng)的紅鯉魚動(dòng)畫(上篇)
此篇中的小魚動(dòng)畫是模仿國(guó)外一個(gè)大牛做的flash動(dòng)畫,第一眼就愛(ài)上它了,簡(jiǎn)約靈動(dòng)又不失美學(xué),于是抽空試著嘗試了一下,如下是我用Android實(shí)現(xiàn)的效果圖:
小魚兒
由于整個(gè)繪制分析過(guò)程比較繁瑣所以靈動(dòng)的紅鯉魚準(zhǔn)備做成上下兩篇,本篇是小魚兒繪制的實(shí)現(xiàn)篇,第二篇是小魚兒游動(dòng)控制篇下篇傳送門。本篇實(shí)現(xiàn)如下效果:
原地?cái)[尾版
繪制實(shí)現(xiàn)篇用到如下主要的技術(shù):
1)、自定義Drawable動(dòng)畫
2)、Android的坐標(biāo)及角度
3)、Canvas中l(wèi)ayer的使用
4)、正余弦函數(shù)的使用以及角度角和弧度角的轉(zhuǎn)換
下圖是我實(shí)現(xiàn)小魚兒的分解圖紙:
部件分解圖
一、動(dòng)畫拆解
拿到動(dòng)畫需求或者模仿一個(gè)動(dòng)畫首先需要分析動(dòng)畫主體如何繪制部件如何活動(dòng),就此動(dòng)畫外觀分析如下:
1)、小魚的身體各個(gè)部件都是簡(jiǎn)單的半透明幾何圖形
2)、各個(gè)部件都可以活動(dòng)
3)、從頭到尾方向的部件擺動(dòng)幅度越來(lái)越大、頻率越來(lái)越高
二、技術(shù)分析
小魚擺動(dòng)是周期運(yùn)動(dòng),三角函數(shù)正好有此特性,角度問(wèn)題也需要和坐標(biāo)掛鉤,所以我們先來(lái)明確一下兩個(gè)最重要也是最基本的問(wèn)題:坐標(biāo)和角度。與平面直角坐標(biāo)系不同的是Android的坐標(biāo)系中Y軸正方向是朝下的,但是角度卻和平面直角坐標(biāo)系的計(jì)算方法一樣,即原點(diǎn)指向X軸正方向?yàn)?°,正角度是逆時(shí)針旋轉(zhuǎn),負(fù)角度是順時(shí)針旋轉(zhuǎn)那么問(wèn)題就來(lái)了:坐標(biāo)系不同,角度轉(zhuǎn)動(dòng)方式卻一樣,為了讓java中的Math函數(shù)計(jì)算出來(lái)的角度跟Android的坐標(biāo)習(xí)慣一致我們需要將與Y軸相關(guān)的角度都減去180°,這樣解決了既用Android的坐標(biāo)又用自然角度的問(wèn)題,即下圖所示的角度和坐標(biāo)系關(guān)系
Android坐標(biāo)系下的自然角度
統(tǒng)一完角度問(wèn)題,接下來(lái)我們就看看魚的各部件是怎么關(guān)聯(lián)在一起的。需要先了解三個(gè)重要參數(shù)
1)、魚的重心
因?yàn)樽罱K我們要實(shí)現(xiàn)魚兒根據(jù)手指點(diǎn)擊的位置而移動(dòng)的效果,必須確保能讓點(diǎn)擊點(diǎn)成為唯一確定魚兒位置的點(diǎn),所以我們必須找到一個(gè)讓魚兒的各個(gè)部件都相對(duì)此點(diǎn)繪制的點(diǎn)。參考點(diǎn)可以任意選,但是考慮到轉(zhuǎn)彎的時(shí)候或者身體擺動(dòng)的時(shí)候不會(huì)往某一邊偏,于是將參考點(diǎn)選在魚的中軸線上,本來(lái)選在中軸線和魚兒頭頂橡膠的點(diǎn)但是最后轉(zhuǎn)彎的時(shí)候就跟秋名山老司機(jī)漂移一樣,那叫一個(gè)飄逸,最后將參考點(diǎn)選在了魚的腹部重心處。
2)、魚頭半徑
比例示意圖
此案例中魚的各個(gè)部件都是以魚頭半徑R為單位衡量的,比如魚的身子第一節(jié)長(zhǎng)度是3.2R,依次確定好身體的各個(gè)部件相對(duì)于魚頭半徑的尺寸就能確定整條魚的總長(zhǎng)度為6.79R,繼而確定控件的總尺寸。如下圖,經(jīng)過(guò)計(jì)算控件最小尺寸為8.36R,這樣就保證魚兒轉(zhuǎn)動(dòng)任意角度都在控件之內(nèi)
打轉(zhuǎn)圖
3)、魚身角度
此處的魚身角度是指重心到魚頭圓心的連線和X軸正方向的夾角角度,即魚兒前進(jìn)方向的角度。此方向是確定各個(gè)部件方向及位置的的基礎(chǔ)方向,部件的定位、魚身角度以及尾部的擺動(dòng)角度都是在此角度基礎(chǔ)上通過(guò)加減角度來(lái)控制左右搖擺。
下邊我將演示一下如何通過(guò)這三個(gè)因素來(lái)確定頭部以及魚鰭的點(diǎn)坐標(biāo)(其他部位原理相同)
先假設(shè)魚身角度為0°,即頭朝向X軸正方向。通過(guò)重心點(diǎn)以及第一節(jié)身長(zhǎng)的一半的長(zhǎng)度,以及角度即可計(jì)算出頭部的圓心坐標(biāo),然后再以頭部圓心坐標(biāo)和0.9R的長(zhǎng)度,順時(shí)針旋轉(zhuǎn)80°確定右邊魚鰭的坐標(biāo)點(diǎn)
魚鰭定位過(guò)程
魚鰭繪制原理相似,通過(guò)上文的右鰭坐標(biāo)可以計(jì)算出右鰭的另一端坐標(biāo),魚鰭弧度是通過(guò)二階貝塞爾曲線繪制的
魚尾張合分析。魚尾是內(nèi)外兩個(gè)三角形疊加而成的,三角形頂點(diǎn)和三角形底邊中點(diǎn)連線的角度和最后一節(jié)身體的角度一直,三角形底邊左右兩點(diǎn)通過(guò)底邊的中點(diǎn)以及動(dòng)態(tài)計(jì)算出來(lái)的長(zhǎng)度確定的
最后用放出骨架系統(tǒng):黑線為各個(gè)部件的主軸,圓圈為各個(gè)部件邊界的定位點(diǎn)或貝塞爾曲線的控制點(diǎn),是不是很酷,像不像電影里的動(dòng)作捕捉
骨架系統(tǒng)(點(diǎn)擊查看動(dòng)圖)
三、代碼實(shí)現(xiàn)
文章只貼出主要代碼,完整代碼文末提供鏈接
0)自定義Drawable
自定義View可能大家都知道,但是自定義Drawable卻并不是很常見。我們知道Drawable在Android里常常和ImageView配合使用,或者作為某個(gè)View的background,它不能通過(guò)標(biāo)簽的方式在xml里定義,所以嚴(yán)格意義上來(lái)說(shuō)它不是一個(gè)可以獨(dú)立展示的控件,需要依附在其他控件中。在attrs.xml里自定義屬性也和它無(wú)緣,measure測(cè)量也可以省略,這么一看Drawabe好像就只是專著繪制,沒(méi)錯(cuò),這就是它比View和ViewGroup繪圖的優(yōu)勢(shì) —— 輕量。
既然說(shuō)到不用Measure,那么它的大小怎么確定呢?
當(dāng)ImageView使用我們自定義Drawable的時(shí)候,如果設(shè)置的是wrap_content,那么content的內(nèi)容寬高從哪里來(lái)?Drawable提供了兩個(gè)函數(shù) getIntrinsicHeight()、getIntrinsicWidth(),從名字上看是獲得固有寬高,所以我們就可以在這里控制我們的Drawable本來(lái)的寬高。如果ImageView的寬高是具體值的話,具體值超過(guò)Drawable的固有寬高,那么Drawable就會(huì)被拉伸(具體拉伸方案是依據(jù)ImageView的scaleType類型),如果不想讓自己的內(nèi)容因拉伸而導(dǎo)致不清晰的話可以在draw()函數(shù)里通過(guò)canvas.getHeight()和canvas.getWidth()來(lái)獲取ImageView的大小。也可以通過(guò)getBounds方法獲取到一個(gè)Rect邊界來(lái)獲取尺寸。
本例中的固有寬高就是可以容納小魚360°旋轉(zhuǎn)的尺寸8.38R
- @Override
- public int getIntrinsicHeight() {
- return (int) (8.38f * HEAD_RADIUS);
- }
- @Override
- public int getIntrinsicWidth() {
- return (int) (8.38f * HEAD_RADIUS);
- }
其次自定義Drawable只需復(fù)寫必要的四個(gè)函數(shù),比較簡(jiǎn)單具體作用見注釋
- @Override
- public void draw(Canvas canvas) {
- //和自定義View中的onDraw()異曲同工
- }
- @Override
- public void setAlpha(int alpha) {
- //設(shè)置Drawable的透明度,一般情況下將此alpha值設(shè)置給Paint
- }
- @Override
- public void setColorFilter(ColorFilter colorFilter) {
- //設(shè)置顏色濾鏡,一般情況下將此值設(shè)置給Paint
- }
- @Override
- public int getOpacity() {
- //決定繪制的部分是否遮住Drawable下邊的東西,有點(diǎn)抽象,有幾種模式
- //PixelFormat.UNKNOWN
- //PixelFormat.TRANSLUCENT 只有繪制的地方才蓋住下邊
- //PixelFormat.TRANSPARENT 透明,不顯示繪制內(nèi)容
- //PixelFormat.OPAQUE 完全蓋住下邊內(nèi)容
- return PixelFormat.TRANSLUCENT;
- }
主要是復(fù)寫draw()方法,利用canvas繪制各種想要的東西。
1)坐標(biāo)部分
最最最主要的坐標(biāo)計(jì)算代碼,小魚兒所有部件都是通過(guò)此方法計(jì)算出坐標(biāo)的 ,功能是計(jì)算一個(gè)點(diǎn)的坐標(biāo),可以理解為一個(gè)長(zhǎng)度為length的線繞起點(diǎn)startPoint旋轉(zhuǎn)angle角度后線段另一端的坐標(biāo)
- /**
- * 輸入起點(diǎn)、長(zhǎng)度、旋轉(zhuǎn)角度計(jì)算終點(diǎn)
- * @param startPoint 起點(diǎn)
- * @param length 長(zhǎng)度
- * @param angle 旋轉(zhuǎn)角度
- * @return 計(jì)算結(jié)果點(diǎn)
- */
- private static PointF calculatPoint(PointF startPoint, float length, float angle) {
- float deltaX = (float) Math.cos(Math.toRadians(angle)) * length;
- //符合Android坐標(biāo)的y軸朝下的標(biāo)準(zhǔn)
- float deltaY = (float) Math.sin(Math.toRadians(angle-180)) * length;
- return new PointF(startPoint.x + deltaX, startPoint.y + deltaY);
- }
這里要特別說(shuō)明一下Math.sin()、Math.cos()、Math.toRadians()這三個(gè)函數(shù),其中sin\cos的參數(shù)是弧度制角度。說(shuō)到弧度制可能大家都忘得差不多了,帶大家回顧一下中學(xué)數(shù)學(xué)。角的度量可以用弧度制也可以用角度制表示。其中弧度和角度轉(zhuǎn)換的橋梁就是圓周率π
- 1角度=(π/180)弧度
比如說(shuō)想計(jì)算30°的正弦值,用Java代碼需要先將角度制的30°轉(zhuǎn)為弧度值即通過(guò)Math.toRadians(30)得到30°對(duì)應(yīng)的弧度,完整代碼如下:
- double sin30 = Math.sin( Math.toRadians(30) );
打印結(jié)果是
- 0.49999999999999994
如果非要得到0.5的話就強(qiáng)轉(zhuǎn)成float型就行了,可能是由于double的精度問(wèn)題。
2)、第一節(jié)身體
第一節(jié)身體包括頭部和身體的第一段,代碼如下(虛線部分是身體其他部分的生成方法,暫時(shí)不管)
頭身
- 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的初始角度決定發(fā)髻線的高低值越大越低
- 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);
- }
其中最難理解的是角度的計(jì)算這句話:
- float angle = mainAngle + (float) Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence)) * 2;//中心軸線和X軸順時(shí)針?lè)较驃A角
這里Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence))是控制第一節(jié)身體擺動(dòng)的核心方法,變量currentValue是ValueAnimator動(dòng)畫的過(guò)程數(shù)值,1.2是用來(lái)控制身體擺動(dòng)的固有頻率,waveFrequence是全局頻率,用于控制魚兒運(yùn)動(dòng)時(shí)的擺動(dòng)頻率,因?yàn)閟in函數(shù)是周期函數(shù),且值域?yàn)閇-1,1],計(jì)算結(jié)果乘2之后這句話就可以生成一個(gè)[-2,2]的變化范圍,用這個(gè)值加上mainAngle(身體前進(jìn)方向和X軸正方向夾角)就可以讓魚的第一節(jié)身體在身體主軸左右搖擺2°了。上邊的代碼生成了頭的圓心坐標(biāo),第一節(jié)身體的四個(gè)頂角以及身體兩側(cè)的貝塞爾曲線控制點(diǎn),通過(guò)這幾個(gè)點(diǎn),就可以畫出魚的頭和第一節(jié)身體了,并且可以根據(jù)動(dòng)畫控制器的數(shù)值左右擺動(dòng)身體
第二節(jié)第三節(jié)身體思想和第一節(jié)身體一致,不過(guò)腰線沒(méi)有用貝塞爾曲線,而是直接用直線代替,所以二三節(jié)身體是梯形,需要注意的是在計(jì)算第二三節(jié)身體角度的時(shí)候擺動(dòng)核心方法要正余弦相互交替,否則就順拐了
3)、魚鰭
魚鰭的畫法也不難,麻煩的地方在于要判斷魚鰭是左邊的還是右邊的,因?yàn)轸~鰭的弧線是貝塞爾曲線生成的,而曲線的控制點(diǎn)要分左右。其中fatherAngle是魚身主軸方向和X軸的的夾角,finsAngle是魚鰭向內(nèi)擺動(dòng)時(shí)的偏移角度
- private void makeFins(Canvas canvas, PointF startPoint, int type, float fatherAngle) {
- //魚鰭控制點(diǎn)相對(duì)于魚主軸方向的角度
- 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);
- //曲線的控制點(diǎn)
- 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));
- }
魚鰭定位過(guò)程
4)、魚尾
魚尾是大小兩個(gè)等腰三角形疊加而成的,三角形的頂點(diǎn)重合。繪制原理是根據(jù)三角形底邊中點(diǎn)來(lái)確定底邊的兩個(gè)點(diǎn),其中角度和魚尾主方向垂直。其中newWith變量的是根據(jù)當(dāng)前動(dòng)畫的過(guò)程值動(dòng)態(tài)生成的
- 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為三角形底邊中點(diǎn)
- 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);
- //內(nèi)
- 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)、動(dòng)畫引擎
接下來(lái)就是激動(dòng)人心的引擎“發(fā)動(dòng)”時(shí)間了,看過(guò)上篇文章Android仿百度貼吧客戶端Loading小球的朋友就知道引擎部分是一個(gè)ValueAnimator,此篇也是。 動(dòng)畫周期180秒,數(shù)值變化從0到54000,無(wú)限循環(huán)往復(fù)運(yùn)行,將過(guò)程值賦值給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();
- }
- });
運(yùn)行結(jié)果:
感謝女朋友的默默支持
四、結(jié)語(yǔ)
動(dòng)畫的分析和實(shí)現(xiàn)是一個(gè)枯燥又費(fèi)腦筋的過(guò)程,時(shí)不時(shí)還要復(fù)習(xí)一下還給老師的數(shù)學(xué)知識(shí),不過(guò)當(dāng)引擎發(fā)動(dòng)的時(shí)候看到繪制的東西動(dòng)起來(lái)了你會(huì)覺(jué)得所有的努力都是值得的。下一篇將分析如何讓魚兒游動(dòng)起來(lái),希望大家繼續(xù)關(guān)注。
下篇地址:自定義Drawable實(shí)現(xiàn)靈動(dòng)的紅鯉魚動(dòng)畫(下篇)
繪制部分源碼:靈動(dòng)的紅鯉魚Github源碼
【本文為51CTO專欄作者“季晨生”的原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)通過(guò)51CTO聯(lián)系作者獲取授權(quán)】