開源庫原始碼學習--DslAdapter的側滑刪除和拖拽功能

語言: CN / TW / HK

前言

上一篇文章說了側滑按鈕的實現,可謂是非常精彩,尤其是判斷觸控事件的狀態以及細節處理,本章來說一下側滑刪除和拖拽功能,這裡看起來效果更復雜,但是實現起來卻簡單很多。

上一篇側滑按鈕的地址:
https://juejin.cn/post/7007624309657042974

因為RecyclerView有一個類專門是處理側滑刪除和拖拽功能的,這個類就是:ItemTouchHelper,這個類的註釋是:

``` This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.

It works with a RecyclerView and a Callback class, which configures what type of interactions are enabled and also receives events when user performs these actions. ``` 專門用來實現滑動刪除和拖拽的工具類,同時它需要一個RecyclerView和Callback,用來配置哪些觸控操作是啟用的,同時當執行這些操作需要幹些什麼。

既然Android系統有程式碼為我們實現這些功能,那我們話不多說,直接開整。

源專案github地址: github.com/angcyo/DslA…

正文

首先定義一個拖拽幫助類:

class DragCallbackHelper : ItemTouchHelper.Callback()

這個Callback上面也說了,主要是應用想做哪些操作通過這個類的回撥告訴ItemTouchHelper類,依次看一下這些方法回撥。

getMovementFlags

方法原型:

public abstract int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull ViewHolder viewHolder); 註釋:

Should return a composite flag which defines the enabled move directions in each state (idle, swiping, dragging). 這裡說應該返回一個組合的flag用來表示哪些方向的move是可行的在不同的狀態(idle,滑動,拖拽)。

咋一看還需要為每個狀態都設定不同的方向還比較複雜,其實系統早就為我們想好了,我們只需要執行一個方法,同樣在註釋裡有說: Instead of composing this flag manually, you can use makeMovementFlags(int, int) or makeFlag(int, int). 這裡你只需要執行makeMovementFlags方法,然後把這個方法的返回值當成getMovementFlags的返回值即可,不用自己拼接,makeMovementFlags原型:

public static int makeMovementFlags(int dragFlags, int swipeFlags) { return makeFlag(ACTION_STATE_IDLE, swipeFlags | dragFlags) | makeFlag(ACTION_STATE_SWIPE, swipeFlags) | makeFlag(ACTION_STATE_DRAG, dragFlags); } 到這裡我們便只需要湊齊dragFlags和swipeFlags2個引數即可,分別表示拖拽啟用的方向和側滑啟用的方向,那如何得到我們想要的引數呢?

比如我現在想實現4個方向的拖拽和左右的側滑,那就先看方向的定義,這個是在TouchHelper中定義:

public static final int UP = 1; public static final int DOWN = 1 << 1; public static final int LEFT = 1 << 2; public static final int RIGHT = 1 << 3; 其實也就是為了進行與運算才這樣定義,那全方向就是: const val FLAG_ALL = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT or ItemTouchHelper.DOWN or ItemTouchHelper.UP 水平方向就是: const val FLAG_HORIZONTAL = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT 好了,那我們來看一下getMovementFlags中的實現程式碼: override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { //拿到這個viewHolder對應的Item資料 val dslAdapterItem = _dslAdapter?.getItemData(viewHolder.adapterPosition) return dslAdapterItem?.run { //看item自己有沒有配置dragFlag,否則使用DragCallbackHelp預設配置 val dFlag = if (itemDragFlag >= 0) itemDragFlag else [email protected] //看item自己有沒有配置swipeFlag,否則使用DragCallbackHelp預設配置 val sFlag = if (itemSwipeFlag >= 0) itemSwipeFlag else [email protected] //呼叫makeMovementFlags方法來返回flag值 makeMovementFlags( if (itemDragEnable) dFlag else FLAG_NONE, if (itemSwipeEnable) sFlag else FLAG_NONE ) } ?: FLAG_NONE }

當配置了這個後,只需要進行以下程式碼把ItemTouchHelper繫結到一個RecyclerView即可: fun attachToRecyclerView(recyclerView: RecyclerView) { _recyclerView = recyclerView _itemTouchHelper = _itemTouchHelper ?: ItemTouchHelper(this) _itemTouchHelper?.attachToRecyclerView(recyclerView) } 這時便可以拖拽或者滑動Item了,完全由ItemTouchHelper幫我們完成,當然這裡有很多回調方法會執行,以達到自定義的目的,下面挨個介紹。

onSelectedChanged

方法原型: public void onSelectedChanged(@Nullable ViewHolder viewHolder, int actionState) { if (viewHolder != null) { ItemTouchUIUtilImpl.INSTANCE.onSelected(viewHolder.itemView); } } 註釋:

``` Called when the ViewHolder swiped or dragged by the ItemTouchHelper is changed

If you override this method, you should call super. Params: viewHolder – The new ViewHolder that is being swiped or dragged. Might be null if it is cleared. actionState – One of ACTION_STATE_IDLE, ACTION_STATE_SWIPE or ACTION_STATE_DRAG. ``` 當進行拖拽時,這個是第一個回撥的方法,表示這個viewHolder發生了變化,當需要重寫這個方法時,必須呼叫super。

其中actionState一共有3種狀態,比如拖拽一個ViewHolder,它的狀態變化是:

ACTION_STATE_DRAG -> ACTION_STATE_IDLE,

所以我們可以在這個方法裡執行一些回撥,比如拖拽刪除,就可以當這個方法回撥DRAG時顯示出刪除View(仿微信發朋友圈)即可。

這個方法回撥後,ViewHolder會在手指拖拽中進行位移,這時就會立馬回調出該ViewHolder的位移資訊,也就是onChildDraw方法。

onChildDraw

方法原型: public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { ItemTouchUIUtilImpl.INSTANCE.onDraw(c, recyclerView, viewHolder.itemView, dX, dY, actionState, isCurrentlyActive); } 註釋:

``` Called by ItemTouchHelper on RecyclerView's onDraw callback.

If you would like to customize how your View's respond to user interactions, this is a good place to override.

Default implementation translates the child by the given dX, dY. ItemTouchHelper also takes care of drawing the child after other children if it is being dragged. This is done using child re-ordering mechanism. On platforms prior to L, this is achieved via android.view.ViewGroup.getChildDrawingOrder(int, int) and on L and after, it changes View's elevation value to be greater than all other children.) ``` 呼叫時機:在RecyclerView進行繪製時回撥這個方法。

作用:當拖拽時或者滑動時,想根據手勢自定義View的響應時。

介紹:預設的實現就是對子View即拖拽的ViewHolder進行位移,通過dX和dY。同時ItemTouchHelper還負責拖拽後繪製這個子物件,這個是通過修改ViewGroup的子View繪製順序來實現的,也就相當於把被拖拽的View的z軸至高,這個後面有機會細說。

到這裡我們就可以做一些效果了,比如最常見的是當側滑刪除時,在被刪除的那個ViwHolder位置上顯示一個文字,來看一下程式碼: override fun onChildDraw( canvas: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean ) { super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) //當是滑動狀態且需要顯示swipeTip時 if (enableSwipeTip && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { //需要被側滑刪除的View val itemView = viewHolder.itemView //x表示需要繪製swipeTipText的x座標 val x: Float = if (dX > 0) { //向右滑動刪除 itemView.left.toFloat() } else { //向左滑動刪除 (itemView.right - _drawText._paint.measureText(swipeTipText.toString())) } //y表示需要繪製swipeTipText的y座標 val y: Float = itemView.top + itemView.measuredHeight / 2 - _drawText._paint.textHeight() / 2 //先儲存畫布 canvas.save() //位移畫布 canvas.translate(x, y) //在被滑動的View下面繪製Text canvas.drawText(swipeTipText.toString(),0f,0f,_paint) canvas.restore() } } 看一下效果:

滑動刪除.gif

這裡有個canvas的操作,為什麼要頻繁的save、translate和restore呢,原因是這個canvas是RecyclerView的canvas,也就是這個畫布的寬高是RecyclerView的寬高,所以就需要對畫布進行平移到需要的位置再進行繪製,當然我不位移,直接在繪製Text加上座標也可以:

canvas.drawText(swipeTipText.toString(),x,y,_paint) 這樣可以同樣實現效果。

所以這個函式的關鍵是根據拖拽或者滑動通過canvas來繪製一些效果,其中獲取當前itemView的位置是關鍵以及繪製的位置是關鍵。

既然,現在可以拖拽或者滑動了,同時在滑動時還可以自己繪製View,那拖拽或者滑動結束肯定有回撥,先說滑動刪除後的回撥:onSwiped方法。

onSwiped

方法原型: public abstract void onSwiped(@NonNull ViewHolder viewHolder, int direction); 註釋: ``` Called when a ViewHolder is swiped by the user.

If you are returning relative directions (START , END) from the getMovementFlags(RecyclerView, RecyclerView.ViewHolder) method, this method will also use relative directions. Otherwise, it will use absolute directions.

If you don't support swiping, this method will never be called. ``` 呼叫時機:當滑動發生時,注意是滑動,不是拖拽。

注意點:這裡的操作是ItemTouchHelper這個類幫我們做的,比如側滑符合Fling,就認為是需要刪除,這時就會把這個子View刪除,但是不會刪除資料,只是在顯示層的效果,我們看一下:

側滑刪除後不刪除資料.gif

所以就需要在Swipe的回撥後進行處理,也就是刪除這個item的資料,同時重新整理介面。

看一下程式碼: override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { Log.i(TAG, "onSwiped: direction = $direction") _swipeHappened = true //拿到item進行刪除 _dslAdapter?.apply { getItemData(viewHolder.adapterPosition)?.apply { //刪除資料同時,呼叫notify removeItem(this) //item刪除回撥 onItemSwipeDeleted?.invoke(this) } } } 這樣在刪除後就可以迅速重新繪製,效果如下:

側滑刪除後刪除資料.gif

好了,滑動刪除說完,再說一下拖拽,對於拖拽重新排序,這個也是ItemTouchHelper幫我們實現好了,這裡會涉及到onMove函式。

onMove

方法原型: public abstract boolean onMove(@NonNull RecyclerView recyclerView, @NonNull ViewHolder viewHolder, @NonNull ViewHolder target);

註釋:

Called when ItemTouchHelper wants to move the dragged item from its old position to the new position.

這裡說當你拖拽一個ViewHolder時,ItemTouchHelper會自動幫你處理,當它覺得可以重新排序且交換位置時,這個方法會回撥,當達不到這個要求時,方法不會回撥,這裡還是看一下效果:

move回撥.gif

這裡會發現在拖拽到一定位置時,會認為可以發生互動則會發生互動且重新排序,注意這裡每互動一次,就會回撥一次moMove,比如這gif裡我一共發生了4次互動和重排序,所以會回撥4次,列印如下:

2021-09-16 09:11:50.546 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:11:52.396 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 6 toPos = 7 2021-09-16 09:11:53.520 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 7 toPos = 8 2021-09-16 09:11:54.995 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 8 toPos = 5 從這個列印也可以看出,每次交換都會觸發onMove。

當然上面程式碼是邏輯正確的,且寫好資料交換的,這裡想寫好邏輯必須要了解這個回撥函式的引數和返回值。

引數:

recyclerView:表示正在被拖拽的recyclerView。

viewHolder:正在被拖拽的ViewHolder。

target:目標被替換的ViewHolder。

返回值:

函式返回true,ItemTouchHelper會認為這個viewHolder從原位置已經move到target位置,所以你要返回true,就必須是真的做了處理。

比如下面程式碼,我任何操作都不做,直接返回true:

override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { val fromPosition = viewHolder.adapterPosition val toPosition = target.adapterPosition Log.i(TAG, "onMove: fromPos = $fromPosition toPos = $toPosition") //如果[viewHolder]已經移動到[target]位置, 則返回[true] return true }

效果如下:

move返回true.gif

列印如下:

``` 2021-09-16 09:35:10.174 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.183 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.191 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.199 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.208 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.216 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.225 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.232 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.241 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.249 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.258 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.266 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.274 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.283 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.291 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.299 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.308 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.316 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6 2021-09-16 09:35:10.332 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.341 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.349 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.358 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.366 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.374 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.382 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.391 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.399 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.408 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.416 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.424 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.432 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.441 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.483 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.491 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8 2021-09-16 09:35:10.499 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8

``` 會發現雖然動畫效果有了,確無法正確替換位置,會不停的回撥,所以這裡正確的邏輯是當onMove回撥時,把這2個位置的資料替換,且呼叫notify來重新整理2個位置的資料:

下面是正常的處理:

//發生拖拽時回撥 override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { val fromPosition = viewHolder.adapterPosition val toPosition = target.adapterPosition Log.i(TAG, "onMove: fromPos = $fromPosition toPos = $toPosition") //如果[viewHolder]已經移動到[target]位置, 則返回[true] return _dslAdapter?.run { //拿資料這裡程式碼可以忽略,是源庫有這個邏輯 val validFilterDataList = getValidFilterDataList() val fromItem = validFilterDataList.getOrNull(fromPosition) val toItem = validFilterDataList.getOrNull(toPosition) if (fromItem == null || toItem == null) { //異常操作 返回false false } else { //這裡程式碼可以忽略,源庫有這個邏輯,這裡做的操作就是互換2個位置的值 val fromPair = getItemListPairByItem(fromItem) val toPair = getItemListPairByItem(toItem) val fromList: MutableList<DslAdapterItem>? = fromPair.first val toList: MutableList<DslAdapterItem>? = toPair.first if (fromList.isNullOrEmpty() && toList.isNullOrEmpty()) { false } else { Collections.swap(validFilterDataList, fromPosition, toPosition) //介面上的集合 if (fromList == toList) { Collections.swap(fromList, fromPair.second, toPair.second) //資料池的集合 } else { val temp = fromList!![fromPair.second] fromList[fromPair.second] = toList!![toPair.second] toList[toPair.second] = temp } _updateAdapterItems() //互換完資料,再呼叫notifyItemMove即可 notifyItemMoved(fromPosition, toPosition) _dragHappened = true onItemMoveChanged?.invoke(fromList!!, toList!!, fromPair.second, toPair.second) true } } } ?: false } 總結一下:當onMove呼叫時就是發生了拖拽,想正確處理需要先交換2個位置的值,再呼叫notifyItemMove來重新整理介面,最後都操作完返回true,否則返回false。

關於拖拽還有一個回撥,就是是否可以把當前viewHolder和target的viewHolder進行交換,這個有個回撥來控制,叫做canDropOver。

canDropOver

方法原型: public boolean canDropOver(@NonNull RecyclerView recyclerView, @NonNull ViewHolder current, @NonNull ViewHolder target) { return true; }

註釋:

Return true if the current ViewHolder can be dropped over the the target ViewHolder. 非常簡單,返回true就表示當前的ViewHolder可以被目標ViewHolder替換。

比如下面程式碼:

override fun canDropOver( recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { //當被拖拽的viewHolder是5時,則無法被拖拽替換 return current.absoluteAdapterPosition != 5 return super.canDropOver(recyclerView, current, target) } 效果如下,拖拽pos等於5個view,無法和別的進行交換:

pos5無法拖拽.gif

總結

對於拖拽和側滑刪除是在平時開發中經常可能會用到的,同時瞭解了ItemTouchHelper這個類的各個回撥意義對我們做其他的需求也非常有用,下面來做個總結,方便大家按照自己需求實現。

其中原始碼部分大家可以檢視上面說的開源庫中的DragCallbackHelper類,就不復制大量程式碼了,主要說一下流程:

側滑刪除

側滑刪除的導圖.png

對於側滑刪除主要就是上面3個方法回撥,具體邏輯根據需求制定。

拖拽替換

拖拽替換導圖.png

對於拖拽也就是上面幾個方法回撥,注意先後順序以及onMove中的操作即可。

本章內容實現的效果其實不侷限於此,這裡主要是要了解ItemTouchHelper幾個回撥的用法,其他很多效果都是在這幾個回撥裡實現,後續有補充的話再更新。