關於 Android 實現 iOS 上的滾輪選擇效果的控制元件,到 github 上一搜一大堆,之所以還要造這個輪子,目的是為了更好的學習自定義控制元件,這個控制元件是幾個月前寫的了,經過一段時間的完善,現在開源,順便寫這一篇簡單的介紹文章。
效果如下,錄屏軟體看起來可能有點卡頓,具體可以下載原始碼執行:
自定義控制元件無非是 measure,draw,layout 三個過程,如果要支援手勢動作,那麼就再加上 touch 。
-
measure
測量過程比較簡單,以文字大小所需要的尺寸,再加上 padding。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int wantWith = getPaddingLeft() + getPaddingRight(); int wantHeight = getPaddingTop() + getPaddingBottom(); calculateTextSize(); wantWith += mTextRect.width(); //可見 item 數量計算文字尺寸 if (mVisibilityCount > 0) { wantHeight += mTextRect.height() * mVisibilityCount; } else { wantHeight += mTextRect.height() * DEFALUT_VISIBILITY_COUNT; } setMeasuredDimension( resolveSize(wantWith, widthMeasureSpec), resolveSize(wantHeight, heightMeasureSpec) ); mNeedCalculate = true; } 複製程式碼
-
draw
繪製過程是通過 canvas 的位移去繪製不同位置的部件,包括文字內容和選擇框之類的,這裡可能需要注意下的地方是,不要一次性把所有文字繪製出來,只需要繪製可見文字即可。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (hasDataSource()) { // 省略 // 這裡計算下需要繪製的數量,+2 只是確保不會出現空白 final int drawCount = mContentRect.height() / mTextRect.height() + 2; int invisibleCount = 0; int dy = -mDistanceY; // 省略 // 通過 translate 繪製文字 for (int i = 0; (i < drawCount && mDataSources.size() > (invisibleCount + i)); i++) { final int position = invisibleCount + i; String text = mDataSources.get(position); if (i > 0) { canvas.translate(0, mTextRect.height()); } final PointF pointF = calculateTextGravity(text); mTextPaint.setTextSize(mTextSize); if (position == selctPosition) { mTextPaint.setColor(mSelectedTextColor); } else { mTextPaint.setColor(mNormalTextColor); } canvas.drawText(text, pointF.x, pointF.y, mTextPaint); } canvas.restoreToCount(saveCount); } // 繪製選擇框 int saveCount = canvas.save(); mDrawPaint.setColor(mSelectedLineColor); canvas.translate(mContentRect.left, mContentRect.top); canvas.drawLine( mSelctedRect.left, mSelctedRect.top, mSelctedRect.right, mSelctedRect.top, mDrawPaint ); canvas.drawLine( mSelctedRect.left, mSelctedRect.bottom, mSelctedRect.right, mSelctedRect.bottom, mDrawPaint ); canvas.restoreToCount(saveCount); } 複製程式碼
-
layout
因為這個控制元件是繼承於 View,所以不需要處理
onLayout
。 -
touch
如果對 touch event 分發流程熟悉的話,那麼很多處理可以說是模版程式碼,可以參考
NestedScrollView
、ScrollView
。在
onInterceptTouchEvent
中,判斷是否開始進行拖動手勢,儲存到變數(mIsBeingDragged
)中:// 多指處理 final int pointerIndex = ev.findPointerIndex(activePointerId); if (pointerIndex == -1) { Log.e(TAG, "Invalid pointerId=" + activePointerId + " in onInterceptTouchEvent"); break; } final int y = (int) ev.getY(pointerIndex); final int yDiff = Math.abs(y - mLastMotionY); if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) { // 開始拖動 mIsBeingDragged = true; mLastMotionY = y; initVelocityTrackerIfNotExists(); mVelocityTracker.addMovement(ev); mNestedYOffset = 0; if (mScrollStrictSpan == null) { mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll"); } final ViewParent parent = getParent(); if (parent != null) { // 禁止父控制元件攔截事件分發 parent.requestDisallowInterceptTouchEvent(true); } } 複製程式碼
在
onTouchEvent
中對ACTION_MOVR
進行拖動的處理,如果支援巢狀滾動,那麼會預先進行巢狀滾動的分發。如果支援陰影效果,那麼使用EdgeEffect
。// 和 onInterceptTouchEvent 一樣進行拖動手勢開始的判斷 if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) { final ViewParent parent = getParent(); if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } mIsBeingDragged = true; if (deltaY > 0) { deltaY -= mTouchSlop; } else { deltaY += mTouchSlop; } } if (mIsBeingDragged) { // 拖動處理 // Scroll to follow the motion event mLastMotionY = y - mScrollOffset[1]; final int oldY = mScrollY; final int range = getScrollRange(); final int overscrollMode = getOverScrollMode(); boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS || (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); // Calling overScrollBy will call onOverScrolled, which // calls onScrollChanged if applicable. // 滾動處理,overScrollBy 中會處理巢狀滾動預先分發 if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true) && !hasNestedScrollingParent()) { // Break our velocity if we hit a scroll barrier. mVelocityTracker.clear(); } final int scrolledDeltaY = mScrollY - oldY; final int unconsumedY = deltaY - scrolledDeltaY; // 巢狀滾動 if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) { mLastMotionY -= mScrollOffset[1]; vtev.offsetLocation(0, mScrollOffset[1]); mNestedYOffset += mScrollOffset[1]; } else if (canOverscroll) { final int pulledToY = oldY + deltaY; // 拖動陰影效果 if (pulledToY < 0) { mEdgeGlowTop.onPull((float) deltaY / getHeight(), ev.getX(activePointerIndex) / getWidth()); if (!mEdgeGlowBottom.isFinished()) { mEdgeGlowBottom.onRelease(); } } else if (pulledToY > range) { mEdgeGlowBottom.onPull((float) deltaY / getHeight(), 1.f - ev.getX(activePointerIndex) / getWidth()); if (!mEdgeGlowTop.isFinished()) { mEdgeGlowTop.onRelease(); } } if (mEdgeGlowTop != null && (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) { postInvalidateOnAnimation(); } } } 複製程式碼
支援滾動手勢的控制元件,一般都會支援 fling 手勢,可以理解為慣性滾動。這也是模版程式碼,在
onTouchEvent
中對ACTION_UP
中對拖動速度進行分析。case MotionEvent.ACTION_UP: if (mIsBeingDragged) { final VelocityTracker velocityTracker = mVelocityTracker; velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); // 獲取拖動速度 int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId); if ((Math.abs(initialVelocity) > mMinimumVelocity)) { // 可以進行 fling 操作 flingWithNestedDispatch(-initialVelocity); } else if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange())) { postInvalidateOnAnimation(); } mActivePointerId = INVALID_POINTER; endDrag(); } break; 複製程式碼
具體的程式碼可以在
ScrollView
中閱讀。回到我實現的自定義控制元件來,對 touch event 的處理程式碼可以說是和系統控制元件的處理沒有什麼兩樣,在獲取到拖動的距離後,根據這個值繪製不同位置的可見區域。這裡多了兩個處理是:
第一拖動結束後,進行復位處理。拖動結束後,選擇框如果停留在兩個 item 之間,那麼根據和兩個 item 的距離進行比較,選擇更近的 item。
private void correctionDistanceY() { if (mDistanceY % mTextRect.height() != 0) { int position = mDistanceY / mTextRect.height(); int remainder = mDistanceY % mTextRect.height(); if (remainder >= mTextRect.height() / 2f) { position++; } int newDistanceY = position * mTextRect.height(); animChangeDistanceY(newDistanceY); } } 複製程式碼
第二個是在使用上發現的問題,如果剩餘可滾動的距離過短,拖動的手勢速度又很快,就會導致 fling 處理沒結束,視覺上又沒有改變,同時是在滾動結束後才進行選擇的回撥,所以體檢上不好,但是
Scroller
並沒有提供setDuration
,所以拷貝Scroller
中計算 duration 的方法,根據剩餘的滾動計算合適的 duration,手動中斷Scroller
的 fling 處理。if ((SystemClock.elapsedRealtime() - mStartFlingTime) >= mFlingDuration || currY == mScroller.getFinalY()) { //duration or current == final if (DEBUG) { Logger.d("abortAnimation"); } mScroller.abortAnimation(); } 複製程式碼
具體的程式碼可以閱讀原始碼。