做 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);
一起来看下效果吧
总结:
自定义 View 其实没有那么难,我们看到一些复杂的效果,往往不是几十行代码能搞定的,可能就被吓到了。把任务分解成小目标,设计良好的算法,一步一步就能做出来。