前段时间回看里约奥运会的国球比赛,岛国媒体给我龙队一个响亮的称号—— 六边形战士 !
马龙是我的偶像,看到这样的称号当然很骄傲。
分析图片可以知道:六个定点分别标识个技术点名称,对应 半径 所填充长度表示分值,龙队在各方面的分数都是满分,所以在雷达分析图上覆盖区全部填充。
作为程序员的我,不免要从技术实现的角度思考问题,接下来我们一起造轮子:
先上效果图:
回顾两个知识点:
从效果图来看,我们应该把view区域按照数学中的平面坐标来区分,雷达图中心点(外接圆圆心)为坐标原点,水平向右的半径为x轴正方向,竖直向上的半径为y轴正方向,从右上方开始顺时针依次为第一象限、第二象限、第三象限和第四象限。
大致的思路是这样的,我们需要自定义属性,自定义view:重写构造、onDraw方法,这些都是必须的。我们可以在onSizeChanged方法中进行计算和确认各组成内容的位置与大小。在计算好大小和位置后,在onDraw中进行绘制。
关键的是:我们的目标是将各数据绘制在各半径上,最后链接起来构成完整区域,那就需要将各半径上所有点计算出,找到对应数据对应的点的坐标,然后绘制。
A、定义属性:
<declare-styleable name="RadarChart">
<attr name="radarLineWidth" format="dimension"/>
<attr name="radarLineColor" format="color"/>
<attr name="radarLineSegments" format="integer"/>
<attr name="radarTextColor" format="color"/>
<attr name="radarTextSize" format="integer"/>
<attr name="radarCoverColor" format="color"/>
declare-styleable>
主要就是一些线条颜色、字体颜色、大小等属性。
B、继承View,声明各属性
/**
* Description: 雷达表
* Created by jia on 2017/10/18.
* 人之所以能,是相信能
*/
public class JsRadarChart extends View {
private static final String TAG = "JsRadarChart";
/**
* 雷达边数 默认6
*/
private int mPieceNumber = 6;
/**
* 外接圆半径
*/
private int mRadius = 50;
/**
* 线条宽度 默认10
*/
private int mLineWidth = 8;
/**
* 线条颜色 默认灰色
*/
private int mLineColor = 0xffd0d6dc;
/**
* 半径分为4段
*/
private int mLineSegments = 4;
/**
* 字体颜色和字体大小
*/
private int mTextColor = 0xff647d91;
private int mTextSize = 10;
/**
* 覆盖物颜色
*/
private int mCoverColor = 0x55ced657;
/**
* 中心位置
*/
private int mPositionX = 0;
private int mPositionY = 0;
/**
* 多边形 边的角度
*/
private double mAverageAngle = 0;
C、定义画笔和数据集合
注意:覆盖物区域我们使用Path实现。
/**
* 三只画笔
*/
private Paint mRadarPaint;
private TextPaint mTextPaint;
private Paint mCoverPaint;
private Path mCoverPath;
/**
* 所有边对应的点的集合,每一个item是每条边上所有的结点的集合
*/
List mRadarPointses = new ArrayList<>();
/**
* 实体类集合
*/
List mRadarEntries = new ArrayList<>();
/**
* 覆盖物的顶点的集合
*/
List mCoverPoints = new ArrayList<>();
/**
* 文字对应点的集合
*/
List mTextPoints = new ArrayList<>();
其中,创建了两个类:RadarEntry是数据实体类,RadarPoints 是每条半径上的所有点,其中用List存放各店,index表识哪条半径。
/**
* 雷达图数据载体
*/
public static class RadarEntry {
private String title;
private double level;
public RadarEntry(String title, double level) {
this.title = title;
this.level = level;
}
}
/**
* 每一条线上的所有点集合
*/
class RadarPoints {
int lineIndex;
List mPointFs;
public RadarPoints(int lineIndex, List pointFs) {
this.lineIndex = lineIndex;
mPointFs = pointFs;
}
public int getLineIndex() {
return lineIndex;
}
public void setLineIndex(int lineIndex) {
this.lineIndex = lineIndex;
}
public List getPointFs() {
return mPointFs;
}
public void setPointFs(List pointFs) {
mPointFs = pointFs;
}
}
D、重写构造方法
public JsRadarChart(Context context) {
this(context, null);
}
public JsRadarChart(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public JsRadarChart(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 获取属性类型集
TypedArray attributes = getContext().obtainStyledAttributes(attrs, R.styleable.RadarChart);
mLineWidth = (int) attributes.getDimension(R.styleable.RadarChart_radarLineWidth, 5);
mLineColor = attributes.getColor(R.styleable.RadarChart_radarLineColor, 0xffd0d6dc);
mLineSegments = attributes.getInteger(R.styleable.RadarChart_radarLineSegments, 4);
mTextColor = attributes.getColor(R.styleable.RadarChart_radarTextColor, 0xff647d91);
mTextSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
attributes.getInteger(R.styleable.RadarChart_radarTextSize, 10), getResources().getDisplayMetrics());
mCoverColor = attributes.getColor(R.styleable.RadarChart_radarCoverColor, 0x55ced6dc);
init();
}
显而易见得:在构造方法中获取来自xml文件中的各属性值。
E、初始化
将各画笔根据设置的属性进行初始化。
private void init() {
// 蜘蛛网 画笔初始化
mRadarPaint = new Paint();
mRadarPaint.setColor(mLineColor);
mRadarPaint.setStrokeWidth(mLineWidth);
mRadarPaint.setAntiAlias(true);
mRadarPaint.setStyle(Paint.Style.STROKE);
// 文字绘制 画笔初始化
mTextPaint = new TextPaint();
mTextPaint.setColor(mTextColor);
mTextPaint.setTextSize(mTextSize);
mTextPaint.setAntiAlias(true);
mTextPaint.setStyle(Paint.Style.STROKE);
// 覆盖物 画笔初始化
mCoverPaint = new Paint();
mCoverPaint.setColor(mCoverColor);
mCoverPaint.setAntiAlias(true);
mCoverPaint.setStyle(Paint.Style.FILL);
mCoverPath = new Path();
}
A、中心点位置、每部分对应圆形角
// 计算中心点位置
mPositionX = w / 2;
mPositionY = h / 2;
// 计算角度
mAverageAngle = 360.0 / mPieceNumber;
B、计算文字所占大小,进而得出半径大小
// 计算出文字所占大小
int textSizeMax = 0;
for (RadarEntry entry : mRadarEntries) {
Rect textRect = new Rect();
mTextPaint.getTextBounds(entry.title, 0, entry.title.length(), textRect);
textSizeMax = Math.max(textRect.width(), textSizeMax);
}
// 出去文字区域,算出最合适半径
mRadius = Math.min(mPositionX - textSizeMax, mPositionY);
if (mRadarEntries == null || mRadarEntries.size() == 0) {
throw new NullPointerException("请先设置数据集");
}
因为文字和各半径处在同一条线上,而view创建后,每条线的长度就已经确定,那我们就需要将文字计算出大小,去除就是雷达半径的最佳长度。
在计算文字大小时,应该使用Rect和Paint结合计算得出。
循环各文字大小,找到最大的值,用图形半径减去最大值,就是雷达半径的最佳长度。
C、创建根据百分比计算位置的工具方法
public float getPloygonX(double angle, double percent) {
return Float.parseFloat(
String.valueOf(
mPositionX + Math.cos(angle / 360.0 * 2 * Math.PI) * mRadius * percent));
}
public float getPloygonY(double angle, double percent) {
return Float.parseFloat(String.valueOf(
mPositionY + Math.sin(angle / 360.0 * 2 * Math.PI) * mRadius * percent));
}
因为直角三角形一个角的邻边,等于直角边*该角的余弦值。
所以,横坐标x的值,应该是对应半径*角度的余弦值乘以百分比,当然,此图中的原点实际为外接圆的圆心,并不在屏幕的原点上,所以需要在+圆心的横坐标。
计算y值也是如此。
D、计算每条边上的结点位置,计算各数据对应点
/**
* 计算每一条轴线上的所有结点
* x轴正方向为第一条轴线,顺时针旋转
*/
for (int i = 0; i < mPieceNumber; i++) {
List pointFs = new ArrayList<>();
for (int j = 0; j < mLineSegments; j++) {
PointF point = new PointF();
double percent = j * 1.0 / (mLineSegments - 1);
point.set(getPloygonX(mAverageAngle * i, percent),
getPloygonY(mAverageAngle * i, percent));
pointFs.add(point);
}
RadarPoints radarPoints = new RadarPoints(i, pointFs);
mRadarPointses.add(radarPoints);
}
/**
* 根据数据集计算覆盖多变形的点
*/
for (int m = 0; m < mPieceNumber; m++) {
PointF pointF = new PointF();
double percent = mRadarEntries.get(m).level / 100.0;
pointF.set(getPloygonX(mAverageAngle * m, percent),
getPloygonY(mAverageAngle * m, percent));
mCoverPoints.add(pointF);
}
以上就用到了刚刚创建的根据百分比获取坐标的方法。
注意:x轴正方向为第一条轴线,顺时针旋转。
其实我们只是将每条半径上的所有节点的坐标进行了计算。
E、计算文字的位置
/**
* 设置文字显示位置
*/
for (int m = 0; m < mPieceNumber; m++) {
PointF pointF = new PointF();
String title = mRadarEntries.get(m).title;
Rect textBound = new Rect();
mTextPaint.getTextBounds(title, 0, title.length(),
textBound);
// 每条边最后一个点的位置,设置文字
float boundx = mRadarPointses.get(m).getPointFs().get(mLineSegments - 1).x;
float boundy = mRadarPointses.get(m).getPointFs().get(mLineSegments - 1).y;
if (boundx > mRadius && boundy <= mRadius) {
pointF.set(getPloygonX(mAverageAngle * m, 1),
getPloygonY(mAverageAngle * m, 1) - textBound.height() * 2);
} else if (boundx <= mRadius && boundy <= mRadius) {
pointF.set(getPloygonX(mAverageAngle * m, 1) - textBound.width(),
getPloygonY(mAverageAngle * m, 1) - textBound.height() * 2);
} else if (boundx <= mRadius && boundy > mRadius) {
pointF.set(getPloygonX(mAverageAngle * m, 1) - textBound.width(),
getPloygonY(mAverageAngle * m, 1));
} else {
pointF.set(getPloygonX(mAverageAngle * m, 1),
getPloygonY(mAverageAngle * m, 1));
}
mTextPoints.add(pointF);
}
此步骤是将文字大小计算并设置到对应List中。
我们将文字位置分了四类:类似于数学中的四各象限,不同象限的位置应该分别处理。
到这里,我们的所有计算相关的操作就做完了,接下来开始绘制。
一下步骤都在onDraw方法中执行。
A、绘制中心点、根据各节点绘制环形网
/**
* 绘制中心点
*/
canvas.drawPoint(mPositionX, mPositionY, mRadarPaint);
/**
* 绘制蜘蛛网
*/
for (int i = 0; i < mLineSegments; i++) {
for (int j = 0; j < mPieceNumber - 1; j++) {
canvas.drawLine(mRadarPointses.get(j).getPointFs().get(i).x, mRadarPointses.get(
j).getPointFs().get(i).y,
mRadarPointses.get(j + 1).getPointFs().get(i).x, mRadarPointses.get(
j + 1).getPointFs().get(i).y, mRadarPaint);
}
canvas.drawLine(mRadarPointses.get(mPieceNumber - 1).getPointFs().get(i).x,
mRadarPointses.get(mPieceNumber - 1).getPointFs().get(i).y,
mRadarPointses.get(0).getPointFs().get(i).x, mRadarPointses.get(
0).getPointFs().get(i).y, mRadarPaint);
}
因为各节点我们已经计算得出,并且全部设置在对应List中,所以直接循环进行绘制各点,并将各点进行连接,就可以得到环形网。
B、当然还有半径
/**
* 绘制轴线
*/
for (int k = 0; k < mPieceNumber; k++) {
canvas.drawLine(mRadarPointses.get(k).getPointFs().get(0).x,
mRadarPointses.get(k).getPointFs().get(0).y,
mRadarPointses.get(k).getPointFs().get(mLineSegments - 1).x, mRadarPointses.get(
k).getPointFs().get(mLineSegments - 1).y, mRadarPaint);
}
也是将各点连接起来就可以。
C、将覆盖物绘出
/**
* 绘制数据
*/
if (mCoverPoints != null && mCoverPoints.size() == mPieceNumber) {
mCoverPath.reset();
mCoverPath.moveTo(mCoverPoints.get(0).x, mCoverPoints.get(0).y);
for (int i = 1; i < mPieceNumber; i++) {
mCoverPath.lineTo(mCoverPoints.get(i).x, mCoverPoints.get(i).y);
}
mCoverPath.close();
canvas.drawPath(mCoverPath, mCoverPaint);
} else {
throw new NullPointerException("请先设置数据集");
}
将各半径上的数据对应的点全部循环连接,但要注意,循环结束后,并没有将最后一个点和第一个点连接起来,所以需要在特意将它俩连接。完成后填充闭合图形。
到此覆盖物便画好了。
D、绘制文字
/**
* 绘制文字,使用StaticLayout进行换行文字的绘制
*/
for (int i = 0; i < mPieceNumber; i++) {
canvas.save();
String str = mRadarEntries.get(i).title + "\n" +
Math.floor(mRadarEntries.get(i).level * 10) / 10;
StaticLayout layout = new StaticLayout(str, mTextPaint, 300,
Layout.Alignment.ALIGN_NORMAL, 1.0F, 0.0F, true);
canvas.translate(mTextPoints.get(i).x, mTextPoints.get(i).y);
layout.draw(canvas);
canvas.restore();
}
根据计算得出的文字位置进行绘制,不再累赘。
到此,所以的绘制全部结束!
/**
* 设置数据集,数据集的index决定位置,顺时针方向,起始角度为0度
*/
public void setRadatEntries(List entries) {
this.mRadarEntries = entries;
mPieceNumber = entries.size();
postInvalidate();
}
注意要调用postInvalidate方法进行刷新。
怎么样,有木有很简单,有木有感觉把中学的数学再一次拾起。哈哈。
大家的支持就是我前进的动力!
大家还可以关注我的微信公众号获取更多精彩内容。