拓冰建站拓冰建站
首页 / 资讯中心 / 正文

RecyclerView侧滑删除与拖拽排序实战:ItemTouchHelper完全指南

简介面向Android开发者的RecyclerView侧滑拖拽源码包是一套基于ItemTouchHelper实现列表滑动删除、拖拽排序的完整Demo工程适合需要快速掌握RecyclerView交互原理以及准备在聊天、邮件、待办等列表中加入侧滑手势的初中级开发者。压缩包约763KB共44个文件主要包含Java源码、XML布局、PNG图片素材、Gradle构建配置以及两个压缩文档demo-ui.zip与RecyclerView侧滑源码解析.zip导入Android Studio后即可运行查看效果工程内也保留了gradlew脚本、properties配置等便于本地构建。内容上覆盖Adapter、LayoutManager、ViewHolder的协作方式以及ItemTouchHelper.Callback中onMove、onSwiped、dragFlags/swipeFlags设置、onChildDraw绘制等关键代码并配有逐段解析可对照Demo理解手势触发、数据集更新、notifyItemMoved触发与视图重绘流程从而将侧滑删除、长按拖拽等交互能力迁移到真实业务场景中。目前已有369人学习下载适合希望以较短时间吃透RecyclerView ItemTouchHelper源码与自定义交互逻辑的广大开发者可帮助快速上手项目开发。1. 项目需求与技术落点1.1 为什么偏偏绕不开RecyclerView的侧滑拖拽做安卓开发的早晚会碰到这类需求消息列表左滑删除、待办事项长按调整顺序、首页宫格拖拽换位、购物车滑动移除商品。这些交互现在几乎成了App的标配而绝大多数情况下我们都是在RecyclerView里实现它们。先说结论RecyclerView本身并没有提供任何侧滑或拖拽的能力它只负责高效展示列表这一件事。真正让列表动起来的是官方附带的ItemTouchHelper。这个东西属于androidx.recyclerview包下的工具类专门用来处理列表项的滑动swipe和拖拽drag手势。它不依赖第三方库也不需要在布局里嵌套各种复杂的容器实现成本低、可定制性高是首选的方案。这篇文章就直接带你把ItemTouchHelper从入门到进阶捋一遍重点放在侧滑菜单、滑动删除、拖拽排序这三个最常见的场景上同时把我在实际开发里踩过的坑和绕过的弯路一并写出来。适合对RecyclerView有一定基础、但想快速搞定交互的开发者参考。1.2 选型对比自己写手势监听有多痛有些人可能会想侧滑删除不就是监听TouchEvent然后自己处理坐标和动画吗理论上确实能实现但实际操作起来很麻烦你要自己判断滑动方向、处理ViewHolder的坐标平移、处理动画回弹、处理多个手指同时触摸的情况、处理滑动冲突……而且一旦列表项高度不一、或者需要同时支持拖拽排序逻辑复杂度会翻好几倍。ItemTouchHelper帮我们把这些脏活累活全做了。它内部实现了完整的触摸事件分发和动画机制我们只需要通过ItemTouchHelper.Callback告诉它哪些方向允许滑动哪些方向允许拖拽然后在对应的回调方法里处理数据和刷新即可。这是一套标准的命令模式加回调机制既灵活又省心。2. 核心原理与准备工作2.1 ItemTouchHelper的运作机制在使用之前有必要搞清楚它内部是怎么工作的。ItemTouchHelper默认拦截了RecyclerView的触摸事件当你的手指在列表项上按下并移动时它会通过Callback里的几个核心方法来判断应该如何响应getMovementFlags决定当前列表项支持哪些手势。返回值由makeMovementFlags(dragFlags, swipeFlags)构成分别表示拖拽方向和滑动方向。onMove拖拽到新位置时回调需要在这里交换数据并刷新列表。onSwiped滑动到指定距离松手后回调需要在这里删除或操作数据。onSelectedChangedViewHolder被选中时触发常用于抬升层级或修改透明度。clearView手势结束时触发用于恢复View的状态。这几个方法基本就是整个组件的全部入口。其中getMovementFlags是最关键的一个方法因为它决定了整个交互的能力边界。比如只想支持侧滑删除就只给swipeFlags赋值dragFlags传0只想支持拖拽排序就反过来。2.2 数据层和布局环境的搭建我假设你已经建好了一个常规的RecyclerView列表包含Adapter、ViewHolder和item布局。下面是参与实现的核心对象public class ItemBean { public String title; public String desc; public boolean isPinned; // 是否置顶用于拖拽排序时跳过 public ItemBean(String title, String desc) { this.title title; this.desc desc; this.isPinned false; } }数据的变更统一用ArrayListItemBean管理在拖拽或滑动结束后通过Adapter的notifyItemMoved()或notifyItemRemoved()局部刷新避免全量刷新带来的闪烁和性能损耗。!-- item_layout.xml 主要结构 -- FrameLayout android:layout_widthmatch_parent android:layout_heightwrap_content !-- 底层侧滑时露出的删除按钮 -- TextView android:idid/btn_delete_bg android:layout_width80dp android:layout_heightmatch_parent android:layout_gravityend android:background#FF5252 android:gravitycenter android:text删除 android:textColor#FFFFFF android:textSize16sp / !-- 顶层内容主体需要跟随手势平移 -- LinearLayout android:idid/item_content android:layout_widthmatch_parent android:layout_heightwrap_content android:background#FFFFFF android:orientationvertical android:padding16dp TextView android:idid/tv_title android:layout_widthwrap_content android:layout_heightwrap_content android:textSize16sp android:textStylebold / TextView android:idid/tv_desc android:layout_widthwrap_content android:layout_heightwrap_content android:layout_marginTop4dp android:textColor#888888 android:textSize13sp / /LinearLayout /FrameLayout布局采用FrameLayout层级嵌套这是后面做侧滑菜单的基础。内容层在顶层删除按钮垫在底部滑开时按钮自然露出。3. 侧滑删除的实现与进阶3.1 基础版滑动即删三分钟搭起来先来最快的一种方案用户左滑item跟着手指移动松手后如果超过阈值就直接删除。这是ItemTouchHelper最简单的使用形态。第一步定义Callback核心实现类public class SwipeDeleteCallback extends ItemTouchHelper.Callback { Override public int getMovementFlags(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { // LEFT | RIGHT 表示支持左滑和右滑 int swipeFlags ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; // dragFlags 传0表示不支持拖拽 return makeMovementFlags(0, swipeFlags); } Override public boolean onMove(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder, NonNull RecyclerView.ViewHolder target) { // 不支持拖拽直接返回false return false; } Override public void onSwiped(NonNull RecyclerView.ViewHolder viewHolder, int direction) { int position viewHolder.getAdapterPosition(); // 从数据集合中移除 dataList.remove(position); // 局部刷新注意用notifyItemRemoved而不是notifyDataSetChanged adapter.notifyItemRemoved(position); } }第二步绑定到RecyclerViewItemTouchHelper itemTouchHelper new ItemTouchHelper(new SwipeDeleteCallback()); itemTouchHelper.attachToRecyclerView(recyclerView);就这么两个步骤左滑右滑删除就完成了。注意onSwiped是在动画结束后回调的所以视觉上松手后item会先滑出屏幕然后列表闭合体验很顺滑。对于这种简单场景有个很关键的细节默认情况下ItemTouchHelper需要滑动超过RecyclerView宽度的一半松手后才会触发onSwiped。这个阈值可以通过setSwipeThreshold()调整但一般默认值就够了。3.2 进阶版侧滑露出按钮点击才执行删除第一条路走通后你会发现很多产品经理要的不是滑了就删而是滑出操作按钮点一下才执行。这种交互的确更安全防止误删。核心思路是不完全滑出屏幕而是滑到按钮宽度后自动定位同时把View钉在按钮露出的位置点击按钮再真正删除。要实现这个效果还是基于Callback但需要重写更多内容public class SwipeMenuCallback extends ItemTouchHelper.Callback { private int menuWidth dp2px(80); // 按钮宽度 private boolean isMenuOpen false; // 记录当前是否有item处于菜单打开状态 private int openPosition -1; // 记录当前打开菜单的item位置 Override public int getMovementFlags(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { return makeMovementFlags(0, ItemTouchHelper.LEFT); } Override public boolean onMove(...) { return false; } Override public void onSwiped(...) { // 注意这里不能让系统删除item因为我们要做的是停留在按钮位置 } Override public void onChildDraw(NonNull Canvas c, NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { View itemView ((MyAdapter.MyViewHolder) viewHolder).itemView; // 限制滑动范围最多滑出按钮宽度 float limitedDX Math.min(dX, -menuWidth); if (dX 0) { limitedDX 0; // 不支持右滑 } // 如果不处于滑动激活状态即松手后做自动定位 if (!isCurrentlyActive) { // 如果滑出超过一半自动滑到完全露出按钮 if (Math.abs(limitedDX) menuWidth / 2f) { limitedDX -menuWidth; isMenuOpen true; openPosition viewHolder.getAdapterPosition(); } else { limitedDX 0; isMenuOpen false; openPosition -1; } } viewHolder.itemView.setTranslationX(limitedDX); super.onChildDraw(c, recyclerView, viewHolder, limitedDX, dY, actionState, isCurrentlyActive); } Override public void clearView(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); // 如果当前item不是打开的那一个复位 if (openPosition ! viewHolder.getAdapterPosition()) { viewHolder.itemView.setTranslationX(0); } } }这里面的关键在于onChildDraw。这个方法是真正控制item跟随手指移动的地方。系统每帧都会回调它我们在这里对dX做钳制clamp把滑动距离限制在按钮宽度内然后在松手后根据位置自动决定是打开按钮还是复位。删除按钮的点击事件在Adapter里绑定。因为按钮在item布局的底层item被平移后就会露出点击事件可以正常触发holder.btnDelete.setOnClickListener(v - { // 记录当前打开的item位置需要关闭 if (callback.isMenuOpen()) { callback.closeMenu(); } // 真正执行删除逻辑 int pos holder.getAdapterPosition(); dataList.remove(pos); adapter.notifyItemRemoved(pos); });注意如果侧滑菜单是打开状态点击其他item时最好把上一个item复位。这个逻辑需要在RecyclerView的OnItemTouchListener或addOnScrollListener里处理否则会出现多个item同时处于打开状态产品经理当场找你对线。3.3 实现侧滑时的两个隐藏细节第一个细节是滑动阈值。ItemTouchHelper的默认滑动阈值是RecyclerView宽度的50%但在侧滑菜单场景下我们希望松手时按按钮宽度的一半来判断是否展开菜单。在onChildDraw里自己实现定位就能覆盖默认行为但注意要正确处理isCurrentlyActive这个标志位否则松手后系统会自动把item弹回原位你的定位代码就白写了。第二个细节是getSwipeEscapeVelocity()和getSwipeThreshold()。前者是快速滑动的触发速度后者是滑动的距离比例。如果你的交互要求快速左滑直接删慢速左滑只露出按钮可以通过调整这两个参数实现。我曾在项目里把getSwipeEscapeVelocity调低到200配合上面的侧滑菜单逻辑效果是快滑直接走删除慢滑露出按钮。4. 拖拽排序的实现与优化4.1 核心拖拽逻辑与数据交换拖拽排序的需求场景也很常见长按item然后拖到另一个位置松手列表顺序更新。ItemTouchHelper实现这个的代码量同样非常少。关键点还是在getMovementFlags和onMovepublic class DragSortCallback extends ItemTouchHelper.Callback { Override public int getMovementFlags(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { int dragFlags ItemTouchHelper.UP | ItemTouchHelper.DOWN; int swipeFlags 0; // 拖拽场景下通常不支持滑动 return makeMovementFlags(dragFlags, swipeFlags); } Override public boolean onMove(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder, NonNull RecyclerView.ViewHolder target) { int fromPosition viewHolder.getAdapterPosition(); int toPosition target.getAdapterPosition(); // 如果被拖拽的item不能移动比如置顶、分组头部返回false if (dataList.get(fromPosition).isPinned) { return false; } // 数据交换 Collections.swap(dataList, fromPosition, toPosition); // 局部刷新只通知移动不重建item adapter.notifyItemMoved(fromPosition, toPosition); return true; } Override public boolean isLongPressDragEnabled() { return false; // 后面单独说 } Override public void onSwiped(...) { } }这里有个非常关键的点onMove的返回值必须为true系统才会继续执行拖动动画。如果返回false拖动的item会弹回原位。很多新手在这里踩坑不光要让Adapter刷新还要返回正确的布尔值。数据交换用Collections.swap是最直接的但如果你需要做更精细的控制——比如在拖拽过程中实时更新数据顺序——可以使用notifyItemMoved配合requestLayout但性能上不划算。对于绝大多数列表长度少于200条来说Collections.swap加局部刷新已经足够流畅。4.2 长按拖拽与拖拽视觉优化默认情况下isLongPressDragEnabled()返回true这意味着用户长按列表项就能拖拽。但很多时候我们不希望整个item都能长按拖拽而是只允许通过某个拖拽手柄触发。这时候就需要把isLongPressDragEnabled()改成false然后在手柄View的OnTouchListener里主动调用itemTouchHelper.startDrag(viewHolder)holder.dragHandle.setOnTouchListener((v, event) - { if (event.getActionMasked() MotionEvent.ACTION_DOWN) { itemTouchHelper.startDrag(holder); } return false; // 不消费事件把手势交给ItemTouchHelper处理 });这样只有按住手柄图标时才会进入拖拽模式其余区域正常滚动。拖拽过程中的视觉反馈也很重要。默认的onSelectedChanged可以在这里做状态变化Override public void onSelectedChanged(Nullable RecyclerView.ViewHolder viewHolder, int actionState) { super.onSelectedChanged(viewHolder, actionState); if (actionState ItemTouchHelper.ACTION_STATE_DRAG) { // 拖拽中升高层级适当缩放 viewHolder.itemView.setScaleX(1.05f); viewHolder.itemView.setScaleY(1.05f); viewHolder.itemView.setElevation(dp2px(8)); } else if (actionState ItemTouchHelper.ACTION_STATE_SWIPE) { // 滑动中降低透明度 viewHolder.itemView.setAlpha(0.8f); } } Override public void clearView(NonNull RecyclerView recyclerView, NonNull RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); // 手势结束恢复原状 viewHolder.itemView.setScaleX(1f); viewHolder.itemView.setScaleY(1f); viewHolder.itemView.setElevation(0f); viewHolder.itemView.setAlpha(1f); }注意clearView里要恢复所有状态否则会出现item一直悬浮或一直半透明的情况。另外setElevation在某些不支持硬件加速的设备上可能会有渲染问题稳妥的做法是配合setBackgroundColor做状态区分或者使用ViewCompat.setElevation。5. 常见问题与排查技巧实录5.1 高频问题速查表现象原因解决方案侧滑时整个item背景跟着滑走后面的菜单露不出来内容布局背景覆盖了整个item滑动时内容层没有平移把内容层放在顶层布局并用TranslationX控制它移动松手后item总是弹回原位不会触发删除可能是swipeThreshold设置过大或onSwiped没有正确处理检查onSwiped里是否调用了notifyItemRemoved并确认数据已移除拖拽时item乱跳位置对不上notifyItemMoved和目标位置计算错误务必使用getAdapterPosition()而非getLayoutPosition()长按整个item都会触发拖拽只想让手柄触发isLongPressDragEnabled()返回了true改为返回false在手柄OnTouchListener里主动调用startDrag拖拽过程中列表自动滚动时闪烁数据更新频率过高或未使用setHasStableIdsAdapter中重写getItemId()并返回唯一ID开启setHasStableIds(true)删除item后列表下方的item出现位移动画闪烁数据类型不匹配或notifyItemRemoved后没有notifyItemRangeChanged删除后对后续item做notifyItemRangeChanged(position, list.size() - position)侧滑菜单按钮显示不全或位置错位FrameLayout层级和宽度设置不对按钮使用固定宽度放在layout_gravityend内容层覆盖其上5.2 实战中总结的几条独家心得第一数据刷新要克制。notifyDataSetChanged()是性能杀手在拖拽排序过程中尤其明显。每次onMove只调用一次notifyItemMoved就足够了不要在数据集合的set方法里写刷新。如果数据量大超过500条考虑使用DiffUtil来计算差异避免整个列表重建。第二getAdapterPosition()返回的是当前Adapter中的数据位置getLayoutPosition()返回的是布局中的位置。在拖拽场景中两值基本一致但在动画过程中会有短暂差异。如果发现数据交换后item显示错乱优先排查是不是把一个值用在了另一个值的场景里。第三侧滑方向要考虑手心习惯。国内App普遍只支持左滑删除因为大部分用户右手持机从右向左滑更自然。如果你做的是国际化产品或者横屏适配一定要检查getMovementFlags是否覆盖了所有可能的方向。第四关于列表项高度不一致的情况。如果在拖拽过程里item高度变化比如拖拽时展开预览会导致onMove的位置计算出现偏差。解决方案是保持item高度在拖拽期间不变等clearView后再更新高度并调用notifyItemChanged。第五最后说说和多个RecyclerView的联动。如果你在一个页面里有多个列表都要支持拖拽记得为每个列表单独创建ItemTouchHelper实例。同一个ItemTouchHelper同时attachToRecyclerView多个列表会有奇怪的bug比如拖拽事件串列表触摸监听互相干扰。分开实例化就没这个问题。实际上我在项目中每次都强烈建议把ItemTouchHelper.Callback封装成独立类不要写在Activity或Fragment里。一方面是因为逻辑通常不短写在界面层会让代码很臃肿另一方面是侧滑删除、拖拽排序这种交互在不同业务场景下只是数据操作不同回调逻辑高度复用封装好之后后续接新需求几乎是零成本。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门