Android 自定義弧形旋轉選單欄——衛星選單
概述
現在很多App會在入口比較淺的頁面新增一些快捷操作入口,一方面是為了方便使用者操作,一方面是為了提高產品一些關鍵入口的使用率,讓使用者能夠在瀏覽資訊流的過程中能快速切換至其他一些功能頁面。例如豆瓣的首頁 (右下角紅框選中部分):

豆瓣選單
本文將仿照這種選單效果進行實現,最終效果如下:

弧形選單效果圖
需要定製的特性
1.選單展開半徑
2.設定選單主按鈕Icon
3.設定選單子項的各個Icon
4.展開和收縮的動畫時長
5.所有選單按鈕的寬高
6.是否在展開收縮的同時旋轉主按鈕
注:理論上可以設定很多個,但是會出現重疊情況,這種情況得自行調整按鈕數量和寬高。
實現思路
可以看到這個選單是由多個按鈕組合而成,所以可以考慮用ViewGroup來作為載體,其中的子View再通過屬性動畫進行配合達成效果,而各個選單項的彈出角度可以針對90°來進行弧度平分,再通過三角函式得到最終展開的目標座標,關鍵要注意View的寬高邊距的計算,否則可能會出現超出邊界的情況。
1)初始化基本框架
由於選單是由多個按鈕疊加在一個平面,所以可以考慮採用繼承FrameLayout,然後根據設定的Icon資源Id的數量來作為按鈕的數量進行初始化,程式碼如下:
List<ImageView> mImgViews = new ArrayList<>(); List<Integer> mMenuItemResIds = new ArrayList<>(); /** * 初始化主按鈕 * @param context */ private void initMenuView(Context context) { mMenuIv = new ImageView(context); mMenuIv.setImageResource(mMenuResId); FrameLayout.LayoutParams params = new LayoutParams(mMenuWidth, mMenuWidth); params.bottomMargin = mMenuItemWidth / 2; params.rightMargin = mMenuItemWidth / 2; params.gravity = Gravity.BOTTOM | Gravity.RIGHT; addView(mMenuIv, params); mMenuIv.setOnClickListener(this); } /** * 初始化選單子項按鈕 * @param context */ private void initMenuItemViews(Context context) { mImgViews.clear(); for (int index = 0; index < mMenuItemResIds.size(); index++) { ImageView menuItem = new ImageView(context); menuItem.setImageResource(mMenuItemResIds.get(index)); FrameLayout.LayoutParams params = new LayoutParams(mMenuItemWidth, mMenuItemWidth); params.bottomMargin = mMenuItemWidth / 2; params.rightMargin = mMenuItemWidth / 2; params.gravity = Gravity.BOTTOM | Gravity.RIGHT; menuItem.setTag(index); menuItem.setOnClickListener(this); addView(menuItem, params); menuItem.setScaleX(0f); menuItem.setScaleY(0f); mImgViews.add(menuItem); } }
可以看到都設定在了父容器的右下角,且設定了margin值,這是由於我們點選選單瞬間有放大雙倍的效果,所以這裡需要為其邊緣騰出一點空間,否則處於邊緣的選單項放大時,會有部分被切掉影響觀感,記得為每個子項設定Tag(這裡設定為下標),後面觸發點選事件時會用到。
2)展開選單
前面說過了,主要是根據平分弧度的思路來計算,從效果圖中可以看出,我們的整個展開角度是90°,那麼每個選單項的角度應該是 90°/(選單的數量-1) ,計算出這個角度有什麼作用呢?可以先通過下圖幫忙理解:

弧形選單彈出距離計算示意圖
可以看到,要做彈出動畫,就需要計算出彈出的橫向距離和縱向距離,剛才計算出來的角度在這就派上用場啦,利用三角函式可以得到:
tranX = 彈出半徑*sin(90 * i / (count - 1));
tranY = 彈出半徑*cos(90 * i / (count - 1));
再結合透明度和大小的變化,程式碼如下:
/** * 選單展開動畫 */ private void startOpenAnim() { int count = mMenuItemResIds.size(); List<Animator> animators = new ArrayList<>(); for (int i = 0; i < count; i++) { int tranX = -(int) (mRadius * Math.sin(Math.toRadians(90 * i / (count - 1)))); int tranY = -(int) (mRadius * Math.cos(Math.toRadians(90 * i / (count - 1)))); ObjectAnimator animatorX = ObjectAnimator.ofFloat(mImgViews.get(i), "translationX", 0f, tranX); ObjectAnimator animatorY = ObjectAnimator.ofFloat(mImgViews.get(i), "translationY", 0f, tranY); ObjectAnimator alpha = ObjectAnimator.ofFloat(mImgViews.get(i), "alpha", 0, 1); ObjectAnimator scaleX = ObjectAnimator.ofFloat(mImgViews.get(i), "scaleX", 0.1f, 1); ObjectAnimator scaleY = ObjectAnimator.ofFloat(mImgViews.get(i), "scaleY", 0.1f, 1); animators.add(animatorX); animators.add(animatorY); animators.add(alpha); animators.add(scaleX); animators.add(scaleY); } AnimatorSet animatorSet = new AnimatorSet(); animatorSet.setDuration(mDuration); animatorSet.playTogether(animators); animatorSet.start(); }
3)收回選單
上一步已經理解了如何展開選單,回收選單自然就容易多了,沒錯,就是反其道而行之:
/** * 選單收回動畫 */ private void startCloseAnim() { int count = mMenuItemResIds.size(); List<Animator> animators = new ArrayList<>(); for (int i = 0; i < count; i++) { int tranX = -(int) (mRadius * Math.sin(Math.toRadians(90 * i / (count - 1)))); int tranY = -(int) (mRadius * Math.cos(Math.toRadians(90 * i / (count - 1)))); ObjectAnimator animatorX = ObjectAnimator.ofFloat(mImgViews.get(i), "translationX", tranX, 0f); ObjectAnimator animatorY = ObjectAnimator.ofFloat(mImgViews.get(i), "translationY", tranY, 0f); ObjectAnimator alpha = ObjectAnimator.ofFloat(mImgViews.get(i), "alpha", 1, 0); ObjectAnimator scaleX = ObjectAnimator.ofFloat(mImgViews.get(i), "scaleX", 1, 0.3f); ObjectAnimator scaleY = ObjectAnimator.ofFloat(mImgViews.get(i), "scaleY", 1, 0.3f); animators.add(animatorX); animators.add(animatorY); animators.add(alpha); animators.add(scaleX); animators.add(scaleY); } AnimatorSet animatorSet = new AnimatorSet(); animatorSet.setDuration(mDuration); animatorSet.playTogether(animators); animatorSet.start(); }
其實主要就是在做位移動畫的時候,從tranX和tranY位移到0,回到原來的位置。
4)選單子項點選動畫
以上完成了選單的展開和收縮,基本的模樣已經出來了,還可以為其子項新增一些點選效果,讓整個View更為生動,程式碼如下:
/** * 選單子項點選動畫 * * @param index 子項下標 */ private void startClickItemAnim(int index) { int count = mMenuItemResIds.size(); List<Animator> animators = new ArrayList<>(); //當前被點選按鈕放大且逐漸變透明,造成消散效果 ObjectAnimator clickItemAlpha = ObjectAnimator.ofFloat(mImgViews.get(index), "alpha", 1, 0); ObjectAnimator clickItemScaleX = ObjectAnimator.ofFloat(mImgViews.get(index), "scaleX", 1, 2); ObjectAnimator clickItemScaleY = ObjectAnimator.ofFloat(mImgViews.get(index), "scaleY", 1, 2); animators.add(clickItemAlpha); animators.add(clickItemScaleX); animators.add(clickItemScaleY); for (int i = 0; i < count; i++) { if (index == i) { //過濾當前被點選的子項 continue; } //其他選項縮小且變透明 ObjectAnimator alpha = ObjectAnimator.ofFloat(mImgViews.get(i), "alpha", 1, 0); ObjectAnimator scaleX = ObjectAnimator.ofFloat(mImgViews.get(i), "scaleX", 1, 0.1f); ObjectAnimator scaleY = ObjectAnimator.ofFloat(mImgViews.get(i), "scaleY", 1, 0.1f); animators.add(alpha); animators.add(scaleX); animators.add(scaleY); } AnimatorSet animatorSet = new AnimatorSet(); animatorSet.setDuration(500); animatorSet.playTogether(animators); animatorSet.start(); animatorSet.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animator) { } @Override public void onAnimationEnd(Animator animator) { //點選動畫結束之後要將所有子項歸位 resetItems(); } @Override public void onAnimationCancel(Animator animator) { } @Override public void onAnimationRepeat(Animator animator) { } }); }
首先傳進來一個index引數,其實就是之前我們在初始化的時候為每個子View設定的Tag,在每次onClick的時候,通過 view.getTag() 獲取到對應的下標,傳進來之後,迴圈遍歷所有子View,根據這個下標來判斷當前點選的是哪個選單項,將其做放大消散的動畫效果,其他選單項則單純消散即可。
並且這裡注意,要在動畫結束時,將所有子項設定回展開之前的位置,否則當再次點選選單按鈕時,選單項會在圓弧上閃現了一下,體驗很差, resetItems 程式碼如下:
/** * 重置所有子項位置 */ private void resetItems() { int count = mImgViews.size(); for (int i = 0; i < mImgViews.size(); i++) { int tranX = (int) (mRadius * Math.sin(Math.toRadians(90 * i / (count - 1)))); int tranY = (int) (mRadius * Math.cos(Math.toRadians(90 * i / (count - 1)))); mImgViews.get(i).setTranslationX(tranX); mImgViews.get(i).setTranslationY(tranY); } mIsOpen = false; }
5)旋轉主選單按鈕
我們還可以在展開收縮的同時,還可以為選單按鈕新增上一些花樣,將其旋轉一下,使整個動畫更加自然:
/** * 旋轉主選單按鈕 * * @param startAngel 起始角度 * @param endAngel結束角度 */ private void rotateMenu(int startAngel, int endAngel) { if (!mCanRotate) { return; } ObjectAnimator clickItemAlpha = ObjectAnimator.ofFloat(mMenuIv, "rotation", startAngel, endAngel); clickItemAlpha.setDuration(mDuration); clickItemAlpha.start(); }
6)新增外部點選監聽
提供一個供外界設定banner資料的方法:
ClickMenuListener mItemListener; public void setClickItemListener(ClickMenuListener mItemListener) { this.mItemListener = mItemListener; } public interface ClickMenuListener { void clickMenuItem(int resId); } @Override public void onClick(View view) { if (view == mMenuIv) { ... } else { ... if (mItemListener != null && index < mMenuItemResIds.size()) { mItemListener.clickMenuItem(mMenuItemResIds.get(index)); } } }
就是正常的暴露介面,將選單對應的資源id傳出去,供外界判斷點選的是哪個選單項。
應用
xml佈局中引用(這裡的寬高由設定的弧長半徑決定,只需設定wrap_conetnt即可):
<com.zjywidget.widget.arcmenu.YArcMenuView android:id="@+id/arc_menu" android:layout_width="match_parent" android:layout_height="wrap_content" app:spread_radius="150dp" app:duration="1000" app:menu_width="64dp" app:menu_item_width="64dp" app:can_rotate="true" app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toBottomOf="parent" />
Acitivity中例項程式碼如下:
mArcMenuView = findViewById(R.id.arc_menu); List<Integer> menuItems = new ArrayList<>(); menuItems.add(R.drawable.ic_menu_camera); menuItems.add(R.drawable.ic_menu_photo); menuItems.add(R.drawable.ic_menu_share); mArcMenuView.setMenuItems(menuItems); mArcMenuView.setClickItemListener(new YArcMenuView.ClickMenuListener() { @Override public void clickMenuItem(int resId) { switch (resId){ case R.drawable.ic_menu_camera: Toast.makeText(getApplicationContext(), "點選了相機", Toast.LENGTH_SHORT).show(); break; case R.drawable.ic_menu_photo: Toast.makeText(getApplicationContext(), "點選了相簿", Toast.LENGTH_SHORT).show(); break; case R.drawable.ic_menu_share: Toast.makeText(getApplicationContext(), "點選了分享", Toast.LENGTH_SHORT).show(); break; } } });
後續
最近有點沉迷於自定義View,其實很多看似很基礎的東西還是很重要的,底層基礎決定上層建築,本文主要關鍵還是對屬性動畫的結合應用,由於時間比較短,可能還有些細節未優化處理,還待後期不斷更新,歡迎關注GitHub專案:
原始碼傳送門: GitHub-ZJYWidget
CSDN部落格: IT_ZJYANG
簡 書: Android小Y
裡面還有很多實用的自定義View原始碼及demo,會長期維護,歡迎Star~ 如有不足之處或建議還望指正,相互學習,相互進步,如果覺得不錯動動小手給個Star, 謝謝~