天天看點

用Bezier繪制圓滑曲線

新項目需求裡面,需要根據不同的資料,繪制一條圓滑曲線,最開始想到的是用二階貝塞爾曲線來繪制。兩個資料點,一個控制點,三個資料,從第0個開始,依次推進。實踐的時候 發現不對,繪制出來的資料跟UI要求的或者跟預期中的差距太遠了。

用Bezier繪制圓滑曲線

項目需求的曲線

在度娘哪裡得到的資訊,無外乎兩種:

一種就是教你如何繪制三個點下的二階貝塞爾,或者說4個點下的三階貝塞爾。笑話,單單幾個點獨立成線并且将資料點和控制點都告訴你的繪制,我也會。問題是,我現在隻告訴你兩個資料點,沒有控制點,你如何繪制出完美的貝塞爾曲線?

第二種就是裝13的貨,列出一大堆高深莫測的貝塞爾推導公式,能徹底了解這些公式的數學大牛有幾個?話說我都這麼牛逼的數學大牛了,還這裡給你瞎BBB?一點也不實際。

另外一個共同點就是,無論第一種情況還是第二種情況,原創的都太少了,都是你抄過去,我抄過來,對于臉盲的我來說,一臉懵逼。

度娘沒有,沒大神指導,就自己研究呗。

後面研究發現,繪制這樣的曲線其實需要更高階的三階貝塞爾曲線來完成。并且在兩個資料點和兩個控制點的坐标關系上,有如下規律。有了這個規律,就能大大減輕計算複雜程度了。具體規律是:

三階貝塞爾曲線的繪制方法:linePath.moveTo()、linePth.cubicTo();
繪制三階貝塞爾曲線,需要4個點:兩端的兩個資料點(startPoint,endPoint),中間兩個控制點(controlAPoint、controlBPoint)。
這4個點的坐标值的确定方法(假如給定資料List<Integer> list):
一、startPoint:
1、當繪制第一個點的時候:start.x為曲線起始點到View左邊界的距離,可以簡單的了解為paddingLeft,如下圖中紅框框1的寬度;start.y為list.get(0);
2、當繪制第1+N個點的時候,startPoint = endPoint;endPoint後面會介紹。

二、controlAPoint:
controlA.x = start.x + L/2;		 controlA.y =start.y;     L為如上圖中紅框框2的寬度。

三、controlBPoint:
controlB.x = controlA.x;			controlB.y = end.y;

四、endPoint:
end.x = start.x + L;同上,L為如上圖中紅框框2的寬度。
End.y = list.get(i)(注意,這裡的i>0,從1開始);
           
用Bezier繪制圓滑曲線

這是繪制三階貝塞爾曲線的4個坐标點的關系計算。

具體繪制如下:

public class MyBezierChhar extends View {
    /**曲線的畫筆*/
    private Paint linePaint;
    /**錨點的畫筆*/
    private Paint pointPaint;
    /**軸線文本,坐标文本的畫筆*/
    private Paint textPaint;
    /**警示框文本的畫筆*/
    private Paint warnPaint;
    /**軸線的畫筆*/
    private Paint shaftPaint;
    private Paint warnTextPaint;

    private Path warnPath;

    private Path linePath;
    private Path shaftPath;

    private int viewWidth;
    private int viewHight;
    private float textPaintSize = 24f;
    private float lineWidth = 4f;
    private float pointWidth = 8f;
    
    float maxValue = 0;

    /**Y軸坐标偏移量*/
    private int offSetShaftY = dpTopx(5);
    /**X軸坐标偏移量*/
    private int offSetShaftX = dpTopx(5);

    /**在onDraw方法裡面執行個體化*/
    private PointF[] points;

    private List<Float> list;

    private List<String> dateList;

    private void initNativeParams(){
        linePaint = new Paint();
        linePaint.setStrokeWidth(lineWidth);
        linePaint.setAntiAlias(true);
        linePaint.setStyle(Paint.Style.STROKE);
        linePaint.setStrokeCap(Paint.Cap.ROUND);
        linePaint.setColor(Color.parseColor("#0072ff"));

        pointPaint = new Paint();
        pointPaint.setStrokeWidth(pointWidth);
        pointPaint.setAntiAlias(true);
        pointPaint.setStyle(Paint.Style.FILL);
        pointPaint.setStrokeCap(Paint.Cap.ROUND);
        pointPaint.setColor(Color.parseColor("#0072ff"));

        textPaint = new Paint();
        textPaint.setAntiAlias(true);
        textPaint.setStyle(Paint.Style.FILL);
        textPaint.setColor(Color.parseColor("#0072ff"));
        textPaint.setTextSize(textPaintSize);
        textPaint.setTextAlign(Paint.Align.CENTER);

        warnPaint = new Paint();
        warnPaint.setAntiAlias(true);
        warnPaint.setStyle(Paint.Style.FILL);
        warnPaint.setColor(Color.parseColor("#ffa200"));

        float warnTextPaintSize = 24f;
        warnTextPaint = new Paint();
        warnTextPaint.setTextSize(warnTextPaintSize);
        warnTextPaint.setAntiAlias(true);
        warnTextPaint.setStyle(Paint.Style.FILL);
        /**左對齊*/
        warnTextPaint.setTextAlign(Paint.Align.LEFT);
        warnTextPaint.setColor(Color.parseColor("#ffffff"));

        shaftPaint = new Paint();
        shaftPaint.setStrokeWidth(0.5f);
        shaftPaint.setAntiAlias(true);
        shaftPaint.setStyle(Paint.Style.STROKE);
        shaftPaint.setColor(Color.parseColor("#0072ff"));

        linePath = new Path();
        shaftPath = new Path();
        warnPath = new Path();
        list = new ArrayList<>();
    }

    public MyBezierChhar(Context context) {
        super(context);
        initNativeParams();
    }

    public MyBezierChhar(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initNativeParams();
    }

    public MyBezierChhar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initNativeParams();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        viewWidth = w;
        viewHight = h;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        /**繪制背景色*/
        canvas.drawColor(Color.parseColor("#ffffffff"));
        /**offSetShaftY:Y軸坐标偏移量,分正負和方向*/
        canvas.translate(0, offSetShaftY);

        /**offSetShaftX:X軸坐标偏移量,分正負和方向*/
        canvas.drawLine(offSetShaftX,0, viewWidth, 0, shaftPaint);

        /**areaNumber個資料,areaNumber-1根線*/
        int areaNumber = getList().size();
        /**Y軸便宜後,水準方向還可以用的寬度*/
        int shaftLineWidth = viewWidth - offSetShaftY;
        /**平均每個區間的寬度*/
        float averageWidth = shaftLineWidth / areaNumber;
        /**areaNumber個資料, areaNumber-1根線,第0根線(也可以看做Y坐标),不畫,作用從1開始*/
        for (int i = 1; i < areaNumber; i++) {
            float coordinates = offSetShaftY + averageWidth * i;
            canvas.drawLine(coordinates, 0, coordinates, viewHight, shaftPaint);
        }
        /**Y軸方向偏移5dp的距離,繪制坐标文本*/
        if (dateList.size() != areaNumber) {
            return;
        }

        for (int i = 0; i < dateList.size(); i++) {
            float locationX = averageWidth / 2 + averageWidth * i;
            canvas.drawText(dateList.get(i), locationX, (offSetShaftY + textPaintSize), textPaint);
        }

        float drawAreaY = (viewHight - offSetShaftY - textPaintSize) * 0.75f;

        /**繪制曲線*/
        /**曲線繪制區域:
         * X方向:offSetShaftX開始的整個水準方向
         * Y方向:(viewWidth(View的整體高度) - offSetShaftY(垂直方向的偏移量) - textPaintSize(坐标值得尺寸))*0.75的區域内*/
        /**最大的值*/
        for (int i = 0; i < list.size(); i++) {
            if(list.get(i) > maxValue){
                maxValue = list.get(i);
            }
        }
        /**所有點都位于自己區域内水準中心線上,是以所有點的X坐标公式:
         * float locationX = averageWidth / 2 + averageWidth*i;
         **/
        /**起始點的坐标*/
        PointF start = new PointF();
        start.x = (averageWidth / 2) + offSetShaftX;
        /**(list.get(0) / maxValue):算出給定的資料占總高度的百分比, 乘drawAreaY就得出新的高度*/
        start.y = viewHight - ((list.get(0) / maxValue) * drawAreaY + offSetShaftY + textPaintSize + dpTopx(12));

        points[0] = start;
        linePath.moveTo(start.x, start.y);

        for (int i = 1; i < list.size(); i++) {
            PointF end = new PointF();
            end.x = averageWidth / 2 + offSetShaftX + averageWidth * i;
            end.y = viewHight - ((list.get(i) / maxValue) * drawAreaY + offSetShaftY + textPaintSize + dpTopx(12));

            float controlPointA = start.x + averageWidth / 2;
            /**控制點1*/
            PointF controlA = new PointF();
            controlA.set(controlPointA, start.y);
            /**控制點2*/
            PointF controlB = new PointF();
            controlB.set(controlPointA, end.y);

            linePath.cubicTo(controlA.x, controlA.y, controlB.x, controlB.y, end.x, end.y);

            start = end;
            points[i] = end;
        }
        canvas.drawPath(linePath, linePaint);

        for (int i = 0; i < points.length; i++) {
            PointF pointF = points[i];
            if (pointF == null) {
                return;
            }
            /**繪制錨點*/
            canvas.drawCircle(pointF.x, pointF.y, pointWidth, pointPaint);
            /**繪制坐标值*/
            canvas.drawText(String.valueOf(list.get(i)), pointF.x + textPaintSize, pointF.y - textPaintSize, textPaint);

            /**不合格資料辨別*/
            if (list.get(i) < 50) {
                /**圓角矩形*/
                float startX = pointF.x + (textPaintSize/2);
                float startY = pointF.y + (textPaintSize/2);
                float endX = pointF.x + textPaintSize + 80;
                float endY = pointF.y + textPaintSize + 30;
                RectF rectF = new RectF(startX, startY, endX, endY);
                warnPath.addRoundRect(rectF, 10, 10, Path.Direction.CCW);
                canvas.drawPath(warnPath, warnPaint);

                /**不合格警示文本*/
                String warnText = "未達标";
                canvas.drawText(warnText, startX + 9, startY + 29, warnTextPaint);
            }
        }
    }

    public List<Float> getList() {
        if (list == null) {
            return new ArrayList<>();
        }
        return list;
    }

    public void setList(List<Float> list) {
        this.list = list;
        postInvalidate();
    }

    public List<String> getDateList() {
        if (dateList == null) {
            return new ArrayList<>();
        }
        return dateList;
    }

    public void setDateList(List<String> dateList) {
        this.dateList = dateList;
        if (dateList == null || dateList.size() < 1) {
            return;
        }
        points = new PointF[dateList.size()];
        postInvalidate();
    }

    private int dpTopx(float dipValue) {
        final float scale = getResources().getDisplayMetrics().density;
        return (int) (dipValue * scale + 0.5f);
    }

    private int spTopx(float spValue) {
        final float fontScale = getResources().getDisplayMetrics().scaledDensity;
        return (int) (spValue * fontScale + 0.5f);
    }
}
           

Activity裡面用法如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:background="@color/back_ground"
    tools:context="com.haoyue.demolist.bezier.MyBeizierActivity">

    <FrameLayout
        android:id="@+id/flAddNewView"
        android:layout_width="match_parent"
        android:layout_height="300dp"/>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:layout_margin="10dp"
        android:background="@drawable/view_click"
        android:text="加載曲線圖"
        android:gravity="center"
        android:onClick="loadBeizier"/>
</LinearLayout>
           

Java代碼如下:

FrameLayout flAddNewView;
MyBezierChhar bezierChhar;
List<Float> list = new ArrayList<>();
List<String> dateList = new ArrayList<>();

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_my_beizier);

    flAddNewView = findViewById(R.id.flAddNewView);
    bezierChhar = new MyBezierChhar(this);
}


public void loadBeizier(View view){
    int viewNumber = flAddNewView.getChildCount();
    if (viewNumber > 0) {
        bezierChhar.postInvalidate();
        return;
    }

    for (int i = 1; i < 11; i++) {
        dateList.add(i + "日");
    }
    list.add(200f);
    list.add(100f);
    list.add(300f);
    list.add(20f);
    list.add(120f);
    list.add(60f);
    list.add(160f);
    list.add(300f);
    list.add(50f);
    list.add(150f);
    bezierChhar.setList(list);
    bezierChhar.setDateList(dateList);
    flAddNewView.addView(bezierChhar);
}
           

實際運作效果圖:

用Bezier繪制圓滑曲線

完全滿足了UI設計得需求。

繼續閱讀