怎么简单实现菜单拖拽排序的功能

网友投稿 646 2022-08-31

怎么简单实现菜单拖拽排序的功能

怎么简单实现菜单拖拽排序的功能

1、效果

2、简介

本文主角是​​ItemTouchHelper​​。

它是RecyclerView对于item交互处理的一个「辅助类」,主要用于拖拽以及滑动处理。

以接口实现的方式,达到配置简单、逻辑解耦、职责分明的效果,并且支持所有的布局方式。

3、功能拆解

4、功能实现

4.1、实现接口

自定义一个类,实现​​ItemTouchHelper.Callback​​接口,然后在实现方法中根据需求简单配置即可。

class DragCallBack(adapter: DragAdapter, data: MutableList) : ItemTouchHelper.Callback() {}

ItemTouchHelper.Callback必须实现的3个方法:

getMovementFlagsonMoveonSwiped

其他方法还有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- = spacing } outRect.bottom = spacing } else { outRect.left = column * spacing / spanCount outRect.right = spacing - (column + 1) * spacing / spanCount if (position >= spanCount) { outRect- = spanCount } outRect.bottom = spacing } } is LinearLayoutManager -> { outRect- = 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) : RecyclerView.Adapter() { 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) : 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中有两个方法:

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(); }

这个方法里已经大概可以看出内部实现原理了。

两个关键点:

addOnItemTouchListenerstartGestureDetection

通过​​触摸​​​和​​手势识别​​来处理交互显示。

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​​调用了几个方法:

selectcheckSelectForSwipemoveIfNecessary

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、参考文档

​​ItemTouchHelper​​

8、最后

总之,写作不易,且看且珍惜啊喂~

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:流程控制结构(流程控制结构分别包括)
下一篇:(成长一夏竞赛)- 最大数
相关文章

 发表评论

暂时没有评论,来抢沙发吧~