简介:在Android开发中,RecyclerView结合ItemTouchHelper实现侧滑拖拽是常见的交互优化手段。这一资源面向具备RecyclerView基础、希望实现列表项滑动删除或拖拽排序的开发者,提供完整可运行的工程案例,其中包含自定义Adapter、ItemTouchHelper.Callback、手势监听、数据集更新和UI刷新等关键实现。压缩包采用zip格式,共44个文件,约763KB,包括13个xml布局、11个java源码、10个png图片,另有gradle构建脚本、properties配置及jar包,结构清晰,便于导入Android Studio直接运行与分析。已有371人学习下载。通过该工程可快速掌握侧滑与拖拽的核心回调逻辑,理解onMove、onSwiped与视图绘制等细节,并可直接复用其中的排序动画与删除交互代码,适合在聊天列表、任务管理等需要重排或滑动操作的项目中参考。
1. ItemTouchHelper是什么:RecyclerView 侧滑与拖拽的轻量方案
省事儿,真省事儿。很多做过安卓列表交互的开发者应该都有这种体会:侧滑删除、长按拖拽排序,看起来只是列表的基本操作,但自己写触摸事件处理很容易翻车,边界判断特别烦。RecyclerView 虽然没有直接提供这些能力,但官方给的 ItemTouchHelper 把 90% 的触摸判定都封装好了。它挂到 RecyclerView 上之后,侧滑删除和拖拽排序的实现代码量可以压缩到一个小文件里。适合做仿系统邮件、任务管理、待办清单这类需要手势交互的 App,也是列表能力进阶的一个绕不开的组件。这篇笔记就把它拆开讲,从接入方式、核心回调一直说到真实项目里的踩坑点。
2. 接入与核心回调:getMovementFlags 里四个方向常量怎么配
2.1 先搞清 ItemTouchHelper 的工作原理
ItemTouchHelper 本身是一个 RecyclerView.ItemDecoration,同时也实现了 OnChildAttachStateChangeListener。它通过这两个身份去观察 RecyclerView 里每一个子 View(也就是 ViewHolder 的 itemView)的触摸事件,然后自己决定要不要把这个触摸事件转成滑动手势或者拖拽手势。
整个过程不需要你去修改 RecyclerView 的任何触摸逻辑,也不需要给 itemView 单独设置 OnTouchListener。它内部会拦截并处理 ACTION_MOVE 事件,通过位移量来判断是触发滑动还是拖拽。这也是它比自定义触摸监听稳的地方——触摸冲突已经被它处理了一部分。
接入方式非常简单,核心就两步:
ItemTouchHelper.Callback callback = new MyItemTouchCallback(); ItemTouchHelper helper = new ItemTouchHelper(callback); helper.attachToRecyclerView(recyclerView);这个attachToRecyclerView一旦执行,callback 就开始接管 RecyclerView 上的触摸交互。第二步持有一个ItemTouchHelper对象是有必要的,因为某些场景下你要手动调用helper.startDrag(viewHolder)或helper.startSwipe(viewHolder)来触发拖拽或滑动,比如给 item 加一个拖拽手柄,点击手柄才开始拖动。
我一般习惯把helper定义成 Activity 或 Fragment 的成员变量,而不是局部变量,后面触发拖拽手柄还好用得上。另外注意一点,ItemTouchHelper 要求 RecyclerView 已经设置了 LayoutManager,否则它不知道 item 的摆放方向,拖动行为会很奇怪。
2.2 核心的重写点:getMovementFlags 决定能往哪动
ItemTouchHelper.Callback 是一个抽象类,最核心的方法就是getMovementFlags。返回值决定当前这个 item 允许往哪些方向拖拽和滑动。
@Override public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; int swipeFlags = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; return makeMovementFlags(dragFlags, swipeFlags); }dragFlags是拖拽方向,swipeFlags是侧滑方向,最后要用makeMovementFlags组合返回。ItemTouchHelper.UP / DOWN / LEFT / RIGHT这四个方向常量本质上是二进制位,用位或的方式组合。
注意,这里的方向是相对 item 自身的几何方向,不是相对屏幕。比如 RecyclerView 是横向排列的,那么拖拽方向就应该是LEFT | RIGHT,滑动删除方向是UP | DOWN,因为 LayoutManager 的滚动方向决定了 item 的排列逻辑。写反了之后你会发现手势触发方向怎么都对不上。
还有个小细节:makeMovementFlags在源码里是(dragFlags | (swipeFlags << 8)),高 8 位表示滑动,低 8 位表示拖拽。所以你不能用dragFlags | swipeFlags直接合并,那样方向常量会混在一起。上边的写法才是标准做法。
2.3 四个关键回调:onMove、onSwiped、isLongPressDragEnabled、isItemViewSwipeEnabled
接入 ItemTouchHelper 之后,实际交互逻辑主要落在这几个回调里。
@Override public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { int fromPosition = viewHolder.getBindingAdapterPosition(); int toPosition = target.getBindingAdapterPosition(); // 这里要自己调整数据集合的顺序 Collections.swap(dataList, fromPosition, toPosition); adapter.notifyItemMoved(fromPosition, toPosition); return true; }onMove在拖拽过程中会持续回调,item 每跨过一个位置就调一次。这里要注意两点:viewHolder.getBindingAdapterPosition()拿到的才是真实 adapter 位置,直接用getAdapterPosition()在新版本里已经过时且容易拿到旧值;数据集合的顺序必须同步修改,否则拖动完成后数据和界面对不上。
onSwiped是侧滑完成后的回调:
@Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { int position = viewHolder.getBindingAdapterPosition(); dataList.remove(position); adapter.notifyItemRemoved(position); }删除数据的顺序很关键。一定要先从 dataList 移除数据,再调用notifyItemRemoved。反过来调用,就会触发 item 的动画错位,出现删除一个、消失两个的灵异现象。这个坑后面避坑章节会展开讲。
isLongPressDragEnabled默认返回 false,改成 true 之后用户长按 item 就能进入拖拽状态。
@Override public boolean isLongPressDragEnabled() { return true; } @Override public boolean isItemViewSwipeEnabled() { return true; }这两个开关函数控制拖拽和滑动的总开关。有些场景只需要拖拽排序不要侧滑删除,那isItemViewSwipeEnabled()就返回 false,同时在getMovementFlags里只给 dragFlags 赋值,swipeFlags 写成 0 即可。别小看这两个开关,不想要某个功能但忘了关,用户乱滑就会触发莫名其妙的操作。
2.4 默认动画效果从哪里来
ItemTouchHelper 默认会给被拖拽的 item 加一层视觉反馈——被操作的 item 会半透明并略微抬起。它内部在不同阶段调用了onChildDraw和onChildDrawOver,默认行为里处理了位移、透明度、Elevation。
如果你觉得这个默认反馈不够明显,可以重写onSelectedChanged在拖拽开始时给 itemView 加 scale 动画,释放时在clearView里还原。这块视觉细节放在第 5 章细说,先把核心回调接好,让你的列表能拖能滑,再谈打磨效果。
@Override public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); viewHolder.itemView.setScaleX(1.0f); viewHolder.itemView.setScaleY(1.0f); }3. 两个实战场景:侧滑删除和长按拖拽排序怎么落进工程
3.1 场景一:再给删除加一个「未读/已读」标记动作
很多消息类的列表除了删除还要有已读、未读的切换。纯 ItemTouchHelper 侧滑,默认onSwiped一旦触发 item 就离手了,想做「两个按钮」就需要自己扩展。我的做法是把 swiped item 固定留在屏幕边缘,不直接删掉,而是露出按钮区域。
具体思路是:在onSwiped里不删除数据,而是把这个 holder 的状态记录为「滑出状态」,然后不调用notifyItemRemoved,改用adapter.notifyItemChanged(position, payload)。再重写onChildDraw,控制 itemView 的 translationX,留下一个固定宽度的按钮区。
@Override public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { View itemView = viewHolder.itemView; float maxWidth = dp2px(160); float newTranslationX = Math.max(-maxWidth, Math.min(0f, dX)); itemView.setTranslationX(newTranslationX); // 根据 newTranslationX 的偏移量绘制删除按钮的显示状态 return; } super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); }这里比较关键的是Math.max(-maxWidth, Math.min(0f, dX))这个钳制逻辑。只允许向左滑,且最大滑出宽度不超过 160dp,这样用户松手之后 item 就不会整个飞出屏幕,而是停在按钮露出的位置。按钮操作完成之后,把 translationX 恢复成 0,再调用notifyItemChanged(position)刷新界面。
这种方案的按钮实际上是 itemView 底层的两个控件(布局里提前放好,默认被 itemView 盖住)。让 itemView 的 content 区域滑动露出底部的按钮区。需要注意按钮的点击事件绑在 holder 内部的控件上,不依赖 RecyclerView 的 itemClick,这样就不会被侧滑手势和点击事件相互干扰。
3.2 场景二:拖拽手柄触发排序,避免长按误操作
长按拖拽在列表场景里挺容易误触的:用户本来想长按复制文字,结果把整个 item 拖起来。更稳的方案是拖拽手柄——只在 item 右侧放一个「三横线」图标,点住图标才能拖。
手柄的实现需要借助startDrag。给图标的触摸事件单独处理:
public class MyViewHolder extends RecyclerView.ViewHolder { ImageView dragHandle; public MyViewHolder(View itemView) { super(itemView); dragHandle = itemView.findViewById(R.id.drag_handle); dragHandle.setOnTouchListener((v, event) -> { if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { if (helper != null) { helper.startDrag(this); } } return false; }); } }helper.startDrag(this)传的是当前 ViewHolder,ItemTouchHelper 会从这个 ViewHolder 开始接管触摸事件,把它切换到拖拽状态。注意这个方法要在 ACTION_DOWN 阶段调用,晚一拍就会变成点击事件,拖拽感很弱。
同时要把长按拖拽功能关掉,否则两个触发源会冲突:
@Override public boolean isLongPressDragEnabled() { return false; }手柄这种模式在老项目中改造比较简单:只要布局里加上手柄 Icon,holder 里加一个 OnTouchListener,其他逻辑完全不用动。数据集合的Collections.swap照样在onMove里维护。
3.3 场景三:List 和 Grid 布局下手势方向要分开配置
同一个 ItemTouchHelper 想做网格拖拽排序,最容易踩坑的点是方向常量写死。LinearLayoutManager 竖向列表用UP | DOWN没问题,但 RecyclerView 是 GridLayoutManager 时,item 既能往左往右移动,也能往上往下移动,四个方向都要放开。
@Override public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { int dragFlags; if (recyclerView.getLayoutManager() instanceof GridLayoutManager) { dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; } else { dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; } return makeMovementFlags(dragFlags, 0); }Grid 模式下onMove回调的 target 会随着拖拽位置实时变化,在跨行时尤其频繁。凡是用getBindingAdapterPosition()拿索引就不会乱序,但要注意数据交换算法的方向问题,直接从位置 A 换到位置 B 用Collections.swap在 Grid 拖拽下会产生中间态错位。
我一般用移动式的交换逻辑,不是单纯 swap:
int fromPosition = viewHolder.getBindingAdapterPosition(); int toPosition = target.getBindingAdapterPosition(); if (fromPosition < toPosition) { for (int i = fromPosition; i < toPosition; i++) { Collections.swap(dataList, i, i + 1); } } else { for (int i = fromPosition; i > toPosition; i--) { Collections.swap(dataList, i, i - 1); } } adapter.notifyItemMoved(fromPosition, toPosition);这个写法视觉上更像「挤过去」,不会出现 Grid 上两个 item 互相顶来顶去像打架的现象。数组操作量不大,几十条数据的列表性能完全没问题。
4. 避坑清单:侧滑、拖拽、排序最容易翻车的五个场景
4.1 删除后列表错位,删的却不是刚才那个
现象:快速滑动连续删除多条数据,界面上某些 item 的删除动画结束之后,剩余 item 的位置对不上数据,甚至出现图片闪现错乱。
原因:onSwiped回调里拿 position 的时机不对,或者notifyItemRemoved触发的动画过程中用户又触发下一个删除操作,adapter 位置还没稳定。新版本 RecyclerView 已经迁移到getBindingAdapterPosition(),老代码里的getPosition()拿到的就是旧索引,数据集合 index 和 list 里的实际下标已经错位了。
解决:在onSwiped里强制使用viewHolder.getBindingAdapterPosition(),不要用viewHolder.itemView.getTag()之类的缓存标记位去定位。另外删除之后紧接着调用adapter.notifyItemRemoved(position),不要调用adapter.notifyDataSetChanged()。如果必须刷新整个列表,也要先更新数据再刷新。
4.2 拖动排序完成,数据顺序和界面不一致
现象:在onMove里通过Collections.swap交换数据,拖拽完成后列表项和真实数据顺序不一致,退出页面回来发现顺序错乱。
原因:Collections.swap(from, to)只是交换了两次位置的值,如果目标位置和起始位置之间存在其他元素,这些元素被移动的路径不会体现在数据集合中,但视觉上 item 跨过了他们。换句话说,界面上的拖动是「逐步逼近」的效果,而数据只是最后一步交换,中间项没有被移动,数据顺序自然对不上。
解决:手动模拟「整体移位」。把 from 位置的值移除,插入到 to 位置,也就是 ArrayList 的 add/remove 组合:
int from = viewHolder.getBindingAdapterPosition(); int to = target.getBindingAdapterPosition(); if (from == to) return true; Integer moveItem = dataList.remove(from); dataList.add(to, moveItem); adapter.notifyItemMoved(from, to);这样数据顺序和动画过程严格一致。用 Java 的List接口,这两个操作的时间复杂度都是 O(n),对这个场景来说足够。如果你用的是SparseArray或自定义数组,逻辑类似,核心就是「先删后插」。
4.3 setDragView 的图片效果被默认阴影覆盖
现象:拖拽过程中给 item 加了旋转或者放大动画,但被 ItemTouchHelper 默认的半透明+抬升效果盖住,视觉上显得很脏。
原因:ItemTouchHelper 默认在onSelectedChanged里把 itemView 的 alpha 改为 0.5f,并抬高 Elevation。如果自己又叠了一个 scale 动画,两套动画同时改同一个 View 的属性,会出现互相覆盖的情况。
解决:在自己重写动画的 ViewHolder 里监听onSelectedChanged状态,显式关闭默认效果:
@Override public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) { super.onSelectedChanged(viewHolder, actionState); if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { viewHolder.itemView.setAlpha(0.6f); viewHolder.itemView.setScaleX(1.05f); viewHolder.itemView.setScaleY(1.05f); viewHolder.itemView.setElevation(dp2px(8)); } }注意要调用super,然后自己把想要的属性覆盖一遍。最稳妥的写法是自定义ItemTouchHelper.Callback的onSelectedChanged,把想要的表现样式归纳到这里,不要散落在 Adapter 里。
4.4 拖拽时触摸焦点错乱,松手后 item 卡在半空
现象:拖拽 item 过程中,手指滑到了另一个 item 上方,那个 item 突然被顶起,自己手里拖的 item 反而停在原地不动了。
原因:拖拽过程中onMove返回 false 就不会更新目标位置。有很多开发者在这个回调里习惯先判断viewHolder.getItemViewType() != target.getItemViewType(),不同类型就不允许移动。但如果目标 item 类型不同且没有正确返回 false,就出现了这种情况。另外,如果拖拽的手柄 ImageView 抢占了触摸事件,也会出现拖拽焦点被 switch 掉的现象。
解决:onMove逻辑里类型不匹配时直接 return false,同时保证 handle 的 OnTouchListener 里 ACTION_DOWN 和 ACTION_UP 都返回 true,ACTION_MOVE 返回 false,不要把事件流切碎。如果 item 布局里还有按钮,也需要注意按钮的点击区域不能太大,否则用户按下 item 想拖拽时直接按到了按钮上。
4.5 滑动删除完成后,item 的「幽灵动画」停不下来
现象:onSwiped里手动执行完删除逻辑之后,item 从屏幕上消失的同时,右边又闪现了一个半透明的残影,要过一两秒才消失。
原因:notifyItemRemoved(position)之后 RecyclerView 会根据 ItemAnimator 制造移动动画。如果数据集合又做了remove,新位置上的 item 会和已删除 item 的动画「混合」成残影。还有一种情况是 item 的根布局有背景 Drawable,或者是 shape 文件,在动画过程中 alpha 变化出现了重影效果。
解决:给 RecyclerView 设置一个统一的 ItemAnimator,把默认动画调得温和一点:
recyclerView.setItemAnimator(new DefaultItemAnimator()); recyclerView.getItemAnimator().setAddDuration(200); recyclerView.getItemAnimator().setRemoveDuration(200); recyclerView.getItemAnimator().setMoveDuration(200); recyclerView.getItemAnimator().setChangeDuration(0);setChangeDuration(0)很关键,把变更动画关掉,消除那种「被删的 item 还在走位」的闪烁。做侧滑删除时这个改动通常都是必选项。
5. 状态回调驱动视觉反馈:onSelectedChanged 和 clearView 的时间点用法
ItemTouchHelper 的状态机分三个动作:ACTION_STATE_IDLE、ACTION_STATE_DRAG、ACTION_STATE_SWIPE。onSelectedChanged在进入拖拽或滑动状态时被调用一次,clearView在状态结束回到 IDLE 时调用。这两个回调相当于一对「开始操作」和「结束操作」的锚点,适合做拖拽状态的视觉反馈,也能用来做抗抖逻辑。
@Override public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) { super.onSelectedChanged(viewHolder, actionState); if (actionState == ItemTouchHelper.ACTION_STATE_DRAG || actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { viewHolder.itemView.animate().scaleX(1.08f).scaleY(1.08f).setDuration(150).start(); } } @Override public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); viewHolder.itemView.animate().scaleX(1f).scaleY(1f).setDuration(150).start(); // 释放时顺手把拖拽位置的数据同步回服务器或数据库 if (dragStartPosition != -1 && dragEndPosition != -1) { syncOrderToServer(dragStartPosition, dragEndPosition); } }这套状态锚点最实用的地方在于:拖拽排序往往不只是改本地 list,还需要把新的排序结果提交到服务端。我的做法是在onMove里只更新界面和数据,不触发网络请求;真正松手的瞬间clearView回调里再把排序结果一次性提交。这样拖拽过程再快也不会频繁请求网络,服务器只会收到一次最终排序结果。
动手测试的时候,不要只在「拖一下、松一下」的层面验证。我习惯把四种操作按顺序全部走一遍:长按拖动到目标位置、松手快速滑动删除、滑动后立刻取消、拖到屏幕边缘触发自动滚动。这四类操作几乎覆盖了 ItemTouchHelper 在真实列表页的全部交互路径,全部走通说明状态切换没硬伤。
还有个小习惯:每次写完 ItemTouchHelper 的交互代码,我会在手指松开之后特意等一下再操作下一个 item,观察动画是不是完全归位。如果两次快速操作之间有闪烁,多半是上一个 item 的动画没有执行完,这时候再检查 clearView 是否恢复了 scale 和 alpha 属性。
ItemTouchHelper 看起来只是 RecyclerView 的一个辅助类,但它把触摸事件、动画协调、状态回调串成了一个完整的交互链路。这些状态回调用好了,侧滑删除和拖拽排序就不只是「能用」,而是真的能做到顺手。希望帮到你。
本文还有配套的精品资源,点击获取