写在前面
由于项目需要,下图的图表控件,搜索了各种开源库,没有合适的,只能自定义了。这是我第一次做的自定义控件。写的很渣,请多指教。
该控件是项目中用到的,业务逻辑较多。但作为一个自定义View离不开那几部分
- 重写onMeasure,测量控件大小
- 重写onDraw,绘制界面,其中可能用到部分数学几何知识,但不难。绘制就那么几个drawXXX的方法,只要一步一步走,还是相对简单。
- 重写onTouch,处理交互。
最后附上项目地址
拆分
该控件可以拆分几个部分进行绘制
- 绘制5条水平分割线
- 绘制底部横坐标
- 绘制贝塞尔曲线
- 绘制圆角矩形标注
- 绘制垂直线和底部三角形
绘制5条水平分割线
/**
*画5条分割线 */
private void drawHorizonLine(Canvas canvas){
mLineTextPaint.setColor(0x66cccccc);
mLineTextPaint.setTextSize(DensityUtil.dip2px(mContext,12f));
float intervalY = (getMeasuredHeight()-mMarginTopBottom*2)/4;//每条线段的间隔
for (int i=0; i<5; i++){
canvas.drawLine(
mMarginLeftRight,
getMeasuredHeight()-mMarginTopBottom-intervalY*i,
getMeasuredWidth()-mMarginLeftRight,
getMeasuredHeight()-mMarginTopBottom-intervalY*i,
mLineTextPaint);
}
}
绘制底部横坐标
/**
*画横坐标 */
private void drawXLabel(Canvas canvas){
float xNameintervalX = (mWidth - 2f * mMarginLeftRight) / (mXNameListShow.size()-1);//横坐标的间隔
mLineTextPaint.setTextSize(DensityUtil.dip2px(mContext,12f));
for (int i=0; i<mXNameListShow.size(); i++){
canvas.drawText(mXNameListShow.get(i),
mMarginLeftRight + xNameintervalX*(i)-DensityUtil.dip2px(mContext,12f),
getMeasuredHeight() - DensityUtil.dip2px(mContext,10f),
mLineTextPaint);
canvas.save();
}
}
mXNameListShow是横坐标的集合,默认10个以下的数据,通过外部获取
/**
* 设置底部时间数据*/
public void setxNameDataList(List<String> xNameDataList){
this.mXNameList = xNameDataList;
setxNameListShow();
postInvalidateDelayed(50);
}
因为从外部传来的数据有可能会很多记录几百几千个,因此抽样出其中10个以下的数据
/**
* 设置显示的时间
* 只显示10个时间点*/
private void setxNameListShow(){
int interval = mXNameList.size()/10+1;//只显示10个横坐标,的间隔
for (int i=0; i<mXNameList.size(); i = i+interval){
mXNameListShow.add(mXNameList.get(i));
}
}
01:00, 02:00, 03:00, 04:00, 05:00, 06:00, 07:00, 08:00, 09:00, 10:00
绘制贝塞尔曲线
绘制曲线之前,需要对贝塞尔曲线有最基本的了解。我了解的比较浅显,根据起点,终点和控制点就能绘制出一条(一段)曲线,然后将每一段拼接成一条长长的贝塞尔曲线,通常情况下,只需要3-5点的贝塞尔线拼接成的贝塞尔曲线就很好看了。由于坐标数量很多,所以需要从中抽样出几个点,绘制几段贝赛尔曲线拼接起来。
我认为这难点在于求没一段曲线的控制点。绘制二价贝塞尔曲线需要一个控制点,绘制三价曲线需要两个控制点。
怎么求控制点可以参考这里
然后把上面公式转换成代码就成了这样
这个方法用来获取每一段贝塞尔曲线所需要的点数据,包括起点,终点,控制点1,控制点2
pointList为安卓坐标系中的点集合,原始数据是
(1,1)
(2,10)
(3,6)
(4,8)
.
.
.
.
/**获取每一段曲线所需要的点集*/
private List<BezierLineData> getLineData(List<PointF> pointList){
float t = 0.5f;//比例
List<BezierLineData> lineDataList = new ArrayList<>();
PointF startP;
PointF endP;
PointF cp1;
PointF cp2;
BezierLineData lineData;
for (int i = 0; i<pointList.size() - 1;i ++){
startP = pointList.get(i);
endP = pointList.get(i+1);
cp1 = new PointF();
cp1.x = startP.x + (endP.x-startP.x) * t;
cp1.y = startP.y;
cp2 = new PointF();
cp2.x = startP.x + (endP.x-startP.x) * (1 - t);
cp2.y = endP.y;
lineData = new BezierLineData(startP,endP,cp1,cp2);
lineDataList.add(lineData);
}
return lineDataList;
}
BezierLineData定义如下
/**
* Created by allever on 17-9-28.
*
* 每一段曲线用到的数据点集
*/
public class BezierLineData {
private PointF startP;
private PointF endP;
private PointF cp1;
private PointF cp2;
public BezierLineData(PointF startP,PointF endP,PointF cp1,PointF cp2){
this.startP = startP;
this.endP = endP;
this.cp1 = cp1;
this.cp2 = cp2;
}
public PointF getStartP() {
return startP;
}
public void setStartP(PointF startP) {
this.startP = startP;
}
public PointF getEndP() {
return endP;
}
public void setEndP(PointF endP) {
this.endP = endP;
}
public PointF getCp1() {
return cp1;
}
public void setCp1(PointF cp1) {
this.cp1 = cp1;
}
public PointF getCp2() {
return cp2;
}
public void setCp2(PointF cp2) {
this.cp2 = cp2;
}
}
绘制贝塞尔曲线的方法
private void drawBezier2(Canvas canvas){
//绘制前先获取,保存一些数据,原始点(y值),每条曲线每一段的数据点集,绘制标注时用到.
initData();
mBezierPaint.setStyle(Paint.Style.STROKE);
mBezierPaint.setStrokeWidth(DensityUtil.dip2px(mContext,3f));//设置线宽
mBezierPaint.setAntiAlias(true);//去除锯齿
mBezierPaint.setStrokeJoin(Paint.Join.ROUND);
mBezierPaint.setStrokeCap(Paint.Cap.ROUND);
for (int i=0;i<mLineDataSetList.size();i++){
Path bezierPath = new Path();//曲线路径
bezierPath.moveTo(mBezierLineDataList.get(i).get(0).getStartP().x,mBezierLineDataList.get(i).get(0).getStartP().y);
for (int j=0; j<mBezierLineDataList.get(i).size();j++){
bezierPath.cubicTo(
mBezierLineDataList.get(i).get(j).getCp1().x, mBezierLineDataList.get(i).get(j).getCp1().y,
mBezierLineDataList.get(i).get(j).getCp2().x, mBezierLineDataList.get(i).get(j).getCp2().y,
mBezierLineDataList.get(i).get(j).getEndP().x, mBezierLineDataList.get(i).get(j).getEndP().y);
}
//设置颜色和渐变
int lineColor = mLineDataSetList.get(i).getColor();
mBezierPaint.setColor(lineColor);
LinearGradient mLinearGradient;
int[] colorArr;
if (mLineDataSetList.get(i).getGradientColors() != null){
colorArr = mLineDataSetList.get(i).getGradientColors();
}else {
colorArr = new int[]{lineColor,lineColor,lineColor,lineColor,lineColor};
}
mLinearGradient = new LinearGradient(
0,
mMarginTopBottom,
0,
getMeasuredHeight(),
colorArr,
null,
Shader.TileMode.CLAMP
);
mBezierPaint.setShader(mLinearGradient);
canvas.drawPath(bezierPath,mBezierPaint);
canvas.save();
}
}
绘制前先获取,保存一些数据,原始点(y值),每条曲线每一段的数据点集,绘制标注时用到.
initData();方法如下
private void initData(){
List<PointF> aOriginPointList;
//List<PointF> aAndroidPointList;
//List<BezierLineData> aLineDataList;
//List<PointF> aSelectedOriginPointList;
mBezierLineDataList.clear();
mOriginPointList.clear();
//mAndroidPointList.clear();
for (LineDataSet lineDataSet: mLineDataSetList){
//每一次遍历就是一条曲线数据
aOriginPointList = lineDataSet.getOldPointFsList();
if (aOriginPointList.size() == 0) continue;
mOriginPointList.add(aOriginPointList);
//lineDataSet.getOldPointFsList()获取原始坐标
//getSelectedPoint();获取筛选后的数据
//changePoint();将原始点转化为Android的坐标点
//getLineData();获取贝塞尔曲线的点集
mBezierLineDataList.add(
getLineData(
changePoint(
getSelectedPoint(
lineDataSet.getOldPointFsList()))));
}
}
getSelectedPoint(List pointFList);
/**
* 从全部数据中选中其中指定个数据*/
private List<PointF> getSelectedPoint(List<PointF> pointFList){
PointF pointF;
PointF selectedPoint;
float ySum = 0;
float averageY;
int interval = pointFList.size()/mBezierPointCount+1;
List<PointF> selectedPointList = new ArrayList<>();
if (pointFList.size()==0 ) return selectedPointList;
int j=0;
for (int i=0; i<pointFList.size();i++){
pointF = pointFList.get(i);
ySum += pointF.y;
if (i%interval==0){
j++;
averageY = ySum/interval;
//selectedPoint = new PointF(j, averageY);//求平均
selectedPoint = new PointF(j, pointF.y);//不求平均
selectedPointList.add(selectedPoint);
ySum = 0;
}
}
Log.d(TAG, "getSelectedPoint: selected count = " + selectedPointList.size());
//暂时办法-解决不够n个点
if (selectedPointList.size() < mBezierPointCount){
int curPosition;
for (curPosition= selectedPointList.size();curPosition<mBezierPointCount; curPosition++){
selectedPointList.add(new PointF(curPosition+1,selectedPointList.get(selectedPointList.size()-1).y));
}
}
Log.d(TAG, "getSelectedPoint: after selected count = " + selectedPointList.size());
return selectedPointList;
}
changePoint()
/**
* 把一般坐标转为 Android中的视图坐标**/
private List<PointF> changePoint(List<PointF> oldPointFs){
List<PointF> pointFs = new ArrayList<>();
float maxValueY = 0;
float yValue;
for (int i = 0; i < oldPointFs.size(); i++){
yValue = oldPointFs.get(i).y;
if (maxValueY < yValue) maxValueY = yValue+ (yValue*0.1f);//
}
Log.d(TAG, "changePoint: maxValueY = " + maxValueY);
//间隔,减去某个值是为了空出多余空间,为了画线以外,还要写坐标轴的值,除以坐标轴最大值
//相当于缩小图像
int blockCount = oldPointFs.size() - 1;
float intervalX = (getMeasuredWidth() - mMarginLeftRight * 2f)/blockCount;
float intervalY = (getMeasuredHeight() - mMarginTopBottom * 2f)/maxValueY-0f;
int height = getMeasuredHeight();
PointF p;
float x;
float y;
for (int i = 0; i< oldPointFs.size(); i++){
PointF pointF = oldPointFs.get(i);
//最后的正负值是左移右移
x = (pointF.x-1) * intervalX + mMarginLeftRight;
y = height - mMarginTopBottom - intervalY*pointF.y - DensityUtil.dip2px(mContext,5f);
p = new PointF(x, y);
pointFs.add(p);
}
return pointFs;
}
绘制曲线的核心代码
绘制曲线的方法是,初始化一个路径对象,设置起点,绘制路径
path.moveTo(startPx, startPy);
path.cubicTo(cp1x, cp1y, cp2x, cp2y, endPx, endPy);
for (int i=0;i<mLineDataSetList.size();i++){//绘制n条曲线
Path bezierPath = new Path();//曲线路径
bezierPath.moveTo(mBezierLineDataList.get(i).get(0).getStartP().x,mBezierLineDataList.get(i).get(0).getStartP().y);//移动到起点
//循环绘制路径
for (int j=0; j<mBezierLineDataList.get(i).size();j++){//
bezierPath.cubicTo(
mBezierLineDataList.get(i).get(j).getCp1().x, mBezierLineDataList.get(i).get(j).getCp1().y,
mBezierLineDataList.get(i).get(j).getCp2().x, mBezierLineDataList.get(i).get(j).getCp2().y,
mBezierLineDataList.get(i).get(j).getEndP().x, mBezierLineDataList.get(i).get(j).getEndP().y);
}
//设置颜色和渐变
int lineColor = mLineDataSetList.get(i).getColor();
mBezierPaint.setColor(lineColor);
LinearGradient mLinearGradient;
int[] colorArr;
if (mLineDataSetList.get(i).getGradientColors() != null){
colorArr = mLineDataSetList.get(i).getGradientColors();
}else {
colorArr = new int[]{lineColor,lineColor,lineColor,lineColor,lineColor};
}
mLinearGradient = new LinearGradient(
0,
mMarginTopBottom,
0,
getMeasuredHeight(),
colorArr,
null,
Shader.TileMode.CLAMP
);
mBezierPaint.setShader(mLinearGradient);
canvas.drawPath(bezierPath,mBezierPaint);
canvas.save();
}
曲线参数对象
/**
* Created by allever on 17-8-10.
* 每一条线对应一个对象
*/
public class LineDataSet {
private int color;//颜色,
private int[] gradientColors;//渐变色数组
private List<PointF> oldPointFsList;//原始点
private SportAnalysisType sportAnalysisType;//参数类型,项目中用到,实际上该字段无用
public int getColor() {
return color;
}
public void setColor(int color) {
this.color = color;
}
public int[] getGradientColors() {
return gradientColors;
}
public void setGradientColors(int[] gradientColors) {
this.gradientColors = gradientColors;
}
public List<PointF> getOldPointFsList() {
return oldPointFsList;
}
public void setOldPointFsList(List<PointF> oldPointFsList) {
this.oldPointFsList = oldPointFsList;
}
public SportAnalysisType getSportAnalysisType() {
return sportAnalysisType;
}
public void setSportAnalysisType(SportAnalysisType sportAnalysisType) {
this.sportAnalysisType = sportAnalysisType;
}
}
绘制圆角矩形标注
难点在于,求曲线上的y坐标(Android坐标系)
可以根据公式来求
/**
* B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
*
* @param t 曲线长度比例
* @param p0 起始点
* @param p1 控制点1
* @param p2 控制点2
* @param p3 终止点
* @return t对应的点
*/
public static PointF calculateBezierPointForCubic(float t, PointF p0, PointF p1, PointF p2, PointF p3) {
PointF point = new PointF();
float temp = 1 - t;
point.x = p0.x * temp * temp * temp + 3 * p1.x * t * temp * temp + 3 * p2.x * t * t * temp + p3.x * t * t * t;
point.y = p0.y * temp * temp * temp + 3 * p1.y * t * temp * temp + 3 * p2.y * t * t * temp + p3.y * t * t * t;
return point;
}
/**
* 绘制标注*/
private void drawMark2(Canvas canvas){
if (mDownX == -1) return;
if (mDownX < mMarginLeftRight || mDownX > mWidth-mMarginLeftRight ) return;
List<BezierLineData> lineDataList;
BezierLineData lineData;
float t;//点在曲线上的长度比例
float intevalBezierX = (mWidth-2*mMarginLeftRight)/((float)(mBezierPointCount-1));
PointF linePoint;//曲线上的坐标点
for(int i=0; i<mBezierLineDataList.size();i++){//曲线数量
//设置该条曲线的颜色和渐变,画笔
initLineStyle(i);
//获取该条曲线每段曲线的数据集合
lineDataList = mBezierLineDataList.get(i);
//判断触控点在哪一段曲线上
int bezierLinePosition = -1;//曲线段数索引
for (int n = 0;n<lineDataList.size();n++){
if ((mDownX > intevalBezierX*n + mMarginLeftRight) && ((mDownX < intevalBezierX*(n+1)+mMarginLeftRight))){
bezierLinePosition = n;
break;
}
}
if (bezierLinePosition == -1 ) return;
//根据段数获取该段曲线的数据点集合(起点,终点,控制点)
lineData = lineDataList.get(bezierLinePosition);
//求触控点在该段曲线上的长度比例
t = (mDownX-lineData.getStartP().x)/intevalBezierX;
linePoint = BezierUtil.calculateBezierPointForCubic(t,lineData.getStartP(),lineData.getCp1(),lineData.getCp2(),lineData.getEndP());
//求Marker上显示的数值
float value =0;
//根据触控点所在区间求y值(真实数据)
int position = -1;//触摸点所在区间
float intervalDataX = (mWidth-2*mMarginLeftRight)/((float)(mOriginPointList.get(i).size()));
for (int m=0;m<mOriginPointList.get(i).size();m++){
if ((mDownX > (intervalDataX*m+mMarginLeftRight) ) && (mDownX < (intervalDataX*(m+1)+mMarginLeftRight))){
position = m;
break;
}
}
if (position != -1 )value = mOriginPointList.get(i).get(position).y;
value = (float)(Math.round(value*1000))/1000;
String drawText = value + " " + mLineDataSetList.get(i).getSportAnalysisType().getUnit();
//Marker的宽高
float dataBoxWidth = DensityUtil.dip2px(mContext,drawText.length()*7.7f);//标注边框宽度//根据文字长度动态变化
float dataBoxHeight = DensityUtil.dip2px(mContext,30);
if (i%2==0){//根据曲线索引判断在左边还是在右边绘制
//处理边界,超出边界时在另一边绘制
if (mWidth-mMarginLeftRight-mDownX < dataBoxWidth/2){
//左边绘制
drawLeft(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}else {
//右边绘制
drawRight(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}
}else {
if (mDownX - mMarginLeftRight < dataBoxWidth/2){
//右边绘制
drawRight(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}else {
//左边绘制
drawLeft(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}
}
}
}
drawLeft()和drawRight()
private void drawRight(Canvas canvas, PointF linePoint, String drawText,float dataBoxWidth,float dataBoxHeight){
canvas.drawRoundRect(
new RectF(
mDownX + DensityUtil.dip2px(mContext,3f),
linePoint.y + DensityUtil.dip2px(mContext,5f),
mDownX + dataBoxWidth + DensityUtil.dip2px(mContext,3f),
linePoint.y + dataBoxHeight),
DensityUtil.dip2px(mContext,8f),
DensityUtil.dip2px(mContext,8f),
mRectPaint);
canvas.drawText(drawText,0,
drawText.length(),
mDownX +DensityUtil.dip2px(mContext,8f),
linePoint.y+DensityUtil.dip2px(mContext,21f),
mTextPaint);
}
private void drawLeft(Canvas canvas, PointF linePoint, String drawText,float dataBoxWidth,float dataBoxHeight){
canvas.drawRoundRect(
new RectF(
mDownX - dataBoxWidth - DensityUtil.dip2px(mContext,3f),
linePoint.y + DensityUtil.dip2px(mContext,5f),
mDownX - DensityUtil.dip2px(mContext,3f),
linePoint.y + dataBoxHeight),
DensityUtil.dip2px(mContext,8f),
DensityUtil.dip2px(mContext,8f),
mRectPaint);
canvas.drawText(drawText,0,
drawText.length(),
mDownX - dataBoxWidth + DensityUtil.dip2px(mContext,3f),
linePoint.y+DensityUtil.dip2px(mContext,21f),
mTextPaint);
}
绘制垂直线和底部三角形
/**
* 绘制标线及底部三角形*/
private void drawMarkLine2(Canvas canvas){
if (mDownX == -1) return;
if (mDownX < mMarginLeftRight || mDownX > mWidth-mMarginLeftRight ) return;
mVerticalPaint.setColor(Color.WHITE);
mVerticalPaint.setStrokeWidth(2f);
Path trianglePath = new Path();
canvas.drawLine(
mDownX,
mMarginTopBottom,
mDownX,
mHeight - mMarginTopBottom,
mVerticalPaint);
trianglePath.moveTo(mDownX, mHeight - mMarginTopBottom - 20f);
trianglePath.lineTo(mDownX - 20f, mHeight - mMarginTopBottom);
trianglePath.lineTo(mDownX + 20f, mHeight - mMarginTopBottom);
trianglePath.close();
canvas.drawPath(trianglePath,mVerticalPaint);
mDownX = -1;
}
处理滑动显示
大概思路就是监听到移动事件时,记录当前按下x坐标值,然后重绘制
@Override
public boolean onTouchEvent(MotionEvent event) {
float downX = event.getX();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mDownX = downX;
//postInvalidateDelayed(50);
Log.d(TAG, "onTouchEvent: ACTION_DOWN mDownX = " + mDownX);
invalidate();
break;
case MotionEvent.ACTION_MOVE:
Log.d(TAG, "onTouchEvent: ACTION_MOVE mDownX = " + mDownX);
mDownX = event.getX();
invalidate();
//postInvalidateDelayed(50);
break;
case MotionEvent.ACTION_UP:
break;
}
//postInvalidateDelayed(50);
return true;
}