前言

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

展开过程中,由一开始的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;
利用PathMeasure的getPosTan计算每个子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造成影响,我们想要的只是单单旋转中间按钮而已,因此通过saveLayer和restoreToCount来保证不影响其他部分的绘制,在它们的里面执行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。