網(wǎng)站首頁 編程語言 正文
1、效果
2、簡介
本文主角是ItemTouchHelper
。
它是RecyclerView對于item交互處理的一個「輔助類」,主要用于拖拽以及滑動處理。
以接口實現(xiàn)的方式,達(dá)到配置簡單、邏輯解耦、職責(zé)分明的效果,并且支持所有的布局方式。
3、功能拆解
4、功能實現(xiàn)
4.1、實現(xiàn)接口
自定義一個類,實現(xiàn)ItemTouchHelper.Callback
接口,然后在實現(xiàn)方法中根據(jù)需求簡單配置即可。
class DragCallBack(adapter: DragAdapter, data: MutableList<String>) : ItemTouchHelper.Callback() { }
ItemTouchHelper.Callback必須實現(xiàn)的3個方法:
- getMovementFlags
- onMove
- onSwiped
其他方法還有onSelectedChanged、clearView等
4.1.1、getMovementFlags
用于創(chuàng)建交互方式,交互方式分為兩種:
- 拖拽,網(wǎng)格布局支持上下左右,列表只支持上下(LEFT、UP、RIGHT、DOWN)
- 滑動,只支持前后(START、END)
最后,通過makeMovementFlags
把結(jié)果返回回去,makeMovementFlags接收兩個參數(shù),dragFlags
和swipeFlags
,即上面拖拽和滑動組合的標(biāo)志位。
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { var dragFlags = 0 var swipeFlags = 0 when (recyclerView.layoutManager) { is GridLayoutManager -> { // 網(wǎng)格布局 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
拖拽時回調(diào),這里我們主要對起始位置和目標(biāo)位置的item做一個數(shù)據(jù)交換,然后刷新視圖顯示。
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { // 起始位置 val fromPosition = viewHolder.adapterPosition // 結(jié)束位置 val toPosition = target.adapterPosition // 固定位置 if (fromPosition == mAdapter.fixedPosition || toPosition == mAdapter.fixedPosition) { return false } // 根據(jù)滑動方向 交換數(shù)據(jù) 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
滑動時回調(diào),這個回調(diào)方法里主要是做數(shù)據(jù)和視圖的更新操作。
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
上面接口實現(xiàn)部分我們已經(jīng)簡單寫好了,邏輯也挺簡單,總共不超過100行代碼。
接下來就是把這個輔助類綁定到RecyclerView。
RecyclerView顯示的實現(xiàn)就是基礎(chǔ)的樣式,就不展開了,可以查看源碼
。
val dragCallBack = DragCallBack(mAdapter, list) val itemTouchHelper = ItemTouchHelper(dragCallBack) itemTouchHelper.attachToRecyclerView(mBinding.recycleView)
綁定只需要調(diào)用attachToRecyclerView
就好了。
至此,簡單的效果就已經(jīng)實現(xiàn)了。下面開始優(yōu)化和進(jìn)階的部分。
4.3、設(shè)置分割線
RecyclerView網(wǎng)格布局實現(xiàn)等分,我們一般先是自定義ItemDecoration
,然后調(diào)用addItemDecoration來實現(xiàn)的。
但是我在實現(xiàn)效果的時候遇到一個問題,因為我加了布局切換的功能,在每次切換的時候,針對不同的布局分別設(shè)置layoutManager
和ItemDecoration
,這就導(dǎo)致隨著切換次數(shù)的增加,item的間隔就越大。
addItemDecoration,顧名思義是添加,通過查看源碼發(fā)現(xiàn)RecyclerView內(nèi)部是有一個ArrayList來維護(hù)的,所以當(dāng)我們重復(fù)調(diào)用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種解決辦法:
- 調(diào)用addItemDecoration前,先調(diào)用removeItemDecoration方法remove掉之前所有的分割線
- 調(diào)用addItemDecoration(@NonNull?ItemDecoration decor, int index),通過index來維護(hù)
- add時通過一個標(biāo)示來判斷,添加過就不添加了
好像可行,實際上并不太行...因為始終都有兩個分割線實例。
我們再來梳理一下:
- 兩種不同的布局
- 都有分割線
- 分割線只需設(shè)置一次
我想到另外一個辦法,不對RecyclerView做處理了,既然兩種布局都有分割線,是不是可以把分割線合二為一了,然后根據(jù)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、選中放大/背景變色
為了提升用戶體驗,可以在拖拽的時候告訴用戶當(dāng)前拖拽的是哪個item,比如選中的item放大、背景高亮等。
- 網(wǎng)格布局,選中變大
- 列表布局,背景變色
這里用到ItemTouchHelper.Callback中的兩個方法,onSelectedChanged
和clearView
,我們需要在選中時改變視圖顯示,結(jié)束時再恢復(fù)。
4.4.1、onSelectedChanged
拖拽或滑動 發(fā)生改變時回調(diào),這時我們可以修改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) { // 線性布局 設(shè)置背景顏色 val drawable = it.itemView.background as GradientDrawable drawable.color = ContextCompat.getColorStateList(it.itemView.context, R.color.greenDark) } else { // 網(wǎng)格布局 設(shè)置選中放大 ViewCompat.animate(it.itemView).setDuration(200).scaleX(1.3F).scaleY(1.3F).start() } } } super.onSelectedChanged(viewHolder, actionState) }
actionState:
- ACTION_STATE_IDLE 空閑狀態(tài)
- ACTION_STATE_SWIPE 滑動狀態(tài)
- ACTION_STATE_DRAG 拖拽狀態(tài)
4.4.2、clearView
拖拽或滑動 結(jié)束時回調(diào),這時我們要把改變后的item視圖恢復(fù)到初始狀態(tài)
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { // 恢復(fù)顯示 // 這里不能用if判斷,因為GridLayoutManager是LinearLayoutManager的子類,改用when,類型推導(dǎo)有區(qū)別 when (recyclerView.layoutManager) { is GridLayoutManager -> { // 網(wǎng)格布局 設(shè)置選中大小 ViewCompat.animate(viewHolder.itemView).setDuration(200).scaleX(1F).scaleY(1F).start() } is LinearLayoutManager -> { // 線性布局 設(shè)置背景顏色 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
定義一個固定值,并設(shè)置不同的背景色和其他菜單區(qū)分開。
class DragAdapter(private val mContext: Context, private val mList: List<String>) : RecyclerView.Adapter<DragAdapter.ViewHolder>() { 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回調(diào)
在onMove方法中判斷,只要是固定位置就直接返回false。
class DragCallBack(adapter: DragAdapter, data: MutableList<String>) : ItemTouchHelper.Callback() { /** * 拖動時回調(diào) */ override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { // 起始位置 val fromPosition = viewHolder.adapterPosition // 結(jié)束位置 val toPosition = target.adapterPosition // 固定位置 if (fromPosition == mAdapter.fixedPosition || toPosition == mAdapter.fixedPosition) { return false } // ... return true } }
雖然第一個菜單無法交換位置了,但是它還是可以拖拽的。
效果實現(xiàn)了嗎,好像也實現(xiàn)了,可是又好像哪里不對,就好像填寫完表單點擊提交時你告訴我格式不正確一樣,你不能一開始就告訴我嗎?
為了進(jìn)一步提升用戶體驗,可以讓固定位置不可以拖拽嗎?
可以,ItemTouchHelper.Callback中有兩個方法:
- isLongPressDragEnabled 是否可以長按拖拽
- isItemViewSwipeEnabled 是否可以滑動
這倆方法默認(rèn)都是true,所以即使不能交換位置,但默認(rèn)也是支持操作的。
4.5.3、重寫isLongPressDragEnabled
以拖拽舉例,我們需要重寫isLongPressDragEnabled方法把它禁掉,然后再非固定位置的時候去手動開啟。
override fun isLongPressDragEnabled(): Boolean { //return super.isLongPressDragEnabled() return false }
禁掉之后什么時候再觸發(fā)呢?
因為我們現(xiàn)在的交互是長按進(jìn)入編輯,那就需要在長按事件中再調(diào)用startDrag
手動開啟
mAdapter.setOnItemClickListener(object : DragAdapter.OnItemClickListener { //... override fun onItemLongClick(holder: DragAdapter.ViewHolder) { if (holder.adapterPosition != mAdapter.fixedPosition) { itemTouchHelper.startDrag(holder) } } })
ok,這樣就完美實現(xiàn)了。
4.6、其他
4.6.1、position
因為有拖拽操作,下標(biāo)其實是變化的,在做相應(yīng)的操作時,要取實時位置
holder.adapterPosition
4.6.2、重置
不管是拖拽還是滑動,其實本質(zhì)都是對Adapter內(nèi)已填充的數(shù)據(jù)進(jìn)行操作,實時數(shù)據(jù)通過Adapter獲取即可。
如果想要實現(xiàn)重置功能,直接拿最開始的原始數(shù)據(jù)重新塞給Adapter即可。
5、源碼探索
看源碼時,找對一個切入點,往往能達(dá)到事半功倍的效果。
這里就從綁定RecyclerView開始吧
val dragCallBack = DragCallBack(mAdapter, list) val itemTouchHelper = ItemTouchHelper(dragCallBack) itemTouchHelper.attachToRecyclerView(mBinding.recycleView)
實例化ItemTouchHelper,然后調(diào)用其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判斷,避免重復(fù)操作,直接return
- 第二個if判斷,調(diào)用了destroyCallbacks,在destroyCallbacks里面做了一些移除和回收操作,說明只能綁定到一個RecyclerView;同時,注意這里判斷的主體是mRecyclerView,不是我們傳進(jìn)來的recyclerView,而且我們傳進(jìn)來的recyclerView是支持Nullable的,所以我們可以傳個空值走到destroyCallbacks里來做解綁操作
- 第三個if判斷,當(dāng)我們傳的recyclerView不為空時,調(diào)用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(); }
這個方法里已經(jīng)大概可以看出內(nèi)部實現(xiàn)原理了。
兩個關(guān)鍵點:
- 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); } };
這段代碼刪減之后還是有點多,不過沒關(guān)系,提煉一下,核心通過判斷MotionEvent
調(diào)用了幾個方法:
- 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回調(diào)給我們,最后調(diào)用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()方法統(tǒng)一處理。
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回調(diào)給我們。
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()方法統(tǒng)一處理。
5.5、源碼小結(jié)
- 綁定RecyclerView
- 注冊觸摸手勢監(jiān)聽
- 根據(jù)手勢,先是內(nèi)部處理各種校驗、位置計算、動畫處理、刷新等,然后回調(diào)給ItemTouchHelper.Callback
事兒大概就是這么個事兒,主要工作都是源碼幫我們做了,我們只需要在回調(diào)里根據(jù)結(jié)果處理業(yè)務(wù)邏輯即可。
原文鏈接:https://juejin.cn/post/7124354102296838151
相關(guān)推薦
- 2022-11-26 .Net中Task?Parallel?Library的進(jìn)階用法_實用技巧
- 2022-05-26 Flutter?Drawer抽屜菜單示例詳解_Android
- 2022-05-08 Python?matplotlib實現(xiàn)散點圖的繪制_python
- 2022-05-09 Python數(shù)據(jù)結(jié)構(gòu)與算法中的棧詳解(2)_python
- 2023-03-26 WPF使用觸發(fā)器需要注意優(yōu)先級問題解決_C#教程
- 2023-02-03 Linux設(shè)置每晚定時備份Oracle數(shù)據(jù)表的操作命令_linux shell
- 2022-10-05 Numpy中Meshgrid函數(shù)基本用法及2種應(yīng)用場景_python
- 2023-07-07 TP6的服務(wù)在自定義composer包中如何使用
- 最近更新
-
- window11 系統(tǒng)安裝 yarn
- 超詳細(xì)win安裝深度學(xué)習(xí)環(huán)境2025年最新版(
- Linux 中運(yùn)行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎(chǔ)操作-- 運(yùn)算符,流程控制 Flo
- 1. Int 和Integer 的區(qū)別,Jav
- spring @retryable不生效的一種
- Spring Security之認(rèn)證信息的處理
- Spring Security之認(rèn)證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權(quán)
- redisson分布式鎖中waittime的設(shè)
- maven:解決release錯誤:Artif
- restTemplate使用總結(jié)
- Spring Security之安全異常處理
- MybatisPlus優(yōu)雅實現(xiàn)加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務(wù)發(fā)現(xiàn)-Nac
- Spring Security之基于HttpR
- Redis 底層數(shù)據(jù)結(jié)構(gòu)-簡單動態(tài)字符串(SD
- arthas操作spring被代理目標(biāo)對象命令
- Spring中的單例模式應(yīng)用詳解
- 聊聊消息隊列,發(fā)送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠(yuǎn)程分支