造了個滾輪控制元件輪子

奮鬥的Leo發表於2019-02-27

專案地址

關於 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 分發流程熟悉的話,那麼很多處理可以說是模版程式碼,可以參考 NestedScrollViewScrollView

    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();
              }
    複製程式碼

具體的程式碼可以閱讀原始碼。

相關文章