RecyclerView 拖動/滑動多選的實現(2)
方案三: AndroidDragSelect
前文說到,方案三就是分析了方案一的缺點之後,給出了自己的基於 OnItemTouchListener
的實現方案,耦合度低,可以很容易整合進現有的專案當中。
從自定義 RecyclerView 的方案中可以看到,它是在事件分發的時候進行處理。事實上,在這個方法裡做計算感覺上就有點不對,從原始碼來看,RecyclerView 本身是沒有重寫 dispatchTouchEvent()
方法的,而方案一通過重寫此方法並在這裡完成自動滾動的計算處理,顯得有些重。
回顧一下事件分發機制,其中 dispatchTouchEvent()
用來進行事件的分發,onInterceptTouchEvent()
onTouchEvent()
當中。所以方案三就是利用了 RecyclerView 的 OnItemTouchListener
來對觸控事件進行攔截處理。
在檢視方案三的原始碼之前,我們先來看一下 RecyclerView 中的這個 OnItemTouchListener 介面:
OnItemTouchListener
從原始碼註釋可以看出,三個方法是在與 RecyclerView 同一檢視層級上對事件進行處理的,也就是在分發給子 View 之前。
public static interface OnItemTouchListener {
// public boolean onInterceptTouchEvent(MotionEvent e)
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e);
// public boolean onTouchEvent(MotionEvent e)
public void onTouchEvent(RecyclerView rv, MotionEvent e);
// public void requestDisallowInterceptTouchEvent(boolean disallowIntercept)
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept);
}
其中註釋是 ViewGroup 中這三個方法的定義,可以看到除了 onRequestDisallowInterceptTouchEvent()
方法之外,其他兩個都有一點小差別。
onInterceptTouchEvent()
這個方法引數不一樣,onTouchEvent()
除了引數不一樣,返回值也變了,變成了無返回值。那麼也就可以猜測,如果 OnItemTouchListener 處理了點選事件,就不會再交由父 View 再進行處理了。到底是不是這樣子呢,我們通過 RecyclerView 的原始碼檢視一下。
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
// 省略程式碼……
if (dispatchOnItemTouchIntercept(e)) {
cancelTouch();
return true;
}
// 省略程式碼……
}
再進一步檢視 dispatchOnItemTouchIntercept()
可以看到,如果新增的 OnItemTouchListener 它攔截了 MotionEvent 事件,那麼就返回 true,此時 RecyclerView 也返回 true 表明攔截了此次事件不再由子 View 進行處理。
再去看看 RecyclerView 的 onTouchEvent()
方法,看是不是同樣地把這個事件交由 OnItemTouchListener 來處理。
@Override
public boolean onTouchEvent(MotionEvent e) {
// 省略程式碼……
if (dispatchOnItemTouch(e)) {
cancelTouch();
return true;
}
// 省略程式碼……
}
與 dispatchOnItemTouchIntercept()
類似的,如果新增的 OnItemTouchListener 它攔截了 MotionEvent 事件,那麼就由它在 onTouchEvent()
中進行處理。這裡再稍微看一下 dispatchOnItemTouch()
來解決一個實踐中的小困惑:OnItemTouchListener 裡在 onInterceptTouchEvent()
中對於 MotionEvent.ACTION_DOWN 無論是否返回 true,都不會在 onTouchEvent 裡收到此 MotionEvent。
private boolean dispatchOnItemTouch(MotionEvent e) {
if (mActiveOnItemTouchListener != null) {
if (action == MotionEvent.ACTION_DOWN) {
// Stale state from a previous gesture, we're starting a new one. Clear it.
mActiveOnItemTouchListener = null;
} else {
mActiveOnItemTouchListener.onTouchEvent(this, e);
if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
// Clean up for the next gesture.
mActiveOnItemTouchListener = null;
}
return true;
}
}
// 省略程式碼……
}
可以看到 OnItemTouchListener 中的 onInterceptTouchEvent()
是無法接收到 MotionEvent.ACTION_DOWN 的。
接下來對方案三進行分析,在正式分析之前,先吐槽一句,大家還是比較喜歡 Start 可以直接拿來用的庫,因為這個方案三 weidongjian/AndroidDragSelect-SimulateGooglePhoto:19 ★ 是個 Demo,引數、設定比較粗糙,導致了星星好少,但其設計思路是很好的。而方案二 MFlisar/DragSelectRecyclerView:267 ★ 就是在其基礎上進行的改進,兩者的共同點就是 OnItemTouchListener,它們幾乎是一樣的。而方案三的滑動多選也就只是通過這一個類來實現的,所以下文以方案二程式碼來具體分析,它的程式碼更規範一點,但是方案二程式碼裡面大括號是放在行首以及 if 程式碼塊沒有大括號讓我很難受……
滾動區的定義
方案三的滾動區設定比較簡單,我就直接上圖了,其實這個圖也不對,可能原作者是這樣子想的,但是原始碼裡的那個 mTopBound 設定得不對。
方案二與方案一的滾動區設定一模一樣,只是名稱改了一下。
自動滾動實現
方案一使用的是一種通過 Handler 的 postDelayed 方法的延時策略,可以在大約每 25ms 時滾動一下,這裡使用大約就是因為 Handler 的排程也是需要時間的。在本方案中,使用 Scroller 來實現流暢地滾動,Scroller 的使用、講解可以看《Android 開發藝術探索》及網上資料來學習。具體就見下面的程式碼:
public void startAutoScroll() {
if (recyclerView == null) {
return;
}
// 建立 Scroller
if (scroller == null) {
scroller = ScrollerCompat.create(recyclerView.getContext(),
new LinearInterpolator());
}
if (scroller.isFinished()) {
recyclerView.removeCallbacks(scrollRun);
// 設定引數,這裡只有100000是有意義的,它代表
// 手指在滾動區完全靜止不動時最多可持續滾動100s
scroller.startScroll(0, scroller.getCurrY(), 0, 5000, 100000);
ViewCompat.postOnAnimation(recyclerView, scrollRun);
}
}
public void stopAutoScroll() {
if (scroller != null && !scroller.isFinished()) {
recyclerView.removeCallbacks(scrollRun);
scroller.abortAnimation();
}
}
private Runnable scrollRun = new Runnable() {
@Override
public void run() {
if (scroller != null && scroller.computeScrollOffset()) {
scrollBy(scrollDistance);
ViewCompat.postOnAnimation(recyclerView, scrollRun);
}
}
};
private void scrollBy(int distance) {
int scrollDistance;
// 限制滾動速度
if (distance > 0) {
scrollDistance = Math.min(distance, MAX_SCROLL_DISTANCE);
} else {
scrollDistance = Math.max(distance, -MAX_SCROLL_DISTANCE);
}
recyclerView.scrollBy(0, scrollDistance);
// 自動滾動時的選擇範圍的更新在這裡,因為只在自動滾動時這兩個才有合法值
if (lastX != Float.MIN_VALUE && lastY != Float.MIN_VALUE) {
updateSelectedRange(recyclerView, lastX, lastY);
}
}
觸控事件的處理
onInterceptTouchEvent
首先是 onInterceptTouchEvent()
方法,簡單來說,在這裡判斷一下滑動選擇功能是否啟用,只在啟用時候才攔截觸控事件;事實上,由於長按才 active,所以攔截不到 MotionEvent.ACTION_DOWN 事件,而它將在長按之後處理接收到的第一個 MotionEvent.ACTION_MOVE 事件,在這裡進行引數的初始化。後續再接收到的 MotionEvent 就全部都由 onTouchEvent()
方法來處理了。
@Override
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
if (!mIsActive || rv.getAdapter().getItemCount() == 0)
return false;
int action = MotionEventCompat.getActionMasked(e);
switch (action) {
// 事實上,由於長按才active,所以以下兩個case是不會收到的
case MotionEvent.ACTION_POINTER_DOWN:
case MotionEvent.ACTION_DOWN:
reset();
break;
}
// 引數設定
mRecyclerView = rv;
int height = rv.getHeight();
mTopBoundFrom = mTouchRegionTopOffset;
mTopBoundTo = mTouchRegionTopOffset + mAutoScrollDistance;
mBottomBoundFrom = height - mTouchRegionBottomOffset - mAutoScrollDistance;
mBottomBoundTo = height - mTouchRegionBottomOffset;
return true;
}
onTouchEvent
這裡就是對 Move 事件進行自動滾動、更新選擇範圍的處理。
@Override
public void onTouchEvent(RecyclerView rv, MotionEvent e) {
if (!mIsActive) {
return;
}
int action = MotionEventCompat.getActionMasked(e);
switch (action) {
case MotionEvent.ACTION_MOVE:
// 將此方法提前,因為檢視方法可以知道它只處理滾動區內的事件,
// 包括自動滾動、更新選擇範圍
processAutoScroll(e);
if (!mInTopSpot && !mInBottomSpot) {
// 不在滾動區內的只要更新選擇範圍
updateSelectedRange(rv, e);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
// 退出時重置狀態
reset();
break;
}
}
自動滾動的實現方式上面已經提過了,這裡的的自動滾動處理主要是解決三個問題:
- 記錄手指最後位置,以便在手指不動時還可以更新選擇範圍
- 手指是否在滾動區的判斷,以及是否允許滾動區之上的滾動
- 根據手指在滾動區的位置更新“速度”值
private void processAutoScroll(MotionEvent event) {
int y = (int) event.getY();
if (y >= mTopBoundFrom && y <= mTopBoundTo) {
// 嚴格位於上滾動區內
mLastX = event.getX();
mLastY = event.getY();
// 計算速度 = maxSpeed * (手指離上滾動區下邊界的距離 / 上滾動區的高度)
// 往上滾速度為負數
mScrollSpeedFactor = (mTopBoundTo - y) / (float)mAutoScrollDistance;
mScrollDistance = (int) (mMaxScrollDistance * mScrollSpeedFactor * -1f);
if (!mInTopSpot) {
mInTopSpot = true;
startAutoScroll();
}
} else if (mScrollAboveTopRegion && y < mTopBoundFrom) {
// 允許在上滾動區之上進行自動滾動
mLastX = event.getX();
mLastY = event.getY();
mScrollDistance = mMaxScrollDistance * -1;
if (!mInTopSpot) {
mInTopSpot = true;
startAutoScroll();
}
} else if (y >= mBottomBoundFrom && y <= mBottomBoundTo) {
mLastX = event.getX();
mLastY = event.getY();
mScrollSpeedFactor = ((y - mBottomBoundFrom)) / (float)mAutoScrollDistance;
mScrollDistance = (int) ((float) mMaxScrollDistance * mScrollSpeedFactor);
if (!mInBottomSpot) {
mInBottomSpot = true;
startAutoScroll();
}
} else if (mScrollBelowTopRegion && y > mBottomBoundTo) {
mLastX = event.getX();
mLastY = event.getY();
mScrollDistance = mMaxScrollDistance;
if (!mInBottomSpot) {
mInBottomSpot = true;
startAutoScroll();
}
} else {
// 不在滾動區內重置資料
mInBottomSpot = false;
mInTopSpot = false;
mLastX = Float.MIN_VALUE;
mLastY = Float.MIN_VALUE;
stopAutoScroll();
}
}
選擇範圍的更新與回撥
上面看到,在自動滾動時進行選擇範圍的更新。先來簡單看一下更新範圍的更新方法:
private void updateSelectedRange(RecyclerView rv, float x, float y) {
View child = rv.findChildViewUnder(x, y);
if (child != null) {
int position = rv.getChildAdapterPosition(child);
if (position != RecyclerView.NO_POSITION && mEnd != position) {
mEnd = position;
// 在手指到達新的條目時再通知更新
notifySelectRangeChange();
}
}
}
可見重點在於 notifySelectRangeChange()
方法。這段程式碼可以結合圖來理解。
首先明確一些條件:
- 手指按下的地方為 start,手指當前的地方為 end。但它們的大小關係不定。
- start 與 end 之間的條目一定是被選中的。
- newStart 代表現在 start 與 end 兩者中較小者,newEnd 代表較大者
- lastStart 和 lastEnd 與 newStart 和 newEnd 含義相同,但指的是未更新前的位置
事實上,如果是列表型,那麼因為這個範圍不會跳變,所以 lastStart 和 lastEnd 與 newStart 和 newEnd 只會相差 1。但如果是網格型列表,可以上下行滑動時範圍就會跳變。
private void notifySelectRangeChange() {
if (mSelectListener == null)
return;
if (mStart == RecyclerView.NO_POSITION || mEnd == RecyclerView.NO_POSITION)
return;
int newStart, newEnd;
newStart = Math.min(mStart, mEnd);
newEnd = Math.max(mStart, mEnd);
if (mLastStart == RecyclerView.NO_POSITION || mLastEnd == RecyclerView.NO_POSITION) {
if (newEnd - newStart == 1)
mSelectListener.onSelectChange(newStart, newStart, true);
else
mSelectListener.onSelectChange(newStart, newEnd, true);
} else {
// 重點看這四句,對照著座標圖可以看懂的
if (newStart > mLastStart)
mSelectListener.onSelectChange(mLastStart, newStart - 1, false);
else if (newStart < mLastStart)
// 此條件下如圖,應該把它們之間的選中。而lastStart之前已經選中了。
mSelectListener.onSelectChange(newStart, mLastStart - 1, true);
if (newEnd > mLastEnd)
mSelectListener.onSelectChange(mLastEnd + 1, newEnd, true);
else if (newEnd < mLastEnd)
// 此條件下如圖,應該把它們之間的取消選中。而lastEnd之前已經選中了也要取消。
mSelectListener.onSelectChange(newEnd + 1, mLastEnd, false);
}
mLastStart = newStart;
mLastEnd = newEnd;
}
那麼這個範圍就是通過回撥來通知監聽者的。
public interface OnDragSelectListener {
/**
* @param start the newly (un)selected range start
* @param end the newly (un)selected range end
* @param isSelected true, it range got selected, false if not
*/
void onSelectChange(int start, int end, boolean isSelected);
}
在我的理解中 start 與 end 之間的條目的選中狀態是指一種狀態,它可以代表是選擇條目的狀態,也可以是不選擇條目的狀態,具體來說就是選中與未選中是兩種狀態,我們指定 true 代表某一種狀態,從而使用 false 代表另一種狀態,因此方法 void onSelectChange(int start, int end, boolean isSelected)
的引數 3 確切地說應該命名為 state。這樣子再重新理解一下上面 notifySelectRangeChange
中的那重要的四句話就會明白它指的是:
- 指定 start 與 end 之間的條目的狀態為 A
- 根據座標圖,將 newStart 和 newEnd 之間的狀態也置為 A,另外的則更新狀態為非 A
以上說有這個狀態相關內容,如果不是太理解,可以看看我的實現方案,它是在對方案二進行再次修訂而成的,對於此內容會有更好的理解。
方案一回調為 selectRange(initialSelection, lastDraggedIndex, minReached, maxReached)
有 4 個引數,相當於把方案二的 lastStart、lastEnd、newStart 和 newEnd 全部傳回來。但實際上,傳回之後也是採用同樣的處理方式,因此將選擇與反選的操作放到 OnItemTouchListener 裡會更方便。
方案三的使用與效果
到目前為止,基於這一個單純的回撥,就可以完成 Google 的選擇策略了。實現也非常地簡單:
touchListener.setSelectListener(new DragSelectTouchListener.onSelectListener() {
@Override
public void onSelectChange(int start, int end, boolean isSelected) {
//選擇的範圍回撥
adapter.selectRangeChange(start, end, isSelected);
actionBar.setTitle(String.valueOf(adapter.getSelectedSize()) + " selected");
}
});
是不是特別地簡潔?但是這裡有兩點要注意,
- 一個是由於回撥
onSelectChange()
非常頻繁,所以在 Adapter 裡的相應的選擇的方法selectRangeChange
一定要注意判斷一下條目的原先的狀態,也就是說如果狀態沒有改變,那麼就什麼都不做,如果狀態更改了,才去更新狀態:notifyItemChanged()
。 - 另一個是,由於 Item Change 時預設帶著動畫,所以在滾動時如果速度比較快、條目比較寬,就會看到明顯的殘影。如果沒有自定義的動畫可以採用以下方法去除預設的 Change 動畫即可:
Java
((SimpleItemAnimator)recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
// 或者
recyclerView.getItemAnimator().setChangeDuration(0);
如果有動畫的話可能不行,動畫的內容我後續會進行實踐,並且還會看看使用 OnItemTouchListener 實現的 Click 事件回撥方案與此滑動方案的相容性。大家可以自行測試、處理。