Weixin Official Accounts Platform

前外交部副部长傅莹:一旦中美闹翻,有没有国家会站在中国一边

终于找到了高清版《人间中毒》,各种姿势的图,都能看

去泰国看了一场“成人秀”,画面尴尬到让人窒息.....

2017年受难周每日默想经文(值得收藏!)

生成图片,分享到微信朋友圈

自由微信安卓APP发布,立即下载! | 提交文章网址
查看原文

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

徐公 2022-04-23

作者:Android小Y
来源:https://www.jianshu.com/p/1c0069f07b15

前言

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 负责绘制展开后后面的大圆圈:


 1private Paint mCenterPaint;
2private Paint mRoundPaint;
3
4//收缩状态时的颜色 / 展开时外圈的颜色
5private int mRoundColor;
6
7//展开时中心圆圈的颜色
8private int mCenterColor;
9
10public void init(){
11  mCenterPaint= new Paint(Paint.ANTI_ALIAS_FLAG);
12  mCenterPaint.setColor(mRoundColor);
13  mCenterPaint.setStyle(Paint.Style.FILL);
14  mRoundPaint= new Paint(Paint.ANTI_ALIAS_FLAG);
15  mRoundPaint.setColor(mRoundColor);
16  mRoundPaint.setStyle(Paint.Style.FILL);
17  setWillNotDraw(false);
18}

这里有个地方要注意,由于是自定义ViewGroup,因此要调用setWillNotDraw(false),否则我们调用invalidate的时候将不会触发onDraw。(具体原因可看ViewGroupinitViewGroup方法和mPrivateFlags标志位,ViewGroup在调用onDraw方法前做了判断)

接着初始化属性动画器:

 1mExpandAnimator = ValueAnimator.ofFloat(01);
2mExpandAnimator.setInterpolator(new OvershootInterpolator());
3mExpandAnimator.setDuration(400);
4mExpandAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
5    @Override
6    public void onAnimationUpdate(ValueAnimator animation) {
7        expandProgress = (float)animation.getAnimatedValue();
8        mRoundPaint.setAlpha((int) (expandProgress * 255));
9        invalidate();
10    }
11 });
12
13 mColorAnimator = ValueAnimator.ofObject(new ArgbEvaluator(), mRoundColor, mCenterColor);
14 mColorAnimator.setDuration(400);
15 mColorAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
16    @Override
17    public void onAnimationUpdate(ValueAnimator animation) {
18        mCenterPaint.setColor((Integer) animation.getAnimatedValue());
19    }
20});

1)mExpandAnimator负责动态改变大圆圈的半径和透明度,采用OvershootInterpolator,让它有一种向外快速弹出一定值后再回到原来位置的弹性效果。用一个expandProgress记录当前的进度值,后面onDraw绘制的时候会派上用场。
2)mColorAnimator负责颜色的渐变,采用ArgbEvaluator颜色插值器,实现颜色值的过渡,在动画监听中设置给画笔。

接着在onDraw中根据刚才的动画值进行绘制:

 1@Override
2protected void onDraw(Canvas canvas) {
3    super.onDraw(canvas);
4    //绘制放大的圆
5    if (expandProgress > 0) {
6        canvas.drawCircle(center.x, center.y, collapsedRadius + (expandedRadius - collapsedRadius) * expandProgress, mRoundPaint);
7    }
8    //绘制中间圆
9    canvas.drawCircle(center.x, center.y, collapsedRadius, mCenterPaint);
10}

collapsedRadius 代表完全收缩状态下的圆圈半径,expandedRadius 代表完全展开状态下的圆圈半径。
通过drawCircle绘制两个圆,可以理解为其实是两个圆圈叠加在一块,一旦展开或者收缩,其中一个会发生颜色的渐变(刚才的颜色动画回调里不断给mCenterPaint设置新的过渡颜色),另一个的半径会在collapsedRadiusexpandedRadius之间变化。

展开过程中,由一开始的collapsedRadius逐渐变化为expandedRadius
收缩过程中,由一开始的expandedRadius逐渐变化为collapsedRadius

2.计算每个子View在圆环中的坐标

我们想要实现的效果是子View均匀排列在外围圆环中,那么这些子View的圆心必定刚好处在内外环中间的圆环线上,如下图虚线处:

红色代表最外围的圆的半径,蓝色代表中心圆圈的半径,那么虚线圆的半径便可以通过如下公式计算得出:

1float radius = (expandedRadius - collapsedRadius) / 2 + collapsedRadius;

从而可以得到这个虚圆的路径:

1RectF area = new RectF(
2       center.x - radius,
3       center.y - radius,
4       center.x + radius,
5       center.y + radius);
6Path path = new Path();
7path.addArc(area, 0360);

再通过PathMeasure测量圆的长度,结合子View的数量,得到每个子View之间的间距:

1PathMeasure measure = new PathMeasure(path, false);
2//测量圆的总长度
3float len = measure.getLength();
4//子菜单数量
5int count = getChildCount();
6//每个菜单之间的间距
7float itemLength = len / count;

利用PathMeasuregetPosTan计算每个子View的坐标:

1for (int i = 0; i < getChildCount(); i++) {
2    float[] itemPoints = new float[2];
3    measure.getPosTan(i * itemLength, itemPoints, null);
4    View item = getChildAt(i);
5    item.setX((int) itemPoints[0] - itemWidth / 2);
6    item.setY((int) itemPoints[1] - itemWidth / 2);
7}

getPosTan一共有三个参数,第一个表示距离起点的距离,此处可以根据下标与刚才计算出来的菜单之间的间距相乘,从而使其均匀分布,第二个参数即对应位置的点的坐标,会赋给itemPoints这个数组,第三个参数是用来获取对应位置的正切值,这个可以用来实现一些路径上的指向效果(例如纸飞机沿着某条Path移动,飞机头方向保持与路径平行),此处第三个参数不需要用到,可以为null。
然后由于要获取的是菜单项的左上角的坐标,所以需要减去菜单项的宽度的1/2,如下图:

子View坐标计算示意图

3.菜单子项的出场动画

为了让整个View的效果更加丰富,可以在我们展开菜单的时候,让菜单子项接二连三地浮现出来:


 1//每40ms浮现一个
2int delay = 40;
3for (int i = 0; i < getChildCount(); i++) {
4    getChildAt(i).animate()
5            .setStartDelay(delay)
6            .setDuration(400)
7            .alphaBy(0f)
8            .scaleXBy(0f)
9            .scaleYBy(0f)
10            .scaleX(1f)
11            .scaleY(1f)
12            .alpha(1f)
13            .start();
14    delay += mItemAnimIntervalTime;
15}

遍历所有子View,然后间隔一定时间启动动画,改变子View的大小比例和透明度,使其从无到有。

4.根据点击区域做不同的响应

按照正常的逻辑,
如果当前是收缩状态,则点击中心区域会展开。如果当前是展开状态,则触发收缩效果,除非此时点击的是子View区域,就不拦截事件,留给子View去消费。
我们可以通过计算触摸点与中心点的距离,与内外圆圈半径做比较,来作为判断的依据。

计算两点之间的距离可以采用Math.sqrt来计算,其实就是勾股定理:

1public static double getPointsDistance(Point a, Point b) {
2        int dx = b.x - a.x;
3        int dy = b.y - a.y;
4        return Math.sqrt(dx * dx + dy * dy);
5}

然后在onTouchEvent中去判断:

 1@Override
2public boolean onTouchEvent(MotionEvent event) 
{
3        Point touchPoint = new Point();
4        touchPoint.set((intevent.getX(), (intevent.getY());
5        int action = event.getActionMasked();
6        switch (action) {
7            case MotionEvent.ACTION_DOWN: {
8                //计算触摸点与中心点的距离
9                double distance = getPointsDistance(touchPoint, center);
10                if(state == STATE_EXPAND){
11                    //展开状态下,如果点击区域与中心点的距离不处于子菜单区域,就收起菜单
12                    if (distance > (collapsedRadius + (expandedRadius - collapsedRadius) * expandProgress)
13                            || distance < collapsedRadius) {
14                        collapse();
15                        return true;
16                    }
17                    //展开状态下,如果点击区域处于子菜单区域,则不消费事件
18                    return false;
19                }else{
20                    //收缩状态下,如果点击区域处于中心圆圈范围内,则展开菜单
21                    if(distance < collapsedRadius){
22                        expand();
23                        return true;
24                    }
25                    //收缩状态下,如果点击区域不在中心圆圈范围内,则不消费事件
26                    return false;
27                }
28            }
29        }
30        return super.onTouchEvent(event);
31}

5.中心按钮旋转,添加控件阴影

中心按钮旋转可以在onDraw中直接利用画布的旋转来实现:

 1@Override
2protected void onDraw(Canvas canvas) {
3        super.onDraw(canvas);
4        //绘制放大的圆
5        忽略部分代码...
6        //绘制中间圆
7        忽略部分代码...
8        //绘制中心图标
9        int count = canvas.saveLayer(00, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG);
10        canvas.rotate(45*expandProgress, center.x, center.y);
11        mCenterDrawable.draw(canvas);
12        canvas.restoreToCount(count);
13}

由于画布是ViewGroup的,因此直接旋转画布会对整个ViewGroup造成影响,我们想要的只是单单旋转中间按钮而已,因此通过saveLayerrestoreToCount来保证不影响其他部分的绘制,在它们的里面执行canvas.rota,由于expandProgress是在[0,1]之间变化,所以我们让它的角度在0°~45°之间倾斜。

Android5.0之后View提供了一个新的特性elevation,使用它可以让View产生阴影效果:

1if (Build.VERSION.SDK_INT >= 21) {
2    setElevation(8);
3}

单纯设置elevation还不够,需要为它指定一个轮廓,即搭配ViewOutlineProvider来使用,先自定义一个ViewOutlineProvider,重写它的getOutline,里面定义轮廓的形状和大小区域:

 1@TargetApi(Build.VERSION_CODES.LOLLIPOP)
2public class OvalOutline extends ViewOutlineProvider {
3
4    public OvalOutline() {
5        super();
6    }
7
8    @Override
9    public void getOutline(View view, Outline outline) {
10        int radius = (int) (collapsedRadius + (expandedRadius - collapsedRadius) * expandProgress);
11        Rect area = new Rect(
12                    center.x - radius,
13                    center.y - radius,
14                    center.x + radius,
15                    center.y + radius);
16        outline.setRoundRect(area, radius);
17    }
18}

然后将其设置给我们的ViewGroup,记得加上5.0以上的判断。

1@Override
2protected void onSizeChanged(int w, int h, int oldw, int oldh) {
3    super.onSizeChanged(w, h, oldw, oldh);
4    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
5        setOutlineProvider(new OvalOutline());
6    }
7}

结语

整体效果还是蛮不错的,虽然使用场景可能有点局限,比如在一些列表里点击编辑的时候可以展开,或者是一些悬浮球快捷操作的场景等等,另外还可以加上一些后续的交互,比如手动旋转轮盘的效果,完整代码已上传到
一个集合酷炫效果的自定义组件库,欢迎Issue。

https://github.com/GitHubZJY/ZJYWidget

推荐阅读

android ViewPager 仿画廊/图书翻页 与 palette 使用

RxJava 堆栈异常信息显示不全,怎么搞

Android 启动优化(一) - 有向无环图

如果觉得对你有所帮助的话,可以关注我的微信公众号徐公,5 年中大厂工作经验。
  1. 公众号徐公回复黑马,获取 Android 学习视频
  2. 公众号徐公回复徐公666,获取简历模板,教你如何优化简历,走近大厂
  3. 公众号徐公回复面试,可以获得面试常见算法,剑指 ofer 题解
  4. 公众号徐公回复马士兵,可以获得马士兵学习视频一份



文章有问题?点此查看未经处理的缓存