Android-自定義貝塞爾曲線圖表控件

版權聲明:本文爲博主原創文章,未經博主允許不得轉載。 https://blog.csdn.net/devallever/article/details/78352928

寫在前面

由於項目需要,下圖的圖表控件,搜索了各種開源庫,沒有合適的,只能自定義了。這是我第一次做的自定義控件。寫的很渣,請多指教。
這裏寫圖片描述

拆分

該控件可以拆分幾個部分進行繪製
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;
    }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章