仿豆瓣彈性滑動控制元件,史上最全方位講解事件滑動衝突
前言
先來一道趣味測試,後面的控制元件講解會比較枯燥乏味,看一看你的數學老師是誰教的?
小明向兩位朋友各自借了50元,用借來的錢,小明花費97元買了一件格子衫。這時候還剩3元錢,小明還給兩個小夥伴各1元,自己還剩下1元。
那麼問題來了:小明此時欠兩位小夥伴各49元,再加上自己剩下的1元,49+49+1=99元。剩下的1元去哪了?
正文
近日產品提出了一個新需求, 在首頁列表中新增可以橫向滑動的卡片型別
,效果類似豆瓣彈性滑動控制元件,看下最終效果圖:

滑動彈性控制元件
小編剛開始以為只要實現了 豆瓣彈性滑動控制元件
就萬事大吉了,沒想到這只是一個開始。 滑動控制元件
只不過是一道開胃菜, 事件衝突
才是重頭戲。
首先分析下效果圖中的佈局,典型的 ViewPager + fragment + RecyclerView
佈局方式,在垂直的 RecyclerView
中嵌入了 彈性滑動控制元件
字 item
,那麼會有哪些事件衝突呢?
-
彈性滑動控制元件
會消費左右滑動事件,內部的卡片RecyclerView
同時也會消費左右滑動事件,左右滑動事件就會衝突。光是文字的描述,可能不大好理解,結合以下圖片加以說明:手指向左滑動,是RecyclerView
消費左滑的事件呢?還是彈性滑動控制元件
消費左滑的事件?
scr
- 垂直的
RecyclerView
會預設消費上下滑動事件,彈性滑動控制元件
在左右滑動的同時,y
軸方向的偏移量不會為0
,因為手指的滑動很難保持在一條水平線上,垂直的RecyclerView
就會消費y
方向的事件,導致介面抖動,滑動不靈敏。那麼彈性滑動控制元件
在左右滑動的時候就需要攔截掉垂直的RecyclerView
的滑動事件消費。 -
彈性滑動控制元件
滑動到左右邊緣的時候,最外層的ViewPager
會預設消費掉左右滑動事件,導致滑向上一個tab
或下一個tab
,無任何的彈性效果。處理方式, 在彈性滑動控制元件
左右滑動的時候,需要禁止掉ViewPager
的事件消費。
一個滑動控制元件需要解決這麼多事件衝突,想一想,是時候使用抽屜裡的菜刀了,但讓我沒想到的是,我拿著菜刀急衝衝找到產品,他卻很淡定的從抽屜裡拿出了手槍,拿出了手槍,我內心告訴自己不能慫,嘴上卻不爭氣的說道:沒問題, so easy
,給我2天時間,我真想給自己一大嘴巴,那麼接下來就開整唄。
豆瓣彈性滑動控制元件
需要實現 豆瓣彈性滑動控制元件
的效果,先調研下豆瓣的佈局方式:

在這裡插入圖片描述
uiautomatorviewer.bat
工具中可以分析出,豆瓣是通過自定義
LinearLayout
來實現的,包含了橫向的
RecyclerView
與右側的
釋放檢視TextView
文字子控制元件。那麼
彈性滑動控制元件
實現的大概思路如下:
RecyclerView
滑動到左右邊緣,記錄
x
軸方向的偏移量,通過方法
setTranslationX
設定
RecyclerView
的平移量,手指抬起則執行簡單的平移動畫,接下來會詳細講解,比較乏味,請繫好安全帶。
分解 彈性滑動
過程,新建 HorizontalScrollView
繼承 RelativeLayout
,並沒有繼承 LinearLayout
,後面會講到:
-
RecyclerView
滑動到左邊緣,繼續向右滑動,HorizontalScrollView
攔截事件,同時記錄x
方向的偏移量dx
,RecyclerView
呼叫setTranslationX
方法設定平移量RecyclerView.setTranslationX(dx)
,這裡又分兩種情況:第一種手指抬起執行平移動畫;第二種向左滑動除了RecyclerView.setTranslationX(dx)
還需要判定RecyclerView.getTranslationX()
是否等於0
,如果等於0
則不攔截事件,返回super.dispatchTouchEvent(ev)
。 -
RecyclerView
滑動到右邊緣,繼續向左滑動,處理同1,還需根據偏移量來判定右側的文字顯示狀態。 -
RecyclerView
未滑動到左右邊緣,HorizontalScrollView
不攔截事件,RecyclerView
消費左右滑動事件。
請結合以下程式碼加以理解:
@Override public boolean dispatchTouchEvent(MotionEvent ev) { if (mHorizontalRecyclerView == null) { return super.dispatchTouchEvent(ev); } switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: // 重置變數 mHintLeftMargin = 0; mMoveIndex = 0; mConsumeMoveEvent = false; mLastX = ev.getRawX(); mLastY = ev.getRawY(); break; case MotionEvent.ACTION_MOVE: // 釋放動畫 if (ReboundAnim != null && ReboundAnim.isRunning()) { break; } float mDeltaX = (ev.getRawX() - mLastX); float mDeltaY = ev.getRawY() - mLastY; mLastX = ev.getRawX(); mLastY = ev.getRawY(); mDeltaX = mDeltaX * RATIO; // 右滑 if (mDeltaX > 0) { //canScrollHorizontally 判定是否滑動到邊緣 if (!mHorizontalRecyclerView.canScrollHorizontally(-1) || mHorizontalRecyclerView.getTranslationX() < 0) { float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX(); if (mHorizontalRecyclerView.canScrollHorizontally(-1) && transX >= 0) { transX = 0; } mHorizontalRecyclerView.setTranslationX(transX); setHintTextTranslationX(mDeltaX); } } else if (mDeltaX < 0) { // 左滑 if (!mHorizontalRecyclerView.canScrollHorizontally(1) || mHorizontalRecyclerView.getTranslationX() > 0) { float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX(); if (transX <= 0 && mHorizontalRecyclerView.canScrollHorizontally(1)) { transX = 0; } mHorizontalRecyclerView.setTranslationX(transX); setHintTextTranslationX(mDeltaX); } } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: // 釋放動畫 if (ReboundAnim != null && ReboundAnim.isRunning()) { break; } if (mHintLeftMargin <= mOffsetWidth && mListener != null) { // 鬆手看更多的事件監聽 mListener.onRelease(); } // 手指抬起動畫 ReboundAnim = ValueAnimator.ofFloat(1.0f, 0); ReboundAnim.setDuration(300); ReboundAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); mHorizontalRecyclerView.setTranslationX(value * mHorizontalRecyclerView.getTranslationX()); mMoreTextView.setTranslationX(value * mMoreTextView.getTranslationX()); } }); ReboundAnim.start(); break; } return mHorizontalRecyclerView.getTranslationX() != 0 ? true : super.dispatchTouchEvent(ev); }
程式碼邏輯很清晰,有不理解的童鞋,請留言。彈性效果實現了,但右側還有一個豎直的文字控制元件, ui
需要的效果如下圖,需要實現的功能如下:

dou_4
- 內容垂直排版
- 文字間的間距需要可控
- 可以設定圖示
- 貝塞爾曲線陰影,根據手指偏移量來動態改變貝塞爾曲線的控制點
很遺憾,原生的 TextView
並不支援內容垂直排版,間距也不可控,但欣慰的是支援設定圖示,那麼重寫 onDraw
方法,自己繪製垂直文字,可謂是一個不錯的方案。
VerticalTextView
繼承 AppCompatTextView
,重寫以下方法:
@Override public void setText(CharSequence text, BufferType type) { // super.setText(text, type);// 注意這裡必須要註釋掉,不然繪製出來會多橫向的文字,加個標記控制的話更好 setVerticalText(text); }
public void setVerticalText(CharSequence text) { mDefaultText = text; invalidate(); }
通過獲取基線 baseline
座標,以及整個字元的高度,來調整文字居中對齊,然後根據每個字元的高度,累加繪製文字:
@Override protected void onDraw(Canvas canvas) { mPaint.setTextSize(getTextSize()); mPaint.setColor(getCurrentTextColor()); mPaint.setTypeface(getTypeface()); CharSequence text = mDefaultText; if (getText() != null && !text.toString().trim().equals("")) { Rect bounds = new Rect(); mPaint.getTextBounds(text.toString(), 0, text.length(), bounds); // 最開始就忘記 + getPaddingLeft 導致繪製的文字偏左 float startX = getLayout().getLineLeft(0) + getPaddingLeft(); if (getCompoundDrawables()[0] != null) { Rect drawRect = getCompoundDrawables()[0].getBounds(); // 減去圖示的寬度 startX += (drawRect.right - drawRect.left); } startX += getCompoundDrawablePadding(); float startY = getBaseline(); int cHeight = (bounds.bottom - bounds.top + mCharSpacing); // 居中對齊 startY -= (text.length() - 1) * cHeight / 2; for (int i = 0; i < text.length(); i++) { String c = String.valueOf(text.charAt(i)); canvas.drawText(c, startX, startY + i * cHeight, mPaint); } } super.onDraw(canvas); // 繪製貝塞爾陰影 if (mIsDrawShadow) { mShadowPath.reset(); mShadowPath.moveTo(getWidth(), getHeight() / 4); mShadowPath.quadTo(mShadowOffset, getHeight() / 2, getWidth(), getHeight() / 4 * 3); canvas.drawPath(mShadowPath, mShadowPaint); } }
突然有個想法,如果以路徑 Path
來繪製文字,豈不更棒,有興趣的小夥伴可以下來試一試。 彈性滑動控制元件
到這裡就告一段落了,接下來主要處理整合到專案中的滑動事件衝突。
垂直RecyclerView滑動衝突
垂直 RecyclerView
會消費上下滑動事件,導致 彈性滑動控制元件
在水平方向滑動的時候, y
軸方向產生的偏移量被垂直 RecyclerView
消費,請看下圖:

src_5
那麼怎麼來處理與垂直 RecyclerView
產生的事件衝突呢?處理事件衝突的方式有兩種:
- 子
View
禁止父View
攔截Touch
事件,在分析ViewGroup
的dispatchTouchEvent()
原始碼時,我們知道:Touch
事件是由父View
分發的。如果一個Touch
事件是子View
需要的,但是被其父View
攔截了,子View
就無法處理該Touch
事件了。在此情形下,子View
可以呼叫requestDisallowInterceptTouchEvent( )
禁止父View
對Touch
的攔截 - 在父
View
中準確地進行事件分發和攔截 ,我們可以重寫父View
中與Touch
事件分發相關的方法,比如onInterceptTouchEvent( )
。這些方法中摒棄系統預設的流程,結合自身的業務邏輯重寫該部分程式碼,從而使父View
放行子View
需要的Touch
這裡以第一種的方式解決與垂直方向的 RecyclerView
滑動衝突,第二種方式解決與 ViewPager
的滑動衝突。原理非常的簡單,判定 x
方向的偏移量是否大於 y
方向的偏移量,大於則禁止父 View
攔截 Touch
事件,反之則不攔截,具體程式碼如下:
float mDeltaX = (ev.getRawX() - mLastX); float mDeltaY = ev.getRawY() - mLastY; if (!mConsumeMoveEvent) { // 處理事件衝突 if (Math.abs(mDeltaX) > Math.abs(mDeltaY)) { getParent().requestDisallowInterceptTouchEvent(true); } else { getParent().requestDisallowInterceptTouchEvent(false); } } mMoveIndex++; if (mMoveIndex > 2) { mConsumeMoveEvent = true; } mLastX = ev.getRawX(); mLastY = ev.getRawY();
很多時候觸控式螢幕幕會導致第一次 ACTION_MOVE
獲取的 mDeltaX
與 mDeltaY
都為 0
,導致父 View
攔截了 Touch
事件,彈性效果失效,為了解決這個問題,這裡用到了一個小技巧,多判定一次攔截條件。大家發現沒有,程式碼中還有一處優化的地方, getParent()
方法獲取的父控制元件不一定是列表控制元件,比較合理的方式使用遞迴去獲取,相關程式碼如下:
private ViewParent getParentListView(ViewParent viewParent) { if (viewParent == null) return null; if (viewParent instanceof RecyclerView || viewParent instanceof ListView) { return viewParent; } else { getParentListView(viewParent.getParent()); } return null; }
ViewPager滑動衝突
ViewPager
會預設消費左右滑動事件,當 彈性控制元件
滑動到左右邊緣時,繼續滑動會觸發 ViewPager
的滑動,請看下圖:

src_6
這裡採用第二種方式處理滑動衝突,在父 View
中準確地進行事件分發和攔截,那麼我們什麼時候分發?又什麼時候攔截呢?如果我們左右滑動的是非 彈性控制元件
區域,那麼 ViewPager
應該攔截事件,反之則分發事件。
那麼我們才能知道觸控的是 彈性控制元件
區域呢?可能在螢幕中的任何位置,我們知道 view
的層級是樹形結構,那麼針對 ViewPager
的子 view
進行遍歷,拿到設有 彈性控制元件
的 tag
標記,來進行事件的分發和攔截,具體程式碼如下,不知道小夥伴又沒更好的方案:
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { int action = ev.getAction() & MotionEventCompat.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: mInterceptEvent = !childInterceptEvent(this, (int) ev.getRawX(), (int) ev.getRawY()); break; } // 攔截與分發 return mInterceptEvent ? super.onInterceptTouchEvent(ev) : false; } // 遍歷樹 private boolean childInterceptEvent(ViewGroup parentView, int touchX, int touchY) { boolean isConsume = false; for (int i = parentView.getChildCount() - 1; i >= 0; i--) { View childView = parentView.getChildAt(i); if (!childView.isShown()) { continue; } boolean isTouchView = isTouchView(touchX, touchY, childView); if (isTouchView && childView.getTag() != null && TAG_DISPATCH.equals(childView.getTag().toString())) { isConsume = true; break; } if (childView instanceof ViewGroup) { ViewGroup itemView = (ViewGroup) childView; if (!isTouchView) { continue; } else { isConsume |= childInterceptEvent(itemView, touchX, touchY); if (isConsume) { break; } } } } return isConsume; } // 是否在觸控區域內 private boolean isTouchView(int touchX, int touchY, View view) { Rect rect = new Rect(); view.getGlobalVisibleRect(rect); return rect.contains(touchX, touchY); }
感興趣的小夥伴的可以以第一種方式來解決滑動衝突。文中涉及的知識點都是個人的看法,如果你覺得有什麼地方不妥,歡迎指出?每個人在開發當中的場景可能都不一樣,但是處理衝突的基本原理和方式是相同的。
結語
原始碼小編整理後會上傳到 ofollow,noindex">MeiWidgetView ,同時非常希望各位小夥伴能夠動手點顆 star ,你的鼓勵與支援才是讓小編繼續創作的源泉。