“用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)