“用Android復刻Apple產品UI”(3)—優雅的資料統計圖表

語言: CN / TW / HK

theme: devui-blue highlight: atelier-forest-light


前言

一直想花時間復刻學習一下Apple產品的原生UI和動畫,超級絲滑。
今天,目標是健康的心率資料統計圖表。

健康及Android實現效果預覽

  1. Apple健康的圖表互動效果:

絲滑,有資料條滑動、滑動檢視資料標註兩種模式;資料標註位置自適應;兩端超出邊界會有自動回滾的效果。

  1. 本文用Android復刻的圖表互動效果:

暫時著眼於核心的實現思路,細節有長足的優化空間(如自動回滾的運動曲線、快速滑動、刻度線變化等,但他們對於Demo來說不是重點)😥。

1. 頁面內容分析

在開始前,我們不妨先仔細觀察一下這個頁面所涵蓋的資訊,再將其轉換為我們的業務需求,提前整理好思路再開始上手寫。

1.1 圖表靜態佈局

我們把圖表打散,它本質上由以下三個元件構成:
+ 資料條 + 單個數據條:表示單元時間內的心率分佈情況,這裡我們將它簡化為單元時間內的心率變化範圍(最小~最大) + 資料儲存:每個資料條需要涵蓋的資訊有三點:時間、最小值、最大值,我們使用一個ArrayList將他們放在一起,對於那些空缺的資料,我們可以根據時間來填充數值(設為0),以此實現在圖表上的留白。 + 座標軸 Axis + 橫向:橫向座標軸、背景線及其刻度(0,50,100)幾乎是靜態的,只有刻度會變化,這裡我們暫時忽略這一點。 + 縱向:縱向背景線按照特定的間隔分佈,滑動過程中也會跟著變化,與資料條是相對靜止的。因此,我們嘗試把他們和資料條捆綁在一起來實現。 + 資料標註 IndicatorLabel + 預設形態:它固定在左上角,取當前可見資料的時間範圍、心率變化範圍進行展示 + 指示形態:當用戶長觸控/點選圖表資料條時,它就會展現在其上方;在左右邊界會有位置的自適應調整。 + 預設形態和指示形態是非此即彼的,我們可以設定一個boolean值,isShowIndicator來控制他們,true的時候展示指示形態,false就為預設形態,以此簡化我們的後續處理邏輯。

1.2 圖表動態效果

圖表滑動與邊界效果

  • 滑動變化:圖表左右滑動來調整,滑動過程中,上方的 預設形態資料標註的值會發生變化,縱向背景線、刻度值會跟著移動;
  • 自動回滾:
    • 每次滑動結束後,都會有一個輕微的自動回滾,來保證視窗內呈現的完整的24個數據條。
    • 在滑動視窗超出兩側邊界後,會進行自動回滾,回到原來的邊界。

觸控/點選產生的資料標註

  • 使用者點選/觸控會觸發 指示形態的資料標註,進入此狀態後,手指按住螢幕左右滑動可以實現滑動資料標註的效果
  • 在進入上述狀態後,如果手指快速滑動,則可以恢復標註的預設形態並滑動圖表。

2. 頁面實現

在使用自定義View實現頁面前,結合上述對佈局的分析,思考一下我們的工作流程: 1. 畫一個圖表的框架草圖,標註出重要的尺寸,確保這些尺寸能夠讓我們計算出每一個點的座標; 2. 準備一個數據類來容納每個時間點的資料,用ArrayList打包起來,作為我們的資料來源; 3. 橫向背景線、y軸刻度都是全程靜態的,優先繪製它; 4. 將縱向背景線、x軸刻度與資料條繫結起來繪製;結合ArrayList中每一個item的索引來計算座標、使用item的數值計算資料條的y軸位置; 5. 實現資料標註的繪製函式,它可以通過指定一個item的索引來展示出對應點的具體資訊; 5. 通過重寫onTouchEvent來實現點選/觸控觸發資料標註的效果,實現圖表的滑動效果

腦子裡粗略思考一遍每一步的可能難度,發現我們主要面臨三個難題😥: 1. 使用怎樣的佈局可以讓我們輕鬆地通過item的索引來計算座標? 2. 該怎麼用最簡潔優雅的方式讓我們的資料條動起來? 3. 同樣是滑動,有時候使用者需要資料條左右滑動,有時候卻需要資料條不動,資料標註動,這該怎麼區分呢?

為保證閱讀體驗,實現部分不會列出所有程式碼並闡述所有細節,程式碼可以在最下方Ctrl C+V獲取。

2.1 圖表的基礎結構

我們按照擬定的工作流程一步步來:

2.1.1畫一個圖表的框架草圖。

提前拆解思考過圖表以後,我們可以快速畫出以下結構圖: image.png 對於資料條寬度(lineWidth),及資料條間隙寬度(lineSpace)的選取,假設我們最大可視資料條為n個,為了實現規整的頁面,需要保證以下等式成立:

$\rm{(lineWidth\ +\ lineSpace)\ *\ n = chartWidth}$

其中chartWidth我們在上方結構圖中標出的——存放資料條的chart的寬度;
這麼做的原因很簡單:假設現在n為24,那麼這個chart的寬度就是 24* lineWidth +23* lineSpace + 最左側空白寬度 + 最右側空白寬度;如上等式保證了左右側空白寬度都為 0.5 * lineSpace

2.1.2 準備一個數據類

目前的需求是,存放時間,一個最小值一個最大值,所以建立一個簡單的DataClass即可。 ```kotlin data class HeartRateChartEntry(

val time: Date = Date(), val minValue:Int = 66, val maxValue:Int = 88

) ``` 然後我們建立一些隨機資料,用ArrayList儲存。

2.1.3 繪製橫向背景線、y軸刻度

他們是靜態的,直接用繪製出來的結構圖計算chart、文字的起訖點座標直接畫就好。
+ startX = (getWidth() - chartWidth)/2。當然,你也可以自己定義chart的起點,我建議這個起點的x座標與lineWidth+lineSpace成正比 + endX = startX + chartWidth + endY = startY = totalHeight - bottomTextHeight 我們要繪製k條線,就首先計算線之間的距離unitDistance = chartHeight/(k-1),每次繪製讓unitDistance*i - startY就可以獲取到當前橫線的縱座標了。 ```kotlin (0..mHorizontalLineSliceAmount).forEach{ i -> //獲取當前要寫上去的刻度 currentLabel = .....

//計算當前Y
currentY = startY - i * mVerticalUnitDistance

//畫線
canvas.drawLine(startX, currentY, endX, currentY, mAxisPaint)
//畫text
canvas?.drawText("${currentLabel}", endX + mTextSize/3, currentY+mTextSize/3, mTextLabePaint)

//再畫上最左側的邊界線 canvas.drawLine(startX, startY, startX, startY-mChartHeight, mAxisPaint) } ```

2.1.4繪製資料條與縱向背景線

好,遇到了我們預料的難題,用什麼方式繪製資料條,可以讓他符合我們的滑動需求呢?

被否定的方案:
假設我們通過onTouchEvent計算手指滑動的距離,用滑動的距離來計算我們需要繪製的資料索引;但這種方式雖然符合我們靜態頁面的需求,但沒法實現順暢的動畫效果,滑動過程中只會不停地閃爍
究其原因是他實際上沒有改變資料條繪製時的橫座標,我們再去根據onTouchEvent的滑動距離來微調他們嗎?但這仍然無法避免邊緣資料條的閃爍。

更好的方案:視窗

想象我們正對著坐在視窗前,我們把這個視窗假設為一個viewPort,在這個視窗,我們能夠看到橫向切換的風景,是因為視窗和背景之間的相對移動

如果我們將其設想為我們的chart和資料條,可不可以把chart理解為視窗,資料條是浮在其表面的風景,然後我們只需要移動資料條,就可以切換風景(資料條滑動的視覺效果),這可以保證不會出現割裂感,畢竟所有東西都已經繪製了,只是位置調整了。

想法看來可以一試,上手前,我們還是先畫圖理一下思路。 + 我們需要從右往左繪製資料條以展現時間格式 + 初始起點不如設定為chart的最右端

  • 如果要向右滑動,是不是把繪圖的起始點往右邊移就可以了?

看來這個思路沒錯,我們用viewStartX作為起始點,從右向左畫資料條(for迴圈配合資料下標計算x軸座標),然後去onTouchEvent的ActionMove裡計算滑動的距離,動態調整viewStartX就搞定了。

不過有一點要想一想,如果我們每次都滑動都重新繪製了所有的資料條,如果資料量一大,必定會造成效能問題呀!

不過他很好解決,我們只需要計算當前視窗展示的最左和最右的資料條索引,分別為leftRangeIndex, rightRangeIndex,我們在遍歷畫資料條的過程中設定為只執行(leftRangeIndex-3, rightRangeIndex+3)範圍即可,這就實現了每次只畫視窗內+視窗邊緣的資料條了。

最後,我們需要在繪製完資料條以後,擷取一個視窗下來,放回到我們的chart裡,我們可以通過canvas.saveLayer()canvas.restoreToCount()配對使用來實現。

以下是繪製資料條的核心程式碼,看個思路就好

  1. 用saveLayer()來確定一個視窗範圍 kotlin val windowLayer = canvas?.saveLayer( left = chartLeftMargin, //chart左邊界的x座標 top = 0F, right = chartRightBorner, //chart右邊界的x座標 bottom = widthBottom //chart下邊界的y座標 )

  2. 遍歷我們儲存資料的ArrayList,使用viewStartX和索引來計算每個資料條的橫座標,繪製出來 ```kotlin (0 until mValueArray.size).forEach { it -> //如果不在我們預期的繪製範圍內,那就溜溜球,不畫了 if (it > drawRangeRight || it < drawRangeLeft) { return@forEach } //計算座標x,資料條的y軸起訖點 currentX = mViewStartX - (it) * (mLineWidth + mLineSpace) - chartRightMargin startY = baseY - mChartHeight / mYAxisRange.second * mValueArray[it].maxValue endY = baseY - mChartHeight / mYAxisRange.second * mValueArray[it].minValue

    if (mValueArray[it].maxValue != 0) { canvas?.drawLine(currentX, startY, currentX, endY, mLinePaint) } ```

  3. 在我們既定的特定時間點,繪製縱向背景線和刻度(程式碼略了,完整版在最下方)

  4. 最後,把這個視窗再儲存到我們的view裡去就完成了 kotlin cavas?.restoreToCount(windowLayer!!)

2.1.5 資料標註的繪製函式

前文有提到,我們的圖表一共有兩種資料標註的形式,一是預設形態,二是指示形態,他們是非此即彼的,我們只需要設定一個boolean變數isShowIndicator,然後在onTouchEvent中動態設定這個變數,就可以實現他們的切換了。

同時,我們在onTouchEvent中維護一個變數indexOnClicked,它用來表示當前被點選的那個資料條的索引,並繪製指示形態的資料標註

這裡的繪製流程不贅述了。

2.2 圖表的觸控事件

還是一樣,理清思路再上手寫程式碼。
我們希望:

  • 圖表能夠判定使用者的長觸控、快速滑動行為

    • 我們的圖表需要能夠判斷以下兩個狀態值
      • 正在資料條滑動狀態—isScrolling:表示使用者通過快速的手指滑動 來切換 資料條(也就是改變viewStartX的座標)
      • 正在長觸控狀態-isLongTouch: 使用者的手指一直停留在我們的螢幕上,這是因為他想要檢視資料標註,這個狀態下的切換不會切換資料條,而是切換資料標註的下標。
  • 圖表能夠計算每次滑動的距離,動態調整viewStartX與要繪製的陣列左右邊界

onTouchEvent事件鏈

為了實現以上需求,我們需要研究一下onTouchEvent(event: MotionEvent?)

對於觸控事件,我們處理以下回調: + ACTION_DOWN + 手指按下:無論是點選還是滑動,ACTION_DOWN都是他們的初始動作 + ACTION_MOVE + 手指滑動:在ACTION_DOWN觸發後,如果手指滑動,MOVE就會被觸發若干次,以表示手指在圖表上的滑動 + ACTION_UP + 手指擡起:一定是點選事件的結束步,可能是滑動事件的結束步(也可能是ACTION_CANCEL) + ACTION_CANCEL + 手勢放棄:可能是滑動事件的結束步(也可能是ACTION_UP)

image.png

我們先處理該怎麼讓圖表判斷是快速滑動: 1. 我們維護一個當前時間currentTime 1. 每次ACTION_DOWN手指按下的時候,我們就記錄那一時刻的時間; 1. 在遇到ACTION_MOVE的時候,我們就首先獲取當前時間減去記錄的currentTime來獲取時間間隔 1. 如果這個間隔小於某個時間閾值TIMEDURATION,我們把它認定為是一次快速滑動 1. 但是,我們新增限制條件,這一次move的距離必須大於某個閾值,否則視為一次輕微move(手滑產生的,不是使用者的內心想法) 6. 對於後續的滑動事件來說(上圖中的n號ACTION_MOVE),他們時間可能已經超過了閾值但他們也需要執行這個滑動任務;還記得我們提到的狀態變數isScrolling嗎,我們在1號ACTION_MOVE中將isScrolling設定為true,後續的n號滑動事件中,只要發現當前是isScrolling==true 是正在滑動狀態,它就可以大膽開始執行滑動事件了

據上,我們有了以下程式碼: ```kotlin override fun onTouchEvent(event:MotionEvent?):Boolean{ //獲取當前觸控點的橫座標 mCurrentX = event!!.x

when (event.action) { MotionEvent.ACTION_DOWN -> { //記錄一下觸控的點,用來記錄滑動距離 mLastX = mCurrentX //記錄現在的時間,用來判斷快速滑動 currentMS = System.currentTimeMillis()

}
MotionEvent.ACTION_MOVE -> {
    //獲得滑動的距離
    mMoveX = mLastX - mCurrentX
    //記錄一下觸控的點
    mLastX = mCurrentX

    //如果 move time <Xms and moveX > Xpx, 這是快速滑動
    if (((System.currentTimeMillis() - currentMS) < TOUCHMOVEDURATION && (abs(mMoveX) > mLineWidth)) || isScrolling) {
        isScrolling = true

        //更新viewStartX,實現資料條切換,記得給mViewStartX的setter加invalidate()
        mViewStartX -= mMoveX

        //更新左右邊界
        updateCurrentDrawRange()
    }
}

} ```

接著,我們來處理該怎麼讓圖表判斷是長觸控-isLongTouch: + 怎樣的事件流是長觸控呢? + 長觸控,就是使用者的手放上去以後,沒有擡起,只有輕微滑動 + 我們將這個閾值設定為判斷快速滑動的時間閾值為TIMEDURATION + 如果我們在執行ACTION_DOWN後,TIMEDURATION時間內,除了輕微滑動外沒有任何其他ACTION事件觸發,那就認定為是長觸控 + 用程式碼來實現: + 我們在每次ACTION_DOWN後,都開啟一個子執行緒在TIMEDURATION後,如果他沒有被取消執行,那就將isLongTouch設定為true + 這樣我們就開啟了長觸控模式,可以在ACTION_MOVE中增加判斷,配合isLongTouch來展示我們的資料標註切換。 + 同樣,我們在ACTION_UP和 ACTION_MOVE顯著移動的事件中,取消這個子執行緒。

這裡,我用kotlin協程來實現的這個判斷長觸控的子執行緒

開啟協程的函式: kotlin fun startIndicatorTimer() { showIndicatorJob = mScope.launch(Dispatchers.Default) { //用了hasTimer來輔助外面判斷有沒有子執行緒在執行 hasTimer = true //延時任務進行 delay(TOUCHMOVEDURATION + 10.toLong()) withContext(Dispatchers.Main) { //長觸摸了,那正在滑動狀態就必須是false啦 isScrolling = false //長觸控:輪到我了 isLongTouch = true //找到當前被觸控的資料條索引 setCurrentIndexOnClicked() //展示指示形態的資料標籤 isShowIndicator = true //子執行緒執行完畢,把標記設定為false hasTimer = false } } } 關閉協程的函式: kotlin fun turnOffIndicatorTimer() { if (hasTimer) { showIndicatorJob.cancel() hasTimer = false } }

觸控事件裡的核心程式碼

```kotlin //節選 when(event.action){ MotionEvent.ACTION_DOWN->{ //記錄座標,記錄時間 mLastX = mCurrentX currentMS = System.currentTimeMillis()

    //開始子執行緒的任務
    startIndicatorTimer()
}
MotionEvent.ACTION_MOVE->{
    mMoveX = mLastX - mCurrentX
    mLastX = mCurrentX
if(是快速滑動){
    //關閉這個長觸控判斷執行緒
    turnOffIndicatorTimer()
}
//是長觸控狀態,那我們啟用isShowIndicator
else if(isLongTouch){
    isShowIndicator = true
}
else if(不是輕微滑動){
    //關閉長觸控判斷事件
    turnOffIndicatorTimer()
}
}

} ```

自動回滾

  1. 我們需要每次滑動結束後去判斷,讓視窗內呈現完成的N個數據條
    • 基於我們的結構,這很容易實現,只需要讓我們的viewStartX(繪畫初始點)的座標變為(lineWidth+lineSpace)的整數即可 kotlin mViewStartX - (mViewStartX - mInitialStartX).mod(mLineSpace+mLineWidth)
  2. 我們要在滑動超出邊界後,讓視窗自動回滾到邊界值
    • 這同樣同意實現,我們通過viewStartX來判斷是否出界,然後讓viewStartX回到設定的邊界值就好了

但我們不能採用直接給viewStartX賦值的方法,而是通過ObjectAnimator來實現順滑的切換,我們將這個邏輯寫在方法drawBackToBorder()中,並把它新增到ACTION_CANCEL和ACTION_UP的回撥中,因為只有他們倆可能是觸控事件流的結尾。

別放了給viewStartX的Setter方法新增invalidate(),否則動畫不會觸發。😈 ```

fun drawBackToBorder(){ var endValue:Float = 0F

endValue =
    //out of right borderline
if(mViewStartX < mInitialStartX){
    mInitialStartX
    //out of left borderline
} else if(mViewStartX > mInitialStartX + (mValueArray.size-24)*(mLineWidth+mLineSpace)){
    mInitialStartX + (mValueArray.size-24)*(mLineWidth+mLineSpace)
    //does not reach the bound, need reposition to exact place.
} else {
    mViewStartX - (mViewStartX - mInitialStartX).mod(mLineSpace+mLineWidth)
}

val anim = ObjectAnimator.ofFloat(mViewStartX, endValue)
anim.interpolator = DecelerateInterpolator()
anim.addUpdateListener {
    mViewStartX = it.animatedValue as Float
}
anim.start()

} ```

寫在最後

寫部落格核心是希望能覆盤的同時鍛鍊自己講清楚思路的能力,相比於貼程式碼,畫圖+文字闡述是更我喜歡的做的事。

感謝看到這裡,如果有任何疑問,歡迎留言和我交流。😋

如果你覺得還挺有趣,也可以點選下方連結瀏覽更多同類文章:
“用Android復刻Apple產品UI”(2)—絲滑的AppStore卡片轉場動畫 - 掘金 (juejin.cn)
“用Android復刻Apple產品UI”(1)—絲滑的噪聲監測音量條 - 掘金 (juejin.cn)

3. 附-程式碼

程式碼涵蓋兩個檔案: 1. HeartRateEntry.kt 資料類 2. IsenseChart.kt 自定義view檔案,沒有新增外部引數StyleValue YunmaoLeo/AppleHealthChart (github.com)