『Android自定义View实战』自定义弧形旋转菜单栏——卫星菜单

 

概述

现在很多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,根据这个下标来判断当前点击的是哪个菜单项,将其做放大消散的动画效果,其他菜单项则单纯消散即可。
并且这里注意,要在动画结束时,将所有子项设置回展开之前的位置,否则当再次点击菜单按钮时,菜单项会在圆弧上闪现了一下,体验很差,因此要在onAnimationEnd的回调中重置所有子项,重置代码如下:

     /**
     * 重置所有子项位置
     */
    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~ 如有不足之处或建议还望指正,相互学习,相互进步,如果觉得不错动动小手给个喜欢, 谢谢~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容