当前位置:网站首页>实现菜单拖拽排序
实现菜单拖拽排序
2022-08-04 19:48:00 【锐湃】
作者:yechaoa
链接:
https://juejin.cn/post/7124354102296838151
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
用于创建交互方式,交互方式分为两种:
1. 拖拽,网格布局支持上下左右,列表只支持上下(LEFT、UP、RIGHT、DOWN)。
2. 滑动,只支持前后(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种解决办法:
1. 调用addItemDecoration前,先调用removeItemDecoration方法remove掉之前所有的分割线。
2. 调用addItemDecoration(@NonNull ItemDecoration decor, int index),通过index来维护。
3. 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<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回调
在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中有两个方法:
1. isLongPressDragEnabled 是否可以长按拖拽。
2. 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即可。
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();
}
}
这段代码其实有点意思的,解读一下:
1. 第一个if判断,避免重复操作,直接return。
2. 第二个if判断,调用了destroyCallbacks,在destroyCallbacks里面做了一些移除和回收操作,说明只能绑定到一个RecyclerView;同时,注意这里判断的主体是mRecyclerView,不是我们传进来的recyclerView,而且我们传进来的recyclerView是支持Nullable的,所以我们可以传个空值走到destroyCallbacks里来做解绑操作。
3. 第三个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、源码小结
1. 绑定RecyclerView。
2. 注册触摸手势监听。
3. 根据手势,先是内部处理各种校验、位置计算、动画处理、刷新等,然后回调给ItemTouchHelper.Callback。
事儿大概就是这么个事儿,主要工作都是源码帮我们做了,我们只需要在回调里根据结果处理业务逻辑即可。
Github:
https://github.com/yechaoa/MaterialDesign
总之,写作不易,且看且珍惜啊喂~
参考文档:
ItemTouchHelper
https://developer.android.google.cn/reference/androidx/recyclerview/widget/ItemTouchHelper?hl=en
边栏推荐
- 【着色器实现Glitch单项故障闪烁效果(与Television效果不同)_Shader效果第十四篇】
- 【Attention演变史】翻译模型seq2seq (第二弹)
- 华为企业组网实例:VRRP+MSTP典型组网配置
- 从卖产品到卖“链路”:20条策略 解读直播带货迭代玩法
- IIC驱动OLED
- 高效目标检测:动态候选较大程度提升检测精度(附论文下载)
- 程序员如何在职场上少走弯路?
- 5G NR 笔记记录
- SAP UI5 ensures that the control id is globally unique implementation method
- [Latest Information] 2 new regions will announce the registration time for the soft exam in the second half of 2022
猜你喜欢
VQ Realization of Wavelet Extraction Features
Video Object Detection
电脑一键重装系统后连不上远程了?教你设置的方法
IIC驱动OLED
SIGIR 2022 | 邻域建模Graph-Masked Transformer,显著提高CTR预测性能
SQL Server 遇到报错解决办法--更新中
Highlights of some performance tests
ELECTRA: Pre-training Text Encoders as Discriminators Rather Than Generators
重构指标之如何监控代码圈复杂度
Openharmony first experience (1)
随机推荐
二叉树的遍历
从卖产品到卖“链路”:20条策略 解读直播带货迭代玩法
正畸MIA微种植体支抗技术中国10周年交流会在沈举办
图片延迟加载、预加载
Differences in the working mechanism between SAP E-commerce Cloud Accelerator and Spartacus UI
刷题-洛谷-P1179 数字统计
C#弹出询问对话框
SIGIR 2022 | 邻域建模Graph-Masked Transformer,显著提高CTR预测性能
MYSQL获取数据库的表名和表注释
Industrial CCD and CMOS camera
使用.NET简单实现一个Redis的高性能克隆版(二)
nr部分计算
[Sql brush topic] Query information data--Day1
MYSQL gets the table name and table comment of the database
Defaced Fingerprint Recovery and Identification
前3名突然变了,揭秘 7 月编程语言最新排行榜
量化交易机器人系统开发
02 ts 变量定义,类型
按需视觉识别:愿景和初步方案
SQL Server 遇到报错解决办法--更新中