方案三: AndroidDragSelect
前文說到,方案三就是分析了方案一的缺點(diǎn)之后食呻,給出了自己的基于 OnItemTouchListener
的實(shí)現(xiàn)方案近范,耦合度低,可以很容易集成進(jìn)現(xiàn)有的項(xiàng)目當(dāng)中集峦。
從自定義 RecyclerView 的方案中可以看到伏社,它是在事件分發(fā)的時候進(jìn)行處理。事實(shí)上塔淤,在這個方法里做計(jì)算感覺上就有點(diǎn)不對摘昌,從源碼來看,RecyclerView 本身是沒有重寫 dispatchTouchEvent()
方法的高蜂,而方案一通過重寫此方法并在這里完成自動滾動的計(jì)算處理聪黎,顯得有些重。
回顧一下事件分發(fā)機(jī)制备恤,其中 dispatchTouchEvent()
用來進(jìn)行事件的分發(fā)稿饰,onInterceptTouchEvent()
被前一個方法調(diào)用锦秒,用來進(jìn)行判斷是否進(jìn)行攔截,真正地處理點(diǎn)擊事件則是在 onTouchEvent()
當(dāng)中湘纵。所以方案三就是利用了 RecyclerView 的 OnItemTouchListener
來對觸摸事件進(jìn)行攔截處理脂崔。
在查看方案三的源碼之前,我們先來看一下 RecyclerView 中的這個 OnItemTouchListener 接口:
OnItemTouchListener
從源碼注釋可以看出梧喷,三個方法是在與 RecyclerView 同一視圖層級上對事件進(jìn)行處理的砌左,也就是在分發(fā)給子 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()
方法之外汇歹,其他兩個都有一點(diǎn)小差別。
onInterceptTouchEvent()
這個方法參數(shù)不一樣偿凭,onTouchEvent()
除了參數(shù)不一樣产弹,返回值也變了,變成了無返回值弯囊。那么也就可以猜測痰哨,如果 OnItemTouchListener 處理了點(diǎn)擊事件,就不會再交由父 View 再進(jìn)行處理了匾嘱。到底是不是這樣子呢斤斧,我們通過 RecyclerView 的源碼查看一下。
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
// 省略代碼……
if (dispatchOnItemTouchIntercept(e)) {
cancelTouch();
return true;
}
// 省略代碼……
}
再進(jìn)一步查看 dispatchOnItemTouchIntercept()
可以看到霎烙,如果添加的 OnItemTouchListener 它攔截了 MotionEvent 事件撬讽,那么就返回 true,此時 RecyclerView 也返回 true 表明攔截了此次事件不再由子 View 進(jìn)行處理悬垃。
再去看看 RecyclerView 的 onTouchEvent()
方法游昼,看是不是同樣地把這個事件交由 OnItemTouchListener 來處理。
@Override
public boolean onTouchEvent(MotionEvent e) {
// 省略代碼……
if (dispatchOnItemTouch(e)) {
cancelTouch();
return true;
}
// 省略代碼……
}
與 dispatchOnItemTouchIntercept()
類似的尝蠕,如果添加的 OnItemTouchListener 它攔截了 MotionEvent 事件烘豌,那么就由它在 onTouchEvent()
中進(jìn)行處理。這里再稍微看一下 dispatchOnItemTouch()
來解決一個實(shí)踐中的小困惑: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 的。
接下來對方案三進(jìn)行分析闲昭,在正式分析之前,先吐槽一句靡挥,大家還是比較喜歡 Start 可以直接拿來用的庫序矩,因?yàn)檫@個方案三 weidongjian/AndroidDragSelect-SimulateGooglePhoto:19 ★ 是個 Demo,參數(shù)跋破、設(shè)定比較粗糙簸淀,導(dǎo)致了星星好少瓶蝴,但其設(shè)計(jì)思路是很好的。而方案二 MFlisar/DragSelectRecyclerView:267 ★ 就是在其基礎(chǔ)上進(jìn)行的改進(jìn)租幕,兩者的共同點(diǎn)就是 OnItemTouchListener舷手,它們幾乎是一樣的。而方案三的滑動多選也就只是通過這一個類來實(shí)現(xiàn)的劲绪,所以下文以方案二代碼來具體分析男窟,它的代碼更規(guī)范一點(diǎn),但是方案二代碼里面大括號是放在行首以及 if 代碼塊沒有大括號讓我很難受……
滾動區(qū)的定義
方案三的滾動區(qū)設(shè)定比較簡單贾富,我就直接上圖了歉眷,其實(shí)這個圖也不對,可能原作者是這樣子想的颤枪,但是源碼里的那個 mTopBound 設(shè)定得不對汗捡。
方案二與方案一的滾動區(qū)設(shè)定一模一樣,只是名稱改了一下畏纲。
自動滾動實(shí)現(xiàn)
方案一使用的是一種通過 Handler 的 postDelayed 方法的延時策略扇住,可以在大約每 25ms 時滾動一下,這里使用大約就是因?yàn)?Handler 的調(diào)度也是需要時間的盗胀。在本方案中艘蹋,使用 Scroller 來實(shí)現(xiàn)流暢地滾動,Scroller 的使用读整、講解可以看《Android 開發(fā)藝術(shù)探索》及網(wǎng)上資料來學(xué)習(xí)簿训。具體就見下面的代碼:
public void startAutoScroll() {
if (recyclerView == null) {
return;
}
// 創(chuàng)建 Scroller
if (scroller == null) {
scroller = ScrollerCompat.create(recyclerView.getContext(),
new LinearInterpolator());
}
if (scroller.isFinished()) {
recyclerView.removeCallbacks(scrollRun);
// 設(shè)置參數(shù),這里只有100000是有意義的米间,它代表
// 手指在滾動區(qū)完全靜止不動時最多可持續(xù)滾動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);
// 自動滾動時的選擇范圍的更新在這里强品,因?yàn)橹辉谧詣訚L動時這兩個才有合法值
if (lastX != Float.MIN_VALUE && lastY != Float.MIN_VALUE) {
updateSelectedRange(recyclerView, lastX, lastY);
}
}
觸摸事件的處理
onInterceptTouchEvent
首先是 onInterceptTouchEvent()
方法,簡單來說屈糊,在這里判斷一下滑動選擇功能是否激活的榛,只在激活時候才攔截觸摸事件;事實(shí)上逻锐,由于長按才 active夫晌,所以攔截不到 MotionEvent.ACTION_DOWN 事件,而它將在長按之后處理接收到的第一個 MotionEvent.ACTION_MOVE 事件昧诱,在這里進(jìn)行參數(shù)的初始化晓淀。后續(xù)再接收到的 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) {
// 事實(shí)上盏档,由于長按才active凶掰,所以以下兩個case是不會收到的
case MotionEvent.ACTION_POINTER_DOWN:
case MotionEvent.ACTION_DOWN:
reset();
break;
}
// 參數(shù)設(shè)定
mRecyclerView = rv;
int height = rv.getHeight();
mTopBoundFrom = mTouchRegionTopOffset;
mTopBoundTo = mTouchRegionTopOffset + mAutoScrollDistance;
mBottomBoundFrom = height - mTouchRegionBottomOffset - mAutoScrollDistance;
mBottomBoundTo = height - mTouchRegionBottomOffset;
return true;
}
onTouchEvent
這里就是對 Move 事件進(jìn)行自動滾動、更新選擇范圍的處理。
@Override
public void onTouchEvent(RecyclerView rv, MotionEvent e) {
if (!mIsActive) {
return;
}
int action = MotionEventCompat.getActionMasked(e);
switch (action) {
case MotionEvent.ACTION_MOVE:
// 將此方法提前懦窘,因?yàn)椴榭捶椒梢灾浪惶幚頋L動區(qū)內(nèi)的事件前翎,
// 包括自動滾動、更新選擇范圍
processAutoScroll(e);
if (!mInTopSpot && !mInBottomSpot) {
// 不在滾動區(qū)內(nèi)的只要更新選擇范圍
updateSelectedRange(rv, e);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
// 退出時重置狀態(tài)
reset();
break;
}
}
自動滾動的實(shí)現(xiàn)方式上面已經(jīng)提過了畅涂,這里的的自動滾動處理主要是解決三個問題:
- 記錄手指最后位置港华,以便在手指不動時還可以更新選擇范圍
- 手指是否在滾動區(qū)的判斷,以及是否允許滾動區(qū)之上的滾動
- 根據(jù)手指在滾動區(qū)的位置更新“速度”值
private void processAutoScroll(MotionEvent event) {
int y = (int) event.getY();
if (y >= mTopBoundFrom && y <= mTopBoundTo) {
// 嚴(yán)格位于上滾動區(qū)內(nèi)
mLastX = event.getX();
mLastY = event.getY();
// 計(jì)算速度 = maxSpeed * (手指離上滾動區(qū)下邊界的距離 / 上滾動區(qū)的高度)
// 往上滾速度為負(fù)數(shù)
mScrollSpeedFactor = (mTopBoundTo - y) / (float)mAutoScrollDistance;
mScrollDistance = (int) (mMaxScrollDistance * mScrollSpeedFactor * -1f);
if (!mInTopSpot) {
mInTopSpot = true;
startAutoScroll();
}
} else if (mScrollAboveTopRegion && y < mTopBoundFrom) {
// 允許在上滾動區(qū)之上進(jìn)行自動滾動
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 {
// 不在滾動區(qū)內(nèi)重置數(shù)據(jù)
mInBottomSpot = false;
mInTopSpot = false;
mLastX = Float.MIN_VALUE;
mLastY = Float.MIN_VALUE;
stopAutoScroll();
}
}
選擇范圍的更新與回調(diào)
上面看到午衰,在自動滾動時進(jìn)行選擇范圍的更新立宜。先來簡單看一下更新范圍的更新方法:
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;
// 在手指到達(dá)新的條目時再通知更新
notifySelectRangeChange();
}
}
}
可見重點(diǎn)在于 notifySelectRangeChange()
方法。這段代碼可以結(jié)合圖來理解苇经。
首先明確一些條件:
- 手指按下的地方為 start赘理,手指當(dāng)前的地方為 end滥朱。但它們的大小關(guān)系不定鞠呈。
- start 與 end 之間的條目一定是被選中的。
- newStart 代表現(xiàn)在 start 與 end 兩者中較小者厦幅,newEnd 代表較大者
- lastStart 和 lastEnd 與 newStart 和 newEnd 含義相同蜘澜,但指的是未更新前的位置
事實(shí)上施流,如果是列表型,那么因?yàn)檫@個范圍不會跳變鄙信,所以 lastStart 和 lastEnd 與 newStart 和 newEnd 只會相差 1瞪醋。但如果是網(wǎng)格型列表,可以上下行滑動時范圍就會跳變装诡。
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 {
// 重點(diǎn)看這四句银受,對照著坐標(biāo)圖可以看懂的
if (newStart > mLastStart)
mSelectListener.onSelectChange(mLastStart, newStart - 1, false);
else if (newStart < mLastStart)
// 此條件下如圖,應(yīng)該把它們之間的選中鸦采。而lastStart之前已經(jīng)選中了宾巍。
mSelectListener.onSelectChange(newStart, mLastStart - 1, true);
if (newEnd > mLastEnd)
mSelectListener.onSelectChange(mLastEnd + 1, newEnd, true);
else if (newEnd < mLastEnd)
// 此條件下如圖,應(yīng)該把它們之間的取消選中渔伯。而lastEnd之前已經(jīng)選中了也要取消顶霞。
mSelectListener.onSelectChange(newEnd + 1, mLastEnd, false);
}
mLastStart = newStart;
mLastEnd = newEnd;
}
那么這個范圍就是通過回調(diào)來通知監(jiān)聽者的。
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 之間的條目的選中狀態(tài)是指一種狀態(tài)锣吼,它可以代表是選擇條目的狀態(tài)选浑,也可以是不選擇條目的狀態(tài),具體來說就是選中與未選中是兩種狀態(tài)玄叠,我們指定 true 代表某一種狀態(tài)古徒,從而使用 false 代表另一種狀態(tài),因此方法 void onSelectChange(int start, int end, boolean isSelected)
的參數(shù) 3 確切地說應(yīng)該命名為 state读恃。這樣子再重新理解一下上面 notifySelectRangeChange
中的那重要的四句話就會明白它指的是:
- 指定 start 與 end 之間的條目的狀態(tài)為 A
- 根據(jù)坐標(biāo)圖描函,將 newStart 和 newEnd 之間的狀態(tài)也置為 A崎苗,另外的則更新狀態(tài)為非 A
以上說有這個狀態(tài)相關(guān)內(nèi)容,如果不是太理解舀寓,可以看看我的實(shí)現(xiàn)方案,它是在對方案二進(jìn)行再次修訂而成的肌蜻,對于此內(nèi)容會有更好的理解互墓。
方案一回調(diào)為 selectRange(initialSelection, lastDraggedIndex, minReached, maxReached)
有 4 個參數(shù),相當(dāng)于把方案二的 lastStart蒋搜、lastEnd篡撵、newStart 和 newEnd 全部傳回來。但實(shí)際上豆挽,傳回之后也是采用同樣的處理方式育谬,因此將選擇與反選的操作放到 OnItemTouchListener 里會更方便。
方案三的使用與效果
到目前為止帮哈,基于這一個單純的回調(diào)膛檀,就可以完成 Google 的選擇策略了。實(shí)現(xiàn)也非常地簡單:
touchListener.setSelectListener(new DragSelectTouchListener.onSelectListener() {
@Override
public void onSelectChange(int start, int end, boolean isSelected) {
//選擇的范圍回調(diào)
adapter.selectRangeChange(start, end, isSelected);
actionBar.setTitle(String.valueOf(adapter.getSelectedSize()) + " selected");
}
});
是不是特別地簡潔娘侍?但是這里有兩點(diǎn)要注意咖刃,
- 一個是由于回調(diào)
onSelectChange()
非常頻繁,所以在 Adapter 里的相應(yīng)的選擇的方法selectRangeChange
一定要注意判斷一下條目的原先的狀態(tài)憾筏,也就是說如果狀態(tài)沒有改變嚎杨,那么就什么都不做,如果狀態(tài)更改了氧腰,才去更新狀態(tài):notifyItemChanged()
枫浙。 - 另一個是,由于 Item Change 時默認(rèn)帶著動畫古拴,所以在滾動時如果速度比較快箩帚、條目比較寬,就會看到明顯的殘影斤富。如果沒有自定義的動畫可以采用以下方法去除默認(rèn)的 Change 動畫即可:
((SimpleItemAnimator)recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
// 或者
recyclerView.getItemAnimator().setChangeDuration(0);
如果有動畫的話可能不行膏潮,動畫的內(nèi)容我后續(xù)會進(jìn)行實(shí)踐,并且還會看看使用 OnItemTouchListener 實(shí)現(xiàn)的
Click 事件回調(diào)方案與此滑動方案的兼容性满力。大家可以自行測試焕参、處理。