其实雷达图这个view嘛,绘制起来真的不难,网上也有很多优秀的view和教程,主要知识点就是绘制正N边形的一个过程,也就是对Path类使用,下面在这里简单记录一下自己的编写过程和思路,成品效果如下:
首先简单的分析一下,绘制这样一个雷达图大致需要3步:
- 绘制所有的正N边形
- 绘制中心点到各顶点的连线
- 绘制数据区域N边形
1、绘制所有的正N边形
这个雷达网由半径递减的多个正N边形组成,至于具体绘制几个,应该设置一个参数mLayer
以供随时调整,这里暂定默认值为5。
- 第一步 找到原点坐标,这个好办,直接找view的中心点即可
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
mPointCenter = new PointF(w / 2, h / 2);
}
- 第二步 求出最外层N边形外接圆半径,也就是原点至最外层正N边形顶点的连线距离,这个也好办,因为view的宽高设置的并不一定相等,所以取view的宽高中小的一个。但并不能直接将此值作为半径,还需要为顶点描述文字预留空间,同时我们也希望顶点描述文字和顶点之间有一定的间距,所以最终半径的值是在此基础上再减去顶点描述文字的宽度和间距。
private void calcRadius() {
if (mVertexText == null || mVertexText.size() == 0) {
mRadius = Math.min(mPointCenter.x, mPointCenter.y)
- mVertexTextOffset;
} else {
String maxText = Collections.max(mVertexText,
new Comparator<String>() {
@Override
public int compare(String lhs, String rhs) {
return lhs.length() - rhs.length();
}
});
float maxTextWidth = mVertexTextPaint.measureText(maxText);
if (mVertexTextOffset == 0) {
Paint.FontMetrics fontMetrics = mVertexTextPaint
.getFontMetrics();
float textHeight = fontMetrics.descent - fontMetrics.ascent;
mVertexTextOffset = (int) Math.sqrt(Math.pow(maxTextWidth, 2)
+ Math.pow(textHeight, 2)) / 2;
if (mVertexTextOffset < dp2px(15)) {
mVertexTextOffset = dp2px(15);
}
}
mRadius = Math.min(mPointCenter.x, mPointCenter.y)
- (maxTextWidth + mVertexTextOffset);
}
}
- 第三步 绘制所有正N边形,就需要得出正N边形所有顶点的坐标,因为我们已经有了N边形外接圆半径的值,根据每个顶点相对于原点的圆心角度数,就可以通过三角函数求出顶点x、y的值,所需公式如下:
x = sin(a) × r y = cos(a) × r a为角、r为半径
这里有个小问题需要注意下,在java中Math类的三角函数接收的参数并不角度,而是弧度,所以需要用2 * Math.PI
表示360°
mAngle = 2 * Math.PI / mVertexCount;
for (int i = mLayer; i >= 1; i--) {
float radius = mRadius / mLayer * i;
Path p = new Path();
for (int j = 1; j <= mVertexCount; j++) {
float x = (float) (mPointCenter.x + Math.sin(mAngle * j) * radius);
float y = (float) (mPointCenter.y + Math.cos(mAngle * j) * radius);
if (j == 1) {
p.moveTo(x, y);
} else {
p.lineTo(x, y);
}
}
p.close();
canvas.drawPath(p, mLayerPaint);
}
绘制的时候,我们可以给加点特技什么的,比如每层多边形的画笔设置不同的颜色,效果如下:
或者不绘制多边形,将雷达网直接绘制成圆形,当然,绘制圆形就简单多了,不需要算顶点的坐标,一句话就搞定
canvas.drawCircle(mPointCenter.x, mPointCenter.y, radius, mLayerPaint);
效果如下:
2、绘制中心点到各顶点的连线
有了上面的基础,绘制这个连线就简单多了,这里依然使用Path
来做连线
for (int i = 1; i <= mVertexCount; i++) {
Path p = new Path();
p.moveTo(mPointCenter.x, mPointCenter.y);
float x = (float) (mPointCenter.x + Math.sin(mAngle * i) * mRadius);
float y = (float) (mPointCenter.y + Math.cos(mAngle * i) * mRadius);
p.lineTo(x, y);
canvas.drawPath(p, mRadarLinePaint);
}
同时还可以把顶点描述文字加上去
for (int i = 1; i <= mVertexCount; i++) {
float x = (float) (mPointCenter.x + Math.sin(mAngle * i) * (mRadius + mVertexTextOffset));
float y = (float) (mPointCenter.y + Math.cos(mAngle * i) * (mRadius + mVertexTextOffset));
String text = mVertexText.get(i - 1);
float textWidth = mVertexTextPaint.measureText(text);
Paint.FontMetrics fontMetrics = mVertexTextPaint.getFontMetrics();
float textHeight = fontMetrics.descent - fontMetrics.ascent;
canvas.drawText(text, x - textWidth / 2, y + textHeight / 4, mVertexTextPaint);
}
效果如下:
3、绘制数据区域N边形
数据区域绘制也是使用Path
类,方法和绘制雷达网的N边形一样,只是每次半径的数值是根据数据的值不断变化的为了能方便的添加多组数据先来定义雷达图的数据类
public class RadarData {
private String mLabel;
private List<Float> mValue;
private int mColor;
private List<String> mValueText;
private int mVauleTextColor;
private int mValueTextSize;
private boolean mValueTextEnable;
}
和添加数据的方法
public void addData(RadarData data) {
mRadarData.add(data);
initData(data);
animeValue(2000);
}
然后是数据区域内容的绘制,根据数据值占最大值的比例求出半径
List<Float> values = radarData.getValue();
Path p = new Path();
for (int j = 1; j <= values.size(); j++) {
float value = values.get(j - 1);
double percent = value / mMaxValue;
float x = (float) (mPointCenter.x + Math.sin(mAngle * j + mRotateAngle) * mRadius * percent);
float y = (float) (mPointCenter.y + Math.cos(mAngle * j + mRotateAngle) * mRadius * percent);
if (j == 1) {
p.moveTo(x, y);
} else {
p.lineTo(x, y);
}
}
p.close();
mValuePaint.setAlpha(255);
mValuePaint.setStyle(Paint.Style.STROKE);
canvas.drawPath(p, mValuePaint);
mValuePaint.setStyle(Paint.Style.FILL);
mValuePaint.setAlpha(150);
canvas.drawPath(p, mValuePaint);
效果图就不贴了,和本文第一张动图一样,至此整个雷达图就绘制出来了
好了,接下来我们给雷达图添加手势旋转的功能,转起来
旋转也不难,不过有个前提,旋转的时候顶点描述文字虽然也跟着旋转,但其排列方向不能变,任何时候都要保证是水平排列的,如果只是简单的使用view的setRotation方法来进行旋转操作,就无法保证文字永远是水平排列的,所以我们需要对各顶点的坐标进行操作,使其跟随手指触摸移动距离整体移动,然后不断的对整个视图进行重绘以实现旋转效果
首先重写onTouchEvent方法并使用GestureDetector管理触摸手势
public RadarView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDetector = new GestureDetectorCompat(mContext, new GestureListener());
mDetector.setIsLongpressEnabled(false);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mRotationEnable) return super.onTouchEvent(event);
return mDetector.onTouchEvent(event);
}
既然要处理手指触摸移动,那我们重写GestureListener的onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)
方法
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
double rotate = mRotateAngle;
double dis = RotateUtil.getRotateAngle(new PointF(e2.getX() - distanceX, e2.getY() - distanceY)
, new PointF(e2.getX(), e2.getY()), mPointCenter);
rotate += dis;
handleRotate(rotate);
return super.onScroll(e1, e2, distanceX, distanceY);
}
这里思路是这样的,我们在onScroll里计算本次手指移动前后总共移动了多少度的角
请看下图,假设移动前手指在A点,移动后在B点,目的是根据移动的距离计算出角a的度数
计算出角a的度数后,就可以重绘view,还记得我们在之前绘制正N边形的时候各顶点的坐标都是使用三角函数计算出来的么,我们只需要在计算各顶点的时候,将三角函数当前的角度加上这个角a,这样整个雷达图就旋转了a度,只要手指不断的移动,view就会不断的旋转,比如我们挑之前绘制时第二步绘制中心点到各顶点的连线来改造下
for (int i = 1; i <= mMaxVertex; i++) {
Path p = new Path();
p.moveTo(mPointCenter.x, mPointCenter.y);
float x = (float) (mPointCenter.x + Math.sin(mAngle * i + mRotateAngle) * mRadius);
float y = (float) (mPointCenter.y + Math.cos(mAngle * i + mRotateAngle) * mRadius);
p.lineTo(x, y);
canvas.drawPath(p, mRadarLinePaint);
}
其他只要需要计算顶点的坐标的地方都和这个同样道理
那么我们怎么计算出这么一个移动的角度呢,我这里写了一个RotateUtil类专门来处理
public class RotateUtil {
public static final double CIRCLE_ANGLE = 2 * Math.PI;
protected static double getRotateAngle(PointF p1, PointF p2, PointF mPointCenter) {
int q1 = getQuadrant(p1, mPointCenter);
int q2 = getQuadrant(p2, mPointCenter);
double angle1 = getAngle(p1, mPointCenter);
double angle2 = getAngle(p2, mPointCenter);
if (q1 == q2) {
return angle1 - angle2;
} else {
return 0;
}
}
//得到一个坐标点相对于原点的圆心角度数
public static double getAngle(PointF p, PointF mPointCenter) {
float x = p.x - mPointCenter.x;
float y = mPointCenter.y - p.y;
double angle = Math.atan(y / x);
return getNormalizedAngle(angle);
}
//根据一个坐标点判断其所在象限
public static int getQuadrant(PointF p, PointF mPointCenter) {
float x = p.x;
float y = p.y;
if (x > mPointCenter.x) {
if (y > mPointCenter.y) {
return 4;
} else if (y < mPointCenter.y) {
return 1;
}
} else if (x < mPointCenter.x) {
if (y > mPointCenter.y) {
return 3;
} else if (y < mPointCenter.y) {
return 2;
}
}
return -1;
}
public static double getNormalizedAngle(double angle) {
while (angle < 0)
angle += CIRCLE_ANGLE;
return angle % CIRCLE_ANGLE;
}
}
其实逻辑也很简单,只需要分别得到A点和B点相对于原点的圆心角度数然后相减即可,那么如果根据一个坐标点得到角呢,这里就需要用到反三角函数了
a = arctan(tan(a)) = arctan(y/x)
这里也有一点需要注意,通过反正切计算出来角度(其实是弧度)后,还需要判断其所在的象限,我们知道象限角的函数值是有负值的,所以如果两个角如果不在同一象限,就不能让其相减
可以看到已经可以跟随手指移动进行旋转了,但是仔细观察会发现一个问题,就是旋转的太僵硬了,没有惯性,这个好办,我们可以根据滑动的加速度制造这么一个fling效果,让手指滑动停止后继续旋转一段距离
如何办到呢,这就需要Scroller出场了,使用Scroller的fling
方法,让它根据速度为我们计算这段距离和时间,至于速度怎么获得?,重写GestureListener中onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)
即可
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (Math.abs(velocityX) > Math.abs(velocityY)) {
mFlingPoint = e2.getX();
mScroller.fling((int) e2.getX(), 0, (int) velocityX, 0, (int) (-mPerimeter + e2.getX()), (int) (mPerimeter + e2.getX()), 0, 0);
} else if (Math.abs(velocityY) > Math.abs(velocityX)) {
mFlingPoint = e2.getY();
mScroller.fling(0, (int) e2.getY(), 0, (int) velocityY, 0, 0, (int) (-mPerimeter + e2.getY()), (int) (mPerimeter + e2.getY()));
}
invalidate();
return super.onFling(e1, e2, velocityX, velocityY);
}
fling的min和max的值使用最外层N边形外接圆的周长来做限制,当然这可以按照自己的想法随意制订,想转的距离再长点加大这个值的范围就行了
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
int max = Math.max(Math.abs(x), Math.abs(y));
double rotateDis = RotateUtil.CIRCLE_ANGLE * (Math.abs(max - mFlingPoint) / mPerimeter);
double rotate = mRotateAngle;
if (mRotateOrientation > 0) {
rotate += rotateDis;
} else if (mRotateOrientation < 0) {
rotate -= rotateDis;
}
handleRotate(rotate);
mFlingPoint = max;
invalidate();
}
}
computeScroll里,按照滑动距离相对于外接圆周长的占比求出旋转的角度,重绘view即可
可以看到已经能比较顺滑的旋转了
最后,我们再给数据区加一个动画效果,直接用属性动画就好,比较简单没什么可说的,直接上代码吧
public void animeValue(int duration){
for (int i = 0; i < mRadarData.size(); i++) {
RadarData data = mRadarData.get(i);
ValueAnimator anime = ValueAnimator.ofFloat(0, 1f);
final List<Float> values = data.getValue();
final List<Float> values2 = new ArrayList<>(values);
anime.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float percent = Float.parseFloat(animation.getAnimatedValue().toString());
for (int i = 0; i < values.size(); i++) {
values.set(i, values2.get(i) * percent);
}
invalidate();
}
});
anime.setDuration(duration).start();
}
}
就先写这么一个动画吧,以后想到别的了再慢慢加进去
github:https://github.com/qstumn/RadarView
感谢:http://blog.csdn.net/crazy__chen/article/details/50163693