Android 自定义 View 之绘制雷达图

做 Android 应用开发,界面自然是少不了的,它是最直接可被用户感知的部分。每当看到手机上应用做出绚丽的画面、巧妙的动画,使用体验就像把玩一件艺术品一般,真的令人赞叹!我的工作范围很少涉及界面,所以对视图方面了解不多。在网上找到了一份教程:GcsSloopAndroidNote,里面对自定义 View 讲得非常详细,从基础到进阶,每个绘图的 API 都有解释,想要学习的朋友千万不要错过~

下面两段摘自 GcsSloop 的 Android 笔记,分别总结了自定义 View 的分类和流程。

自定义 View 分类

PS:实际上 ViewGroup 是 View 的一个子类。

类别 继承自 特点
View View SurfaceView 等 不含子 View
ViewGroup ViewGroup、xxLayout 等 包含子 View
自定义 View 流程
步骤 关键字 作用
1 构造函数 初始化 View
2 onMeasure 测量 View 大小
3 onSizeChanged 确定 View 大小
4 onLayout 确定子 View 布局(自定义 View 包含子 View 时有用)
5 onDraw 实际绘制内容
6 提供接口 控制 View 或监听 View 某些状态

学习完 Path 的基本操作,GcsSloop 给我们留了一道作业题 ---- 绘制雷达图,熟悉 Path 的使用。下面我们就按照步骤来做一下,其中涉及一些数学计算,看来算法还是蛮重要的。

1. 构造函数,初始化 View

首先看成员变量的声明,主要是画笔、画布的属性(宽和高)、图形的属性(圈数、半径等)。为了计算 cos 值,重温了高中数学(笑哭 ing)

 // 6条线上的点的 con 值,从 y 轴负方向开始画线,即竖直的上方
    private static final PointF[] UNIT_POINTS = {
            new PointF(0, -1),
            new PointF((float) (Math.cos(Math.PI / 6)), -(float) (Math.cos(Math.PI / 3))),
            new PointF((float) (Math.cos(Math.PI / 6)), (float) (Math.cos(Math.PI / 3))),
            new PointF(0, 1),
            new PointF(-(float) (Math.cos(Math.PI / 6)), (float) (Math.cos(Math.PI / 3))),
            new PointF(-(float) (Math.cos(Math.PI / 6)), -(float) (Math.cos(Math.PI / 3))),
    };
    // 边数
    private static final int EDGE_COUNT = 6;
    private final ILogger log = LoggerFactory.getLogger("RadarView");
    // 雷达线画笔
    private Paint mLinePaint;
    // 填色区画笔
    private Paint mAreaPaint;
    // 数据点画笔
    private Paint mPointPaint;
    // 画布的宽
    private int mWidth;
    // 画布的高
    private int mHeight;
    // 圈数,限制 3--5 圈
    private int mLoop = 5;
    // 步长,限制 50--100
    private float mStep = 100;
    // 「半径」长度
    private float mLength = mStep * mLoop;
    // 最外层端点的坐标
    private List<PointF> mEndPoints;

下面是构造方法,需要重写三个方法,在这里初始化画笔和坐标数据。

    public RadarView(Context context) {
        this(context, null);
    }

    public RadarView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public RadarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initPaints();
        initEndPoints(mLength);
    }

    // 初始化画笔
    private void initPaints() {
        mLinePaint = new Paint();
        mLinePaint.setStyle(Paint.Style.STROKE);
        mLinePaint.setAntiAlias(true);
        mLinePaint.setColor(Color.BLACK);

        mAreaPaint = new Paint();
        mAreaPaint.setStyle(Paint.Style.FILL);
        mAreaPaint.setAntiAlias(true);
        mAreaPaint.setColor(Color.BLUE);
        mAreaPaint.setAlpha(100);

        mPointPaint = new Paint();
        mPointPaint.setAntiAlias(true);
        mPointPaint.setColor(Color.BLUE);
        mPointPaint.setStyle(Paint.Style.FILL);
        mPointPaint.setStrokeWidth(10);
    }

    // 添加最外层的6个端点
    private void initEndPoints(float length) {
        mEndPoints = new ArrayList<>(EDGE_COUNT);
        PointF pointF;
        for (int i = 0; i < EDGE_COUNT; i++) {
            pointF = new PointF();
            pointF.x = length * UNIT_POINTS[i].x;
            pointF.y = length * UNIT_POINTS[i].y;
            mEndPoints.add(pointF);
        }
    }

2. onSizeChanged,确定 View 的大小

由于我们要绘制的是简单的 View,onMeasure 过程暂时不需要重写。然后到了 onSizeChanged 方法,在这里获取当前 View 的宽高。关于 onSizeChanged,API 是这么说的:在 layout 期间,当 View 的尺寸发生变化是被调用。所以这里的宽高就是 View 测量后的真实宽高。

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        log.debug("onSizeChanged. w:{}, h:{}, oldW:{}, oldH:{}", w, h, oldw, oldh);
        mWidth = w;
        mHeight = h;
    }

3. onDraw,绘制实际的内容

由于我们的 View 不包含子 View,所以 onLayout 过程跳过,直接进行 onDraw 绘制。
我的思路和蜘蛛织网差不多:先从中心开始,画出 6 条射线,作为图形的骨架, 然后从外圈向内圈画线,最后打点填色。要不然怎么雷达图又叫「蜘蛛网图」呢 (~ o ~)~zZ

    @Override
    protected void onDraw(Canvas canvas) {
        log.debug("onDraw. canvas:{}", canvas);
        super.onDraw(canvas);

        // 将坐标原点移动到中心
        canvas.translate(mWidth / 2, mHeight / 2);
        Path path = new Path();

        // 先画 6 条射线,这是基本骨架
        int size = mEndPoints.size();
        for (int i = 0; i < size; i++) {
            path.moveTo(0, 0);
            PointF endPoint = mEndPoints.get(i);
            path.lineTo(endPoint.x, endPoint.y);
        }
        canvas.drawPath(path, mLinePaint);
        path.reset();

        // 再从外圈到内圈画闭合线,一圈又一圈~
        PointF firstPoint = mEndPoints.get(0);
        for (int i = mLoop; i >= 1; i--) {
            float rate = i / (float) mLoop;
//            log.info("rate:{}", rate);
            float firstX = firstPoint.x * rate;
            float firstY = firstPoint.y * rate;
            path.moveTo(firstX, firstY);
            for (int j = 1; j < size; j++) {
                PointF endPoint = mEndPoints.get(j);
                path.lineTo(endPoint.x * rate, endPoint.y * rate);
            }
            path.lineTo(firstX, firstY);
        }
        canvas.drawPath(path, mLinePaint);
        path.reset();

        // 画数据点
        List<PointF> pointFs = generateFocused();
        PointF firstF = pointFs.get(0);
        path.moveTo(firstF.x, firstF.y);
        for (PointF pointF : pointFs) {
            canvas.drawPoint(pointF.x, pointF.y, mPointPaint);
            path.lineTo(pointF.x, pointF.y);
        }
        // 画填色区域
        canvas.drawPath(path, mAreaPaint);
        path.reset();
    }

    // 产生随机数据点
    private List<PointF> generateFocused() {
        List<PointF> focused = new ArrayList<>(mEndPoints.size());
        PointF point;
        for (PointF pointF : mEndPoints) {
            point = new PointF();
            float random = 0;
            // 为了让区域好看,所以随机合适的点
            while (random < 0.2 || random > 0.8) {
                random = (float) Math.random();
            }
            point.x = (random * pointF.x);
            point.y = (random * pointF.y);
//            log.debug("point. x:{}, y:{}", point.x, point.y);
            focused.add(point);
        }
        return focused;
    }

4. 提供接口,设置 View 的属性

这里主要提供了两个对外的接口:设置雷达图的圈数和步长,并且做了一些限制。设置完数据后,调用 invalidate 方法进行重绘,这样就能提供多样化的视图啦~

    // 设置圈数
    public void setLoop(int loop) {
        if (loop < 3) {
            loop = 3;
        } else if (loop > 6) {
            loop = 6;
        }
        mLoop = loop;
        mLength = mLoop * mStep;
        setEndPoints(mLength);
        invalidate();
    }

    // 设置步长
    public void setStep(float step) {
        if (step < 50) {
            step = 50;
        } else if (step > 100) {
            step = 100;
        }
        mStep = step;
        mLength = mLoop * mStep;
        setEndPoints(mLength);
        invalidate();
    }

    // 重新设置端点坐标
    private void setEndPoints(float length) {
        for (int i = 0, j = mEndPoints.size(); i < j; i++) {
            PointF pointF = mEndPoints.get(i);
            pointF.x = length * UNIT_POINTS[i].x;
            pointF.y = length * UNIT_POINTS[i].y;
        }
    }

5. 使用 View

直接创建 View,可以设置属性,添加到界面即可~

        LinearLayout container = (LinearLayout) findViewById(R.id.container);
        RadarView radarView = new RadarView(this);
//        radarView.setStep(80);
//        radarView.setLoop(5);
        LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        container.removeAllViews();
        container.addView(radarView, params);

一起来看下效果吧

雷达图
总结:

自定义 View 其实没有那么难,我们看到一些复杂的效果,往往不是几十行代码能搞定的,可能就被吓到了。把任务分解成小目标,设计良好的算法,一步一步就能做出来。

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

推荐阅读更多精彩内容