『Android自定义View实战』实现一个小清新的弹出式圆环菜单

8字数 2086阅读 1203

前言

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。(具体原因可看ViewGroupinitViewGroup方法和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设置新的过渡颜色),另一个的半径会在collapsedRadiusexpandedRadius之间变化。

展开过程中,由一开始的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;

利用PathMeasuregetPosTan计算每个子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造成影响,我们想要的只是单单旋转中间按钮而已,因此通过saveLayerrestoreToCount来保证不影响其他部分的绘制,在它们的里面执行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

Android 玩转PathMeasure之自定义支付结果动画
Android 自定义弧形旋转菜单栏——卫星菜单
Android 自定义带入场动画的弧形百分比进度条

GitHubGitHub-ZJYWidget
CSDN博客IT_ZJYANG
简 书Android小Y
GitHub 上建了一个集合炫酷自定义View的项目,里面有很多实用的自定义View源码及demo,会长期维护,欢迎Star~ 如有不足之处或建议还望指正,相互学习,相互进步,如果觉得不错动动小手点个喜欢, 谢谢~

关注Android 技术小栈,更多精彩原创