写在前面

由于项目需要,下图的图表控件,搜索了各种开源库,没有合适的,只能自定义了。这是我第一次做的自定义控件。写的很渣,请多指教。

该控件是项目中用到的,业务逻辑较多。但作为一个自定义View离不开那几部分

  • 重写onMeasure,测量控件大小
  • 重写onDraw,绘制界面,其中可能用到部分数学几何知识,但不难。绘制就那么几个drawXXX的方法,只要一步一步走,还是相对简单。
  • 重写onTouch,处理交互。

最后附上项目地址

https://github.com/devallever/AndroidUIKit

拆分

该控件可以拆分几个部分进行绘制

  1. 绘制5条水平分割线
  2. 绘制底部横坐标
  3. 绘制贝塞尔曲线
  4. 绘制圆角矩形标注
  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;
    }