Android之自定義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)如下效果:
?
繪制實(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)系?
?
?
?
統(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)
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ò)上文的右鰭坐標(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)作捕捉?
?
三、代碼實(shí)現(xiàn)
0)自定義Drawable
自定義View可能大家都知道,但是自定義Drawable卻并不是很常見(jiàn)。我們知道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)單具體作用見(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ù),其中sincos的參數(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); }
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í)的偏移角度
????????//魚鰭控制點(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)); ????}
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)注。?
繪制部分源碼:靈動(dòng)的紅鯉魚Github源碼