Android自定義View繪製進階-水波浪温度刻度表

語言: CN / TW / HK

theme: smartblue highlight: agate


波浪形温度刻度表實現

前言

之前的繪製圓環,我們瞭解瞭如何繪製想要的形狀和進度的一些特點,那麼此篇文章我們更近一步,繪製一個稍微複雜一點的刻度與波浪。來一起復習一下Android的繪製。

相對應的這種類型的自定義View網上並不少見,但是如果我們要做一些個性化的效果,最好還是自己繪製一份,也相對的比較容易控制效果,如果想實現上面的效果,我們一般來説分為以下幾個步驟:

  1. 重寫測量方法,確保它是一個正方形
  2. 繪製刻度
  3. 繪製中心的圓與文字
  4. 水波紋的動畫
  5. 設置進度與動畫,一起動起來

思路我們已經有了,下面一步一步的來實現吧。

話不多説,Let's go

300.png

1、onMeasure重新測量

之前的圓環進度,我們並沒有重寫 onMeasure 方法,而是在佈局中指定為固定的寬高,其實兼容性和健壯性並不好,萬一寫錯了就會變形導致顯示異常。

最好的辦法是不管xml中設置為什麼值,這裏都能保證為一個正方形,要麼是取寬度為準,讓高度和寬度一致,要麼就是寬度高度取最大值,讓他們保持一致。由於我們是豎屏的應用,所以我就取寬度為準,讓高度和寬度一致。

前面我們只是講了 onDraw 並沒有講到 onMeasure , 這裏簡單的説一下。

我們為什麼要重寫 onMeasure ? 1. 為了自定義View尺寸的規則,如果你的自定義View的尺寸是根據父控件行為一致,就不需要重寫onMeasure()方法。 2. 如果不重寫onMeasure方法,那麼自定義view的尺寸默認就和父控件一樣大小,當然也可以在佈局文件裏面寫死寬高,而重寫該方法可以根據自己的需求設置自定義view大小。

一般來説我們重寫的 onMeasure 長這樣: kotlin override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec,heightMeasureSpec) }

widthMeasureSpec ,heightMeasureSpec 並不是真正的寬高,看名字就知道,它只是寬高測量的規格,我們通過 MeasureSpec 的一些靜態方法,通過它們拿到一些信息。

static int getMode(int measureSpec):根據提供的測量值(規格)提取模式(上述三個模式之一)

測量的 Model 一共有三種 1. UNSPECIFIED(未指定),父元素部隊自元素施加任何束縛,子元素可以得到任意想要的大小; 2. EXACTLY(完全),父元素決定自元素的確切大小,子元素將被限定在給定的邊界裏而忽略它本身大小; 3. AT_MOST(至多),子元素至多達到指定大小的值。

我們常用的就是 EXACTLY 和 AT_MOST ,EXACTLY 對應的就是我們設置的match_parent或者300這樣的精確值,而 AT_MOST 對應的就是wrap_content。

static int getSize(int measureSpec):根據提供的測量值(規格)提取大小值(這個大小也就是我們通常所説的大小)

通過此方法就能獲取控件的寬度和高度值。

static int makeMeasureSpec(int size,int mode):根據提供的大小值和模式創建一個測量值(規格)

通過具體的寬高和model,創建對應的寬高測量規格,用於確定View的測量

onMeasure 的最終設置確定寬度的測量有兩種方式, 1. setMeasuredDimension(width, height) 2. super.onMeasure(widthMeasureSpec,heightMeasureSpec)

實戰:

比如我們的自定義温度刻度View,我們整個View要確保一個正方形,那麼就拿到寬度,設置同樣的高度,然後確定測量,流程如下: ```java //重新測量 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    //獲取控件的寬度,高度
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int newWidthMeasureSpec = widthMeasureSpec;

    //如果沒有指定寬度,默認給200寬度
    if (widthMode != MeasureSpec.EXACTLY) {
        newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY);
    }

    //獲取到最新的寬度
    int width = MeasureSpec.getSize(newWidthMeasureSpec) - getPaddingLeft() - getPaddingRight();

    //我們要的是矩形,不管高度是多高,讓它總是和寬度一致
    int height = width;

    centerPosition.x = width / 2;
    centerPosition.y = height / 2;
    radius = width / 2f;
    mRectF.set(0f, 0f, width, height);


    //最後設置生效-下面兩種方式都可以
    // setMeasuredDimension(width, height);

    super.onMeasure(
            MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
            MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
    );

}

```

這裏有詳細的註釋,大致實現的效果如下:

image.png

2、繪製刻度

由於原本的 Canvas 內部沒有繪製刻度這麼一説,所以我們只能用繪製線條的方式,就是 drawLine 方法。

為了瞭解到座標系和方便實現,我們可以先繪製一個圓環,定位我們刻度需要繪製的位置。 ```java @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas);

    //畫圓環
    canvas.drawArc(
            mRectF.left + 2f, mRectF.top + 2f, mRectF.right - 2f, mRectF.bottom - 2f,
            mStartAngle, mSweepAngle, false, mDegreeCirPaint
    );
}

```

這個圓環是之前講到過了,就不過多贅述了,實現效果如下:

image.png

由於開始繪製的地方在左上角位置,我們要移動到圓的中心點開始繪製,也就是紅色點移動到藍色點。

我們就需要x軸和y軸做一下偏移 canvas.translate(radius, radius);

默認的 drawLine 都是橫向繪製,我們想要實現效果圖的效果,就需要旋轉一下畫筆,也就是用到 canvas.rotate(rotateAngle);

那麼旋轉多少了,如果説最底部是90度,我們的起始角度是120度開始的,我們就起始旋轉30度。後面每一次旋轉就按照百分比來,比如我們100度的温度,那麼就相當於要畫100個刻度,我們就用需要繪製的角度除以100,就是每一個刻度的角度。

具體的刻度實現代碼: ```java

private float mStartAngle = 120f;  // 圓弧的起始角度
private float mSweepAngle = 300f; //繪製的起始角度和滑過角度(繪製300度)
private float mTargetAngle = 300f;  //刻度的角度(根據此計算需要繪製有色的進度)

private void drawDegreeLine(Canvas canvas) {
    //先保存
    canvas.save();

    // 移動畫布
    canvas.translate(radius, radius);
    // 旋轉座標系,需要確定旋轉角度
    canvas.rotate(30);

    // 每次旋轉的角度
    float rotateAngle = mSweepAngle / 100;
    // 累計疊加的角度
    float currentAngle = 0;
    for (int i = 0; i <= 100; i++) {

        if (currentAngle <= mTargetAngle && mTargetAngle != 0) {
            // 計算累計劃過的刻度百分比
            float percent = currentAngle / mSweepAngle;

            //動態的設置顏色
            mDegreelinePaint.setColor(evaluateColor(percent, Color.GREEN, Color.RED));

            canvas.drawLine(0, radius, 0, radius - 20, mDegreelinePaint);

            // 畫過的角度進行疊加
            currentAngle += rotateAngle;

        } else {
            mDegreelinePaint.setColor(Color.WHITE);
            canvas.drawLine(0, radius, 0, radius - 20, mDegreelinePaint);
        }

        //畫完一個刻度就要旋轉移動位置
        canvas.rotate(rotateAngle);
    }

    //再恢復
    canvas.restore();

}

```

加上圓環與刻度的效果圖: image.png

3. 設置刻度動畫

前面的一篇我們使用的是屬性動畫不停的繪製從而實現進度的效果,那麼這一次我們使用定時任務的方式也是可以實現動畫的效果。

由於我們之前的 drawDegreeLine 方法內部控制繪製進度的變量就是 targetAngle 來控制的,所以我們通過入口方法設置温度的時候通過定時任務的方式來控制。

代碼如下:

```java

//動畫狀態
private boolean isAnimRunning;
// 手動實現越來越慢的效果
private int[] slow = {10, 10, 10, 8, 8, 8, 6, 6, 6, 6, 4, 4, 4, 4, 2};
// 動畫的下標
private int goIndex = 0;

//設置温度,入口的開始
public void setupTemperature(float temperature) {
    mCurPercent = 0f;
    totalAngle = (temperature / 100) * mSweepAngle;
    targetAngle = 0f;
    mCurPercent = 0f;
    mCurTemperature = "0.0";
    mWaveUpValue = 0;

    startTimerAnim();
}

  //使用定時任務做動畫
private void startTimerAnim() {

    if (isAnimRunning) {
        return;
    }

    mAnimTimer = new Timer();
    mAnimTimer.schedule(new TimerTask() {

        @Override
        public void run() {

            isAnimRunning = true;
            targetAngle += slow[goIndex];
            goIndex++;
            if (goIndex == slow.length) {
                goIndex--;
            }
            if (targetAngle >= totalAngle) {
                targetAngle = totalAngle;
                isAnimRunning = false;
                mAnimTimer.cancel();
            }

            // 計算的温度
            mCurPercent = targetAngle / mSweepAngle;
            mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

            // 水波紋的高度
            mWaveUpValue = (int) (mCurPercent * (mSmallRadius * 2));

            postInvalidate();
        }
    }, 250, 30);

}

```

那麼刻度動畫的效果如下:

rote-02.gif

4. 繪製中心的圓與文字

我們再動畫中記錄動畫的百分比進度,和動畫當前的温度。

```java ...
// 計算的温度 mCurPercent = targetAngle / mSweepAngle; mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

postInvalidate();

...

```

我們記錄一下小圓的半徑和文本的畫筆資源

```java private float mSmallRadius = 0f; private Paint mTextPaint; private Paint mSmallCirclePaint; private float mCurPercent = 0f; //進度 private String mCurTemperature = "0.0"; private DecimalFormat mDecimalFormat;

private void init() {
    ...

    mTextPaint = new Paint();
    mTextPaint.setAntiAlias(true);
    mTextPaint.setTextAlign(Paint.Align.CENTER);
    mTextPaint.setColor(Color.WHITE);

    mSmallCirclePaint = new Paint();
}

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

    ...

    //畫小圓
    drawSmallCircle(canvas, evaluateColor(mCurPercent, Color.GREEN, Color.RED));

    //畫中心的圓與文本
    drawTemperatureText(canvas);

}

```

具體的文本與小圓的繪製 ```java private void drawSmallCircle(Canvas canvas, int evaluateColor) { mSmallCirclePaint.setColor(evaluateColor); mSmallCirclePaint.setAlpha(65); canvas.drawCircle(centerPosition.x, centerPosition.y, mSmallRadius, mSmallCirclePaint); }

private void drawTemperatureText(Canvas canvas) {

    //提示文字
    mTextPaint.setTextSize(mSmallRadius / 6f);
    canvas.drawText("當前温度", centerPosition.x, centerPosition.y - mSmallRadius / 2f, mTextPaint);

    //温度文字
    mTextPaint.setTextSize(mSmallRadius / 2f);
    canvas.drawText(mCurTemperature, centerPosition.x, centerPosition.y + mSmallRadius / 4f, mTextPaint);

    //繪製單位
    mTextPaint.setTextSize(mSmallRadius / 6f);
    canvas.drawText("°C", centerPosition.x + (mSmallRadius / 1.5f), centerPosition.y, mTextPaint);

}

```

由於進度和温度都是動畫在 invalidate 之前賦值的,所以我們的文本和小圓天然就支持動畫的效果了。

效果如下:

rote-03.gif

5. 水波紋動畫

水波紋的效果,我們不能直接用 Canvas 來繪製,我們可以用刻度的方法用 drawLine的方式來繪製,如何繪製呢?相信大家也有了解,就是正弦函數了。

由於我們的效果是兩個水波紋相互疊加起起伏伏的效果,所以我們定義兩個函數。

總體的思路是:我們定義兩個數組來管理我們的Y軸的值,通過正弦函數給Y軸賦值,然後在drawLine的時候取出對應的x軸的y值就可以繪製出來。

x軸其實就是我們的控件寬度,我們先用一個數組保存起來

```java private float[] mFirstWaterLine; private float[] mSecondWaterLine;

 @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    //獲取控件的寬度,高度
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int newWidthMeasureSpec = widthMeasureSpec;

    //如果沒有指定寬度,默認給200寬度
    if (widthMode != MeasureSpec.EXACTLY) {
        newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY);
    }

    //獲取到最新的寬度
    int width = MeasureSpec.getSize(newWidthMeasureSpec) - getPaddingLeft() - getPaddingRight();

    //我們要的是矩形,不管高度是多高,讓它總是和寬度一致
    int height = width;


    mFirstWaterLine = new float[width];
    mSecondWaterLine = new float[width];


    super.onMeasure(
            MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
            MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
    );

}

```

然後我們再繪製之前就先對x軸對應的y值賦值,然後繪製的時候就取出對應的y值來 drawLine,具體的代碼如下:

動畫的時候先對橫向運動和垂直運動的變量做一個賦值: ```java private int mWaveUpValue = 0; private float mWaveMoveValue = 0f;

//使用定時任務做動畫
private void startTimerAnim() {

    if (isAnimRunning) {
        return;
    }
    mAnimTimer = new Timer();
    mAnimTimer.schedule(new TimerTask() {

        @Override
        public void run() {

            ...

            // 計算的温度
            mCurPercent = targetAngle / mSweepAngle;
            mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

            // 水波紋的高度
            mWaveUpValue = (int) (mCurPercent * (mSmallRadius * 2));

            postInvalidate();
        }
    }, 250, 30);

}

public void moveWaterLine() {
    mWaveTimer = new Timer();
    mWaveTimer.schedule(new TimerTask() {

        @Override
        public void run() {
            mWaveMoveValue += 1;
            if (mWaveMoveValue == 100) {
                mWaveMoveValue = 1;
            }
            postInvalidate();
        }
    }, 500, 200);
}

```

拿到了對應的變量值之後,然後開始繪製:

```java /* * 繪製水波 / private void drawWaterWave(Canvas canvas, int color) {

    int len = (int) mRectF.right;

    // 將週期定為view總寬度
    float mCycleFactorW = (float) (2 * Math.PI / len);

    // 得到第一條波的峯值
    for (int i = 0; i < len; i++) {
        mFirstWaterLine[i] = (float) (10 * Math.sin(mCycleFactorW * i + mWaveMoveValue) - mWaveUpValue);
    }
    // 得到第一條波的峯值
    for (int i = 0; i < len; i++) {
        mSecondWaterLine[i] = (float) (15 * Math.sin(mCycleFactorW * i + mWaveMoveValue + 10) - mWaveUpValue);
    }

    canvas.save();

    // 裁剪成圓形區域
    Path path = new Path();
    path.addCircle(len / 2f, len / 2f, mSmallRadius, Path.Direction.CCW);
    canvas.clipPath(path);
    path.reset();

    // 將座標系移到底部
    canvas.translate(0, centerPosition.y + mSmallRadius);

    mSmallCirclePaint.setColor(color);

    for (int i = 0; i < len; i++) {
        canvas.drawLine(i, mFirstWaterLine[i], i, len, mSmallCirclePaint);
    }
    for (int i = 0; i < len; i++) {
        canvas.drawLine(i, mSecondWaterLine[i], i, len, mSmallCirclePaint);
    }

    canvas.restore();

}

```

一個是對Y軸賦值,一個是取出x軸對應的y軸進行繪製,這裏需要注意的是我們裁剪出了一個小圓的圖形,並且覆蓋在小圓上面實現出效果圖的樣子。

運行的效果如下:

rote-04.gif

要記得對定時器進行資源你的關閉哦。

java @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (mWaveTimer != null) { mWaveTimer.cancel(); } if (mAnimTimer != null && isAnimRunning) { mAnimTimer.cancel(); } }

使用的時候我們只需要設置温度即可開始動畫。

```kotlin findViewById(R.id.set_progress).click {

       val temperatureView = findViewById<TemperatureView>(R.id.temperature_view)
        temperatureView .setupTemperature(70f)
    }

```

後記

由於是自用定製的,本人也比較懶,所以並沒有對一些配置的屬性做自定義屬性的抽取,比如圓環的間距,大小,顏色,波紋的間距,動畫的快慢等等。

內部加了一點點測量的用法,但是主要還是繪製的流程,基本上把常用的幾種繪製方式都用到了。以後有類似的效果大家也可以按需修改即可。

由於是自用的一個View,相對圓環進度沒有那麼多場景使用,就沒有抽取出來上傳到Maven,如果大家有興趣可以查看源碼點擊【傳送門】

同時,你也可以關注我的這個Kotlin項目,我有時間都會持續更新。

慣例,我如有講解不到位或錯漏的地方,希望同學們可以指出交流。

如果感覺本文對你有一點點的啟發,還望你能點贊支持一下,你的支持是我最大的動力。

Ok,這一期就此完結。

本文正在參加「金石計劃 . 瓜分6萬現金大獎」

「其他文章」