简单造轮子系列 - 自定义支持手势旋转的Android Radar Chart(蛛网雷达图)

其实雷达图这个view嘛,绘制起来真的不难,网上也有很多优秀的view和教程,主要知识点就是绘制正N边形的一个过程,也就是对Path类使用,下面在这里简单记录一下自己的编写过程和思路,成品效果如下:

首先简单的分析一下,绘制这样一个雷达图大致需要3步:

  1. 绘制所有的正N边形
  2. 绘制中心点到各顶点的连线
  3. 绘制数据区域N边形

1、绘制所有的正N边形

这个雷达网由半径递减的多个正N边形组成,至于具体绘制几个,应该设置一个参数mLayer以供随时调整,这里暂定默认值为5。

  • 第一步 找到原点坐标,这个好办,直接找view的中心点即可
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    mPointCenter = new PointF(w / 2, h / 2);
}
  • 第二步 求出最外层N边形外接圆半径,也就是原点至最外层正N边形顶点的连线距离,这个也好办,因为view的宽高设置的并不一定相等,所以取view的宽高中小的一个。但并不能直接将此值作为半径,还需要为顶点描述文字预留空间,同时我们也希望顶点描述文字和顶点之间有一定的间距,所以最终半径的值是在此基础上再减去顶点描述文字的宽度和间距。
private void calcRadius() {
        if (mVertexText == null || mVertexText.size() == 0) {
            mRadius = Math.min(mPointCenter.x, mPointCenter.y)
                    - mVertexTextOffset;
        } else {
            String maxText = Collections.max(mVertexText,
                    new Comparator<String>() {
                        @Override
                        public int compare(String lhs, String rhs) {
                            return lhs.length() - rhs.length();
                        }
                    });
            float maxTextWidth = mVertexTextPaint.measureText(maxText);
            if (mVertexTextOffset == 0) {
                Paint.FontMetrics fontMetrics = mVertexTextPaint
                        .getFontMetrics();
                float textHeight = fontMetrics.descent - fontMetrics.ascent;
                mVertexTextOffset = (int) Math.sqrt(Math.pow(maxTextWidth, 2)
                        + Math.pow(textHeight, 2)) / 2;
                if (mVertexTextOffset < dp2px(15)) {
                    mVertexTextOffset = dp2px(15);
                }
            }
            mRadius = Math.min(mPointCenter.x, mPointCenter.y)
                    - (maxTextWidth + mVertexTextOffset);
        }
    }
  • 第三步 绘制所有正N边形,就需要得出正N边形所有顶点的坐标,因为我们已经有了N边形外接圆半径的值,根据每个顶点相对于原点的圆心角度数,就可以通过三角函数求出顶点x、y的值,所需公式如下:
x = sin(a) × r y = cos(a) × r a为角、r为半径

这里有个小问题需要注意下,在java中Math类的三角函数接收的参数并不角度,而是弧度,所以需要用2 * Math.PI表示360°

        mAngle = 2 * Math.PI / mVertexCount;
        for (int i = mLayer; i >= 1; i--) {
              float radius = mRadius / mLayer * i;
              Path p = new Path();
              for (int j = 1; j <= mVertexCount; j++) {
                   float x = (float) (mPointCenter.x + Math.sin(mAngle * j) * radius);
                   float y = (float) (mPointCenter.y + Math.cos(mAngle * j) * radius);
                   if (j == 1) {
                       p.moveTo(x, y);
                   } else {
                       p.lineTo(x, y);
                   }
              }
                   p.close();
                   canvas.drawPath(p, mLayerPaint);
        }

绘制的时候,我们可以给加点特技什么的,比如每层多边形的画笔设置不同的颜色,效果如下:

或者不绘制多边形,将雷达网直接绘制成圆形,当然,绘制圆形就简单多了,不需要算顶点的坐标,一句话就搞定

canvas.drawCircle(mPointCenter.x, mPointCenter.y, radius, mLayerPaint);

效果如下:


2、绘制中心点到各顶点的连线

有了上面的基础,绘制这个连线就简单多了,这里依然使用Path来做连线

        for (int i = 1; i <= mVertexCount; i++) {
             Path p = new Path();
             p.moveTo(mPointCenter.x, mPointCenter.y);
             float x = (float) (mPointCenter.x + Math.sin(mAngle * i) * mRadius);
             float y = (float) (mPointCenter.y + Math.cos(mAngle * i) * mRadius);
             p.lineTo(x, y);
             canvas.drawPath(p, mRadarLinePaint);
        }

同时还可以把顶点描述文字加上去

for (int i = 1; i <= mVertexCount; i++) {
     float x = (float) (mPointCenter.x + Math.sin(mAngle * i) * (mRadius + mVertexTextOffset));
     float y = (float) (mPointCenter.y + Math.cos(mAngle * i) * (mRadius + mVertexTextOffset));
     String text = mVertexText.get(i - 1);
     float textWidth = mVertexTextPaint.measureText(text);
     Paint.FontMetrics fontMetrics = mVertexTextPaint.getFontMetrics();
     float textHeight = fontMetrics.descent - fontMetrics.ascent;
     canvas.drawText(text, x - textWidth / 2, y + textHeight / 4, mVertexTextPaint);
}

效果如下:

3、绘制数据区域N边形

数据区域绘制也是使用Path类,方法和绘制雷达网的N边形一样,只是每次半径的数值是根据数据的值不断变化的为了能方便的添加多组数据先来定义雷达图的数据类

public class RadarData {
    private String mLabel;
    private List<Float> mValue;
    private int mColor;
    private List<String> mValueText;
    private int mVauleTextColor;
    private int mValueTextSize;
    private boolean mValueTextEnable;
}

和添加数据的方法

public void addData(RadarData data) {
    mRadarData.add(data);
    initData(data);
    animeValue(2000);
}

然后是数据区域内容的绘制,根据数据值占最大值的比例求出半径

List<Float> values = radarData.getValue();
Path p = new Path();
for (int j = 1; j <= values.size(); j++) {
     float value = values.get(j - 1);
     double percent = value / mMaxValue;
     float x = (float) (mPointCenter.x + Math.sin(mAngle * j + mRotateAngle) * mRadius * percent);
     float y = (float) (mPointCenter.y + Math.cos(mAngle * j + mRotateAngle) * mRadius * percent);
     if (j == 1) {
         p.moveTo(x, y);
     } else {
         p.lineTo(x, y);
     }
}
p.close();
mValuePaint.setAlpha(255);
mValuePaint.setStyle(Paint.Style.STROKE);
canvas.drawPath(p, mValuePaint);
mValuePaint.setStyle(Paint.Style.FILL);
mValuePaint.setAlpha(150);
canvas.drawPath(p, mValuePaint);

效果图就不贴了,和本文第一张动图一样,至此整个雷达图就绘制出来了


好了,接下来我们给雷达图添加手势旋转的功能,转起来

旋转也不难,不过有个前提,旋转的时候顶点描述文字虽然也跟着旋转,但其排列方向不能变,任何时候都要保证是水平排列的,如果只是简单的使用view的setRotation方法来进行旋转操作,就无法保证文字永远是水平排列的,所以我们需要对各顶点的坐标进行操作,使其跟随手指触摸移动距离整体移动,然后不断的对整个视图进行重绘以实现旋转效果

首先重写onTouchEvent方法并使用GestureDetector管理触摸手势

    public RadarView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDetector = new GestureDetectorCompat(mContext, new GestureListener());
        mDetector.setIsLongpressEnabled(false);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!mRotationEnable) return super.onTouchEvent(event);
        return mDetector.onTouchEvent(event);
    }

既然要处理手指触摸移动,那我们重写GestureListener的onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)方法

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            double rotate = mRotateAngle;
            double dis = RotateUtil.getRotateAngle(new PointF(e2.getX() - distanceX, e2.getY() - distanceY)
                    , new PointF(e2.getX(), e2.getY()), mPointCenter);
            rotate += dis;
            handleRotate(rotate);
            return super.onScroll(e1, e2, distanceX, distanceY);
        }

这里思路是这样的,我们在onScroll里计算本次手指移动前后总共移动了多少度的角
请看下图,假设移动前手指在A点,移动后在B点,目的是根据移动的距离计算出角a的度数
计算出角a的度数后,就可以重绘view,还记得我们在之前绘制正N边形的时候各顶点的坐标都是使用三角函数计算出来的么,我们只需要在计算各顶点的时候,将三角函数当前的角度加上这个角a,这样整个雷达图就旋转了a度,只要手指不断的移动,view就会不断的旋转,比如我们挑之前绘制时第二步绘制中心点到各顶点的连线来改造下

        for (int i = 1; i <= mMaxVertex; i++) {
            Path p = new Path();
            p.moveTo(mPointCenter.x, mPointCenter.y);
            float x = (float) (mPointCenter.x + Math.sin(mAngle * i + mRotateAngle) * mRadius);
            float y = (float) (mPointCenter.y + Math.cos(mAngle * i + mRotateAngle) * mRadius);
            p.lineTo(x, y);
            canvas.drawPath(p, mRadarLinePaint);
        }

其他只要需要计算顶点的坐标的地方都和这个同样道理


那么我们怎么计算出这么一个移动的角度呢,我这里写了一个RotateUtil类专门来处理

public class RotateUtil {
    public static final double CIRCLE_ANGLE = 2 * Math.PI;

    protected static double getRotateAngle(PointF p1, PointF p2, PointF mPointCenter) {
        int q1 = getQuadrant(p1, mPointCenter);
        int q2 = getQuadrant(p2, mPointCenter);
        double angle1 = getAngle(p1, mPointCenter);
        double angle2 = getAngle(p2, mPointCenter);
        if (q1 == q2) {
            return angle1 - angle2;
        } else {
            return 0;
        }
    }

    //得到一个坐标点相对于原点的圆心角度数
    public static double getAngle(PointF p, PointF mPointCenter) {
        float x = p.x - mPointCenter.x;
        float y = mPointCenter.y - p.y;
        double angle = Math.atan(y / x);
        return getNormalizedAngle(angle);
    }

    //根据一个坐标点判断其所在象限
    public static int getQuadrant(PointF p, PointF mPointCenter) {
        float x = p.x;
        float y = p.y;
        if (x > mPointCenter.x) {
            if (y > mPointCenter.y) {
                return 4;
            } else if (y < mPointCenter.y) {
                return 1;
            }
        } else if (x < mPointCenter.x) {
            if (y > mPointCenter.y) {
                return 3;
            } else if (y < mPointCenter.y) {
                return 2;
            }
        }
        return -1;
    }

    public static double getNormalizedAngle(double angle) {
        while (angle < 0)
            angle += CIRCLE_ANGLE;
        return angle % CIRCLE_ANGLE;
    }
}

其实逻辑也很简单,只需要分别得到A点和B点相对于原点的圆心角度数然后相减即可,那么如果根据一个坐标点得到角呢,这里就需要用到反三角函数了

a = arctan(tan(a)) = arctan(y/x)

这里也有一点需要注意,通过反正切计算出来角度(其实是弧度)后,还需要判断其所在的象限,我们知道象限角的函数值是有负值的,所以如果两个角如果不在同一象限,就不能让其相减

可以看到已经可以跟随手指移动进行旋转了,但是仔细观察会发现一个问题,就是旋转的太僵硬了,没有惯性,这个好办,我们可以根据滑动的加速度制造这么一个fling效果,让手指滑动停止后继续旋转一段距离

如何办到呢,这就需要Scroller出场了,使用Scroller的fling方法,让它根据速度为我们计算这段距离和时间,至于速度怎么获得?,重写GestureListener中onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)即可

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (Math.abs(velocityX) > Math.abs(velocityY)) {
                mFlingPoint = e2.getX();
                mScroller.fling((int) e2.getX(), 0, (int) velocityX, 0, (int) (-mPerimeter + e2.getX()), (int) (mPerimeter + e2.getX()), 0, 0);
            } else if (Math.abs(velocityY) > Math.abs(velocityX)) {
                mFlingPoint = e2.getY();
                mScroller.fling(0, (int) e2.getY(), 0, (int) velocityY, 0, 0, (int) (-mPerimeter + e2.getY()), (int) (mPerimeter + e2.getY()));
            }
            invalidate();
            return super.onFling(e1, e2, velocityX, velocityY);
        }

fling的min和max的值使用最外层N边形外接圆的周长来做限制,当然这可以按照自己的想法随意制订,想转的距离再长点加大这个值的范围就行了

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            int x = mScroller.getCurrX();
            int y = mScroller.getCurrY();
            int max = Math.max(Math.abs(x), Math.abs(y));
            double rotateDis = RotateUtil.CIRCLE_ANGLE * (Math.abs(max - mFlingPoint) / mPerimeter);
            double rotate = mRotateAngle;
            if (mRotateOrientation > 0) {
                rotate += rotateDis;
            } else if (mRotateOrientation < 0) {
                rotate -= rotateDis;
            }
            handleRotate(rotate);
            mFlingPoint = max;
            invalidate();
        }
    }

computeScroll里,按照滑动距离相对于外接圆周长的占比求出旋转的角度,重绘view即可

可以看到已经能比较顺滑的旋转了

最后,我们再给数据区加一个动画效果,直接用属性动画就好,比较简单没什么可说的,直接上代码吧

    public void animeValue(int duration){
        for (int i = 0; i < mRadarData.size(); i++) {
            RadarData data = mRadarData.get(i);
            ValueAnimator anime = ValueAnimator.ofFloat(0, 1f);
            final List<Float> values = data.getValue();
            final List<Float> values2 = new ArrayList<>(values);
            anime.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    float percent = Float.parseFloat(animation.getAnimatedValue().toString());
                    for (int i = 0; i < values.size(); i++) {
                        values.set(i, values2.get(i) * percent);
                    }
                    invalidate();
                }
            });
            anime.setDuration(duration).start();
        }
    }

就先写这么一个动画吧,以后想到别的了再慢慢加进去

github:https://github.com/qstumn/RadarView

感谢:http://blog.csdn.net/crazy__chen/article/details/50163693

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,457评论 25 707
  • 当你对各种军用雷达头大的时候,推荐你读读这一篇。 舰载雷达 |型号|应用||:-:|:-:|:-:|:-:|:-:...
    好心态阅读 6,756评论 0 7
  • 岁数大了,注意力早已不集中了,只能用笔记本记下,可现在搞得连笔记本都不知道丢哪了,这才是尴尬的地方。
    东瓯国老何阅读 247评论 0 0
  • 当太晚将要升起 我就踩在海岸上 我追逐着那一抹心头的温暖 当背影投射在沙滩上 正好遇到你迎面而来的笑容 刹那晕开了...
    田萍阅读 284评论 2 8
  • 看经济学书要读出来 小声读,快读 2.自己说话好多时间就是没逻辑的主要问题就是小逻辑结构不总结,就是眉毛胡子一把抓...
    智囊团阅读 144评论 0 0