RecyclerView侧滑删除与拖拽排序:ItemTouchHelper完全指南
简介一份面向 Android 开发者的 RecyclerView 侧滑拖拽功能实现资源基于 ItemTouchHelper 工具类完整演示 Adapter、LayoutManager、ViewHolder 的配合方式以及 onMove()、onSwiped() 等回调的自定义处理涵盖 dragFlags 与 swipeFlags 方向设置、onChildDraw() 视图绘制、notifyItemMoved() 等数据更新方法适合正在学习 RecyclerView 进阶交互或需要在项目中快速接入侧滑删除、拖拽排序的开发者。资源包共 44 个文件包含 11 个 Java 源码、13 个 XML 布局与配置、10 张 PNG 图片以及 Gradle 工程文件等其中 Java 与 XML 对应代码逻辑和界面布局PNG 记录界面效果Gradle 文件辅助构建整体压缩后约 763KB可直接导入 Android Studio 查看运行。内容还附有侧滑源码解析文档能帮助读者理解拖动与滑动过程中的数据更新、UI 通知和动画绘制逻辑同时提供完整 demo 工程与已解析源码便于对照学习并迁移到实际业务。目前已有 371 人学习下载对于希望掌握 RecyclerView 高级特性的初中级开发者具有不错的参考价值。1. RecyclerView 侧滑拖拽为什么我放弃手势监听改用 ItemTouchHelper做待办清单应用的时候我最先想到的侧滑删除是自己在 onTouchEvent 里算坐标结果页面一旦嵌套进 ScrollView滑动冲突直接把列表卡死。后来换成 ItemTouchHelper这个官方组件把手势识别、位移动画和碰撞检测全部封装好了几行代码就能同时拿到拖拽排序和侧滑删除。这篇文章从事件分发的底层逻辑讲到参数调优覆盖侧滑删除、长按拖拽排序、网格布局三个高频场景新手可以直接照搬代码熟手可以重点看第 4 章和第 5 章的踩坑记录。建议把第 2 章读完再动手后面出问题时你才知道该改哪里。2. 事件分发机制ItemTouchHelper 是怎么接管滑动和拖拽的2.1 为什么不用 GestureDetector 自己实现RecyclerView 的触摸事件优先走自身的 dispatchTouchEvent再分发给 item 处理。如果自己监听 onTouchEvent最先要面对的是把“滑动”和“点击”区分开——手指移动多少像素算滑动水平位移和垂直位移同时出现怎么办松手速度超过多少算 fling这些阈值单个拎出来都不复杂但组合在一起就是一堆状态判断再加上嵌套滚动场景里父容器也会参与事件拦截处理起来非常容易出边界问题。ItemTouchHelper 的思路完全不同。它本身是一个 RecyclerView.ItemDecoration通过重写 onDraw 和 onDrawOver 参与绘制同时也实现了触摸事件的分发逻辑在 item 的 TouchEvent 里统一做动作识别。这套机制的好处是手势识别、动画插值和碰撞检测都封装在框架内部我们只需要告诉它“哪些方向允许拖拽、哪些方向允许侧滑”剩下的轨迹计算和位置纠偏全部由它完成。初始化时把 ItemTouchHelper 附着到 RecyclerView 上这是最常见的一行代码ItemTouchHelper itemTouchHelper new ItemTouchHelper(callback); itemTouchHelper.attachToRecyclerView(recyclerView);关键就在这个 callback 上。它定义了四个维度的信息可拖拽的方向、可侧滑的方向、拖拽发生时的数据交换逻辑、侧滑消失时的数据移除逻辑。这些方向最终合并成一个 int 类型的 movementFlags 传给框架去做具体的手势方向匹配。还有一点值得注意自己实现手势识别时item 的点击事件和拖拽事件经常互相干扰——长按进入拖拽后松手时系统无法区分是点击还是拖拽结束。ItemTouchHelper 长按处理内置了时间阈值和位移阈值的双重判定比自己在 onTouchEvent 里写判断要稳得多。2.2 Callback 的五个核心方法的工作顺序ItemTouchHelper.Callback 是抽象类我们继承它时一般需要重写五个方法getMovementFlags、onMove、onSwiped、clearView、onSelectedChanged。它们按照手势生命周期依次触发理解这个顺序对调试非常关键。手指按下后框架先调用 getMovementFlags拿到当前 item 允许的方向判断是否可以进入拖拽或滑动流程手指移动并超过系统触摸阈值后进入 onSelectedChanged此时 item 被“提起”可以在这里播放抬升动画、改变背景色拖拽换位过程中框架反复回调 onMove我们在其中交换 adapter 里的数据并做局部刷新侧滑超过半个 item 宽度后松手框架回调 onSwiped此时移除数据手指抬起、动画归位完成后回调 clearView做视觉状态收尾。这个顺序拆开理解写代码就不会乱。很多人一上来就在 onMove 里调用 notifyDataSetChanged 刷新全表结果拖一个 item 整个列表都在闪原因就是数据和通知的关系没有摆正。onMove 里只做数据交换和局部通知其余交给 ItemTouchHelper 的动画机制去处理性能才有保障。另一个细节是 getMovementFlags 的判定会影响长按拖拽的触发。isLongPressDragEnabled() 默认返回 true表示长按任意位置即可进入拖拽但如果你希望只有拖拽手柄能触发就必须让它返回 false然后自己在手柄 View 上单独监听触摸事件再调用 startDrag 方法让当前 item 进入拖拽状态。这个开关在后面多布局场景里非常常用。3. 基础实现一个 Callback 类加四步接入跑通侧滑删除3.1 完整代码继承 ItemTouchHelper.Callback先上完整代码。这里用最简单的场景说明纵向列表支持上下拖拽排序、左滑删除数据源是 List 。整套实现基于原生 Android SDK 加 Java 编写不依赖第三方库协议的兼容性走标准开源方式方便直接并入现有工程。/* * 拖拽排序与侧滑删除的统一回调 * 基于原生 Android SDK Java 实现不依赖第三方库 */ public class SimpleItemTouchCallback extends ItemTouchHelper.Callback { private final ItemTouchAdapter adapter; public SimpleItemTouchCallback(ItemTouchAdapter adapter) { this.adapter adapter; } Override public int getMovementFlags(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { int dragFlags ItemTouchHelper.UP | ItemTouchHelper.DOWN; int swipeFlags ItemTouchHelper.LEFT; return makeMovementFlags(dragFlags, swipeFlags); } Override public boolean isLongPressDragEnabled() { // 长按任意位置即可进入拖拽 return true; } Override public boolean onMove(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder, NonNull RecyclerView.ViewHolder target) { int fromPosition viewHolder.getBindingAdapterPosition(); int toPosition target.getBindingAdapterPosition(); adapter.onItemMove(fromPosition, toPosition); return true; } Override public void onSwiped(NonNull RecyclerView.ViewHolder viewHolder, int direction) { int position viewHolder.getBindingAdapterPosition(); adapter.onItemDismiss(position); } Override public void clearView(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); // 恢复默认背景色关闭抬升状态 viewHolder.itemView.setBackgroundColor(Color.WHITE); } Override public void onSelectedChanged(Nullable RecyclerView.ViewHolder viewHolder, int actionState) { super.onSelectedChanged(viewHolder, actionState); if (viewHolder null) { return; } if (actionState ItemTouchHelper.ACTION_STATE_DRAG) { // 进入拖拽时给 item 一个抬升阴影 viewHolder.itemView.setBackgroundColor(Color.LTGRAY); } } }这段代码有几个要点。第一个是 getMovementFlags 里 dragFlags 只配了 UP 和 DOWN因为 RecyclerView 是纵向列表横向拖拽在本场景没有意义swipeFlags 配 LEFT 表示只允许左滑删除。要做右滑删除就改成 ItemTouchHelper.RIGHT或者用 LEFT | RIGHT 同时放开两个方向。第二个是 onMove 和 onSwiped 里都用 getBindingAdapterPosition() 而不是 getAdapterPosition()。这两个方法在多层嵌套 adapter 的场景下行为有差异旧方法拿到的可能是包装后的 position而带 binding 前缀的方法拿的是当前 item 在真实数据源里的索引。实际项目里建议统一用这个新方法避免插入头和尾之后位置错乱。第三个是 clearView 和 onSelectedChanged 的配合。onSelectedChanged 里判断 actionState 是否为 ACTION_STATE_DRAG是则说明 item 被提起clearView 在手势结束后负责恢复。如果只在 onSelectedChanged 里改背景色但不在 clearView 里恢复item 会一直保持高亮状态看起来像玄学 bug实际上就是漏了收尾。3.2 适配器侧的数据同步逻辑Callback 里调用了 adapter 的两个方法onItemMove 和 onItemDismiss。这两个方法由 Adapter 实现核心原则是先改数据再通知刷新顺序不能反。public class ItemAdapter extends RecyclerView.AdapterItemAdapter.ItemHolder { private final ListString dataList; public ItemAdapter(ListString dataList) { this.dataList dataList; } public void onItemMove(int fromPosition, int toPosition) { // 先交换数据源中的元素 Collections.swap(dataList, fromPosition, toPosition); // 局部刷新避免整个列表重绘 notifyItemMoved(fromPosition, toPosition); } public void onItemDismiss(int position) { // 先移除数据源中的元素 dataList.remove(position); // 局部移除 notifyItemRemoved(position); } // 其他 Adapter 方法省略 }很多人拖拽后 item 弹回原位根本原因是只调用 notifyItemMoved 没有交换数据源反过来只交换数据源不调用 notifyItemMoved界面也不会有任何变化。移动和侧滑都是两件事数据源集合的变动加上对应的局部通知。onItemDismiss 里还有一个常见的坑。notifyItemRemoved 的 index 必须是手势结束时数据源里的真实索引如果 onSwiped 里只是异步请求服务端删除等服务端返回成功后再删本地数据这期间 item 和数据索引早就错位了。稳妥做法是在 onItemDismiss 里同步删除并立即通知服务端同步放到删除成功之后单独处理数据一致性才有保障。3.3 接入 RecyclerView 的完整链路Adapter 写完Callback 写完最后把它们串起来。在最常见的 Activity 场景里接入代码大概是这样的RecyclerView recyclerView findViewById(R.id.recycler_view); LinearLayoutManager layoutManager new LinearLayoutManager(this); recyclerView.setLayoutManager(layoutManager); ListString dataList new ArrayList(); // 初始化数据 for (int i 0; i 20; i) { dataList.add(item i); } ItemAdapter adapter new ItemAdapter(dataList); recyclerView.setAdapter(adapter); SimpleItemTouchCallback callback new SimpleItemTouchCallback(adapter); ItemTouchHelper itemTouchHelper new ItemTouchHelper(callback); itemTouchHelper.attachToRecyclerView(recyclerView);这里要注意 attachToRecyclerView 和 setAdapter 的顺序。推荐先 setAdapter再 attach 触摸控制器。如果反过来ItemTouchHelper 在 attach 时如果发现 RecyclerView 还没有 adapter部分兼容版本会出现 item 位置识别不到的情况。还有一个容易被忽略的点如果页面里横向滚动和纵向拖拽同时存在比如横向排列的卡片列表做上下拖拽排序LayoutManager 的 scrollDirection 配横向dragFlags 依然配 UP 和 DOWN。ItemTouchHelper 会根据 flags 和 LayoutManager 的组合判断哪些方向可以被接管配置不一致时会有一方逻辑失效表现就是拖拽没反应或者列表不滚动属于典型的配置冲突问题。4. 进阶定制网格布局、多类型 item 与拖拽视觉反馈4.1 LinearLayoutManager 和 GridLayoutManager 下的 flags 差异化线性列表的代码在网格布局下第一个要改的就是 getMovementFlags。GridLayoutManager 中 item 可以上下左右移动dragFlags 应该配满四个方向否则换位能力会受限Override public int getMovementFlags(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { if (recyclerView.getLayoutManager() instanceof GridLayoutManager) { int dragFlags ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; int swipeFlags 0; return makeMovementFlags(dragFlags, swipeFlags); } else { int dragFlags ItemTouchHelper.UP | ItemTouchHelper.DOWN; int swipeFlags ItemTouchHelper.LEFT; return makeMovementFlags(dragFlags, swipeFlags); } }网格模式为什么把 swipeFlags 设为 0因为网格布局中侧滑删除的视觉反馈和线性列表不同。item 消失后留下的空缺需要其他 item 平移补位屏幕上看会出现整体错位除非产品明确要求网格列表更常见的交互是长按拖拽换位不做侧滑删除。如果确实要在网格里做侧滑方向只能取 LEFT 或 RIGHT同时要考虑当前行的 item 数量。比如每行 4 个 item第一行左滑掉一个后第二行第一个 item 需要补位到第一行末尾这个补位动画框架会自动处理但数据源的交互相应也要跟上。适配器里的集合必须是可变 List如果用的是 Arrays.asList 包装的固定长度列表删除时直接抛 UnsupportedOperationException这种报错在网格布局里特别容易踩到。4.2 多类型 item 的拖拽限制与背景动画实际项目里列表很少是单一类型。比如消息列表有普通消息卡片也有底部 loading 占位。loading item 不该被拖拽也不该被侧滑。这种场景下 getMovementFlags 要根据 viewHolder.getItemViewType() 做分支Override public int getMovementFlags(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { int type viewHolder.getItemViewType(); if (type TYPE_LOADING) { return makeMovementFlags(0, 0); } int dragFlags ItemTouchHelper.UP | ItemTouchHelper.DOWN; int swipeFlags ItemTouchHelper.LEFT; return makeMovementFlags(dragFlags, swipeFlags); }把 flags 都设为 0框架就不会对这类 item 做任何手势识别。这是一个容易被忽略的细节——只改 isLongPressDragEnabled 并不能阻止特定 item 被拖拽因为这个方法控制的是“是否允许长按进入拖拽”而 flags 才是真正决定方向是否生效的开关。两者叠加使用才能精细控制。拖拽过程中的视觉反馈也有讲究。很多应用在拖拽时会提升 item 的阴影同时把目标位置让开。阴影是通过 elevation 实现的可以在 onSelectedChanged 里设置Override public void onSelectedChanged(Nullable RecyclerView.ViewHolder viewHolder, int actionState) { super.onSelectedChanged(viewHolder, actionState); if (viewHolder null) { return; } View itemView viewHolder.itemView; if (actionState ItemTouchHelper.ACTION_STATE_DRAG) { itemView.setElevation(12f); } else if (actionState ItemTouchHelper.ACTION_STATE_SWIPE) { itemView.setAlpha(0.5f); } else if (actionState ItemTouchHelper.ACTION_STATE_IDLE) { itemView.setElevation(0f); itemView.setAlpha(1f); } }ACTION_STATE_IDLE 会在手势结束后触发和 clearView 的执行时机接近。两者都可以做收尾恢复但 clearView 里适合恢复布局相关参数ACTION_STATE_IDLE 适合做视觉属性复位。分开放的好处是后期调整视觉方案时不需要动拖拽逻辑这个习惯能省不少麻烦。多类型 item 的另一个问题是拖拽越界。比如一个 section header 被拖到了普通 item 中间这种场景主要靠业务规则限制。常见做法是在 onMove 里对 target 的 itemViewType 做校验不合法的 move 直接返回 falseOverride public boolean onMove(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder, NonNull RecyclerView.ViewHolder target) { if (target.getItemViewType() TYPE_HEADER) { return false; } int fromPosition viewHolder.getBindingAdapterPosition(); int toPosition target.getBindingAdapterPosition(); adapter.onItemMove(fromPosition, toPosition); return true; }返回 false 后框架会回弹到当前位置配合系统默认动画看起来还算自然。校验逻辑放 UI 层做一层就够了数据源层的排序规则建议在业务层单独维护不要把业务判断全堆在 Callback 里否则回调类会越来越臃肿。4.3 拖拽手柄与长按事件的冲突处理有些产品的拖拽只能通过特定手柄触发比如列表行尾部的“三横线”图标。此时 isLongPressDragEnabled() 要返回 false并在手柄 View 上单独处理触摸事件public class ItemAdapter extends RecyclerView.AdapterItemAdapter.ItemHolder { private ItemTouchHelper itemTouchHelper; public void setItemTouchHelper(ItemTouchHelper itemTouchHelper) { this.itemTouchHelper itemTouchHelper; } Override public void onBindViewHolder(NonNull ItemHolder holder, int position) { holder.dragHandle.setOnTouchListener((view, event) - { if (event.getAction() MotionEvent.ACTION_DOWN) { // 长按流程交给 ItemTouchHelper 接管 if (itemTouchHelper ! null) { itemTouchHelper.startDrag(holder); } } return false; }); } }注意 startDrag(holder) 里的 holder 必须是当前绑定的 ViewHolder不能在回调里用临时创建的对象。setOnTouchListener 的返回值要注意ACTION_DOWN 时返回 false让事件继续往下传手柄的点击动画才能生效如果返回 true 消费掉事件后续的 ACTION_UP 不会触发手柄点击反馈会丢失。这里的交互怪圈在于startDrag 本身也需要一次触摸事件驱动而手柄的 OnTouchListener 又接收到了这此触摸。官方推荐的姿势就是在 ACTION_DOWN 里调用 startDrag然后把返回值交给 child 继续处理。Android 官方文档中的拖拽排序示例用的就是这个模式实测在手柄上操作非常流畅。5. 避坑排查五个拖拽场景的典型事故与修复5.1 item 闪回原位数据错位是同一个根因现象拖拽到新位置松手后item 弹回原来的位置。有时列表顺序看起来变了但过一秒又恢复成原样。原因onMove 里只调用了 notifyItemMoved没有操作数据源集合或者数据源用了不可变 List交换操作被静默忽略。解决回到适配器里的 onItemMove 方法确保先执行 Collections.swap 再调用 notifyItemMoved。如果列表用 Arrays.asList 初始化需要改成 new ArrayList(Arrays.asList(...))否则交换时抛异常但上层捕获后无提示表现就是闪回原位。排查时可以断点在 swap 那一行观察两个索引的数据是否真的换了位置。5.2 删除位置越界notifyItemRemoved 删错了行现象左滑删除某个 item结果另一行消失了。原因onSwiped 里拿到的 position 是手势结束时的 adapter 位置但如果删除前列表有数据更新这个位置已经过期。有些场景在 onSwiped 里先调了 notifyItemChanged导致 position 重新映射后整体错位。解决onSwiped 里不要做多余操作直接拿 getBindingAdapterPosition() 传给 adapter 的 onItemDismiss在 onItemDismiss 里同步删除数据源并立即 notifyItemRemoved。如果必须做异步删除则用 ListAdapter 配合 DiffUtil或者用 AdapterListUpdateCallback 做增量更新避免手动 position 计算。5.3 网格布局左滑不响应方向被点击消费现象GridLayoutManager 下按住某个 item 左滑屏幕上有轻微位移但松手后没有删除反而触发了 item 点击。原因swipeFlags 没有被正确传递。默认的 makeMovementFlags 在 dragFlags 为 0、swipeFlags 为 LEFT 时如果框架没有识别出水平方向的滑动意图事件会被当作普通点击处理。解决确认 getMovementFlags 里的分支覆盖了 GridLayoutManager并显式设置 swipeFlags。如果业务上不需要网格侧滑删除直接设成 0如果需要建议在侧滑同时校验 itemViewType比如 footer 不允许删除。这类问题在 LinearLayoutManager 里极难复现一旦换成网格布局就必现属于布局类型相关的边界条件。5.4 头部 item 被误拖拽position 0 也要限制现象列表第一项是固定的搜索栏或轮播图长按后也被提起来拖走了。原因getMovementFlags 没有根据 position 或 viewType 做排除头部 item 被当成普通 item 处理。解决在 getMovementFlags 开头判断位置和类型int position viewHolder.getBindingAdapterPosition(); if (position 0 || viewHolder.getItemViewType() TYPE_HEADER) { return makeMovementFlags(0, 0); }注意 position 判断要在 getBindingAdapterPosition 不为 NO_POSITION 的前提下做。如果 viewHolder 刚绑定完成还没有布局position 可能返回 -1此时直接返回 0 会让新绑定的 item 在一瞬间无法拖拽。稳妥做法是先用条件过滤掉头部类型再用 position 0 做兜底。5.5 嵌套滚动页面拖拽无响应事件被父容器拦截现象RecyclerView 外层套了 NestedScrollView 或垂直 ScrollView长按 item 后无法进入拖拽状态列表只能滚动。原因父容器消费了纵向触摸事件RecyclerView 的子 item 拿不到足够长的事件流来触发长按判定。解决优先确认布局结构不要用 ScrollView 包裹 RecyclerView改用 NestedScrollView 并关闭嵌套滚动NestedScrollView nestedScrollView findViewById(R.id.nested_scroll_view); nestedScrollView.setNestedScrollingEnabled(false); recyclerView.setNestedScrollingEnabled(false);如果必须保留父容器滚动可以在 item 的 root view 上调用 getParent().requestDisallowInterceptTouchEvent(true) 阻止父容器拦截但这个方法要配合 ACTION_UP 时恢复 false否则父容器后续永远无法滚动。实际项目里更推荐用 ConstraintLayout 加固定高度或者让 RecyclerView 自身处理滚动避免双重滚动冲突。6. 验证与手感调优从能用到好用的一次实践6.1 拖拽动画时长与贝塞尔插值的调整ItemTouchHelper 默认动画时长在 200 毫秒左右对列表操作来说偏快拖拽时的跟随感不够明显。我一般会把动画拆成两部分拖拽过程中的 item 位移交给框架手指松开后的回弹动画用自定义插值器控制。框架提供的 setAnimationDuration 影响的是归位动画单位是毫秒建议设置为 250 到 300 之间低于 150 会显得生硬高于 400 则会让连续拖拽显得拖沓。6.2 自动化验证的最小测试用例拖拽功能不像普通点击那么好做单元测试我习惯把操作流程拆成三个可验证的动作。第一步是验证长按后 item 是否被提起可以在 onSelectedChanged 里打日志确认 actionState 切换第二步是验证拖拽到非法位置时 onMove 是否返回 false这需要在多类型 item 的列表中手动拖一个 header 试试第三步是验证数据源和 UI 的同步拖拽完成后比较 dataList 的排列顺序和界面显示是否一致。这三步跑通核心链路基本没有大问题。插值器方面可以直接调 itemView 的 translationX 来实现侧滑阻尼。比如左侧滑时让 item 的位移量乘以 0.9 的比例系数松手时用 ValueAnimator 恢复 translationX 到 0配合 DecelerateInterpolator 会有明显的物理回弹感。我一般是把比例系数抽成常量方便产品在真机上一版一版试手感。从那以后我每次接入 ItemTouchHelper 都会做一个固定检查flags 配置、数据同步、视觉恢复、嵌套滚动四个点各看一遍。这套动作基本成了习惯踩坑也少了大半。希望这份拆解也能帮到你。本文还有配套的精品资源点击获取