Android 自定义 View 之绘制雷达图

做 Android 应用开发,界面自然是少不了的,它是最直接可被用户感知的部分。每当看到手机上应用做出绚丽的画面、巧妙的动画,使用体验就像把玩一件艺术品一般,真的令人赞叹!我的工作范围很少涉及界面,所以对视图方面了解不多。在网上找到了一份教程:GcsSloop 的 AndroidNote,里面对自定义 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 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 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 generateFocused() {
        List 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);

一起来看下效果吧

Android 自定义 View 之绘制雷达图_第1张图片
雷达图
总结:

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

你可能感兴趣的:(Android 自定义 View 之绘制雷达图)