Android上一个类似PathMenu效果的自定义View源码分析

出处
炎之铠邮箱:yanzhikai_yjk@qq.com
博客地址:http://blog.csdn.net/totond
本文原创,转载请注明本出处!
本项目GitHub地址:https://github.com/totond/YMenuView
欢迎 Star or Fork!
本文章已授权微信公众号code小生独家发布!

效果总图:


前言

网上这种类似PathMenu的菜单很多,但是基本都不符合我项目的需求,想看他们的源码实现然后做出修改,进行二次开发来适应我的项目需求,但是发现——以我现在的能力,如果不是以前做过类似的功能,看别人的代码,很难很快地找出主要实现思路,而且不同的作者的代码有不同的风格(特别是命名),于是就自己按照自己的思路来实现,然后把实现思路都写出来分享一下,让大家了解我这个自定义View控件是怎么实现的,到时候大家根据需求修改源码,进行二次开发的时候也可以参考,也希望和大家一起探讨怎样实现更好。

需求

做这个控件的目的是为了实现一个平板上的全屏视频播放器的菜单栏,点击之后会弹出一堆按钮来让用户选择 ,这样的话网上很多开源控件都能实现,问题就是这个播放器是要支持Android7.0的分屏功能,(平板比较坑爹,还打开了Freeform模式的入口,这个Freeform模式可以让用户自由调节APP的界面宽高,就像在Windows桌面的那些应用窗口一样),要适应分屏功能,APP的宽高可能会改变,这些按钮的位置分布情况也要根据宽高来改变,想想就蛋疼。而网上很多的这类型PathMenu是固定分布方式的,所以就做出了这个可以调整选项位置的自定义菜单控件——YMenuView(取名技术不好不知道怎么取,就用这个挫名字啦(≧▽≦)/)。

介绍

基本上使用介绍都在我的GitHub地址上说明了:https://github.com/totond/YMenuView

具体实现

具体实现思路

思路大概如下图:



  其中主要难点是第二个和第三个。简单来说,本质上YMenuView是一个ViewGroup,然后在里面动态生成一些控件,点击MenuButton的时候就会把一堆OptionButton显示/消失,这个过程加上一些动画,就形成最后的效果。

创建ViewGroup

这部分其实没什么好说的,就是创建一个名为YMenuView的ViewGroup,然后获取一些自定义的属性(自定义属性的介绍可以自行搜索或者看看我的笔记,这里不多说了),为下一步的创建MenuButton和OptionButton做准备,获取的属性在项目的Github地址上有详细的说明了。篇幅原因,这里就放出部分重要的属性图示:

创建MenuButton和OptionButton

如上图所示,MenuButton就是那个用于按下弹出菜单的按钮,OptionButton就是可弹出收回的选项按钮。

MenuButton

下面先来看看如何创建MenuButton:

private void setMenuButton() {
        mYMenuButton = new Button(mContext);
        //设置MenuButton的大小位置
        LayoutParams layoutParams = new LayoutParams(mYMenuButtonWidth, mYMenuButtonHeight);
        layoutParams.setMarginEnd(mYMenuButtonRightMargin);
        layoutParams.bottomMargin = mYMenuButtonBottomMargin;
        layoutParams.addRule(ALIGN_PARENT_RIGHT);
        layoutParams.addRule(ALIGN_PARENT_BOTTOM);
        //生成ID
        mYMenuButton.setId(generateViewId());

        mYMenuButton.setLayoutParams(layoutParams);
        //设置打开关闭事件
        mYMenuButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                if (!isShowMenu) {
                    showMenu();
                } else {
                    closeMenu();
                }
            }
        });
        mYMenuButton.setBackgroundResource(mMenuButtonBackGroundId);
        addView(mYMenuButton);
    }

主要是动态生成一个Button,利用LayoutParams来控制它的位置,生成ID是为后面要使用mYMenuButton的信息做准备,然后就是设置点击事件来控制菜单开关(开关的操作实现后面讲),最后就是设置背景和addView()加入父ViewGroup视图。

OptionButton

说是Button,但是实际上OptionButton是继承ImageView,因为我发现ImageView除了可以通过setImageDrawable()方法设置图片资源之外,还可以通过setBackground()方法设置背景,这样的话可以很容易实现给图片加框的效果(demo中的OptionButton效果就是通过圆形shape和图片资源合成的),下面的OptionButton创建过程中,位置计算是比较复杂的:

    private void initBan() {
        //对Ban数组进行从小到大排序
        Arrays.sort(banArray);
    }

//设置选项按钮
    private void setOptionButtons() throws Exception {
        optionButtonList = new ArrayList<>(optionPositionCount);
        initBan();
        boolean isBan = true;
        for (int i = 0,n = 0; i < optionPositionCount; i++) {
            if (isBan && banArray.length > 0) {
                //Ban判断
                if (i > banArray[n] || banArray[n] > optionPositionCount - 1) {
                    throw new Exception("Ban数组设置不合理,含有负数、重复数字或者超出范围");
                } else if (i == banArray[n]) {
                    if (n < banArray.length - 1) {
                        n++;
                    }else {
                        isBan = false;
                    }
                    continue;
                }
            }

            OptionButton button = new OptionButton(mContext);
            //设置动画的模式和时长
            button.setSD_Animation(mOptionSD_AnimationMode);
            button.setDuration(mOptionSD_AnimationDuration);
            int btnId = generateViewId();
            button.setId(btnId);

            RelativeLayout.LayoutParams layoutParams = new LayoutParams(mYOptionButtonWidth, mYOptionButtonHeight);

            //计算OptionButton的位置
            int position = i % optionColumns;

            layoutParams.rightMargin = mYOptionToMenuRightMargin
                    + mYOptionHorizontalMargin * position
                    + mYOptionButtonWidth * position;

            layoutParams.bottomMargin = mYOptionToMenuBottomMargin
                    + (mYOptionButtonHeight + mYOptionVerticalMargin) * (i / optionColumns);
            layoutParams.addRule(ALIGN_PARENT_BOTTOM);
            layoutParams.addRule(ALIGN_PARENT_RIGHT);

            button.setLayoutParams(layoutParams);
            addView(button);
            optionButtonList.add(button);
        }
    }

先不看Ban判断,看下面的位置计算,OptionButton的布局是矩形的,每一排中的每个ImageView从左到右的,而optionColumns是列数(也就是每排的个数),通过这个属性和总个数就可以确定所有OptionButton的布局。如下面就是optionPositionCount = 8,optionColumns = 3的效果:


  然后再看Ban判断,这段代码的目的就是让序号为Ban数组里面的位置跳过这一轮循环,不放置OptionButton。所以Ban这个功能可以通过setBanArray(int... banArray)方法设置banArray数组,里面填入位置序号,然后这个位置就不放OptionButton了,如下图,就是设置了banArray = {0,2,6}optionPositionCount = 8

  前面只是生成了OptionButton,后面还要为它们设置图片和背景:

    //设置选项按钮的background
    public void setOptionBackGrounds(@DrawableRes Integer drawableId){
        for (int i = 0; i < optionButtonList.size(); i++) {
            if (drawableId == null){
                optionButtonList.get(i).setBackground(null);
            }else {
                optionButtonList.get(i).setBackgroundResource(drawableId);
            }

        }

    }

    //设置选项按钮的图片资源,顺便设置点击事件
    private void setOptionsImages(int... drawableIds) throws Exception {
        this.drawableIds = drawableIds;
        if (optionPositionCount > drawableIds.length + banArray.length) {
            throw new Exception("Drawable资源数量不足");
        }

        for (int i = 0; i < optionButtonList.size(); i++) {
            optionButtonList.get(i).setOnClickListener(new MyOnClickListener(i));
            if (drawableIds == null){
                optionButtonList.get(i).setImageDrawable(null);
            }else {
                optionButtonList.get(i).setImageResource(drawableIds[i]);
            }

        }
    }

实现动画

MenuButton的动画没什么好说的,开关动画就是两个旋转(一个逆时针,一个顺时针),动画已经在xml写好了,太简单了就不展示出来,想看的话直接看源码好了:

//初始化MenuButton的点击动画
    private void initMenuAnim() {
        menuOpenAnimation = AnimationUtils.loadAnimation(mContext, R.anim.rotate_open);
        menuCloseAnimation = AnimationUtils.loadAnimation(mContext, R.anim.rotate_close);
        animationListener = new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {
                mYMenuButton.setClickable(false);

            }

            @Override
            public void onAnimationEnd(Animation animation) {
                mYMenuButton.setClickable(true);
            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        };
        menuOpenAnimation.setDuration(mOptionSD_AnimationDuration);
        menuCloseAnimation.setDuration(mOptionSD_AnimationDuration);
        menuOpenAnimation.setAnimationListener(animationListener);
        menuCloseAnimation.setAnimationListener(animationListener);
    }

还开放了方法,可以在外部改变这个开关动画:

    //设置MenuButton弹出菜单选项时候MenuButton自身的动画,默认为顺时针旋转180度,为空则是关闭动画
    public void setMenuOpenAnimation(Animation menuOpenAnimation) {
        menuOpenAnimation.setAnimationListener(animationListener);
        this.menuOpenAnimation = menuOpenAnimation;

    }

    //设置MenuButton收回菜单选项时候MenuButton自身的动画,默认为逆时针旋转180度,为空则是关闭动画
    public void setMenuCloseAnimation(Animation menuCloseAnimation) {
        menuCloseAnimation.setAnimationListener(animationListener);
        this.menuCloseAnimation = menuCloseAnimation;
    }

然后重点就是OptionButton的动画了,它的动画有四种:

sd_animMode 描述
FROM_BUTTON_LEFT 选项从菜单键左边缘飞入
FROM_BUTTON_TOP 选项从菜单键上边缘飞入
FROM_RIGHT 选项从View左边缘飞入
FROM_BOTTOM 选项从View左边缘飞入

这些动画封装在OptionButton里面,因为动画的设置需要用到自身的位置信息,所以需要注册OnGlobalLayoutListener来监听,等自身Layout完毕之后再设置,不然getLeft()等方法返回的都是0:

    private void init(){
        setClickable(true);

        //在获取到宽高参数之后再进行初始化
        ViewTreeObserver viewTreeObserver = getViewTreeObserver();
        viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                if (getX() != 0 && getY() != 0 && getWidth() != 0 && getHeight() != 0) {
                    setShowAndDisappear();
                    //设置完后立刻注销,不然会不断回调,浪费很多资源
                    getViewTreeObserver().removeOnGlobalLayoutListener(this);
                }

            }
        });

    }

因为进入和退出的动画只是基本只是相反,篇幅原因这里就只展示退出动画的实现(看的时候要注意动画的初始坐标零点默认都是基于View的左上角顶点):

    private void setShowAndDisappear() {
        setShowAnimation(mDuration);
        setDisappearAnimation(mDuration);
        //在这里才设置Gone很重要,让View可以一开始就触发onGlobalLayout()进行初始化
        setVisibility(GONE);
    }

    public void setDisappearAnimation(int duration) {
        //获取父ViewGroup的对象,用于获取宽高参数
        YMenuView parent = (YMenuView) getParent();
        AlphaAnimation alphaAnimation = new AlphaAnimation(1,0);
        alphaAnimation.setDuration(duration);
        TranslateAnimation translateAnimation = new TranslateAnimation(0,0,0,0);
        switch (mSD_Animation) {
            case FROM_BUTTON_LEFT:
                //从MenuButton的左边移入
                translateAnimation= new TranslateAnimation(0,parent.getYMenuButton().getX() - getRight()
                        ,0,0);
                translateAnimation.setDuration(duration);
                break;
            case FROM_RIGHT:
                //从右边缘移出
                translateAnimation = new TranslateAnimation(0, (parent.getWidth()- getX()),
                        0, 0);
                translateAnimation.setDuration(duration);
                break;
            case FROM_BUTTON_TOP:
                //从MenuButton的上边移入
                translateAnimation = new TranslateAnimation(0, 0,
                        0, parent.getYMenuButton().getY() - getBottom());
                translateAnimation.setDuration(duration);
                break;
            case FROM_BOTTOM:
                //从下边缘移出
                translateAnimation = new TranslateAnimation(0,0,0,parent.getHeight() - getY());
                translateAnimation.setDuration(duration);
        }
        disappearAnimation = new AnimationSet(true);
        disappearAnimation.addAnimation(translateAnimation);
        disappearAnimation.addAnimation(alphaAnimation);
        disappearAnimation.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {

          

            @Override
            public void onAnimationEnd(Animation animation) {
                setVisibility(GONE);
            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        });
    }

实现点击事件

由于OptionButton都是在代码动态生成的,所以它们的ID也是动态生成的,不能作为switch语句的case条件,所以这里自己写了一个接口OnOptionsClickListener,来让OptionButton的每次点击都调用OnOptionsClickListener的带索引参数的方法,这样就实现让点击事件可以在外部实现并加以区分OptionButton了:

    //用于让用户在外部实现点击事件的接口,index可以区分OptionButton
    public interface OnOptionsClickListener {
        public void onOptionsClick(int index);
    }


    private class MyOnClickListener implements OnClickListener {
        private int index;

        public MyOnClickListener(int index) {
            this.index = index;
        }

        @Override
        public void onClick(View v) {
            if (mOnOptionsClickListener != null) {
                mOnOptionsClickListener.onOptionsClick(index);
            }
        }
    }

    //设置选项按钮的图片资源,顺便设置点击事件
    private void setOptionsImages(int... drawableIds) throws Exception {
        this.drawableIds = drawableIds;
        if (optionPositionCount > drawableIds.length + banArray.length) {
            throw new Exception("Drawable资源数量不足");
        }

        for (int i = 0; i < optionButtonList.size(); i++) {
            optionButtonList.get(i).setOnClickListener(new MyOnClickListener(i));
            if (drawableIds == null){
                optionButtonList.get(i).setImageDrawable(null);
            }else {
                optionButtonList.get(i).setImageResource(drawableIds[i]);
            }

        }
    }

这样做完之后,外部就可以通过实现OnOptionsClickListener接口来实现点击事件了。

结尾

以上就是YMenuView的主要思路了,至于一些细节大家有兴趣的话可以去代码的GitHub上Fork下来或者直接下载下来看看,有什么意见或者建议的话也可以在issue上提出。
  虽然YMenuView的实现挺简单的,功能也不多,但是足够实现我的需求了,我写这篇文章的目的就是把思路记录下来,还有让有类似需求的朋友们参考一下,看了之后二次开发也方便一些。

后话

最近刚正式入职,事情比较多,很多天晚上忙完都是懒得开电脑,所以没怎么写博客。虽然写博客耗时比较长,但是我觉得这是一件很有意义的事情,不但总结巩固了自己的知识,还能帮助他人,我要坚持下去。现在快稳定下来了,后面再忙都会抽多点时间来总结的,在这里说一下,激励下自己。

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

推荐阅读更多精彩内容