轉(zhuǎn)載文章作者:一息尚存? ?
原文鏈接:http://www.lxweimin.com/p/349aa6153fcc
效果圖:
在以前的一個項目中,需要實現(xiàn)類似QQ討論組頭像的控件,只是頭像數(shù)量和布局有一小點不一樣:一是最頭像數(shù)是4個,二是頭像數(shù)是2個時的布局是橫著排的。其實當時GitHub上就有類似的開源控件,只是那個控件在每一次繪制View的時候都會新創(chuàng)建一些Bitmap對象,這肯定是不可取的,而且那個控件頭像輸入的是Bitmap對象,不滿足需求。所以只能自己實現(xiàn)一個了。實現(xiàn)的時候也沒有過多的考慮,傳入頭像Drawable對象,根據(jù)數(shù)量排列顯示就算完成了,而且傳入的圖像還必需是圓形的,限制很大,根本不具備通用性。因此要實現(xiàn)和QQ討論組頭像一樣的又具備一定通用性的控件,還得重新設(shè)計、實現(xiàn)。下面就讓我們開始實現(xiàn)吧。
布局
首先需要解決的是頭像的布局,在頭像數(shù)量分別為1至5的情況下,定義頭像的布局排列方式,并計算出圖像的大小和位置。先把布局圖畫出來再說:
布局
其中黑色正方形就是View的顯示區(qū),藍色圓形就是頭像了。已知的條件是View大小,姑且設(shè)為 D 吧,還有頭像的數(shù)量 n ,求藍色圓的半徑 r 及圓心位置。這不就是一道幾何題嗎?翻開初中的數(shù)學課本——勾三股四弦五……好像不夠用啊……
輔助線畫了又畫,頭皮撓了又撓,α,θ,OMG......sin,cos,sh*t......終于算出了r與D和n的關(guān)系:
公式1
其實 n=3 的時候半徑和 n=4 的時候是一樣的,但是考慮到 n=3,5 時在Y軸上還有一個偏移量 dy ,而且 r 和 dy 在 n=3,5 時是有通式的,所以就合在一起了。求偏移量 dy 的公式:
公式2
式中 R 就是布局圖中紅色大圓的半徑。
有了公式,那么代碼就好寫了,計算每個頭像的大小和位置的代碼如下:
// 頭像信息類,記錄大小、位置等信息privatestaticclassDrawableInfo{intmId = View.NO_ID;? ? Drawable mDrawable;// 中心點位置floatmCenterX;floatmCenterY;// 頭像上缺口弧所在圓上的圓心位置,其實就是下一個相鄰頭像的中心點floatmGapCenterX;floatmGapCenterY;booleanmHasGap;// 頭像邊界finalRectF mBounds =newRectF();// 圓形蒙板路徑,把頭像弄成圓形finalPath mMaskPath =newPath();}
privatevoidlayoutDrawables(){? ? mSteinerCircleRadius =0;? ? mOffsetY =0;intwidth = getWidth() - getPaddingLeft() - getPaddingRight();intheight = getHeight() - getPaddingTop() - getPaddingBottom();? ? mContentSize = Math.min(width, height);finalList drawables = mDrawables;finalintN = drawables.size();floatcenter = mContentSize * .5f;if(mContentSize >0&& N >0) {// 圖像圓的半徑。finalfloatr;if(N ==1) {? ? ? ? ? ? r = mContentSize * .5f;? ? ? ? }elseif(N ==2) {? ? ? ? ? ? r = (float) (mContentSize / (2+2* Math.sin(Math.PI /4)));? ? ? ? }elseif(N ==4) {? ? ? ? ? ? r = mContentSize /4.f;? ? ? ? }else{? ? ? ? ? ? r = (float) (mContentSize / (2* (2* Math.sin(((N -2) * Math.PI) / (2* N)) +1)));finaldoublesinN = Math.sin(Math.PI / N);// 以所有圖像圓為內(nèi)切圓的圓的半徑finalfloatR = (float) (r * ((sinN +1) / sinN));? ? ? ? ? ? mOffsetY = (float) ((mContentSize - R - r * (1+1/ Math.tan(Math.PI / N))) /2f);? ? ? ? }// 初始化第一個頭像的中心位置finalfloatstartX, startY;if(N %2==0) {? ? ? ? ? ? startX = startY = r;? ? ? ? }else{? ? ? ? ? ? startX = center;? ? ? ? ? ? startY = r;? ? ? ? }// 變換矩陣finalMatrix matrix = mLayoutMatrix;// 坐標點臨時數(shù)組finalfloat[] pointsTemp =this.mPointsTemp;? ? ? ? matrix.reset();for(inti =0; i < drawables.size(); i++) {? ? ? ? ? ? DrawableInfo drawable = drawables.get(i);? ? ? ? ? ? drawable.reset();? ? ? ? ? ? drawable.mHasGap = i >0;// 缺口弧的中心if(drawable.mHasGap) {? ? ? ? ? ? ? ? drawable.mGapCenterX = pointsTemp[0];? ? ? ? ? ? ? ? drawable.mGapCenterY = pointsTemp[1];? ? ? ? ? ? }? ? ? ? ? ? pointsTemp[0] = startX;? ? ? ? ? ? pointsTemp[1] = startY;if(i >0) {// 以上一個圓的圓心旋轉(zhuǎn)計算得出當前圓的圓位置matrix.postRotate(360.f / N, center, center + mOffsetY);? ? ? ? ? ? ? ? matrix.mapPoints(pointsTemp);? ? ? ? ? ? }// 取出中心點位置drawable.mCenterX = pointsTemp[0];? ? ? ? ? ? drawable.mCenterY = pointsTemp[1];// 設(shè)置邊界drawable.mBounds.inset(-r, -r);? ? ? ? ? ? drawable.mBounds.offset(drawable.mCenterX, drawable.mCenterY);// 設(shè)置“蒙板”路徑drawable.mMaskPath.addCircle(drawable.mCenterX, drawable.mCenterY, r, Path.Direction.CW);? ? ? ? ? ? drawable.mMaskPath.setFillType(Path.FillType.INVERSE_WINDING);? ? ? ? }// 設(shè)置第一個頭像的缺口,頭像數(shù)量少于3個的時候沒有if(N >2) {? ? ? ? ? ? DrawableInfo first = drawables.get(0);? ? ? ? ? ? DrawableInfo last = drawables.get(N -1);? ? ? ? ? ? first.mHasGap =true;? ? ? ? ? ? first.mGapCenterX = last.mCenterX;? ? ? ? ? ? first.mGapCenterY = last.mCenterY;? ? ? ? }? ? ? ? mSteinerCircleRadius = r;? ? }? ? invalidate();}
繪制
計算好每個頭像的大小和位置后,就可以把它們繪制出來了。但在此之前,還得先解決一個問題——如何使頭像圖像變圓?因為輸入Drawable對象并沒有任何限制。在上面的layoutDrawables方法中有這樣兩行代碼:
drawable.mMaskPath.addCircle(drawable.mCenterX, drawable.mCenterY, r, Path.Direction.CW);
drawable.mMaskPath.setFillType(Path.FillType.INVERSE_WINDING);
其中第一行是添加一個圓形路徑,這個路徑就是布局圖中藍色圓的路徑,而第二行是設(shè)置路徑的填充模式,默認的填充模式是填充路徑內(nèi)部,而INVERSE_WINDING模式是填充路徑外部,再配合Paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR))就可以繪制出圓形的圖像了。頭像上的缺口同理。(ps:關(guān)于Path.FillType和PorterDuff.Mode網(wǎng)上介紹挺多的,這里就不詳細介紹了)
下面來看一下onDraw方法:
@OverrideprotectedvoidonDraw(Canvas canvas){super.onDraw(canvas);? ? ...? ? canvas.translate(0, mOffsetY);finalPaint paint = mPaint;finalfloatgapRadius = mSteinerCircleRadius * (mGap +1f);for(inti =0; i < drawables.size(); i++) {? ? ? ? DrawableInfo drawable = drawables.get(i);? ? ? ? RectF bounds = drawable.mBounds;finalintsavedLayer = canvas.saveLayer(0,0, mContentSize, mContentSize,null, Canvas.ALL_SAVE_FLAG);// 設(shè)置Drawable的邊界drawable.mDrawable.setBounds((int) bounds.left, (int) bounds.top,? ? ? ? ? ? ? ? Math.round(bounds.right), Math.round(bounds.bottom));// 繪制Drawabledrawable.mDrawable.draw(canvas);// 繪制“蒙板”路徑,將Drawable繪制的圖像“剪”成圓形canvas.drawPath(drawable.mMaskPath, paint);// “剪”出弧形的缺口if(drawable.mHasGap && mGap >0f) {? ? ? ? ? ? canvas.drawCircle(drawable.mGapCenterX, drawable.mGapCenterY, gapRadius, paint);? ? ? ? }? ? ? ? canvas.restoreToCount(savedLayer);? ? }}
Drawable支持
既然輸入的是Drawable對象,那就不能像Bitmap那樣繪制出來就完事了的,除非你不打算支持Drawable的一些功能,如自更新、動畫、狀態(tài)等。
Drawable自更新和動畫Drawable
Drawable的自更新和動畫Drawable(如AnimationDrawable,AnimatedVectorDrawable等)都是依賴于Drawable.Callback接口。其定義如下:
publicinterfaceCallback{/**? ? * 當drawable需要重新繪制時調(diào)用。此時的view應(yīng)該使其自身失效(至少drawable展示部分失效)? ? *@paramwho 要求重新繪制的drawable? ? */voidinvalidateDrawable(@NonNull Drawable who);/**? ? * drawable可以通過調(diào)用該方法來安排動畫的下一幀。? ? *@paramwho 要預定的drawable? ? *@paramwhat 要執(zhí)行的動作? ? *@paramwhen 執(zhí)行的時間(以毫秒為單位),基于android.os.SystemClock.uptimeMillis()? ? */voidscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what,longwhen);/**? ? * drawable可以通過調(diào)用該方法來取消先前通過scheduleDrawable(Drawable, Runnable, long)調(diào)度的動作。? ? *@paramwho 要取消預定的drawable? ? *@paramwhat 要取消執(zhí)行的動作? ? */voidunscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what);}
所以要支持Drawable自更新和動畫Drawable,得通過Drawable.setCallback(Drawable.Callback)方法設(shè)置Drawable.Callback接口的實現(xiàn)對象才行。好在android.view.View已經(jīng)實現(xiàn)了這個接口,在設(shè)置Drawable的時候調(diào)用一下Drawable.setCallback(MyView.this)即可。但需要注意的是,android.view.View實現(xiàn)Drawable.Callback接口的時候都調(diào)用了View.verifyDrawable(Drawable)以驗證需要顯示更新的Drawable是不是自己的Drawable,且其實現(xiàn)只是驗證了View自己的背景和前景:
protectedbooleanverifyDrawable(@NonNull Drawable who){// ...returnwho == mBackground || (mForegroundInfo !=null&& mForegroundInfo.mDrawable == who);}
所以只是設(shè)置了Callback的話,當Drawable內(nèi)容改變需要重新繪制時View還是不會更新重繪的,動畫需要計劃下一幀或者取消一個計劃時也不會成功。因此我們也得驗證自己的Drawable:
privatebooleanhasSameDrawable(Drawable drawable){for(DrawableInfo d : mDrawables) {if(d.mDrawable == drawable) {returntrue;? ? ? ? }? ? }returnfalse;}@OverrideprotectedbooleanverifyDrawable(@NonNull Drawable drawable){returnhasSameDrawable(drawable) ||super.verifyDrawable(drawable);}
此時,Drawable自更新的支持和動畫Drawable的支持基本上是完成了。當然,View不可見和onDetachedFromWindow()時應(yīng)該是要暫停或者停止動畫的,這些在這里就不多說了,可以去看源碼(在文章結(jié)尾處有鏈接),主要是調(diào)用Drawable.setVisible(boolean, boolean)方法。下面展示一下效果:
AnimationDrawable
狀態(tài)
一些Drawable是有狀態(tài)的,它能根據(jù)View的狀態(tài)(按下,選中,激活等)改變其顯示內(nèi)容,如StateListDrawable。要支持View狀態(tài)的話,其實只要擴展View.drawableStateChanged()和View.jumpDrawablesToCurrentState()方法,當View的狀態(tài)改變的時候更新Drawable的狀態(tài)就行了:
// 狀態(tài)改變時被調(diào)用@OverrideprotectedvoiddrawableStateChanged(){super.drawableStateChanged();booleaninvalidate =false;for(DrawableInfo drawable : mDrawables) {? ? ? ? Drawable d = drawable.mDrawable;// 判斷Drawable是否支持狀態(tài)并更新狀態(tài)if(d.isStateful() && d.setState(getDrawableState())) {? ? ? ? ? ? invalidate =true;? ? ? ? }? ? }if(invalidate) {? ? ? ? invalidate();? ? }}// 這個方法主要針對狀態(tài)改變時有過渡動畫的Drawable@OverridepublicvoidjumpDrawablesToCurrentState(){super.jumpDrawablesToCurrentState();for(DrawableInfo drawable : mDrawables) {? ? ? ? drawable.mDrawable.jumpToCurrentState();? ? }}
效果:
狀態(tài)
好了,到這里控件算是完成了。
其他效果展示:
效果1
效果2
源代碼:https://github.com/YiiGuxing/CompositionAvatar
我的GitHub:https://github.com/YiiGuxing
歡迎Star,謝謝!