RecyclerView侧滑删除与拖拽排序:基于ItemTouchHelper的完整实现
发布时间:2026/10/21 3:58:15 锦皓数字建站

简介一份面向Android开发者的RecyclerView侧滑拖拽实现资料聚焦如何利用ItemTouchHelper为列表项添加滑动删除、拖拽排序等交互操作适合需要完善列表交互体验或理解RecyclerView事件机制的开发者。压缩包共44个文件包含11个java源码文件、13个xml布局与配置文件、10张png效果图示以及gradle构建脚本、属性配置等整体大小仅763KB目录紧凑便于按需查阅。内容从RecyclerView的Adapter、LayoutManager、ViewHolder三部分基础讲起逐步深入ItemTouchHelper.Callback中onMove与onSwiped的处理逻辑覆盖自定义Adapter绑定、开关拖拽方向标志、在Activity中关联ItemTouchHelper与RecyclerView以及通过notifyDataSetChanged、notifyItemMoved刷新UI和自定义onChildDraw动画等完整实现步骤。对于希望在项目中快速集成侧滑菜单或拖拽排序的开发者这份资源提供了可运行的示例工程与源码注释能直接对照学习并迁移到实际业务场景。目前已有371人学习下载适合Android中高级开发者作为参考。1. 这个Demo解决什么问题三行代码搞不定的侧滑拖拽接手第一个列表交互需求的时候我差点翻车。需求看起来很简单RecyclerView 列表左右滑删除长按拖动排序。本想自己写个触摸监听结果发现事件跟 RecyclerView 的滚动永远在打架滑快了直接触发点击拖拽时列表跟着乱抖。翻源码项目资料时看到这套通过 ItemTouchHelper 实现侧滑拖拽的 Android Demo才明白官方早就封装好了拖拽方案只是一直没人按这个路径讲透。这套工程把滑动删除、拖拽排序集中在一个 Callback 里适合想快速跑通、再在这个骨架上改业务的新手也适合想抄一套稳定交互回血的老手。2. 工程结构与核心链路触摸事件怎么变成删除和移动2.1 目录结构与各文件的职责这套 Demo 不是那种几百个文件的玩具它小而完整几乎把“列表交互”这件事压缩成了一个最小可运行闭环。打开工程后真正需要关心的只有三层负责装配的 Activity、负责展示数据的 Adapter、负责手势处理的 Callback。app/src/main/java/ com/demo/swipeablelist/ MainActivity.java data/ItemBean.java adapter/DemoAdapter.java viewholder/DemoViewHolder.java callback/DemoItemTouchHelperCallback.java app/src/main/res/ layout/activity_main.xml layout/item_main.xml drawable/MainActivity 只做三件事准备假数据、创建 RecyclerView 和 Adapter、把 ItemTouchHelper 挂到 RecyclerView 上。你可以把它理解成装配车间所有手势相关的逻辑都塞在 ItemTouchHelper 里所以 Activity 看起来清爽得不像写过列表交互的样子。ItemBean 就是一个普通的数据模型Demo 里用 id 和 title 两个字段就够了二次开发时按你的业务字段往里加。DemoAdapter 负责把 ItemBean 渲染成 item_main.xml 对应的 ViewHolder。大多数 RecyclerView 项目都会踩一个坑Activity 里直接操作数据源Adapter 只是被动的显示器。这套 Demo 把数据操作方法放在 Adapter 内部比如 remove 和 move让数据的增删排序跟 notify 刷新绑定在一起外部只用调用一个方法思路很干净值得直接搬走。DemoItemTouchHelperCallback 是整个项目的主角它继承了 ItemTouchHelper.Callback。这个抽象类要求你实现三个方法getMovementFlags 返回允许的手势方向onMove 处理拖拽到底之后的位置交换onSwiped 处理滑出之后的删除。后面第 3 章我会把这三个方法逐段拆开讲。另外还要注意构建脚本里的依赖工程默认只在 AndroidX 环境下引用了 recyclerview并没有额外引入第三方手势库这也是它好上手的原因。2.2 触摸事件如何被识别成滑动或拖拽很多人在网上搜“RecyclerView 侧滑删除”搜出来的回答十有八九是重写 onTouchEvent 或者给 RecyclerView 加 OnItemTouchListener。这两种方案不是不能用而是事件冲突全得自己兜着手指竖向滑动时列表要滚动横向滑动时 item 要平移两种手势同时出现时系统根本不知道你到底是想要滚还是想要滑。这套 Demo 选择 ItemTouchHelper 去解决冲突是因为它本质上是一个 RecyclerView.OnItemTouchListener 加 ItemDecoration 的组合体。注意看项目里 ItemTouchHelper 的用法构造的时候传入一个 Callback然后调用 attachToRecyclerView 绑定。绑定之后RecyclerView 把收到的触摸事件先交给 ItemTouchHelper 过一遍ItemTouchHelper 根据 Callback 里返回的 flag 判断要不要接管。判定逻辑的核心是 getMovementFlags 返回的整数。Android 里每个方向都是一个二进制位ItemTouchHelper.UP、DOWN、LEFT、RIGHT 各自占一位你用按位或把它们组合起来再通过 makeMovementFlags 合并成两组 flagdragFlags 决定长按托起后能往哪个方向拖swipeFlags 决定手指横向滑动时能往哪个方向滑。如果 dragFlags 里只有 UP 和 DOWN那么竖滑手势永远优先给拖拽如果 swipeFlags 里给了 LEFT 和 RIGHT横向滑动就会被 ItemTouchHelper 截获完全不会干扰列表滚动。一旦 ItemTouchHelper 识别出手势成立它就不再只是传声筒了而是接管了后续所有触摸事件。滑动删除时它会把 itemView 的 translationX 和透明度一点点改掉滑过阈值再触发 onSwiped长按拖拽时它会改变被拖 item 的 elevation绘制一个浮起来的效果并且在拖到边缘时自动让 RecyclerView 滚动。这些动画全是内置的你不用自己写加速度公式也不用管事件分发顺序。之所以强调这一点是因为不少半路写这个功能的人会自己把触摸事件消费掉然后在 move 事件里手动改 translationX最后发现 item 点击失灵、RecyclerView 滚动卡顿、状态栏被下拉唤出各种奇怪症状一起冒出来。用 ItemTouchHelper 的最大收益不是少写代码而是让手势判定有一张明确的状态机待命、识破、滑动中、动画结算每一步都可预期出了问题也只需要查一个 Callback 里的配置。这套 Demo 的价值就是把这张状态机用 80 行代码给你跑通了一遍。2.3 交互参数和三个可调的手感开关看源码时你会发现 Callback 里还有一些可以被重写的手感参数这些参数才是让 Demo 变成产品级交互的关键。默认情况下ItemTouchHelper 会在 item 滑动到自身宽度的一半时判定可以松手一旦松手就播放滑出动画如果快速 fling 的速率超过逃逸速度也会提前触发滑出。这些默认行为对大多数场景都够用但如果你想调出更跟手的感觉需要重写两个方法。getSwipeEscapeVelocity 控制逃逸速度单位是 dp/s默认约为 800。手指快速扫过时哪怕位移不足一半只要速度达标item 也会被判定为“要滑走”。getSwipeVelocityThreshold 控制速度权重在判定里的占比默认是 0.5f值越大越不容易滑出适合需要“精准删除”而不是“快速删除”的场景。项目里你会在 DemoItemTouchHelperCallback 下方看到这些方法的注释想调手感就先动这两个返回值。拖拽侧也有一个实用的开关isLongPressDragEnabled。默认返回 true意味着长按才能拖如果你希望进入某个编辑模式后再开启拖拽就返回 false然后通过外部按钮调用 ItemTouchHelper 的 startDrag(viewHolder) 主动发起拖拽。这边 Demo 在 MainActivity 里留了注释但实际业务里我一般会把它做成一个布尔开关字段让长按拖拽和按钮拖拽可以动态切换改动成本只有几行。提示拖动方向的 flag 和滑动方向的 flag 是可以共存的同一个 item 既能左右滑删除也能上下拖排序。只要两个方向的 flag 都放进 makeMovementFlags 里ItemTouchHelper 会自动处理手势区分这也是这套代码这么做的主要原因。3. 核心代码拆解三件套方法的参数与装配顺序3.1 数据层先行让 Adapter 自己管理增删拖拽排序和滑动删除有一个共同前提列表数据必须是可变集合并且每次变化都要同步通知 Adapter。很多新手直接在 MainActivity 里拿到一个 ArrayList 就开干在 onSwiped 里删除完忘了调 notifyItemRemoved或者在 onMove 里交换完数据忘了调 notifyItemMoved结果列表画面和数据源越走越偏。这个 Demo 把这两类操作收敛到了 Adapter 里面代码长这样public class DemoAdapter extends RecyclerView.AdapterDemoAdapter.VH { private final ListItemBean data new ArrayList(); Override public DemoAdapter.VH onCreateViewHolder(ViewGroup parent, int viewType) { View view LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_main, parent, false); return new VH(view); } Override public void onBindViewHolder(DemoAdapter.VH holder, int position) { ItemBean bean data.get(position); holder.title.setText(bean.getTitle()); holder.itemView.setTag(bean); } Override public int getItemCount() { return data.size(); } public void removeAt(int position) { if (position 0 || position data.size()) { return; } data.remove(position); notifyItemRemoved(position); } public void moveItem(int fromPosition, int toPosition) { if (fromPosition toPosition) { return; } Collections.swap(data, fromPosition, toPosition); notifyItemMoved(fromPosition, toPosition); } }removeAt 里我先做了一次越界检查原因是 ItemTouchHelper 的 onSwiped 拿到的位置在某些异常情况下会变成 -1不拦截的话 remove(-1) 不会有编译错误但会直接导致运行时崩溃这是真真实实踩过的坑。moveItem 里用了 Collections.swap 而不是 removeadd因为拖拽排序只是交换两个位置没必要让中间所有 item 都重建一遍。这里有个细节要说明notifyItemMoved 只负责告诉 RecyclerView 某个位置的 item 挪到了另一个位置如果你在数据源里没有同步做交换列表就会出现在动画上挪了、但刷新后弹回原位的现象。反过来如果你只改了数据源、没有调用 notifyItemMoved列表刷新时就会闪一下。这个 Demo 把“数据改动”和“通知刷新”绑在同一个方法里就是为了避免这种错位。ViewHolder 部分没有再额外贴代码它只是持有 item_main.xml 里的控件引用。要注意的是我在 onBindViewHolder 里给 holder.itemView 设了一个 tag这个 tag 在某些回调场景里可以用来定位 ViewHolder 对应的数据虽然 ItemTouchHelper 的回调本身会传 ViewHolder但 Adapter 内的点击事件处理经常会用到它。如果你要在这个 Demo 上扩展 item 点击和长按点击从 holder.itemView.setOnClickListener 入手即可。3.2 Callback 三件套flags、onMove、onSwiped 的分工真正驱动整个交互的核心类 DemoItemTouchHelperCallback是这段代码的灵魂所在。它把 ItemTouchHelper 需要的三个关键方法全部实现了public class DemoItemTouchHelperCallback extends ItemTouchHelper.Callback { private final DemoAdapter adapter; public DemoItemTouchHelperCallback(DemoAdapter adapter) { this.adapter adapter; } 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); } Override public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { int from viewHolder.getAbsoluteAdapterPosition(); int to target.getAbsoluteAdapterPosition(); adapter.moveItem(from, to); return true; } Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { int position viewHolder.getAbsoluteAdapterPosition(); adapter.removeAt(position); } }getMovementFlags 里的方法名容易误导人它其实不是“获取当前手势的方向”而是“声明你这个列表支持哪些手势方向”。dragFlags 我设成 UP 和 DOWN表示长按后只允许上下拖动swipeFlags 设成 LEFT 和 RIGHT表示左右都能滑。makeMovementFlags 是一个位运算合并工具把两组 flag 合成一个 int 返回给 ItemTouchHelper后续的事件判定全靠这个返回值。onMove 在拖拽到另一个位置时被反复调用。每次手指拖过一个 item系统都会拿着当前的 ViewHolder 和目标位置的 target ViewHolder 调一次 onMove。我在方法里第一行取 fromPosition用的是现在推荐的 getAbsoluteAdapterPosition而不是已经废弃的 getAdapterPosition。两者区别在于notifyItemMoved 之后position 不一定立刻跟数据源同步绝对位置更贴近数据源的真实下标尤其在做批删批改时更安全。不止一个方案会用 Collections.swap 而不是把整个列表重新排序配合 Adapter 里的 moveItem 方法数据源交换和 notifyItemMoved 被保证在同一个调用栈里完成动画和最终结果完全一致。onMove 返回 true 表示这次拖拽有效ItemTouchHelper 才会继续绘制后续动画如果返回 false拖拽会立刻中断这也是“拖不动”的常见原因之一。onSwiped 是滑动动画结算后的回调。direction 参数传进来的是 LEFT 或 RIGHT可以用来区分“左滑删除”和“右滑收藏”这种业务差异。Demo 里没区分方向直接把对应位置的 item 删掉。要注意的是 onSwiped 被调用时ItemTouchHelper 已经播放完滑出动画但它不会替你做任何数据操作删数据、调 notify 全得自己来。如果这里忘了调 adapter.removeAt就会出现 item 滑出屏幕后又被拉回来的灵异动画。3.3 装配顺序Helper 挂载和开关控制核心代码都齐了最后一步是把它们接起来。MainActivity 里的装配顺序其实很有讲究先把 Adapter 给 RecyclerView再创建 Callback最后才 new ItemTouchHelper 并 attach。顺序反了也不会崩溃但你把 helper 挂到一个还没有 Adapter 的 RecyclerView 上某些回调会拿到空数据表现出来就是拖拽时 item 突然消失。public class MainActivity extends AppCompatActivity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); RecyclerView recyclerView findViewById(R.id.recyclerView); recyclerView.setLayoutManager(new LinearLayoutManager(this)); DemoAdapter adapter new DemoAdapter(); recyclerView.setAdapter(adapter); adapter.setData(createMockData()); DemoItemTouchHelperCallback callback new DemoItemTouchHelperCallback(adapter); ItemTouchHelper helper new ItemTouchHelper(callback); helper.attachToRecyclerView(recyclerView); } }setData 传入的假数据是从 createMockData 生成的里面放了 20 条左右带标题的 ItemBean足够你测试滚动、拖拽、滑动删除时的手感。真正容易被忽略的是最后一行 attachToRecyclerView。有人说这个方法的命名像“把 helper 放进去”其实它的内部做了大量初始化注册 ItemDecoration、添加 OnItemTouchListener、监听 ViewHolder 的 attach/detach 状态这些前置条件缺一不可。你不需要知道每个内部细节但要明白 attach 之后不要再手动把 recyclerView 的 OnItemTouchListener 清掉否则 ItemTouchHelper 会瞬间失灵。我在二次开发时习惯在这个类里加一个 boolean 字段来控制 enableSwipe 和 enableDrag然后通过外部方法动态切换。比如列表在普通展示模式下只允许滑动删除进入编辑模式才允许长按拖拽这比写两套 Callback 省事得多。public class DemoItemTouchHelperCallback extends ItemTouchHelper.Callback { private boolean swipeEnabled true; private boolean dragEnabled true; public void setSwipeEnabled(boolean enabled) { this.swipeEnabled enabled; } Override public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { int dragFlags dragEnabled ? (ItemTouchHelper.UP | ItemTouchHelper.DOWN) : 0; int swipeFlags swipeEnabled ? (ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) : 0; return makeMovementFlags(dragFlags, swipeFlags); } }这样改的好处是getMovementFlags 每次在手势判定前都会调用返回 0 的方向会被完全禁用。你在 Activity 里可以通过 callback.setDragEnabled(false) 一键关掉拖拽而 ItemTouchHelper 本身的动画和事件管线完全不动这是最保守、最低成本的扩展方式。要注意的是关掉拖拽不会立刻取消正在拖拽的 item所以如果有需求要在拖拽中途切成禁用需要先调用 helper.getParent().stopScroll() 之类的兜底操作。4. 避坑与排查拖拽动效失灵和数据错位的 5 个现场4.1 长按拖拽没反应手指一动触发了点击事件现象左右滑动删除完全正常但长按 item 就是拖不起来松手后反而把 item 的点击事件触发了页面跳转或弹出按钮让人很烦。原因这类问题八成出在 getMovementFlags 里。dragFlags 如果只给了 LEFT 和 RIGHT没有 UP 和 DOWN长按后 ItemTouchHelper 根本不知道你要往哪里拖自然就不会发起拖拽。另一个常见原因是 RecyclerView 外层套了 ScrollView 或嵌套了其他可滚动容器长按手势被父容器抢走或者 isLongPressDragEnabled 被某段代码误设成了 false。解决先在 getMovementFlags 里确认 dragFlags 包含 UP 和 DOWN。注意是同时包含两个方向不是只声明一个纵向方向。接着检查 DemoItemTouchHelperCallback 是否重写了 isLongPressDragEnabled如果重写了就必须返回 true 或者改成可配置的开关。最隐蔽的情况是 item 根布局里 id 设置某些自定义 View 在拦截事件时把长按消息干掉了遇到就先把 item 根布局换成普通 FrameLayout 试一次基本能定位是谁在作怪。4.2 删除之后列表位置错乱下一个 item 顶上来又闪回去现象左滑删除一个 item动画正常但列表刷新后会出现两个 item 内容相同或者删除后 ItemTouchHelper 又自动把位置补了回来数据源里明明已经没了。原因这是 onSwiped 里没同步data和notify导致的典型病。常见写法是直接在 Activity 里data.remove(position)然后调notifyDataSetChanged看起来数据没了但notifyDataSetChanged会让 RecyclerView 整个重绘而 ItemTouchHelper 这个时候正在执行滑动删除的收尾动画两个刷新机制撞在一起列表就错乱了。更隐蔽的写法是 onSwiped 里调了两次 remove第一次把数据删了第二次又用同一个 position 去删新数据等于误删了后面一个 item。解决统一走项目里 Adapter 的 removeAt 方法它内部先做越界检查再 remove 数据最后只调一次 notifyItemRemoved。这个方法配合position拿到的值必须用getAbsoluteAdapterPosition()不要用getLayoutPosition()动画期间后者的值不一定对得上。短信服务 app 里批量删除时尤其容易踩这个坑每条消息的删除动画还没结束就删下一条position 已经全乱了。4.3 拖拽过程中 item 闪白、阴影乱飘、动画总差一口气现象拖拽功能本身能用但 item 被拖起来的时候背景一闪一闪松手落下的一瞬间整个 item 会跳一下甚至拖拽过程中列表背景出现不规则的灰白残影。看起来像做动画没有做干净。原因这是把 ItemTouchHelper 默认动画和自定义 translationX 混在一起使用造成的。有些人觉得 ItemTouchHelper 拖拽的阴影不好看就在 onSelectedChanged 里手动改了 view 的 translationZ然后 clearView 时又没重置回来。还有些人在 item 里放了太多层有 elevation 的 View拖拽时 Android 会重新走一遍绘制流程这些 View 的阴影叠加起来把 RecyclerView 的图层弄脏了就会出现残影。解决先保证 item 根布局的 background 是纯色或者 drawable不要用透明背景加多层阴影叠加。如果你自己重写了 clearView 或者 onSelectedChanged记得在 clearView 里把 ViewHolder 的 itemView 的 translationX、translationY、translationZ 全部重置为 0。项目里如果没有动这些方法就不需要理会。最省事的排查法是在 Manifest 里给 Activity 关掉硬件加速逐个对比如果关掉后残影消失说明是绘制加速和 ItemTouchHelper 的离屏缓冲冲突再想办法把 item 的 elevation 收敛到一个值即可。4.4 拖到列表边缘不自动滚动列表像卡在大海里现象长按拖拽 item 拖到屏幕顶部或底部边缘时列表不会像微信那样自动滚动要么手指超出屏幕后 item 悬空空转要么滚动速度极其慢放到老机型上基本拖不过 100 个 item。原因千万不要说漏ItemTouchHelper其实自带边缘自动滚动只是触发条件比较苛刻。默认情况下当被拖拽的 item 距离屏幕边缘很近时它每秒会回调scrollAmount这个逻辑在源码里叫scrollIfNeeded。如果你的 Callback 重写了getBoundingBoxMargin()且返回 0或者 RecyclerView 的 LayoutManager 不是 LinearLayoutManager 而是 GridLayoutManager触发边缘滚动的判定会有差异。还有一种情况是外层 ScrollView 把内层 RecyclerView 的 scroll 请求吃掉了。解决确认 RecyclerView 用的是 LinearLayoutManager并且外层没有套 ScrollView。需要滚动速度更灵敏时在 Callback 里重写getBoundingBoxMargin()返回一个大于 0 的值比如 40dp这样 item 离屏幕边缘还有 40dp 时就会开始滚动而不是等它完全贴边才动。拖拽流畅度上还可以让 Adapter 实现 StableId 或者开启 setHasStableIds(true)避免拖拽间隔大时 item 的动画状态被重建。那一刻你就知道让系统自动滚永远比自己写 scrollToPosition 稳。4.5 滑动删除触发太灵敏手指刚碰一下就滑没了现象同一个列表在平板上玩得好好换到小屏手机手指轻轻一碰还没怎么滑item 就飞出去删掉了用户体感非常差。原因onSwiped的触发条件包含了位移和速度两项默认位移阈值是 item 宽度的一半速度阈值由getSwipeEscapeVelocity控制。小屏手机 item 宽度比较窄同样滑动 40dp在大屏上只是轻轻一蹭在小屏上却可能占了一半以上。所以不是代码写错了而是默认阈值尺度不适合小屏。解决在 DemoItemTouchHelperCallback 里重写getSwipeEscapeVelocity()把默认的 800dp/s 调成 1200 或 1400让快速扫过也不至于立刻删除重写getSwipeVelocityThreshold()把默认 0.5f 调成 0.7f让 ItemTouchHelper 更看重长距离滑动而不是速度。这样改了之后真正想删除时需要滑过半屏或明显 fling 才会触发误触率明显下降。我给这种参数起个外号叫“防手滑三连”上线前一定要拿小屏真机试一下。5. 二次开发把固定 Demo 改成业务通用的拖拽组件5.1 从固定 Demo 到接口化跑通这套 Demo 之后如果只是改改 onSwiped 里的逻辑就交差下次再遇到一个需要拖拽的列表还得重新写一遍 Callback。我一般会把 Callback 改成面向接口的写法定义一个 SwipeDragListener把 onMove 和 onSwiped 的决策权交给外部保证 Adapter 和 Callback 都不需要因为业务变更而频繁改。public interface SwipeDragListener { void onItemSwiped(int position, int direction); void onItemMoved(int fromPosition, int toPosition); }Callback 持有这个接口onSwiped 里直接调用 listener.onItemSwiped(position, direction)onMove 里先执行数据源交换再把位置回调给业务层。这样 Activity 只需要实现 SwipeDragListener就能在不改 Callback 的前提下决定“左滑收藏、右滑删除”或“拖拽后持久化顺序”。5.2 验证方法和持久化技巧改造完后最容易被忽视的一步是横竖屏切换验证。RecyclerView 在旋转后会重建如果你的拖拽结果只存在内存 List 里屏幕一转就全没了。所以做完整套拖拽之后把排序结果写进 SharedPreferences 或者数据库里启动时读回来再 setData才是一个真正能上线的列表交互。我从那套 Demo 里得到最值钱的教训是手势动画是表象数据同步才是内核。别让手指滑得爽一刷新就露馅。从那以后我每次做列表拖拽都会强制走一遍“手势动画 → 数据源更新 → 持久化存储 → 重建恢复”的闭环宁可动画朴素一点也要保证位置和数据永远对得上。这套 Demo 已经帮你把最难的事件冲突和动画骨架搭好了剩下的业务细节其实都是体力活希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。