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

效果图

本文原创,转载请注明本出处!
本项目 GitHub 地址:https://github.com/totond/YMenuView
欢迎 Star or Fork!

前言

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

需求

做这个控件的目的是为了实现一个平板上的全屏视频播放器的菜单栏,点击之后会弹出一堆按钮来让用户选择 ,这样的话网上很多开源控件都能实现,问题就是这个播放器是要支持 Android7.0 的分屏功能,(平板比较坑爹,还打开了 Freeform 模式的入口,这个 Freeform 模式可以让用户自由调节 APP 的界面宽高,就像在 Windows 桌面的那些应用窗口一样),要适应分屏功能,APP 的宽高可能会改变,这些按钮的位置分布情况也要根据宽高来改变,想想就蛋疼。而网上很多的这类型 PathMenu是固定分布方式的,所以就做出了这个可以调整选项位置的自定义菜单控件——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 https://github.com/totond/YMenuView 上Fork下来或者直接下载下来看看,有什么意见或者建议的话也可以在issue上提出。

虽然 YMenuView 的实现挺简单的,功能也不多,但是足够实现我的需求了,我写这篇文章的目的就是把思路记录下来,还有让有类似需求的朋友们参考一下,看了之后二次开发也方便一些。

后话

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

与之相关

种一棵树最好的时间是十年前,其次是现在

2017 | 我在 5 个月时间里分享了 98 篇文章

微信号:code-xiaosheng

公众号

「code小生」

(0)

相关推荐