本篇文章已授權(quán)微信公眾號 guolin_blog (郭霖)獨家發(fā)布
前言
先來一道趣味測試,后面的控件講解會比較枯燥乏味,看一看你的數(shù)學(xué)老師是誰教的?
小明向兩位朋友各自借了50元,用借來的錢,小明花費97元買了一件格子衫。這時候還剩3元錢,小明還給兩個小伙伴各1元,自己還剩下1元。
那么問題來了:小明此時欠兩位小伙伴各49元,再加上自己剩下的1元,49+49+1=99元。剩下的1元去哪了?
正文
近日產(chǎn)品提出了一個新需求,在首頁列表中新增可以橫向滑動的卡片類型,效果類似豆瓣彈性滑動控件,看下最終效果圖:

小編剛開始以為只要實現(xiàn)了 豆瓣彈性滑動控件 就萬事大吉了,沒想到這只是一個開始。滑動控件 只不過是一道開胃菜,事件沖突 才是重頭戲。
首先分析下效果圖中的布局,典型的 ViewPager + fragment + RecyclerView 布局方式,在垂直的 RecyclerView 中嵌入了 彈性滑動控件 字 item,那么會有哪些事件沖突呢?
-
彈性滑動控件會消費左右滑動事件,內(nèi)部的卡片RecyclerView同時也會消費左右滑動事件,左右滑動事件就會沖突。光是文字的描述,可能不大好理解,結(jié)合以下圖片加以說明:手指向左滑動,是RecyclerView消費左滑的事件呢?還是彈性滑動控件消費左滑的事件?
scr - 垂直的
RecyclerView會默認(rèn)消費上下滑動事件,彈性滑動控件在左右滑動的同時,y軸方向的偏移量不會為0,因為手指的滑動很難保持在一條水平線上,垂直的RecyclerView就會消費y方向的事件,導(dǎo)致界面抖動,滑動不靈敏。那么彈性滑動控件在左右滑動的時候就需要攔截掉垂直的RecyclerView的滑動事件消費。 -
彈性滑動控件滑動到左右邊緣的時候,最外層的ViewPager會默認(rèn)消費掉左右滑動事件,導(dǎo)致滑向上一個tab或下一個tab,無任何的彈性效果。處理方式, 在彈性滑動控件左右滑動的時候,需要禁止掉ViewPager的事件消費。
一個滑動控件需要解決這么多事件沖突,想一想,是時候使用抽屜里的菜刀了,但讓我沒想到的是,我拿著菜刀急沖沖找到產(chǎn)品,他卻很淡定的從抽屜里拿出了手槍,拿出了手槍,我內(nèi)心告訴自己不能慫,嘴上卻不爭氣的說道:沒問題,so easy ,給我2天時間,我真想給自己一大嘴巴,那么接下來就開整唄。
豆瓣彈性滑動控件
需要實現(xiàn) 豆瓣彈性滑動控件 的效果,先調(diào)研下豆瓣的布局方式:
從
uiautomatorviewer.bat 工具中可以分析出,豆瓣是通過自定義 LinearLayout 來實現(xiàn)的,包含了橫向的 RecyclerView 與右側(cè)的 釋放查看TextView 文本子控件。那么 彈性滑動控件 實現(xiàn)的大概思路如下:RecyclerView 滑動到左右邊緣,記錄 x 軸方向的偏移量,通過方法 setTranslationX 設(shè)置 RecyclerView 的平移量,手指抬起則執(zhí)行簡單的平移動畫,接下來會詳細(xì)講解,比較乏味,請系好安全帶。
分解 彈性滑動 過程,新建HorizontalScrollView繼承RelativeLayout,并沒有繼承LinearLayout,后面會講到:
-
RecyclerView滑動到左邊緣,繼續(xù)向右滑動,HorizontalScrollView攔截事件,同時記錄x方向的偏移量dx,RecyclerView調(diào)用setTranslationX方法設(shè)置平移量RecyclerView.setTranslationX(dx),這里又分兩種情況:第一種手指抬起執(zhí)行平移動畫;第二種向左滑動除了RecyclerView.setTranslationX(dx)還需要判定RecyclerView.getTranslationX()是否等于0,如果等于0則不攔截事件,返回super.dispatchTouchEvent(ev)。 -
RecyclerView滑動到右邊緣,繼續(xù)向左滑動,處理同1,還需根據(jù)偏移量來判定右側(cè)的文本顯示狀態(tài)。 -
RecyclerView未滑動到左右邊緣,HorizontalScrollView不攔截事件,RecyclerView消費左右滑動事件。
請結(jié)合以下代碼加以理解:
@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) {
// 松手看更多的事件監(jiān)聽
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);
}
代碼邏輯很清晰,有不理解的童鞋,請留言。彈性效果實現(xiàn)了,但右側(cè)還有一個豎直的文本控件,ui 需要的效果如下圖,需要實現(xiàn)的功能如下:
- 內(nèi)容垂直排版
- 文字間的間距需要可控
- 可以設(shè)置圖標(biāo)
- 貝塞爾曲線陰影,根據(jù)手指偏移量來動態(tài)改變貝塞爾曲線的控制點
很遺憾,原生的 TextView 并不支持內(nèi)容垂直排版,間距也不可控,但欣慰的是支持設(shè)置圖標(biāo),那么重寫 onDraw 方法,自己繪制垂直文本,可謂是一個不錯的方案。
VerticalTextView 繼承 AppCompatTextView ,通過 setVerticalText() 方法設(shè)置繪制文本:
public void setVerticalText(CharSequence text) {
mDefaultText = text;
invalidate();
}
通過獲取基線 baseline 坐標(biāo),以及整個字符的高度,來調(diào)整文本居中對齊,然后根據(jù)每個字符的高度,累加繪制文本:
@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 導(dǎo)致繪制的文本偏左
float startX = getLayout().getLineLeft(0) + getPaddingLeft();
if (getCompoundDrawables()[0] != null) {
Rect drawRect = getCompoundDrawables()[0].getBounds();
// 減去圖標(biāo)的寬度
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 會消費上下滑動事件,導(dǎo)致 彈性滑動控件 在水平方向滑動的時候,y 軸方向產(chǎn)生的偏移量被垂直 RecyclerView 消費,請看下圖:

那么怎么來處理與垂直 RecyclerView 產(chǎn)生的事件沖突呢?處理事件沖突的方式有兩種:
- 子
View禁止父View攔截Touch事件,在分析ViewGroup的dispatchTouchEvent()源碼時,我們知道:Touch事件是由父View分發(fā)的。如果一個Touch事件是子View需要的,但是被其父View攔截了,子View就無法處理該Touch事件了。在此情形下,子View可以調(diào)用requestDisallowInterceptTouchEvent( )禁止父View對Touch的攔截 - 在父
View中準(zhǔn)確地進(jìn)行事件分發(fā)和攔截 ,我們可以重寫父View中與Touch事件分發(fā)相關(guān)的方法,比如onInterceptTouchEvent( )。這些方法中摒棄系統(tǒng)默認(rèn)的流程,結(jié)合自身的業(yè)務(wù)邏輯重寫該部分代碼,從而使父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();
很多時候觸摸屏幕會導(dǎo)致第一次 ACTION_MOVE 獲取的 mDeltaX 與 mDeltaY 都為 0,導(dǎo)致父 View 攔截了 Touch 事件,彈性效果失效,為了解決這個問題,這里用到了一個小技巧,多判定一次攔截條件。大家發(fā)現(xiàn)沒有,代碼中還有一處優(yōu)化的地方,getParent() 方法獲取的父控件不一定是列表控件,比較合理的方式使用遞歸去獲取,相關(guān)代碼如下:
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 會默認(rèn)消費左右滑動事件,當(dāng) 彈性控件 滑動到左右邊緣時,繼續(xù)滑動會觸發(fā) ViewPager 的滑動,請看下圖:

這里采用第二種方式處理滑動沖突,在父 View 中準(zhǔn)確地進(jìn)行事件分發(fā)和攔截,那么我們什么時候分發(fā)?又什么時候攔截呢?如果我們左右滑動的是非 彈性控件 區(qū)域,那么 ViewPager 應(yīng)該攔截事件,反之則分發(fā)事件。
那么我們才能知道觸摸的是 彈性控件 區(qū)域呢?可能在屏幕中的任何位置,我們知道 view 的層級是樹形結(jié)構(gòu),那么針對 ViewPager 的子 view 進(jìn)行遍歷,拿到設(shè)有 彈性控件 的 tag 標(biāo)記,來進(jìn)行事件的分發(fā)和攔截,具體代碼如下,不知道小伙伴又沒更好的方案:
@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;
}
// 攔截與分發(fā)
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;
}
// 是否在觸摸區(qū)域內(nèi)
private boolean isTouchView(int touchX, int touchY, View view) {
Rect rect = new Rect();
view.getGlobalVisibleRect(rect);
return rect.contains(touchX, touchY);
}
感興趣的小伙伴的可以以第一種方式來解決滑動沖突。文中涉及的知識點都是個人的看法,如果你覺得有什么地方不妥,歡迎指出?每個人在開發(fā)當(dāng)中的場景可能都不一樣,有時候需要根據(jù)特定的規(guī)則去處理滑動沖突,但是處理沖突的基本原理和方式是相同的,希望本篇文章對大家有所幫助,想了解更多炫酷控件,別忘了關(guān)注小編。
結(jié)語
源碼小編整理后會上傳到 MeiWidgetView ,同時非常希望各位小伙伴能夠動手點顆 star ,你的鼓勵與支持才是讓小編繼續(xù)創(chuàng)作的源泉。