Android自定义View - 绘制雷达图

养振濂
2023-12-01

序言

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

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

自定义 View 分类

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

类别继承自特点
ViewView SurfaceView 等不含子 View
ViewGroupViewGroup、xxLayout 等包含子 View
自定义 View 流程
步骤关键字作用
1构造函数初始化 View
2onMeasure测量 View 大小
3onSizeChanged确定 View 大小
4onLayout确定子 View 布局(自定义 View 包含子 View 时有用)
5onDraw实际绘制内容
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 其实没有那么难,我们看到一些复杂的效果,往往不是几十行代码能搞定的,可能就被吓到了。把任务分解成小目标,设计良好的算法,一步一步就能做出来。

转载于:https://juejin.im/post/5c938ee7f265da611b1ed0b1

 类似资料: