這篇文章主要是記錄、總結在實際項目中遇到關于手勢沖突的“破冰之旅”。
旅途目的地
新版本優(yōu)化searching模塊的UI展現(xiàn),頁面列表主要用來展示用戶發(fā)表的moment,上部配備navigation bar和banner位,banner用來展示、引導話題和活動,允許配置多張,是個ViewPager。因為banner可以劃出屏幕,所以決定讓banner作為GridView的headerView。
航行
目標明確,準備就緒,啟航!
旅途中,我們使用 GridViewWithHeaderAndFooter 來作伴(從下圖.xml代碼中看出支持了下拉刷新),一路上海風吹拂、心情愉悅,眼見著美女們的圖片、視頻都顯示出來了,忍不住欣賞了起來…
<FPtrFrameLayout
android:id="@+id/layout_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LoadMoreGridViewContainer
android:id="@+id/layout_loadmore"
android:layout_width="match_parent"
android:layout_height="match_parent">
<GridViewWithHeaderAndFooter
android:id="@+id/gridView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:numColumns="3" />
</LoadMoreGridViewContainer>
</FPtrFrameLayout>
前方海域
往往,當我們以為征服了海洋的時候,它便會還以顏色。
當我欣賞佳作正酣之時,突然感覺到前方海域寒氣逼人,趕緊轉換為高能狀態(tài),嚴陣以待。經過仔細的勘測和偵查,查明低溫造成海面結冰,如不想辦法破除,可能會給航行帶來不好的體驗,旅客會覺得你的服務不夠專業(yè)。
原來是,從后臺配置完banner后,發(fā)現(xiàn)banner處 ViewPager 的滑動不順暢,在ViewPager 的滑動過程中,如果手指偏向下滑時,便引起 GridView 的下拉刷新操作。奧,直覺再次告訴我這肯定是手勢沖突了,常在海上行,哪能沒見過點風浪~
OK,回顧下Android的事件分發(fā)機制,下面用偽代碼來展示涉及到的幾個關鍵方法之間的關系:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
//首先執(zhí)行當前view的onInterceptTouchEvent(ev)方法
if (onInterceptTouchEvent(ev)) {
//如果當前View進行攔截(onInterceptTouchEvent(ev)返回true),
//接著執(zhí)行當前Veiw的onTouchEvent(ev)方法
consume = onTouchEvent(ev);
} else {
//如果當前View不攔截,則事件就交給子view,調用子view的dispatchTouchEvent(ev)
consume = getFocusedChild().dispatchTouchEvent(ev);
}
return consume;
}
大家也都知道手勢沖突的一般解決方法有兩種(此處默認是老水手):
1、外部攔截法:因為事件都是先經過父View的攔截處理,如果父View需要此事件就直接攔截,如果不需要就不進行攔截讓子View來處理就好了。
2、內部攔截法:前提是子View都能接受到事件,如果需要此事件直接消耗掉便是,否則就交由父View進行處理,需要配合 requestDisallowInterceptTouchEvent(boolean disallowIntercept) 方法來使用。這種方法和Android的事件分發(fā)機制不一致,因此屬于逆向思維,但是在有些場景是很有用的。比如:父View不易修改(直接compile的開源庫)、不易繼承時(別人寫的,且融合有比較多的業(yè)務邏輯)…
破冰
了解了當前的處境,那么我們就針對性地開始破冰吧。
由于是compile的 UltraPullToRefresh 庫,所以我的第一選擇是調整本地自定義 ViewPager 的代碼:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
float x = ev.getX();
float y = ev.getY();
switch (ev.getAction()) {
……
case MotionEvent.ACTION_HOVER_MOVE:
// 當X方向的移動距離大于Y方向時,請求不允許父View攔截,讓ViewPager來處理事件
if (Math.abs(x - mLastX) > Math.abs(y - mLastY)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
……
}
mLastX = x;
mLastY = y;
}
當我滿心歡喜地準備重新起航時,不料,現(xiàn)實給了我當頭一棒,你!休!想!破!
為什么破不了呢?
是因為 requestDisallowInterceptTouchEvent 方法沒起作用?
還是父View直接處理消耗了事件,ViewPager 根本就沒機會接收?
對啊!內部攔截法的前提就是子View必須接收到事件,然后才能根據(jù)需要進行相應的處理。看來問題是出在父View上了,那么我們就直接找來造事者吧,打開GridViewWithHeaderAndFooter 卻并沒有發(fā)現(xiàn)有關事件處理的方法,它繼承自 GridView ,那么就是繼承了 GridView 的事件處理機制,基于之前的經驗,系統(tǒng)的 AbsListView 組件是沒有這個問題的。
好吧,繼續(xù)追蹤水面異常結冰的原因。經過測試發(fā)現(xiàn)了一點就是我們在橫向滑動 ViewPager 時如果手勢偏上的話是沒有問題的,只是偏下時會出現(xiàn)下拉刷新操作,然后導致 ViewPager 的橫向滑動失效,如果速度快的話就有橫向劃不動的感覺…
哈哈,原來是下拉刷新在搞鬼,還記得上面我給出的.xml布局中GridViewWithHeaderAndFooter 外部的兩個父View LoadMoreGridViewContainer 和 FPtrFrameLayout 吧,基于當前的現(xiàn)象,估計是 FPtrFrameLayout 被施了魔咒,異常纏身了。
OK,讓 FPtrFrameLayout 快快現(xiàn)身,快速定位到他的 dispatchTouchEvent 方法:
@Override
public boolean dispatchTouchEvent(MotionEvent e) {
……
int action = e.getAction();
switch (action) {
……
case MotionEvent.ACTION_MOVE:
mLastMoveEvent = e;
mPtrIndicator.onMove(e.getX(), e.getY());
float offsetX = mPtrIndicator.getOffsetX();
float offsetY = mPtrIndicator.getOffsetY();
// 橫向滑動相關的判斷邏輯
if (mDisableWhenHorizontalMove && !mPreventForHorizontal && (Math.abs(offsetX) > mPagingTouchSlop && Math.abs(offsetX) > Math.abs(offsetY))) {
if (mPtrIndicator.isInStartPosition()) {
mPreventForHorizontal = true;
}
}
if (mPreventForHorizontal) {
return dispatchTouchEventSupper(e);
}
// 以下是處理下拉刷新的邏輯
boolean moveDown = offsetY > 0;
boolean moveUp = !moveDown;
boolean canMoveUp = mPtrIndicator.hasLeftStartPosition();
// disable move when header not reach top
if (moveDown && mPtrHandler != null && !mPtrHandler.checkCanDoRefresh(this, mContent, mHeaderView)) {
return dispatchTouchEventSupper(e);
}
if ((moveUp && canMoveUp) || moveDown) {
movePos(offsetY);
return true;
}
}
return dispatchTouchEventSupper(e);
}
因為懷疑是下拉刷新在搞鬼,所以我們先來看他的邏輯:
// 以下是處理下拉刷新的邏輯
boolean moveDown = offsetY > 0;
boolean moveUp = !moveDown;
boolean canMoveUp = mPtrIndicator.hasLeftStartPosition();
// disable move when header not reach top
// 向下滑動,且配置不支持刷新,則返回
if (moveDown && mPtrHandler != null && !mPtrHandler.checkCanDoRefresh(this, mContent, mHeaderView)) {
return dispatchTouchEventSupper(e);
}
// 向上或向下滑動,執(zhí)行操作
if ((moveUp && canMoveUp) || moveDown) {
movePos(offsetY);
return true;
}
我們測試的情況是橫向滑時偏向下move,配置是支持刷新的,所以會進入 movePos 方法,該方法主要是得到Y方向move的距離,然后執(zhí)行 updatePos 方法。
private void movePos(float deltaY) {
// has reached the top
if ((deltaY < 0 && mPtrIndicator.isInStartPosition())) {
if (DEBUG) {
PtrCLog.e(LOG_TAG, String.format("has reached the top"));
}
return;
}
int to = mPtrIndicator.getCurrentPosY() + (int) deltaY;
// over top
if (mPtrIndicator.willOverTop(to)) {
if (DEBUG) {
PtrCLog.e(LOG_TAG, String.format("over top"));
}
to = PtrIndicator.POS_START;
}
mPtrIndicator.setCurrentPos(to);
int change = to - mPtrIndicator.getLastPosY();
updatePos(change);
}
private void updatePos(int change) {
if (change == 0) {
return;
}
boolean isUnderTouch = mPtrIndicator.isUnderTouch();
// once moved, cancel event will be sent to child
if (isUnderTouch && !mHasSendCancelEvent && mPtrIndicator.hasMovedAfterPressedDown()) {
mHasSendCancelEvent = true;
sendCancelEvent();
}
……
}
在 updatePos 方法中又會通過判斷執(zhí)行到 sendCancelEvent() 方法,即向子View派發(fā) MotionEvent.ACTION_CANCEL 事件。呵,這便是我剛才橫向滑動然后偏向下move時 ViewPager 滑動失效的原因。
OK,滑動失效的原因找到了,那么我們需要做的就是在 dispatchTouchEvent 方法中繞開下拉刷新的邏輯唄,理所當然,lib庫作者明顯考慮了橫向判斷的邏輯,我們接著分析:
// (判斷1)
// X方向滑動距離的絕對值大于 mPagingTouchSlop 且大于Y方向
if (mDisableWhenHorizontalMove && !mPreventForHorizontal && (Math.abs(offsetX) > mPagingTouchSlop && Math.abs(offsetX) > Math.abs(offsetY))) {
// 列表是否滑到了頂部
if (mPtrIndicator.isInStartPosition()) {
mPreventForHorizontal = true;
}
}
// (判斷2)
// 為橫向滑動,直接返回。
if (mPreventForHorizontal) {
return dispatchTouchEventSupper(e);
}
可以看出關鍵點是:如果 mPreventForHorizontal 為true就直接交給super.dispatchTouchEvent(e),那么便不會執(zhí)行下拉刷新的邏輯,破冰就指秒可待!mDisableWhenHorizontalMove 默認為false,我們給設成true,mPreventForHorizontal 在 ACTION_DOWN 時被設為false,也不影響。嘿,見鬼了,邏輯沒問題吶(哈哈,太多時候我們都會不禁發(fā)出這樣的疑問:不應該啊,這么沒問題啊~)。“看看 mPagingTouchSlop 吧,萬一是這哥們呢”,心里回響起這個聲音。
mPagingTouchSlop = conf.getScaledTouchSlop() * 2;
即系統(tǒng)的 TOUCH_SLOP * 2,乘以2的話那么 Math.abs(offsetX) > mPagingTouchSlop 條件就不容易成立(除非你快速且準確地橫向滑動)。改為正常試試?修改、運行、啟動一氣呵成,著實有效,你能相信只是兩個字符的改動?第一次觸發(fā) ACTION_MOVE 分支的邏輯經過判斷1為true后,之后的move事件直接進入判斷2并返回。哈哈,我們手上揮舞著跳動的字符,幻化起飛躍屏幕的世界…
揚帆
眼見堅冰悄然融化,何不趁機揚起風帆!
1、這并不是一個通過內截法解決手勢沖突問題的案例。因為父View在處理下拉時并沒有做Intercept,而是向子View派發(fā) ACTION_CANCEL 事件。
2、issues中有人提供了如下解法,但會導致在 ViewPager 區(qū)域下拉不會出現(xiàn)刷新效果。
mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageScrollStateChanged(int state) {
mPtrFrame.setEnabled(state == ViewPager.SCROLL_STATE_IDLE);
}
});
3、也有人說把判斷1中的這個去掉,嗯,一樣的思路,都是使判斷1容易成立。不過 TouchSlop 畢竟是Api定義的認為觸發(fā)移動的最小距離,不過Y方向的滑動卻并沒有對比 TouchSlop,所以這點你可以根據(jù)情況前后統(tǒng)一便是。
Math.abs(offsetX) > mPagingTouchSlop
4、后來在另外一個issues中看到庫的作者說這并不是bug,而是下拉操作比較靈敏而已。對啊,為什么使他如此靈敏呢?可能作者當初設計庫時主要考慮邏輯是下拉的動作,隨著使用場景的復雜,需要支持橫向的業(yè)務,后來增加了 mDisableWhenHorizontalMove 字段來控制,但此時2倍的 TouchSlop 就不太合適了。
航海總結
以上,或許帶你領略、加深了某些技能,但有可能都是錯的,因為這個是我的驗證,或許你有不同的聲音。so,重要的是讓理論指導思考,在實踐中驗證想法和理論,最后達成目標并反過來總結思考整個過程。
最后,感謝同事鵬哥,是他引入了這個組件并在項目中方便使用,我向他描述了問題,他便準確、快速定位到了關鍵點。
「spiritTalk的航海日記」
轉載請標明出處:http://www.lxweimin.com/p/0336b527da3f