PathMeasure的API講解與實戰——Android高階UI

猛猛的小盆友發表於2019-01-05

目錄
一、前言
二、API講解
三、實戰
四、更多案例
五、寫在最後

一、前言

2019年了,然而2017計劃寫的東西還沒開始?,這次的拖延症來的比平常早卻去的比平常晚。今天進行分享的是UI中的PathMeasure,同時記錄自己在使用過程中的幾個疑惑點。話不多說,開始進入正題。

二、API講解

這一小節主要是對PathMeasure的構造方法公有方法進行講解

1、構造方法

(1)PathMeasure()

public PathMeasure() 
複製程式碼

方法描述: 建立一個空的PathMeasure,但是使用之前需要先呼叫 setPath 方法來與 Path 進行關聯。被關聯的 Path 必須是已經建立好的,如果關聯之後 Path 內容進行了更改,則需要使用 setPath 方法重新關聯。

(2)PathMeasure(Path path, boolean forceClosed)

public PathMeasure(Path path, boolean forceClosed) 
複製程式碼

方法描述: 建立 PathMeasure 並關聯一個指定的Path,且Path需要已經建立完成。 這個構造方法其實 和 使用 PathMeasure() 後呼叫 setPath方法 進行關聯一個Path的效果是一樣的;當然,被關聯的 Path 也必須是已經建立好的,如果關聯之後 Path 內容進行了更改,則需要使用 setPath 方法重新關聯。

引數解析: 第一個引數 path: 被關聯的 Path,也就是需要測量的Path; 第二個引數 forceClosed: 是否要閉合Path。 設定為true:則不論Path是否閉合,都會自動閉合該 Path(如果Path可以閉合的話),然後進行測量; 設定為false:則Path保持原來的樣子,進行測量;

值得注意的兩個小點:(敲黑板了!!!)
1、不論 forceClosed 設定為何種狀態(true 或者 false),都不會影響原有Path的狀態,即 Path 與 PathMeasure 關聯之後,之前的Path 不會有任何改變
2、forceClosed 的設定狀態可能會影響測量結果,如果 Path 未閉合但在與 PathMeasure 關聯的時候設定 forceClosed 為 true 時,測量結果可能會比 Path 實際長度稍長一點,具體請看下面的例子。

舉個例子?

完整程式碼請看這裡,傳送門

程式碼主要畫了如下圖的路徑,然後對使用PathMeasure與該path進行關聯,一個對forceClosed設定為true,一個為false,然後進行日誌列印。

PathMeasure的API講解與實戰——Android高階UI
可以清楚的設定為true的路徑長度為800(五段折線加起來是600,再加上頭尾相連的長度200,正好是800),而為false的長度為600(正好是五段折線加起來是600) 如果你的Path已經是閉合的(即頭尾相連的),則此時forceClosed設定為true或false,其長度結果是一樣的。

Path mPath;
Paint mPaint;
int width;
int height;
boolean isInit = false;
PathMeasure closePathMeasure;
PathMeasure noClosePathMeasure;

@Override
protected void init(Context context) {
    mPath = new Path();
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mPaint.setColor(ContextCompat.getColor(context, R.color.color_blue));
    mPaint.setStyle(Paint.Style.STROKE);
}

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

    if (!isInit) {
        isInit = true;
        width = getMeasuredWidth() / 2;
        height = getMeasuredHeight() / 2;
        
        mPath.lineTo(0, 100);
        mPath.lineTo(100, 100);
        mPath.lineTo(100, -100);
        mPath.lineTo(200, -100);
        mPath.lineTo(200, 0);

        closePathMeasure = new PathMeasure(mPath, true);
        float closeLength = closePathMeasure.getLength();

        noClosePathMeasure = new PathMeasure(mPath, false);
        float noCloseLength = noClosePathMeasure.getLength();
        
        Log.i(TAG, "[closeLength:" + closeLength +
                "; noCloseLength:" + noCloseLength + "]");
    }
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(width, height);
    canvas.drawPath(mPath, mPaint);
}
複製程式碼

日誌輸出:

PathMeasure的API講解與實戰——Android高階UI

2、共有方法

(1)setPath

public void setPath(Path path, boolean forceClosed) 
複製程式碼

方法描述: 關聯一個Path,該方法的作用是:當路徑Path變動後,PathMeasure需要重新關聯,否則從PathMeasure得到的資料還是之前關聯的Path資料,而並非新的Path資料。

引數解析: 第一個引數 path: 被關聯的 Path,也就是需要測量的Path; 第二個引數 forceClosed: 是否要閉合Path。 設定為true:則不論Path是否閉合,都會自動閉合該 Path(如果Path可以閉合的話),然後進行測量; 設定為false:則Path保持原來的樣子,進行測量;

值得注意的兩個小點:(此處和構造方法PathMeasure(Path path, boolean forceClosed)的描述是一樣)
1、不論 forceClosed 設定為何種狀態(true 或者 false),都不會影響原有Path的狀態,即 Path 與 PathMeasure 關聯之後,之前的Path 不會有任何改變
2、forceClosed 的設定狀態可能會影響測量結果,如果 Path 未閉合但在與 PathMeasure 關聯的時候設定 forceClosed 為 true 時,測量結果可能會比 Path 實際長度稍長一點,具體可看PathMeasure(Path path, boolean forceClosed)方法講解中的例子。

(2)getLength

public float getLength()
複製程式碼

方法描述: 返回當前關聯路徑輪廓的總長度,或者如果沒有路徑,則返回0。

(3)isClosed

public boolean isClosed()
複製程式碼

方法描述: 測量的路徑是否閉合。ture為閉合,false為不閉合。

值得注意 這裡的閉合取決於兩點: 1、Path 本來就是閉合的,則isClosed返回的就是true。 2、如果 Path 不是閉合的,但在與PathMeasure關聯時(通過構造方法關聯或是通過setPath關聯),將forceClosed設定為true。此時,isClosed返回true。

(4)nextContour

public boolean nextContour()
複製程式碼

方法描述: 獲取在路徑中下一個輪廓,如果有下一個輪廓,則返回true,且PathMeasure切至下一個輪廓的資料;如果沒有下一個輪廓則返回false。至於怎麼才算一個輪廓,且看下面例子:

舉個例子? 這段程式碼主要是畫了三次,即moveTo了三次,所以即使在圖中看起來是兩個正方形,但在PathMeasure中可以得出三段輪廓。每次呼叫nextContour,都按我們畫的順序給我們切換,直至最後一個輪廓在呼叫nextContour時返回false,則中斷迴圈。

Path mNextContourPath;
PathMeasure nextContourPathMeasure;
int width;
int height;
boolean isInit = false;
Paint mPaint;

@Override
protected void init(Context context) {
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mPaint.setColor(ContextCompat.getColor(context, R.color.color_purple));
    mPaint.setStyle(Paint.Style.STROKE);

    mNextContourPath = new Path();
}

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

    if (!isInit) {
        isInit = true;

        width = getMeasuredWidth() / 2;
        height = getMeasuredHeight() / 2;
    
    	 // 第一個輪廓
        mNextContourPath.moveTo(-100, -100);
        mNextContourPath.lineTo(-100, 100);
        mNextContourPath.lineTo(100, 100);
        mNextContourPath.lineTo(100, -100);
        mNextContourPath.lineTo(-100, -100);

		 // 第二個輪廓
        mNextContourPath.moveTo(-50, -50);
        mNextContourPath.lineTo(-50, 50);
        mNextContourPath.lineTo(50, 50);
        mNextContourPath.lineTo(50, -50);

		 // 第三個輪廓
        mNextContourPath.moveTo(50, -50);
        mNextContourPath.lineTo(-50, -50);

        nextContourPathMeasure = new PathMeasure(mNextContourPath, false);

        int i = 0;
        while (nextContourPathMeasure.nextContour()) {
            ++i;
            Log.i(TAG, "第" + i + "個輪廓的 Length:" + nextContourPathMeasure.getLength());
        }
    }
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(width, height);
    canvas.drawPath(mNextContourPath, mPaint);
}
複製程式碼

效果圖:

PathMeasure的API講解與實戰——Android高階UI
日誌輸出:
PathMeasure的API講解與實戰——Android高階UI

(5)getMatrix

public boolean getMatrix(float distance, Matrix matrix, int flags)
複製程式碼

方法描述: 用於獲取關聯的Path上距離起始點長度( 即傳入的distance,範圍0<=distance<=getLength() )的點的座標和正切值(兩者可選,由flags決定)。

返回值: 1、為true時,說明獲取成功,資料存進matrix; 2、為false時,說明獲取失敗,matrix不變動;

引數解析: 第一個引數 distance: 即需要的測量點與當前path起始位置的距離,取值範圍:0<=distance<=getLength() ; 第二個引數 matrix: 測量點的矩陣,可以選擇包含點的座標和正切值,所包含的資料由flags決定; 第三個引數 flags: 決定matrix中包含的資料,可以選擇的值有:POSITION_MATRIX_FLAG(位置)ANGENT_MATRIX_FLAG(正切) 如果需要兩個值時,可以用或“|”將其拼湊後傳入,例如:

pathMeasure.getMatrix(distance, matrix, PathMeasure.TANGENT_MATRIX_FLAG | PathMeasure.POSITION_MATRIX_FLAG);
複製程式碼

知識點擴充:如果對 POSITION_MATRIX_FLAG|ANGENT_MATRIX_FLAG 這種傳值不太理解的童鞋可以檢視我寫的另外一篇文章《android位運算簡單講解》

(6)getSegment

public boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo)
複製程式碼

方法描述: 獲取關聯的path的片段路徑,新增至dst路徑中(並非替換,是增加)

返回值: 1、為true時,說明擷取成功,新增至dst路徑中; 2、為false時,說明擷取失敗,dst路徑不變動;

引數解析: 第一個引數 startD: 擷取的路徑的起始點距離path起始點的長度,取值範圍:0<=startD<stopD<=Path.getLength(); 第二個引數 stopD: 擷取的路徑的終止點距離path起始點的長度,取值範圍:0<=startD<stopD<=Path.getLength(); 第三個引數 dst: 擷取的路徑儲存的地方,此處特別注意擷取的路徑是新增到dst中,而非替換第四個引數 startWithMoveTo: 擷取的片段的第一個點是否保持不變; 設定為true:保持擷取的片段不變,新增至dst路徑中; 設定為false:會將擷取的片段的起始點移至dst路徑中的最後一個點,讓dst路徑保持連續

值得一提 如果你在4.4或更早的版本使用在使用這個函式時,需要先呼叫一下 mDst.lineTo(0, 0); 這句程式碼,這是因為硬體加速導致的問題;如不呼叫,會導致沒有任何效果。

舉個例子? 我們以螢幕中心為原點,先畫一條從 (0,0) 到 (200,200) 的直線,然後從一個順時針畫的圓中擷取 0.25 到 0.5 距離的圓弧放置dst中,先將startWithMoveTo設定為true,具體程式碼如下:

mGetSegmentPathMeasure = new PathMeasure();
// 順時針畫 半徑為400px的圓
mPath.addCircle(0, 0, 400, Path.Direction.CW);
mGetSegmentPathMeasure.setPath(mPath, false);

// 畫直線
mDst.moveTo(0, 0);
mDst.lineTo(200, 200);

// 擷取 0.25 到 0.5 距離的圓弧放置dst中
mGetSegmentPathMeasure.getSegment(mGetSegmentPathMeasure.getLength() * 0.25f,
         mGetSegmentPathMeasure.getLength() * 0.5f,
         mDst,
         true);

canvas.drawPath(mDst, mPaint);
複製程式碼

程式碼只是擷取主要部門,需要檢視完整程式碼的童鞋,請入傳送門

效果圖

PathMeasure的API講解與實戰——Android高階UI

如果將 startWithMoveTo 引數值改為 false,則效果不同,程式碼如下:

mGetSegmentPathMeasure = new PathMeasure();
// 順時針畫 半徑為400px的圓
mPath.addCircle(0, 0, 400, Path.Direction.CW);
mGetSegmentPathMeasure.setPath(mPath, false);

// 畫直線
mDst.moveTo(0, 0);
mDst.lineTo(200, 200);

// 擷取 0.25 到 0.5 距離的圓弧放置dst中
mGetSegmentPathMeasure.getSegment(mGetSegmentPathMeasure.getLength() * 0.25f,
         mGetSegmentPathMeasure.getLength() * 0.5f,
         mDst,
         false);

canvas.drawPath(mDst, mPaint);
複製程式碼

效果圖

PathMeasure的API講解與實戰——Android高階UI

從兩個效果圖,可看出startWithMoveTo引數設定為true和false,會導致dst路徑的不同。為true時,保持 擷取的片段路徑 的原樣將其新增至 dst路徑 中;為false時,會將擷取的片段的起始點移至dst路徑中的最後一個點,讓dst路徑保持連續。

值得注意 在寫這篇部落格時,將startWithMoveTo引數設定為false,在兩臺測試機(Mate10 Android 8.1.0和oppo A57 Android 6.0.1)上執行,效果有些許不同。 Demo使用的是px作為單位,兩臺手機的解析度不同,所以在 A57 機型上按比例縮小了一倍進行繪製 (即圓半徑從400px變為200px,斜線從(0,0)->(200,200)變為(0,0)->(100,100) ),從下面?的OPPO A57的效果圖可以很明顯的看出,圓弧的路徑已經受到dst中最後一個點的影響,改變了形狀。(Mate10的效果圖請翻閱上面?)

OPPO A57的效果圖

PathMeasure的API講解與實戰——Android高階UI

(6)getPosTan

public boolean getPosTan(float distance, float pos[], float tan[]) 
複製程式碼

方法描述: 獲取關聯的Path距離起始點長度(distance)的點座標(pos)餘弦(tan[0],即cos)正弦(tan[1],即sin)

返回值 1、為true時,說明獲取成功,該點的 座標 以及 正餘弦 將各自存進pos和tan引數 2、為false時,說明獲取失敗,pos與tan沒有變動

引數解析: 第一個引數 distance: 即需要的測量點與當前path起始位置的距離,取值範圍:0<=distance<=getLength() ; 第二個引數 pos: 測量點的座標,pos[0]為x座標,pos[1]為y座標; 第三個引數 tan: 測量點的正餘弦值,tan[0]為cos,即餘弦值或稱為單位圓的x座標;tan[1]為sin,即正弦值或稱為單位圓的y座標

數學小課堂: 單位圓指的是平面直角座標系上,圓心為原點,半徑為1的圓。 cos = 鄰邊/斜邊 = OB/OA = OB(因為OA長度為1)= x sin = 對邊/斜邊 = AB/OA = AB (因為OA長度為1) = y

在這裡插入圖片描述

三、實戰

轉圈的箭頭

按照國際慣例,先上效果圖

PathMeasure的API講解與實戰——Android高階UI

動畫解析 讓箭頭繞著紅色圓轉圈,同時需要改變箭頭的方向,使其朝向當前位置的切線方向

實現思路與程式碼解析 先進行初始化物件,主要是初始化畫筆、圖片、路徑、PathMeasure、裝載變數、估值器,具體為每個物件設定的屬性請看下面程式碼,此處比較簡單,就不再贅述

// 初始化 畫筆 [抗鋸齒、不填充、紅色、線條2px]
mCirclePaint = new Paint();
mCirclePaint.setAntiAlias(true);
mCirclePaint.setStyle(Paint.Style.STROKE);
mCirclePaint.setColor(Color.RED);
mCirclePaint.setStrokeWidth(2);

// 獲取圖片
mArrowBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.arrow, null);

// 初始化 圓路徑 [圓心(0,0)、半徑200px、順時針畫]
mCirclePath = new Path();
mCirclePath.addCircle(0, 0, 200, Path.Direction.CW);

// 初始化 裝載 座標 和 正餘弦 的陣列
mPos = new float[2];
mTan = new float[2];

// 初始化 PathMeasure 並且關聯 圓路徑
mPathMeasure = new PathMeasure();
mPathMeasure.setPath(mCirclePath, false);

// 初始化矩陣
mMatrix = new Matrix();

// 初始化 估值器 [區間0-1、時長5秒、線性增長、無限次迴圈]
valueAnimator = ValueAnimator.ofFloat(0, 1f);
valueAnimator.setDuration(5000);
// 勻速增長
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        // 第一種做法:通過自己控制,是箭頭在原來的位置繼續執行
        mCurrentValue += DELAY;
        if (mCurrentValue >= 1) {
            mCurrentValue -= 1;
        }

        // 第二種做法:直接獲取可以通過估值器,改變其變動規律
		//mCurrentValue = (float) animation.getAnimatedValue();

        invalidate();
    }
});
複製程式碼

初始化工作完成後,接下來就是進行繪製工作,我們按照步驟來講解: 第一步,將螢幕的中心點作為原點,方便操作和繪製

// 移至canvas中間
canvas.translate(mWidth / 2, mHeight / 2);
複製程式碼

第二步,繪製圓,即箭頭走的軌跡,PathMeasure所關聯的Path就是此處的mCirclePath,在上面的初始化程式碼可以清晰的看到

// 畫圓路徑
canvas.drawPath(mCirclePath, mCirclePaint);
複製程式碼

第三步,獲取當前點的座標以及正餘弦的值,存放至mPos和mTan變數中

// 測量 pos(座標) 和 tan(正切)
mPathMeasure.getPosTan(mPathMeasure.getLength() * mCurrentValue, mPos, mTan);
複製程式碼

第四步,通過反正弦atan2計算出角度(單位為弧度),所以需要進行將單位在轉為度。

// 計算角度
float degree = (float) (Math.atan2(mTan[1], mTan[0]) * 180 / Math.PI);
複製程式碼

數學小課堂 我們來拆分下這個公式,先看Math.atan2(mTan[1], mTan[0]) 這段,這裡關係到的是直角座標系與極座標系的轉換,所以我們先來重拾下忘記的第一個知識點 (1)直角座標系與極座標系的轉換(圖片是自己手寫的,字跡粗糙勿噴?)

PathMeasure的API講解與實戰——Android高階UI
從圖中可以知道 θ 的計算是通過該點的x和y座標得出,並且還要根據 y/x 計算結果的符號和該點存在的象限來共同決定。而通過getPosTan方法獲得的tan[]中的值便可以看作該點的x、y座標值(具體原因可以檢視前面getPosTan方法中的數學小課堂)。

所以只需對(y/x)進行求反正切便可,但這裡有存在一個問題,也就是我們剛剛提到的 θ 由 y/x 計算結果的符號和該點存在的象限決定,如果使用 Math.atan(double a) 方法進行求反正切,其結果範圍為開區間的 (-pi/2,pi/2),然而一個圓的的範圍是(-pi,pi),這顯然直接使用是不能滿足的。幸好Math類提供了一個讓我們省事的API atan2(double y, double x),其返回值的範圍正是 (-pi,pi)

到這裡已經能通過atan2函式得到該點的角度,但是其單位是弧度,並不能在直角座標系中直接拿來使用,需要進行轉換。所以我們需要引出第二個被遺忘的知識點

(2)弧度制 弧度制是什麼這裡就不做過多解釋。這裡涉及到一個公式就是 1° = π/180 rad ,看到這裡大家應該就明白為什麼要 乘以 180 / Math.PI,因為求出的反正切的值單位為弧度,需要轉為我們通常使用的角度制中的度。

第五步,重置矩陣,避免矩陣內有之前遺留的操作。

// 重置矩
mMatrix.reset();
複製程式碼

第六步,根據第四步計算得出的角度並且以圖片的中心點進行旋轉

// 設定旋轉角度
mMatrix.postRotate(degree, mArrowBitmap.getWidth() / 2, mArrowBitmap.getHeight() / 2);
複製程式碼

第七部,進行偏移,因為直接繪製的話,箭頭會在軌道之外,需要挪動箭頭的寬和高各一半

// 設定偏移量
mMatrix.postTranslate(mPos[0] - mArrowBitmap.getWidth() / 2,
        mPos[1] - mArrowBitmap.getHeight() / 2);
複製程式碼

第八步,使用矩陣將箭頭繪製至畫布中

// 畫箭頭,使用矩陣旋轉
canvas.drawBitmap(mArrowBitmap, mMatrix, mCirclePaint);
複製程式碼

至此,效果已完成。

需要檢視完整程式碼的童鞋,請進傳送門

四、更多案例

1、拖動的loading線條

效果圖

PathMeasure的API講解與實戰——Android高階UI

程式碼傳送門 完整程式碼請進

2、乘風破浪的小船

效果圖

PathMeasure的API講解與實戰——Android高階UI

程式碼傳送門 完整程式碼請進

五、寫在最後

PathMeasure可以說是自定義UI的利器之一,熟練的掌握能讓我們斬獲更多的產品?。如果各位童鞋在閱讀中發現有錯誤或是晦澀難懂的地方請與我聯絡,我會及時修改,讓我們共同進步。同樣如果你喜歡的話,請給個贊並關注我吧?。

高階UI系列的Github地址:請進入傳送門,如果喜歡的話給我一個star吧?

相關文章