『Android自定義View實戰』實現一個小清新的彈出式圓環選單
前言
Android表現快捷選單的形式有很多種,比如使用PopupWindow彈出來的小彈窗,類似QQ的側拉功能選單,以及之前講過的弧形選單( Android 自定義弧形旋轉選單欄——衛星選單 ),這次要實現的是一個比較酷炫的選單效果,雖然適合使用的場景可能不如前幾種,但是整體動畫效果還是蠻不錯的,如下:

YRoundelMenu.gif
實現
思路
由於我們是作為一個選單的形式,所以可以採用繼承 ViewGroup 來作為一個容器,每個選單子項都是一個子View的形式,展開和收縮動畫可以採用屬性動畫的進度動態修改圓的半徑。 圖示的排列需要考慮到各種數量情況下(1,2,3,4,5,6) ,能夠平分圓周佈局,可以通過計算圓弧內圈和外圈中間的弧線長度,再除以子View的數量得到每個子View的座標即可。主要步驟和實現方式如下:
1.繪製內外圓圈,通過屬性動畫實現展開和收縮,以及顏色的漸變
2.通過 PathMeasure 計算圓周的長度,除以子View,計算每個子View在圓環中的座標
3.子View的出場動畫,通過呼叫 setStartDelay 實現間隔浮現效果
4.onTouchEvent中通過判斷點選的區域處理點選事件,實現點選時展開或收縮
5.中心按鈕旋轉,新增控制元件陰影

效果截圖
1.繪製內外圓圈,通過屬性動畫實現展開和收縮以及顏色的漸變
一共需要繪製兩個圓,一個負責展示中心圓圈部分,一個負責展示外圈的選單子項。
首先初始化兩個狀態下我們需要的畫筆引數,這裡 mCenterPaint
負責繪製中心部分, mRoundPaint
負責繪製展開後後面的大圓圈:
private Paint mCenterPaint; private Paint mRoundPaint; //收縮狀態時的顏色 / 展開時外圈的顏色 private int mRoundColor; //展開時中心圓圈的顏色 private int mCenterColor; public void init(){ mCenterPaint= new Paint(Paint.ANTI_ALIAS_FLAG); mCenterPaint.setColor(mRoundColor); mCenterPaint.setStyle(Paint.Style.FILL); mRoundPaint= new Paint(Paint.ANTI_ALIAS_FLAG); mRoundPaint.setColor(mRoundColor); mRoundPaint.setStyle(Paint.Style.FILL); setWillNotDraw(false); }
這裡有個地方要注意,由於是自定義ViewGroup,因此要呼叫 setWillNotDraw(false)
,否則我們呼叫 invalidate
的時候將不會觸發 onDraw
。(具體原因可看 ViewGroup
的 initViewGroup
方法和 mPrivateFlags
標誌位,ViewGroup在呼叫onDraw方法前做了判斷)
接著初始化屬性動畫器:
mExpandAnimator = ValueAnimator.ofFloat(0, 1); mExpandAnimator.setInterpolator(new OvershootInterpolator()); mExpandAnimator.setDuration(400); mExpandAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { expandProgress = (float)animation.getAnimatedValue(); mRoundPaint.setAlpha((int) (expandProgress * 255)); invalidate(); } }); mColorAnimator = ValueAnimator.ofObject(new ArgbEvaluator(), mRoundColor, mCenterColor); mColorAnimator.setDuration(400); mColorAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { mCenterPaint.setColor((Integer) animation.getAnimatedValue()); } });
1) mExpandAnimator
負責動態改變大圓圈的半徑和透明度,採用 OvershootInterpolator
,讓它有一種向外快速彈出一定值後再回到原來位置的彈性效果。用一個 expandProgress
記錄當前的進度值,後面onDraw繪製的時候會派上用場。
2) mColorAnimator
負責顏色的漸變,採用 ArgbEvaluator
顏色插值器,實現顏色值的過渡,在動畫監聽中設定給畫筆。
接著在 onDraw
中根據剛才的動畫值進行繪製:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //繪製放大的圓 if (expandProgress > 0) { canvas.drawCircle(center.x, center.y, collapsedRadius + (expandedRadius - collapsedRadius) * expandProgress, mRoundPaint); } //繪製中間圓 canvas.drawCircle(center.x, center.y, collapsedRadius, mCenterPaint); }
collapsedRadius
代表完全收縮狀態下的圓圈半徑, expandedRadius
代表完全展開狀態下的圓圈半徑。
通過 drawCircle
繪製兩個圓,可以理解為其實是兩個圓圈疊加在一塊,一旦展開或者收縮,其中一個會發生顏色的漸變(剛才的顏色動畫回撥裡不斷給 mCenterPaint
設定新的過渡顏色),另一個的半徑會在 collapsedRadius
和 expandedRadius
之間變化。
展開過程中,由一開始的collapsedRadius逐漸變化為expandedRadius
收縮過程中,由一開始的expandedRadius逐漸變化為collapsedRadius

繪製內外圓圈.gif
2.計算每個子View在圓環中的座標
我們想要實現的效果是子View均勻排列在外圍圓環中,那麼這些子View的圓心必定剛好處在內外環中間的圓環線上,如下圖虛線處:

計算虛線圓圈的半徑示意圖
紅色代表最外圍的圓的半徑,藍色代表中心圓圈的半徑,那麼虛線圓的半徑便可以通過如下公式計算得出:
float radius = (expandedRadius - collapsedRadius) / 2 + collapsedRadius;
從而可以得到這個虛圓的路徑:
RectF area = new RectF( center.x - radius, center.y - radius, center.x + radius, center.y + radius); Path path = new Path(); path.addArc(area, 0, 360);
再通過 PathMeasure
測量圓的長度,結合子View的數量,得到每個子View之間的間距:
PathMeasure measure = new PathMeasure(path, false); //測量圓的總長度 float len = measure.getLength(); //子選單數量 int count = getChildCount(); //每個選單之間的間距 float itemLength = len / count;
利用 PathMeasure
的 getPosTan
計算每個子View的座標:
for (int i = 0; i < getChildCount(); i++) { float[] itemPoints = new float[2]; measure.getPosTan(i * itemLength, itemPoints, null); View item = getChildAt(i); item.setX((int) itemPoints[0] - itemWidth / 2); item.setY((int) itemPoints[1] - itemWidth / 2); }
getPosTan
一共有三個引數,第一個表示距離起點的距離,此處可以根據下標與剛才計算出來的選單之間的間距相乘,從而使其均勻分佈,第二個引數即對應位置的點的座標,會賦給 itemPoints
這個陣列,第三個引數是用來獲取對應位置的正切值,這個可以用來實現一些路徑上的指向效果(例如紙飛機沿著某條Path移動,飛機頭方向保持與路徑平行),此處第三個引數不需要用到,可以為null。
然後由於要獲取的是選單項的左上角的座標,所以需要減去選單項的寬度的1/2,如下圖:

子View座標計算示意圖
3.選單子項的出場動畫
為了讓整個View的效果更加豐富,可以在我們展開選單的時候,讓選單子項接二連三地浮現出來:
//每40ms浮現一個 int delay = 40; for (int i = 0; i < getChildCount(); i++) { getChildAt(i).animate() .setStartDelay(delay) .setDuration(400) .alphaBy(0f) .scaleXBy(0f) .scaleYBy(0f) .scaleX(1f) .scaleY(1f) .alpha(1f) .start(); delay += mItemAnimIntervalTime; }
遍歷所有子View,然後間隔一定時間啟動動畫,改變子View的大小比例和透明度,使其從無到有。
4.根據點選區域做不同的響應
按照正常的邏輯, 如果當前是收縮狀態,則點選中心區域會展開。如果當前是展開狀態,則觸發收縮效果,除非此時點選的是子View區域,就不攔截事件,留給子View去消費。 我們可以通過計算觸控點與中心點的距離,與內外圓圈半徑做比較,來作為判斷的依據。
計算兩點之間的距離可以採用 Math.sqrt
來計算,其實就是勾股定理:
public static double getPointsDistance(Point a, Point b) { int dx = b.x - a.x; int dy = b.y - a.y; return Math.sqrt(dx * dx + dy * dy); }
然後在onTouchEvent中去判斷:
@Override public boolean onTouchEvent(MotionEvent event) { Point touchPoint = new Point(); touchPoint.set((int) event.getX(), (int) event.getY()); int action = event.getActionMasked(); switch (action) { case MotionEvent.ACTION_DOWN: { //計算觸控點與中心點的距離 double distance = getPointsDistance(touchPoint, center); if(state == STATE_EXPAND){ //展開狀態下,如果點選區域與中心點的距離不處於子選單區域,就收起選單 if (distance > (collapsedRadius + (expandedRadius - collapsedRadius) * expandProgress) || distance < collapsedRadius) { collapse(); return true; } //展開狀態下,如果點選區域處於子選單區域,則不消費事件 return false; }else{ //收縮狀態下,如果點選區域處於中心圓圈範圍內,則展開選單 if(distance < collapsedRadius){ expand(); return true; } //收縮狀態下,如果點選區域不在中心圓圈範圍內,則不消費事件 return false; } } } return super.onTouchEvent(event); }
5.中心按鈕旋轉,新增控制元件陰影
中心按鈕旋轉可以在 onDraw
中直接利用畫布的旋轉來實現:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //繪製放大的圓 忽略部分程式碼... //繪製中間圓 忽略部分程式碼... //繪製中心圖示 int count = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG); canvas.rotate(45*expandProgress, center.x, center.y); mCenterDrawable.draw(canvas); canvas.restoreToCount(count); }
由於畫布是ViewGroup的,因此直接旋轉畫布會對整個ViewGroup造成影響,我們想要的只是單單旋轉中間按鈕而已,因此通過 saveLayer
和 restoreToCount
來保證不影響其他部分的繪製,在它們的裡面執行 canvas.rota
,由於 expandProgress
是在[0,1]之間變化,所以我們讓它的角度在0°~45°之間傾斜。
Android5.0之後View提供了一個新的特性 elevation
,使用它可以讓View產生陰影效果:
if (Build.VERSION.SDK_INT >= 21) { setElevation(8); }
單純設定elevation還不夠,需要為它指定一個輪廓,即搭配 ViewOutlineProvider
來使用,先自定義一個ViewOutlineProvider,重寫它的getOutline,裡面定義輪廓的形狀和大小區域:
@TargetApi(Build.VERSION_CODES.LOLLIPOP) public class OvalOutline extends ViewOutlineProvider { public OvalOutline() { super(); } @Override public void getOutline(View view, Outline outline) { int radius = (int) (collapsedRadius + (expandedRadius - collapsedRadius) * expandProgress); Rect area = new Rect( center.x - radius, center.y - radius, center.x + radius, center.y + radius); outline.setRoundRect(area, radius); } }
然後將其設定給我們的ViewGroup,記得加上5.0以上的判斷。
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { setOutlineProvider(new OvalOutline()); } }
結語
整體效果還是蠻不錯的,雖然使用場景可能有點侷限,比如在一些列表裡點選編輯的時候可以展開,或者是一些懸浮球快捷操作的場景等等,另外還可以加上一些後續的互動,比如手動旋轉輪盤的效果,完整程式碼已上傳到 一個集合酷炫效果的自定義元件庫 ,歡迎Issue。
歡迎關注Android小Y 的簡書,更多Android精選自定義View
GitHub: GitHub-ZJYWidget
CSDN部落格: IT_ZJYANG
簡 書: Android小Y
在 GitHub 上建了一個集合炫酷自定義View的專案,裡面有很多實用的自定義View原始碼及demo,會長期維護,歡迎Star~ 如有不足之處或建議還望指正,相互學習,相互進步,如果覺得不錯動動小手點個喜歡, 謝謝~

關注Android 技術小棧,更多精彩原創