Android自定義滑動刻度尺
自定義View實現跟隨手指滾動的刻度尺,實現了類似SeekBar的滑動選中效果。 專案地址,歡迎star!
UI圖:

功能:
- 通過設定最小值跟最大值的範圍,以及offset值。View將根據這些資料去計算出需要幾個小刻度和幾個長刻度,和每個長刻度上面顯示的數值。
- 指標可以隨意的定製。
- 當滑動停止後,刻度尺會根據四捨五入將距離指標最近的長刻度滑動到指標的位置。
- 支援範圍越界回彈。
- 支援設定預設值。

二 實現:
先扯一下,再看別人寫的控制元件的時候總有一種一臉懵逼的感覺,好多凌亂的變數和一大堆的計算邏輯都不知道幹嘛用的。比如:PullToRefreshLayout。除非自己按著整體的設計流程寫一遍,一步步的寫,等出了bug你就明白那些操作的價值。結合之前讀第三方控制元件的經驗,寫這個刻度尺控制元件的時候就一步步的去完成,從簡單的繪製,到點選事件,再到滑動fling,最後滑動結束更正滑動位置。每一步遇到的問題都記錄下來,之後再補全解決方法,這就是成長。
1.繪製刻度
這裡省略了onMeasure,這裡的需求只是計算一下高度就好了。接著看onDraw方法:
private void drawRuler(Canvas canvas) { mTextIndex = 0; for (int index = 0; index <= mRulerHelper.getCounts(); index++) { boolean longLine = mRulerHelper.isLongLine(index); int lineCount = mLineWidth * index; mRect.left = index * mLineSpace + lineCount + mMarginLeft; mRect.top = getStartY(longLine); mRect.right = mRect.left + mLineWidth; mRect.bottom = getEndY(); if (longLine) { if (!mRulerHelper.isFull()) { mRulerHelper.addPoint(mRect.left); } String text = mRulerHelper.getTextByIndex(mTextIndex); mTextIndex++; canvas.drawText(text, mRect.centerX(), getMeasuredHeight() - dpFor14, mTextPaint); } canvas.drawRect(mRect, mLinePaint); mRect.setEmpty(); } } 複製程式碼
這裡解釋一下為什麼刻度採用Rect而不是設定line的寬度,其實最簡單的就是設定Paint的寬度然後canvas.drawLine()。剛繪製的時候就是採用的canvas.drawLine(),繪製完之後發現每個刻度的寬度都被削減了一半,canvas.drawLine()是在設定的(x,y)座標開始平分line的寬度的(這個你要去體驗一下就會明白)。所以給定座標之後每個刻度看起來就像是被擠了一樣,所以才採用Rect簡單方便一點。進入正題,繪製有幾個問題:
-
怎麼確定要繪製幾個Rect?
這個比較靈活,要看具體的需求了。也就是一大格里麵包含幾個刻度,一般是包含10個刻度,刻度包括長短刻度。然後一大格刻度表示多少數值,也就是offSet值是多少。之後刻度的範圍也要明確並且能被offSet整除,比如範圍是(low,height),那麼(height-low)/(offSet/10)就是你需要繪製多少個刻度。
public void setScope(int start, int count,int offSet) { if(offSet != 0) { this.offSet = offSet; } lineNumbers = (count - start) / (this.offSet / 10); } 複製程式碼
-
怎麼確定那個是長刻度?
這個問題要確定一大格之間有幾個小刻度了,一般為10個的話,那麼當前的index/10能整除就是到了該繪製長刻度的時候了,mRulerHelper.getCounts()就是我們計算出的總共有幾個刻度。
for (int index = 0; index <= mRulerHelper.getCounts(); index++) { boolean longLine = mRulerHelper.isLongLine(index); ... if (longLine) { canvas.drawText(text, mRect.centerX(), getMeasuredHeight() - dpFor14, mTextPaint); } canvas.drawRect(mRect, mLinePaint); } 複製程式碼
之後呢就是我們計算Rect的左邊跟繪製Text的座標了。。。不細講。。。具體可看 這裡啊。
有個問題就是你得明白Rect的left top right bottom分別表示那個區間:

2.處理點選事件
目前採取的是點選該View的事件全攔截,感覺也沒別的什麼需求需要過濾事件了。事件處理起來很簡單的就是計算出每次移動的差值就好了:
case MotionEvent.ACTION_DOWN: mPressUp = false; isFling = false; startX = event.getX(); break; case MotionEvent.ACTION_MOVE: mPressUp = false; float distance = event.getX() - startX; if (mPreDistance != distance) { doScroll((int) -distance, 0, 0); invalidate(); } startX = event.getX(); break; 複製程式碼
問題就是:
-
怎麼實現滑動的效果?
刻度尺如果範圍很大的話總寬度肯定會超出螢幕的,但是Canvas不會繪製螢幕之外的部分,除非等到螢幕之外的部分顯示出來。另外讓View滑動的方法很多,最初使用的是scrollTo方法,該方法滑動的是View的內容,也符合我們要的效果,不過結果查強人意。差值計算之後稍微一滑動,刻度直接沒了,成了一片空白,看起來那個變化值也不大,ok!這是一個疑問ScrollTo+invalidate內容不會顯示,直接沒了。之後呢換成了Scroller,這個玩意不用太多的介紹了,使用之後便達到了我們想要的效果,一樣的變化值。
private void doScroll(int dx, int dy, int duration) { mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), dx, dy, duration); } 複製程式碼
是否有疑問?既然螢幕之外的東西Canvas不會去繪製,那麼滑動的時候肯定是將螢幕之外的部分滑到螢幕中,也就是在滑動的過程中要繼續繪製。從上面的繪製程式碼能看到這個繪製過程中跟滑動並沒有任何的聯絡,只是單純的for迴圈繪製而已,為什麼呢?第一 我們scrollTo移動的是View的內容,一開始View的實際寬度會超過螢幕的寬度,當沒有滑動的時候,View只會繪製螢幕中的可見區域,即使for迴圈依然執行也不會繪製到螢幕外面,然後在滑動的時候會不斷的觸發invalidate()方法,也就是for迴圈會被觸發,View開始在新出現的未繪製的區域繪製。已經繪製過的區域會被滑出螢幕,這樣就會給使用者一個平滑的效果。做完以上兩步你的刻度尺已經有了滑動的效果了。下面就是解決邊界的問題。
3.邊界的處理
UI說當超過邊界之後鬆手回彈,這樣的互動效果好。這種互動其實最簡單了,在手指離開的時候計算當前的x座標距離中心指標的x座標的距離,然後讓Scroller去執行回彈的效果。不過這個操作是整個控制元件中最為重要的一步,因為當手指抬起的時候,中間指標必須指向一個長刻度,不能停留再短刻度上面,那這個操作就跟邊界回彈的操作重合了,邊界回彈也是讓最小或者最大長刻度滑動到中間指標的位置。所以鬆手之後的操作就分為三種:
currentX :滑動停止時的x座標。
Point:中間指標位置。
low:刻度尺的最小邊界。
height:刻度尺的最大邊界。
-
當前的currentX小於中間指標刻度Point的x座標,並且小於刻度的最小值low的x座標。
-----------------Point-currentX--low------height----------
-
當前的currentX小於中間指標刻度Point的x座標,並且大於刻度的最小值low表示的x座標小於刻度尺的最大刻度height的x座標。
------low-------currentX--Point--------height----------
-
當前的currentX大於中間指標刻度Point的x座標,並且大於刻度的最大值height表示的x座標。
------low-------height-----currentX-Point-------
簡單的表示了一下三種位置。
處理就是,先計算出滑動結束之後的當前x座標跟中間Point的x座標的距離,然後不為0就使用Scroller滑動:
//計算距離 public int getScrollDistance(int x) { for (int i = 0; i < mPoints.size(); i++) { int pointX = mPoints.get(i); if (0 == i && x < pointX) { //當前的x比第一個位置的x座標都小 也就是需要往右移動到第一個長線的位置. setCurrentText(0); return x - pointX; } else if (i == mPoints.size() - 1 && x > pointX) { //當前的x比最後一個左邊的x都大,也就是需要往左移動到最後一個長線位置. setCurrentText(texts.size() - 1); return x - pointX; } else { if (i + 1 < mPoints.size()) { int nextX = mPoints.get(i + 1); if (x > pointX && x <= nextX) { int distance = (nextX - pointX) / 2; int dis = x - pointX; if (dis > distance) { //說明往下一個移動 setCurrentText(i + 1); return x - nextX; } else { setCurrentText(i); //往前一個移動 return x - pointX; } } } } } return 0; } 複製程式碼
開始執行滑動:
public void scrollFinish() { int finalX = mScroller.getFinalX(); int centerPointX = mRulerHelper.getCenterPointX(); int currentX = centerPointX + finalX; int scrollDistance = mRulerHelper.getScrollDistance(currentX); if (0 != scrollDistance) { //第一個引數是滾動開始時的x的座標 //第二個引數是滾動開始時的y的座標 //第三個引數是在X軸上滾動的距離, 負數向右滾動. //第四個引數是在Y軸上滾動的距離,負數向下滾動. mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), -scrollDistance, 0, 300); invalidate(); if (scrollSelected != null) { scrollSelected.selected(getCurrentText()); } } } 複製程式碼
這樣已經可以使用了,滑動的刻度尺已經完成了。不過交給UI一看,人家說這東西怎麼那麼難滑動呢,每次怎麼只能滑一大格呢,我要那種fling的感覺。確實,因為在MotionEvent.ACTION_UP的時候都會去矯正一下位置,所以給使用者的感覺就是一次只能滑一格,滑動體驗很不好,只能去增加fling。。。
4.fling
增加fling多簡單啊,Scroller不是有這個方法嗎mScroller.fling(),使用方法這裡不再介紹了。fling增加之後,使用者的體驗確實好了很多,不過一個新的問題出現了,就是在fling停止之後怎麼矯正位置呢?這是個大問題,卡住了好大一會兒,最終找到了解決方法:
@Override public void computeScroll() { if (mScroller.computeScrollOffset()) { //這裡是結束之後呼叫矯正位置的方法。scrollFinish()。 if (mScroller.getCurrX() == mScroller.getFinalX() && mPressUp && isFling) { mPressUp = false; isFling = false; scrollFinish(); } scrollTo(mScroller.getCurrX(), 0); invalidate(); } super.computeScroll(); } 複製程式碼
三 結束
效果在文章一開始已經展示出來了,指標並沒有在該自定義View中繪製,底部的線也是,因為對於指標的需求是多變的,所以用了一個自定義的ViewGroup去完成剩餘的指標和底部的實線。底部的實線放在Group中是因為我們的UI效果,底部的實線上面可以沒有刻度,也就是這個底部的線是固定在底部,比我畫在刻度下面跟隨刻度滑動要簡單的多。想到之後的變體,感覺刻度本身的View跟指標分開是比較好擴充套件的,Group只需要給刻度尺控制元件傳入中間指標的(x,y)座標就好了。