<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
本文主角是ItemTouchHelper
。
它是RecyclerView對於item互動處理的一個「輔助類」,主要用於拖拽以及滑動處理。
以介面實現的方式,達到設定簡單、邏輯解耦、職責分明的效果,並且支援所有的佈局方式。
自定義一個類,實現ItemTouchHelper.Callback
介面,然後在實現方法中根據需求簡單設定即可。
class DragCallBack(adapter: DragAdapter, data: MutableList<String>) : ItemTouchHelper.Callback() { }
ItemTouchHelper.Callback必須實現的3個方法:
其他方法還有onSelectedChanged、clearView等
用於建立互動方式,互動方式分為兩種:
最後,通過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 } } }
拖拽時回撥,這裡我們主要對起始位置和目標位置的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 }
滑動時回撥,這個回撥方法裡主要是做資料和檢視的更新操作。
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) }
上面介面實現部分我們已經簡單寫好了,邏輯也挺簡單,總共不超過100行程式碼。
接下來就是把這個輔助類繫結到RecyclerView。
RecyclerView顯示的實現就是基礎的樣式,就不展開了,可以檢視原始碼
。
val dragCallBack = DragCallBack(mAdapter, list) val itemTouchHelper = ItemTouchHelper(dragCallBack) itemTouchHelper.attachToRecyclerView(mBinding.recycleView)
繫結只需要呼叫attachToRecyclerView
就好了。
至此,簡單的效果就已經實現了。下面開始優化和進階的部分。
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種解決辦法:
好像可行,實際上並不太行...因為始終都有兩個分割線範例。
我們再來梳理一下:
我想到另外一個辦法,不對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 } } } } }
為了提升使用者體驗,可以在拖拽的時候告訴使用者當前拖拽的是哪個item,比如選中的item放大、背景高亮等。
這裡用到ItemTouchHelper.Callback中的兩個方法,onSelectedChanged
和clearView
,我們需要在選中時改變檢視顯示,結束時再恢復。
拖拽或滑動 發生改變時回撥,這時我們可以修改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:
拖拽或滑動 結束時回撥,這時我們要把改變後的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) }
在實際需求中,互動可能要求我們第一個選單不可以變更順序,只能固定,比如效果中的第一個選單「推薦」固定在首位這種情況。
定義一個固定值,並設定不同的背景色和其他選單區分開。
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) } } //... }
在onMove方法中判斷,只要是固定位置就直接返回false。
class DragCallBack(adapter: DragAdapter, data: MutableList<String>) : ItemTouchHelper.Callback() { /** * 拖動時回撥 */ 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 } // ... return true } }
雖然第一個選單無法交換位置了,但是它還是可以拖拽的。
效果實現了嗎,好像也實現了,可是又好像哪裡不對,就好像填寫完表單點選提交時你告訴我格式不正確一樣,你不能一開始就告訴我嗎?
為了進一步提升使用者體驗,可以讓固定位置不可以拖拽嗎?
可以,ItemTouchHelper.Callback中有兩個方法:
這倆方法預設都是true,所以即使不能交換位置,但預設也是支援操作的。
以拖拽舉例,我們需要重寫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,這樣就完美實現了。
因為有拖拽操作,下標其實是變化的,在做相應的操作時,要取實時位置
holder.adapterPosition
不管是拖拽還是滑動,其實本質都是對Adapter內已填充的資料進行操作,實時資料通過Adapter獲取即可。
如果想要實現重置功能,直接拿最開始的原始資料重新塞給Adapter即可。
看原始碼時,找對一個切入點,往往能達到事半功倍的效果。
這裡就從繫結RecyclerView開始吧
val dragCallBack = DragCallBack(mAdapter, list) val itemTouchHelper = ItemTouchHelper(dragCallBack) itemTouchHelper.attachToRecyclerView(mBinding.recycleView)
範例化ItemTouchHelper,然後呼叫其attachToRecyclerView方法系結到RecyclerView。
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(); } }
這段程式碼其實有點意思的,解讀一下:
private void setupCallbacks() { ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext()); mSlop = vc.getScaledTouchSlop(); mRecyclerView.addItemDecoration(this); mRecyclerView.addOnItemTouchListener(mOnItemTouchListener); mRecyclerView.addOnChildAttachStateChangeListener(this); startGestureDetection(); }
這個方法裡已經大概可以看出內部實現原理了。
兩個關鍵點:
通過觸控
和手勢識別
來處理互動顯示。
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
呼叫了幾個方法:
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()實時重新整理。
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()方法統一處理。
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回撥給我們。
private void startGestureDetection() { mItemTouchHelperGestureListener = new ItemTouchHelperGestureListener(); mGestureDetector = new GestureDetectorCompat(mRecyclerView.getContext(), mItemTouchHelperGestureListener); }
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()方法統一處理。
事兒大概就是這麼個事兒,主要工作都是原始碼幫我們做了,我們只需要在回撥里根據結果處理業務邏輯即可。
到此這篇關於Android簡單實現選單拖拽排序的功能的文章就介紹到這了,更多相關Android拖拽排序功能內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45