自定義View進階篇《八》——PathMeasure

Path & PathMeasure

顧名思義,PathMeasure是一個用來測量Path的類,主要有以下方法:

構造方法
公共方法

PathMeasure的方法也不多,接下來我們就逐一的講解一下。

1.構造函數

構造函數有兩個

無參構造函數:
 PathMeasure ()

用這個構造函數可創建一個空的 PathMeasure,但是使用之前需要先調用 setPath 方法來與 Path 進行關聯。被關聯的 Path 必須是已經創建好的,如果關聯之后 Path 內容進行了更改,則需要使用 setPath 方法重新關聯。

有參構造函數:
  PathMeasure (Path path, boolean forceClosed)

用這個構造函數是創建一個 PathMeasure 并關聯一個 Path, 其實和創建一個空的 PathMeasure 后調用 setPath 進行關聯效果是一樣的,同樣,被關聯的 Path 也必須是已經創建好的,如果關聯之后 Path 內容進行了更改,則需要使用 setPath 方法重新關聯。

該方法有兩個參數,第一個參數自然就是被關聯的 Path 了,第二個參數是用來確保 Path 閉合,如果設置為 true, 則不論之前Path是否閉合,都會自動閉合該 Path(如果Path可以閉合的話)。

在這里有兩點需要明確:

  • 不論 forceClosed 設置為何種狀態(true 或者 false), 都不會影響原有Path的狀態,即 Path 與 PathMeasure 關聯之后,之前的的 Path 不會有任何改變。
  • forceClosed 的設置狀態可能會影響測量結果,如果 Path 未閉合但在與 PathMeasure 關聯的時候設置 forceClosed 為 true 時,測量結果可能會比 Path 實際長度稍長一點,獲取到到是該 Path 閉合時的狀態。

下面我們用一個例子來驗證一下:

canvas.translate(mViewWidth/2,mViewHeight/2);

Path path = new Path();

path.lineTo(0,200);
path.lineTo(200,200);
path.lineTo(200,0);

PathMeasure measure1 = new PathMeasure(path,false);
PathMeasure measure2 = new PathMeasure(path,true);

Log.e("TAG", "forceClosed=false---->"+measure1.getLength());
Log.e("TAG", "forceClosed=true----->"+measure2.getLength());

canvas.drawPath(path,mDeafultPaint);

log如下:

com.gcssloop.canvas E/TAG: forceClosed=false---->600.0
com.gcssloop.canvas E/TAG: forceClosed=true----->800.0

繪制在界面上的效果如下:



我們所創建的 Path 實際上是一個邊長為 200 的正方形的三條邊,通過上面的示例就能驗證以上兩個問題。

  • 1.我們將 Path 與兩個的 PathMeasure 進行關聯,并給 forceClosed 設置了不同的狀態,之后繪制再繪制出來的 Path 沒有任何變化,所以與 Path 與 PathMeasure進行關聯并不會影響 Path 狀態。
  • 2.我們可以看到,設置 forceClosed 為 true 的方法比設置為 false 的方法測量出來的長度要長一點,這是由于 Path 沒有閉合的緣故,多出來的距離正是 Path 最后一個點與最開始一個點之間點距離。forceClosed 為 false 測量的是當前 Path 狀態的長度, forceClosed 為 true,則不論Path是否閉合測量的都是 Path 的閉合長度。

2.setPath、 isClosed 和 getLength

這三個方法都如字面意思一樣,非常簡單,這里就簡單是敘述一下,不再過多講解。

setPath 是 PathMeasure 與 Path 關聯的重要方法,效果和 構造函數 中兩個參數的作用是一樣的。

isClosed 用于判斷 Path 是否閉合,但是如果你在關聯 Path 的時候設置 forceClosed 為 true 的話,這個方法的返回值則一定為true。

getLength 用于獲取 Path 的總長度,在之前的測試中已經用過了。

3.getSegment

getSegment 用于獲取Path的一個片段,方法如下:

boolean getSegment (float startD, float stopD, Path dst, boolean startWithMoveTo)

方法各個參數釋義:


  • 如果 startD、stopD 的數值不在取值范圍 [0, getLength] 內,或者 startD == stopD 則返回值為 false,不會改變 dst 內容。
  • 如果在安卓4.4或者之前的版本,在默認開啟硬件加速的情況下,更改 dst 的內容后可能繪制會出現問題,請關閉硬件加速或者給 dst 添加一個單個操作,例如: dst.rLineTo(0, 0)

我們先看看這個方法如何使用:

我們創建了一個 Path, 并在其中添加了一個矩形,現在我們想截取矩形中的一部分,就是下圖中紅色的部分。

矩形邊長400dp,起始點在左上角,順時針


canvas.translate(mViewWidth / 2, mViewHeight / 2);          // 平移坐標系

Path path = new Path();                                     // 創建Path并添加了一個矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);

Path dst = new Path();                                      // 創建用于存儲截取后內容的 Path

PathMeasure measure = new PathMeasure(path, false);         // 將 Path 與 PathMeasure 關聯

// 截取一部分存入dst中,并使用 moveTo 保持截取得到的 Path 第一個點的位置不變
measure.getSegment(200, 600, dst, true);                    

canvas.drawPath(dst, mDeafultPaint);                        // 繪制 dst

從上圖可以看到我們成功到將需要到片段截取了出來,然而當 dst 中有內容時會怎樣呢?

canvas.translate(mViewWidth / 2, mViewHeight / 2);          // 平移坐標系

Path path = new Path();                                     // 創建Path并添加了一個矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);

Path dst = new Path();                                      // 創建用于存儲截取后內容的 Path
dst.lineTo(-300, -300);                                     // <--- 在 dst 中添加一條線段

PathMeasure measure = new PathMeasure(path, false);         // 將 Path 與 PathMeasure 關聯

measure.getSegment(200, 600, dst, true);                   // 截取一部分 并使用 moveTo 保持截取得到的 Path 第一個點的位置不變

canvas.drawPath(dst, mDeafultPaint);                        // 繪制 Path

結果如下:



從上面的示例可以看到 dst 中的線段保留了下來,可以得到結論:被截取的 Path 片段會添加到 dst 中,而不是替換 dst 中到內容。

前面兩個例子中 startWithMoveTo 均為 true, 如果設置為false會怎樣呢?

canvas.translate(mViewWidth / 2, mViewHeight / 2);          // 平移坐標系

Path path = new Path();                                     // 創建Path并添加了一個矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);

Path dst = new Path();                                      // 創建用于存儲截取后內容的 Path
dst.lineTo(-300, -300);                                     // 在 dst 中添加一條線段

PathMeasure measure = new PathMeasure(path, false);         // 將 Path 與 PathMeasure 關聯

measure.getSegment(200, 600, dst, false);                   // <--- 截取一部分 不使用 startMoveTo, 保持 dst 的連續性

canvas.drawPath(dst, mDeafultPaint);                        // 繪制 Path

結果如下:



從該示例我們又可以得到一條結論:如果 startWithMoveTo 為 true, 則被截取出來到Path片段保持原狀,如果 startWithMoveTo 為 false,則會將截取出來的 Path 片段的起始點移動到 dst 的最后一個點,以保證 dst 的連續性。

從而我們可以用以下規則來判斷 startWithMoveTo 的取值:


4.nextContour

我們知道 Path 可以由多條曲線構成,但不論是 getLength , getgetSegment 或者是其它方法,都只會在其中第一條線段上運行,而這個 nextContour 就是用于跳轉到下一條曲線到方法,如果跳轉成功,則返回 true, 如果跳轉失敗,則返回 false。

如下,我們創建了一個 Path 并使其中包含了兩個閉合的曲線,內部的邊長是200,外面的邊長是400,現在我們使用 PathMeasure 分別測量兩條曲線的總長度。


canvas.translate(mViewWidth / 2, mViewHeight / 2);      // 平移坐標系

Path path = new Path();

path.addRect(-100, -100, 100, 100, Path.Direction.CW);  // 添加小矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);  // 添加大矩形

canvas.drawPath(path,mDeafultPaint);                    // 繪制 Path

PathMeasure measure = new PathMeasure(path, false);     // 將Path與PathMeasure關聯

float len1 = measure.getLength();                       // 獲得第一條路徑的長度

measure.nextContour();                                  // 跳轉到下一條路徑

float len2 = measure.getLength();                       // 獲得第二條路徑的長度

Log.i("LEN","len1="+len1);                              // 輸出兩條路徑的長度
Log.i("LEN","len2="+len2);

log輸出結果:

com.gcssloop.canvas I/LEN: len1=800.0
com.gcssloop.canvas I/LEN: len2=1600.0

通過測試,我們可以得到以下內容:

  • 1.曲線的順序與 Path 中添加的順序有關。
  • 2.getLength 獲取到到是當前一條曲線分長度,而不是整個 Path 的長度。
  • 3.getLength 等方法是針對當前的曲線(其它方法請自行驗證)。
5.getPosTan

這個方法是用于得到路徑上某一長度的位置以及該位置的正切值:

boolean getPosTan (float distance, float[] pos, float[] tan)

方法各個參數釋義:


這個方法也不難理解,除了其中 tan 這個東東,這個東西是干什么的呢?

tan 是用來判斷 Path 上趨勢的,即在這個位置上曲線的走向,請看下圖示例,注意箭頭的方向:


點擊這里下載箭頭圖片

可以看到 上圖中箭頭在沿著 Path 運動時,方向始終與 Path 走向保持一致,保持方向主要就是依靠 tan 。

下面我們來看看代碼是如何實現的,首先我們需要定義幾個必要的變量:

private float currentValue = 0;     // 用于紀錄當前的位置,取值范圍[0,1]映射Path的整個長度

private float[] pos;                // 當前點的實際位置
private float[] tan;                // 當前點的tangent值,用于計算圖片所需旋轉的角度
private Bitmap mBitmap;             // 箭頭圖片
private Matrix mMatrix;             // 矩陣,用于對圖片進行一些操作

初始化這些變量(在構造函數中調用這個方法):

private void init(Context context) {
    pos = new float[2];
    tan = new float[2];
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inSampleSize = 2;       // 縮放圖片
    mBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.arrow, options);
    mMatrix = new Matrix();
}

具體繪制:

canvas.translate(mViewWidth / 2, mViewHeight / 2);      // 平移坐標系

Path path = new Path();                                 // 創建 Path

path.addCircle(0, 0, 200, Path.Direction.CW);           // 添加一個圓形

PathMeasure measure = new PathMeasure(path, false);     // 創建 PathMeasure

currentValue += 0.005;                                  // 計算當前的位置在總長度上的比例[0,1]
if (currentValue >= 1) {
  currentValue = 0;
}

measure.getPosTan(measure.getLength() * currentValue, pos, tan);        // 獲取當前位置的坐標以及趨勢

mMatrix.reset();                                                        // 重置Matrix
float degrees = (float) (Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI); // 計算圖片旋轉角度

mMatrix.postRotate(degrees, mBitmap.getWidth() / 2, mBitmap.getHeight() / 2);   // 旋轉圖片
mMatrix.postTranslate(pos[0] - mBitmap.getWidth() / 2, pos[1] - mBitmap.getHeight() / 2);   // 將圖片繪制中心調整到與當前點重合

canvas.drawPath(path, mDeafultPaint);                                   // 繪制 Path
canvas.drawBitmap(mBitmap, mMatrix, mDeafultPaint);                     // 繪制箭頭

invalidate();                                                           // 重繪頁面

核心要點:

  • 1.通過 tan 得值計算出圖片旋轉的角度,tan 是 tangent 的縮寫,即中學中常見的正切, 其中tan[0]是鄰邊邊長,tan[1]是對邊邊長,而Math中 atan2 方法是根據正切是數值計算出該角度的大小,得到的單位是弧度(取值范圍是 -pi 到 pi),所以上面又將弧度轉為了角度。
  • 2.通過 Matrix 來設置圖片對旋轉角度和位移,這里使用的方法與前面講解過對 canvas操作 有些類似,對于 Matrix 會在后面專一進行講解,敬請期待。
  • 3.頁面刷新,頁面刷新此處是在 onDraw 里面調用了 invalidate 方法來保持界面不斷刷新,但并不提倡這么做,正確對做法應該是使用 線程 或者 ValueAnimator 來控制界面的刷新,關于控制頁面刷新這一部分會在后續的 動畫部分 詳細講解,同樣敬請期待。

關于tan這個參數有很多魔法師不理解,特此拉出來詳述一下,tan 在數學中被稱為正切,在直角三角形中,一個銳角的正切定義為它的對邊(Opposite side)與鄰邊(Adjacent side)的比值(來自維基百科):


我們此處用 tan 來描述 Path 上某一點的切線方向,主要用了兩個數值 tan[0] 和 tan[1] 來描述這個切線的方向(切線方向與x軸夾角) ,看上面公式可知 tan 既可以用 對邊/鄰邊 來表述,也可以用 sin/cos 來表述,此處用兩種理解方式均可以(注意下面等價關系):

  • tan[0] = cos = 鄰邊(單位圓x坐標)
  • tan[1] = sin = 對邊(單位圓y坐標)

以 sin/cos理解:



在圓上最右側點的切線方向向下(動圖中小飛機朝向和切線朝向一致),切線角度為90度.

sin90 = 1,cos90 = 0
tan[0] = cos = 0
tan[1] = sin = 1

以 對邊/鄰邊 理解(單位圓上坐標):
按照這種理解方式需要借助一個單位圓,單位圓上任意一點到圓心到距離均為 1,以下圖30度為例:


tan30 = 對邊/鄰邊 = AB/OA = B點y坐標/B點x坐標

另外根據單位圓性質同樣可以證得:
sin30 = 對邊/斜邊 = AB/OB = AB = B點y坐標 (單位圓邊上任意一點距離圓心距離均為1,故OB = 1)
cos30 = 鄰邊/斜邊 = OA/OB = OA = B點x坐標

化為通用公式即為:
sin = 該角度在單位圓上對應點的y坐標
cos = 該角度在單位圓上對應點的x坐標
即 tan = sin/cos = y/x
tan[0] = x
tan[1] = y

另外注意,這個單位圓與小飛機路徑沒有半毛錢關系,例如上一個例子中的90度切線,不要在單位圓上找對應位置,要找對應角度的位置,90度對應的位置是(0,1),所以:
tan[0] = x = 0
tan[1] = y = 1

PS: 使用 Math.atan2(tan[1], tan[0]) 將 tan 轉化為角(單位為弧度)的時候要注意參數順序。

6.getMatrix

這個方法是用于得到路徑上某一長度的位置以及該位置的正切值的矩陣:

boolean getMatrix (float distance, Matrix matrix, int flags)

方法各個參數釋義:



其實這個方法就相當于我們在前一個例子中封裝 matrix 的過程由 getMatrix 替我們做了,我們可以直接得到一個封裝好到 matrix,豈不快哉。

但是我們看到最后到 flags 選項可以選擇 位置 或者 正切 ,如果我們兩個選項都想選擇怎么辦?

如果兩個選項都想選擇,可以將兩個選項之間用 | 連接起來,如下:

measure.getMatrix(distance, matrix, PathMeasure.TANGENT_MATRIX_FLAG | PathMeasure.POSITION_MATRIX_FLAG);

我們可以將上面都例子中 getPosTan 替換為 getMatrix, 看看是不是會顯得簡單很多:

具體繪制:

Path path = new Path();                                 // 創建 Path

path.addCircle(0, 0, 200, Path.Direction.CW);           // 添加一個圓形

PathMeasure measure = new PathMeasure(path, false);     // 創建 PathMeasure

currentValue += 0.005;                                  // 計算當前的位置在總長度上的比例[0,1]
if (currentValue >= 1) {
    currentValue = 0;
}

// 獲取當前位置的坐標以及趨勢的矩陣
measure.getMatrix(measure.getLength() * currentValue, mMatrix, PathMeasure.TANGENT_MATRIX_FLAG | PathMeasure.POSITION_MATRIX_FLAG);

mMatrix.preTranslate(-mBitmap.getWidth() / 2, -mBitmap.getHeight() / 2);   // <-- 將圖片繪制中心調整到與當前點重合(注意:此處是前乘pre)

canvas.drawPath(path, mDeafultPaint);                                   // 繪制 Path
canvas.drawBitmap(mBitmap, mMatrix, mDeafultPaint);                     // 繪制箭頭

invalidate();                                                           // 重繪頁面

由于此處代碼運行結果與上面一樣,便不再貼圖片了,請參照上面一個示例的效果圖。

可以看到使用 getMatrix 方法的確可以節省一些代碼,不過這里依舊需要注意一些內容:

Path & SVG

我們知道,用Path可以創建出各種個樣的圖形,但如果圖形過于復雜時,用代碼寫就不現實了,不僅麻煩,而且容易出錯,所以在繪制復雜的圖形時我們一般是將 SVG 圖像轉換為 Path。

你說什么是 SVG?

SVG 是一種矢量圖,內部用的是 xml 格式化存儲方式存儲這操作和數據,你完全可以將 SVG 看作是 Path 的各項操作簡化書寫后的存儲格式。

Path 和 SVG 結合通常能誕生出一些奇妙的東西,如下:



該圖片來自這個開源庫 ->PathView
SVG 轉 Path 的解析可以用這個庫 -> AndroidSVG

限于篇幅以及本人精力,這一部分就暫不詳解了,感興趣的可以直接看源碼,或者搜索一些相關的解析文章。

Path使用技巧

話說本篇文章的名字不是叫 玩出花樣么?怎么只見前面啰啰嗦嗦的扯了一大堆不明所以的東西,花樣在哪里?

前面的內容雖然啰嗦繁雜,但卻是重中之重的基礎,如果在修仙界,這叫根基,而下面講述的內容的是招式,有了根基才能演化出千變萬化的招式,而沒有根基只學招式則是徒有其表,只能學一樣會一樣,很難適應千變萬化的需求。

先放一個效果圖,然后分析一下實現過程:



這是一個搜索的動效圖,通過分析可以得到它應該有四種狀態,分別如下:



這些狀態是有序轉換的,轉換流程以及轉換條件如下:
其中 正在搜索 這個狀態持續時間長度是不確定的,在沒有搜索完成前,應該一直處于搜索狀態。

簡單的分析了其大致的流程之后,就到了制作的重點:對細節對把握。

Path 劃分

為了制作對方便,此處整個動效用了兩個 Path, 一個是中間對放大鏡, 另一個則是外側的圓環,將兩者全部畫出來是這樣子的。



其中 Path 的走向要把握好,如下(只是一個放大鏡,并不是♂):



其中圓形上面的點可以用 PathMeasure 測量,無需計算。

動畫狀態與時間關聯

此處使用的是 ValueAnimator,它可以將一段時間映射到一段數值上,隨著時間變化不斷的更新數值,并且可以使用插值器開控制數值變化規律(此處使用的是默認插值器)。

PS: 本來不想提前暴露這個的,準備偷偷留到動畫部分(?-_-?) 但實在是沒有優雅的替代方案了。

具體繪制

繪制部分是根據 當前狀態以及從 ValueAnimator 獲得的數值來截取 Path 中合適的部分繪制出來。
查看源碼

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容