怎麼簡單實現選單拖拽排序的功能
1、效果
2、簡介
本文主角是ItemTouchHelper
。
它是RecyclerView對於item互動處理的一個「輔助類」,主要用於拖拽以及滑動處理。
以介面實現的方式,達到配置簡單、邏輯解耦、職責分明的效果,並且支援所有的佈局方式。
3、功能拆解
4、功能實現
4.1、實現介面
自定義一個類,實現ItemTouchHelper.Callback
介面,然後在實現方法中根據需求簡單配置即可。
class DragCallBack(adapter: DragAdapter, data: MutableList<String>) : ItemTouchHelper.Callback() {
}
ItemTouchHelper.Callback必須實現的3個方法:
- getMovementFlags
- onMove
- onSwiped
其他方法還有onSelectedChanged、clearView等
4.1.1、getMovementFlags
用於建立互動方式,互動方式分為兩種:
- 拖拽,網格佈局支援上下左右,列表只支援上下(LEFT、UP、RIGHT、DOWN)
- 滑動,只支援前後(START、END)
最後,通過makeMovementFlags
把結果返回回去,makeMovementFlags接收兩個引數,dragFlags
和swipeFlags
,即上面拖拽和滑動組合的標誌位。
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
var dragFlags = 0
var swipeFlags = 0
when (recyclerView.layoutManager) {
is GridLayoutManager -> {
// 網格佈局
dragFlags = ItemTouchHelper.LEFT or ItemTouchHelper.UP or ItemTouchHelper.RIGHT or ItemTouchHelper.DOWN
return makeMovementFlags(dragFlags, swipeFlags)
}
is LinearLayoutManager -> {
// 線性佈局
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
return makeMovementFlags(dragFlags, swipeFlags)
}
else -> {
// 其他情況可自行處理
return 0
}
}
}
4.1.2、onMove
拖拽時回撥,這裡我們主要對起始位置和目標位置的item做一個數據交換,然後重新整理檢視顯示。
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
// 起始位置
val fromPosition = viewHolder.adapterPosition
// 結束位置
val toPosition = target.adapterPosition
// 固定位置
if (fromPosition == mAdapter.fixedPosition || toPosition == mAdapter.fixedPosition) {
return false
}
// 根據滑動方向 交換資料
if (fromPosition < toPosition) {
// 含頭不含尾
for (index in fromPosition until toPosition) {
Collections.swap(mData, index, index + 1)
}
} else {
// 含頭不含尾
for (index in fromPosition downTo toPosition + 1) {
Collections.swap(mData, index, index - 1)
}
}
// 重新整理佈局
mAdapter.notifyItemMoved(fromPosition, toPosition)
return true
}
4.1.3、onSwiped
滑動時回撥,這個回撥方法裡主要是做資料和檢視的更新操作。
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
if (direction == ItemTouchHelper.START) {
Log.i(TAG, "START--->向左滑")
} else {
Log.i(TAG, "END--->向右滑")
}
val position = viewHolder.adapterPosition
mData.removeAt(position)
mAdapter.notifyItemRemoved(position)
}
4.2、繫結RecyclerView
上面介面實現部分我們已經簡單寫好了,邏輯也挺簡單,總共不超過100行程式碼。
接下來就是把這個輔助類繫結到RecyclerView。
RecyclerView顯示的實現就是基礎的樣式,就不展開了,可以檢視原始碼
。
val dragCallBack = DragCallBack(mAdapter, list)
val itemTouchHelper = ItemTouchHelper(dragCallBack)
itemTouchHelper.attachToRecyclerView(mBinding.recycleView)
繫結只需要呼叫attachToRecyclerView
就好了。
至此,簡單的效果就已經實現了。下面開始優化和進階的部分。
4.3、設定分割線
RecyclerView網格佈局實現等分,我們一般先是自定義ItemDecoration
,然後呼叫addItemDecoration來實現的。
但是我在實現效果的時候遇到一個問題,因為我加了佈局切換的功能,在每次切換的時候,針對不同的佈局分別設定layoutManager
和ItemDecoration
,這就導致隨著切換次數的增加,item的間隔就越大。
addItemDecoration,顧名思義是新增,通過檢視原始碼發現RecyclerView內部是有一個ArrayList來維護的,所以當我們重複呼叫addItemDecoration方法時,分割線是以遞增的方式在增加的,並且在繪製的時候會從集合中遍歷所有的分割線繪製。
部分原始碼:
``` @Override public void draw(Canvas c) { super.draw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDrawOver(c, this, mState);
}
//...
}
```
既然知道了問題所在,也大概想到了3種解決辦法:
- 呼叫addItemDecoration前,先呼叫removeItemDecoration方法remove掉之前所有的分割線
- 呼叫addItemDecoration(@NonNull ItemDecoration decor, int index),通過index來維護
- add時通過一個標示來判斷,新增過就不添加了
好像可行,實際上並不太行...因為始終都有兩個分割線例項。
我們再來梳理一下:
- 兩種不同的佈局
- 都有分割線
- 分割線只需設定一次
我想到另外一個辦法,不對RecyclerView做處理了,既然兩種佈局都有分割線,是不是可以把分割線合二為一了,然後根據LayoutManager去繪製不同的分割線?
理論上是可行的,事實上也確實可以...
自定義分割線:
``` class GridSpaceItemDecoration(private val spanCount: Int, private val spacing: Int = 20, private var includeEdge: Boolean = false) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, recyclerView: RecyclerView, state: RecyclerView.State) {
recyclerView.layoutManager?.let {
when (recyclerView.layoutManager) {
is GridLayoutManager -> {
val position = recyclerView.getChildAdapterPosition(view) // 獲取item在adapter中的位置
val column = position % spanCount // item所在的列
if (includeEdge) {
outRect.left = spacing - column * spacing / spanCount
outRect.right = (column + 1) * spacing / spanCount
if (position < spanCount) {
outRect.top = spacing
}
outRect.bottom = spacing
} else {
outRect.left = column * spacing / spanCount
outRect.right = spacing - (column + 1) * spacing / spanCount
if (position >= spanCount) {
outRect.top = spanCount
}
outRect.bottom = spacing
}
}
is LinearLayoutManager -> {
outRect.top = spanCount
outRect.bottom = spacing
}
}
}
}
} ```
4.4、選中放大/背景變色
為了提升使用者體驗,可以在拖拽的時候告訴使用者當前拖拽的是哪個item,比如選中的item放大、背景高亮等。
- 網格佈局,選中變大
- 列表佈局,背景變色
這裡用到ItemTouchHelper.Callback中的兩個方法,onSelectedChanged
和clearView
,我們需要在選中時改變檢視顯示,結束時再恢復。
4.4.1、onSelectedChanged
拖拽或滑動 發生改變時回撥,這時我們可以修改item的檢視
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) {
viewHolder?.let {
// 因為拿不到recyclerView,無法通過recyclerView.layoutManager來判斷是什麼佈局,所以用item的寬度來判斷
// itemView.width > 500 用這個來判斷是否是線性佈局,實際取值自己看情況
if (it.itemView.width > 500) {
// 線性佈局 設定背景顏色
val drawable = it.itemView.background as GradientDrawable
drawable.color = ContextCompat.getColorStateList(it.itemView.context, R.color.greenDark)
} else {
// 網格佈局 設定選中放大
ViewCompat.animate(it.itemView).setDuration(200).scaleX(1.3F).scaleY(1.3F).start()
}
}
}
super.onSelectedChanged(viewHolder, actionState)
}
actionState:
- ACTION_STATE_IDLE 空閒狀態
- ACTION_STATE_SWIPE 滑動狀態
- ACTION_STATE_DRAG 拖拽狀態
4.4.2、clearView
拖拽或滑動 結束時回撥,這時我們要把改變後的item檢視恢復到初始狀態
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
// 恢復顯示
// 這裡不能用if判斷,因為GridLayoutManager是LinearLayoutManager的子類,改用when,型別推導有區別
when (recyclerView.layoutManager) {
is GridLayoutManager -> {
// 網格佈局 設定選中大小
ViewCompat.animate(viewHolder.itemView).setDuration(200).scaleX(1F).scaleY(1F).start()
}
is LinearLayoutManager -> {
// 線性佈局 設定背景顏色
val drawable = viewHolder.itemView.background as GradientDrawable
drawable.color = ContextCompat.getColorStateList(viewHolder.itemView.context, R.color.greenPrimary)
}
}
super.clearView(recyclerView, viewHolder)
}
4.5、固定位置
在實際需求中,互動可能要求我們第一個選單不可以變更順序,只能固定,比如效果中的第一個選單「推薦」固定在首位這種情況。
4.5.1、修改adapter
定義一個固定值,並設定不同的背景色和其他選單區分開。
```
class DragAdapter(private val mContext: Context, private val mList: List
val fixedPosition = 0 // 固定選單
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.mItemTextView.text = mList[position]
// 第一個固定選單
val drawable = holder.mItemTextView.background as GradientDrawable
if (holder.adapterPosition == 0) {
drawable.color = ContextCompat.getColorStateList(mContext, R.color.greenAccent)
}else{
drawable.color = ContextCompat.getColorStateList(mContext, R.color.greenPrimary)
}
}
//...
} ```
4.5.1、修改onMove回撥
在onMove方法中判斷,只要是固定位置就直接返回false。
```
class DragCallBack(adapter: DragAdapter, data: MutableList
// 固定位置
if (fromPosition == mAdapter.fixedPosition || toPosition == mAdapter.fixedPosition) {
return false
}
// ...
return true
}
} ```
雖然第一個選單無法交換位置了,但是它還是可以拖拽的。
效果實現了嗎,好像也實現了,可是又好像哪裡不對,就好像填寫完表單點選提交時你告訴我格式不正確一樣,你不能一開始就告訴我嗎?
為了進一步提升使用者體驗,可以讓固定位置不可以拖拽嗎?
可以,ItemTouchHelper.Callback中有兩個方法:
- isLongPressDragEnabled 是否可以長按拖拽
- isItemViewSwipeEnabled 是否可以滑動
這倆方法預設都是true,所以即使不能交換位置,但預設也是支援操作的。
4.5.3、重寫isLongPressDragEnabled
以拖拽舉例,我們需要重寫isLongPressDragEnabled方法把它禁掉,然後再非固定位置的時候去手動開啟。
override fun isLongPressDragEnabled(): Boolean {
//return super.isLongPressDragEnabled()
return false
}
禁掉之後什麼時候再觸發呢?
因為我們現在的互動是長按進入編輯,那就需要在長按事件中再呼叫startDrag
手動開啟
mAdapter.setOnItemClickListener(object : DragAdapter.OnItemClickListener {
//...
override fun onItemLongClick(holder: DragAdapter.ViewHolder) {
if (holder.adapterPosition != mAdapter.fixedPosition) {
itemTouchHelper.startDrag(holder)
}
}
})
ok,這樣就完美實現了。
4.6、其他
4.6.1、position
因為有拖拽操作,下標其實是變化的,在做相應的操作時,要取實時位置
holder.adapterPosition
4.6.2、重置
不管是拖拽還是滑動,其實本質都是對Adapter內已填充的資料進行操作,實時資料通過Adapter獲取即可。
如果想要實現重置功能,直接拿最開始的原始資料重新塞給Adapter即可。
Author:yechaoa
5、原始碼探索
看原始碼時,找對一個切入點,往往能達到事半功倍的效果。
這裡就從繫結RecyclerView開始吧
val dragCallBack = DragCallBack(mAdapter, list)
val itemTouchHelper = ItemTouchHelper(dragCallBack)
itemTouchHelper.attachToRecyclerView(mBinding.recycleView)
例項化ItemTouchHelper,然後呼叫其attachToRecyclerView方法繫結到RecyclerView。
5.1、attachToRecyclerView
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
if (mRecyclerView == recyclerView) {
return; // nothing to do
}
if (mRecyclerView != null) {
destroyCallbacks();
}
mRecyclerView = recyclerView;
if (recyclerView != null) {
final Resources resources = recyclerView.getResources();
mSwipeEscapeVelocity = resources.getDimension(R.dimen.item_touch_helper_swipe_escape_velocity);
mMaxSwipeVelocity = resources.getDimension(R.dimen.item_touch_helper_swipe_escape_max_velocity);
setupCallbacks();
}
}
這段程式碼其實有點意思的,解讀一下:
- 第一個if判斷,避免重複操作,直接return
- 第二個if判斷,呼叫了destroyCallbacks,在destroyCallbacks裡面做了一些移除和回收操作,說明只能繫結到一個RecyclerView;同時,注意這裡判斷的主體是mRecyclerView,不是我們傳進來的recyclerView,而且我們傳進來的recyclerView是支援Nullable的,所以我們可以傳個空值走到destroyCallbacks裡來做解綁操作
- 第三個if判斷,當我們傳的recyclerView不為空時,呼叫setupCallbacks
5.2、setupCallbacks
private void setupCallbacks() {
ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
mSlop = vc.getScaledTouchSlop();
mRecyclerView.addItemDecoration(this);
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
mRecyclerView.addOnChildAttachStateChangeListener(this);
startGestureDetection();
}
這個方法裡已經大概可以看出內部實現原理了。
兩個關鍵點:
- addOnItemTouchListener
- startGestureDetection
通過觸控
和手勢識別
來處理互動顯示。
5.3、mOnItemTouchListener
``` private final OnItemTouchListener mOnItemTouchListener = new OnItemTouchListener() { @Override public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) { mGestureDetector.onTouchEvent(event); if (action == MotionEvent.ACTION_DOWN) { //... if (mSelected == null) { if (animation != null) { //... select(animation.mViewHolder, animation.mActionState); } } } else if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { select(null, ACTION_STATE_IDLE); } else if (mActivePointerId != ACTIVE_POINTER_ID_NONE) { //... if (index >= 0) { checkSelectForSwipe(action, event, index); } } return mSelected != null; }
@Override
public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) {
mGestureDetector.onTouchEvent(event);
//...
if (activePointerIndex >= 0) {
checkSelectForSwipe(action, event, activePointerIndex);
}
switch (action) {
case MotionEvent.ACTION_MOVE: {
if (activePointerIndex >= 0) {
moveIfNecessary(viewHolder);
}
break;
}
//...
}
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
select(null, ACTION_STATE_IDLE);
}
};
```
這段程式碼刪減之後還是有點多,不過沒關係,提煉一下,核心通過判斷MotionEvent
呼叫了幾個方法:
- select
- checkSelectForSwipe
- moveIfNecessary
5.3.1、select
void select(@Nullable ViewHolder selected, int actionState) {
if (selected == mSelected && actionState == mActionState) {
return;
}
//...
if (mSelected != null) {
if (prevSelected.itemView.getParent() != null) {
final float targetTranslateX, targetTranslateY;
switch (swipeDir) {
case LEFT:
case RIGHT:
case START:
case END:
targetTranslateY = 0;
targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth();
break;
//...
}
//...
} else {
removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView);
mCallback.clearView(mRecyclerView, prevSelected);
}
}
//...
mCallback.onSelectedChanged(mSelected, mActionState);
mRecyclerView.invalidate();
}
這裡面主要是在拖拽或滑動時對translateX/Y
的計算和處理,然後通過mCallback.clearView和mCallback.onSelectedChanged回撥給我們,最後呼叫invalidate()實時重新整理。
5.3.2、checkSelectForSwipe
void checkSelectForSwipe(int action, MotionEvent motionEvent, int pointerIndex) {
//...
if (absDx < mSlop && absDy < mSlop) {
return;
}
if (absDx > absDy) {
if (dx < 0 && (swipeFlags & LEFT) == 0) {
return;
}
if (dx > 0 && (swipeFlags & RIGHT) == 0) {
return;
}
} else {
if (dy < 0 && (swipeFlags & UP) == 0) {
return;
}
if (dy > 0 && (swipeFlags & DOWN) == 0) {
return;
}
}
select(vh, ACTION_STATE_SWIPE);
}
這裡是滑動處理的check,最後也是收斂到select()方法統一處理。
5.3.3、moveIfNecessary
void moveIfNecessary(ViewHolder viewHolder) {
if (mRecyclerView.isLayoutRequested()) {
return;
}
if (mActionState != ACTION_STATE_DRAG) {
return;
}
//...
if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
// keep target visible
mCallback.onMoved(mRecyclerView, viewHolder, fromPosition,
target, toPosition, x, y);
}
}
這裡檢查拖拽時是否需要交換item,通過mCallback.onMoved回撥給我們。
5.4、startGestureDetection
private void startGestureDetection() {
mItemTouchHelperGestureListener = new ItemTouchHelperGestureListener();
mGestureDetector = new GestureDetectorCompat(mRecyclerView.getContext(),
mItemTouchHelperGestureListener);
}
5.4.1、ItemTouchHelperGestureListener
private class ItemTouchHelperGestureListener extends GestureDetector.SimpleOnGestureListener {
//...
@Override
public void onLongPress(MotionEvent e) {
//...
View child = findChildView(e);
if (child != null) {
ViewHolder vh = mRecyclerView.getChildViewHolder(child);
if (vh != null) {
//...
if (pointerId == mActivePointerId) {
//...
if (mCallback.isLongPressDragEnabled()) {
select(vh, ACTION_STATE_DRAG);
}
}
}
}
}
}
這裡主要是對長按事件的處理,最後也是收斂到select()方法統一處理。
5.5、原始碼小結
- 繫結RecyclerView
- 註冊觸控手勢監聽
- 根據手勢,先是內部處理各種校驗、位置計算、動畫處理、重新整理等,然後回撥給ItemTouchHelper.Callback
事兒大概就是這麼個事兒,主要工作都是原始碼幫我們做了,我們只需要在回撥里根據結果處理業務邏輯即可。
6、Github
https://github.com/yechaoa/MaterialDesign
7、參考文件
8、最後
總之,寫作不易,且看且珍惜啊喂~
我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿。
- 【yechaoa】2022進階成長,揚帆再起航!
- 【Gradle-5】Gradle常用命令與引數
- 【Gradle-2】一文搞懂Gradle配置
- 【yechaoa】5年Android開發的2021年終總結,實現Flag的一年
- 程式碼規範-對抗軟體複雜度
- 【建議收藏】17個XML佈局小技巧
- 【造輪子】自定義一個隨意拖拽可吸邊的懸浮View
- 怎麼簡單實現選單拖拽排序的功能
- 【保姆級】包體積優化教程
- Android通知Notification使用全解析,看這篇就夠了
- 【首發】根據桌布修改App主題,它真的來了
- Android原生TabLayout使用全解析,看這篇就夠了
- 【漲姿勢】你沒用過的BadgeDrawable
- Android包體積優化(常規、進階、極致)
- Android Studio Arctic Fox | 2020.3.1、Gradle 7.0升級記錄
- Android 11適配指南之Toast解析
- Android 自定義View之隨機數驗證碼(仿寫鴻洋)
- Jetpack之Room的使用,結合Flow
- Android MediaPlayer音訊播放器詳解
- Android 修改系統音量及監聽