☰
Android侧滑删除与拖拽排序:ItemTouchHelper实战指南
2026/10/26 4:21:05 网站建设 项目流程

简介:这份资源面向具备一定 Android 基础的开发者,聚焦 RecyclerView 中侧滑拖拽这一常见交互的实现,帮助读者掌握 ItemTouchHelper 的用法与事件处理思路。包内共 44 个文件,以 13 个 xml 布局与配置、11 个 java 源码为主,辅以 10 张 png 效果截图、3 个 properties 与 3 个 gradle 构建脚本,另有 bat、gradlew、jar 等工程运行文件,压缩包约 763KB,可直接导入 Android Studio 对照调试。内容围绕自定义 Adapter 与 ViewHolder、重写 ItemTouchHelper.Callback 的 onMove 与 onSwiped、关联 RecyclerView、数据更新通知及 onChildDraw 动画绘制等环节展开,并涉及 LinearLayoutManager 与 GridLayoutManager 下的拖放处理。已有 371 人学习,适合想弄清侧滑删除、拖动排序底层逻辑并动手复现的读者参考。

1. 侧滑删除和长按拖拽,其实是一套手势系统

列表里左滑露出删除按钮、长按卡片上下拖动换位置,这两个交互在安卓项目里出现频率极高。很多人第一反应是找第三方库,结果引入一堆依赖还得改源码适配主题。其实 AndroidX 自带的ItemTouchHelper已经把这两件事的骨架搭好了,它挂在RecyclerView上,通过回调把「滑动方向」「拖拽方向」「绘制装饰」「状态变化」全部暴露给你,剩下的只是填业务逻辑。这份资源就是一个可运行的 Java 工程,把侧滑删除、长按拖拽排序、滑动时的背景绘制、数据与视图同步这几块串成一条完整链路,适合正在做列表交互、想摆脱第三方库束缚的安卓开发者直接拆开用。下面按「能干什么 → 怎么跑 → 核心怎么实现 → 怎么改」的顺序讲。

2. 工程结构与运行链路:从 ItemTouchHelper 挂载到列表响应

2.1 目录结构与关键类职责

这个工程是标准的单模块 Android 项目,用 Java 编写,没有引入额外的第三方手势库,核心逻辑集中在几个类里。目录大致是这样组织的:

app/src/main/java/com/example/swipedrag/ ├── MainActivity.java // 入口,初始化 RecyclerView 与数据 ├── adapter/ │ └── ItemAdapter.java // 列表适配器,持有数据源 ├── model/ │ └── ItemBean.java // 数据实体,含标题、类型等字段 ├── helper/ │ └── SwipeDragCallback.java // ItemTouchHelper.Callback 实现,核心 └── widget/ └── SwipeItemLayout.java // 可选:自定义条目容器,处理侧滑按钮

MainActivity负责把RecyclerView、LinearLayoutManager、ItemAdapter组装起来,然后创建ItemTouchHelper并attachToRecyclerView。SwipeDragCallback继承ItemTouchHelper.Callback,是整个交互的大脑,决定哪些方向允许滑动、哪些方向允许拖拽、滑动时画什么背景、松手后怎么处理数据。ItemAdapter只做数据绑定,但需要提供onItemMove和onItemDismiss这类方法供回调调用,保证数据源和视图一致。SwipeItemLayout不是必须的,如果侧滑只是「滑出删除背景」而不需要常驻按钮,可以省掉,直接用onChildDraw画背景即可。

2.2 依赖与运行环境

工程基于 AndroidX,build.gradle里需要的关键依赖只有两个方向:recyclerview和appcompat。ItemTouchHelper本身在recyclerview包里,不需要额外引入。常见配置如下:

dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'com.google.android.material:material:1.11.0' }

compileSdk建议 33 以上,minSdk21 即可,因为ItemTouchHelper从 support 包时代就存在,API 兼容性很好。运行方式就是常规的 Android Studio 打开工程、Sync、连真机或模拟器点 Run。第一次跑起来会看到一个纵向列表,长按条目可以上下拖动,左滑条目会露出红色删除背景,滑过阈值松手即删除。如果只想验证拖拽,可以把侧滑方向关掉,后面会讲怎么改。

2.3 挂载 ItemTouchHelper 的最小闭环

真正让交互生效的代码不多,但顺序不能错。先在MainActivity里拿到RecyclerView,设置布局管理器和适配器,然后构造回调并挂载:

RecyclerView recyclerView = findViewById(R.id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); ItemAdapter adapter = new ItemAdapter(buildData()); recyclerView.setAdapter(adapter); // 核心:把 Callback 挂到 RecyclerView 上 SwipeDragCallback callback = new SwipeDragCallback(adapter); ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback); itemTouchHelper.attachToRecyclerView(recyclerView);

这段代码里attachToRecyclerView是入口,它会把RecyclerView的OnItemTouchListener替换成自己的实现,后续的onTouchEvent都会先经过ItemTouchHelper判断是否命中某个条目。SwipeDragCallback构造时传入adapter,是为了在滑动或拖拽结束后直接操作数据源。注意attachToRecyclerView只能调用一次,重复调用会导致手势监听叠加,表现为滑动一次触发两次删除,这是后面避坑章节会展开的一个点。

2.4 回调方向配置:哪些能滑、哪些能拖

ItemTouchHelper.Callback里第一个要重写的方法是getMovementFlags,它决定每个条目的「可滑动方向」和「可拖拽方向」。工程里的写法是:

@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); }

makeMovementFlags(dragFlags, swipeFlags)会把两个方向位掩码打包成一个整数返回。UP | DOWN表示长按后可以上下拖,LEFT表示只能左滑。如果想让右滑也生效,加上ItemTouchHelper.RIGHT即可;如果某个条目不允许拖拽,比如列表头部的固定项,可以在这里判断viewHolder.getAdapterPosition()返回 0,然后return makeMovementFlags(0, 0),这样该条目就完全不响应手势。这个方法是每次触摸都会调用的,不要在里面做耗时操作。

2.5 拖拽与滑动的数据同步

方向配置好之后,真正移动和删除发生在onMove和onSwiped里。onMove在拖拽过程中持续回调,需要交换数据源并通知适配器;onSwiped在滑动超过阈值松手后回调,需要移除数据。工程里的实现:

@Override public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { int from = viewHolder.getAdapterPosition(); int to = target.getAdapterPosition(); // 交给 adapter 交换数据并 notifyItemMoved adapter.onItemMove(from, to); return true; } @Override public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { int position = viewHolder.getAdapterPosition(); // 先移除数据,再通知适配器 adapter.onItemDismiss(position); }

onMove返回true表示这次移动被接受,ItemTouchHelper会继续处理后续的动画。adapter.onItemMove内部一般是Collections.swap(list, from, to)加notifyItemMoved(from, to),注意不要用notifyDataSetChanged,否则拖拽动画会断掉。onSwiped里direction是ItemTouchHelper.LEFT或RIGHT,可以用来区分左滑删除还是右滑标记。onItemDismiss里做list.remove(position)加notifyItemRemoved(position),如果删除后需要刷新后续位置,再补一个notifyItemRangeChanged(position, list.size() - position)。

3. 核心模块拆解:滑动绘制、拖拽动画与状态回调

3.1 onChildDraw:侧滑背景是怎么画出来的

侧滑时露出的红色背景不是布局里写死的,而是ItemTouchHelper在滑动过程中通过onChildDraw回调让你自己画。工程里重写了这个方法,根据dX的偏移量画一个红色矩形加删除图标:

@Override public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { View itemView = viewHolder.itemView; if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { // 只处理滑动状态,拖拽状态交给默认实现 Paint paint = new Paint(); paint.setColor(Color.parseColor("#E53935")); // 背景区域:从条目右侧向左延伸 dX 的绝对值 RectF background = new RectF( itemView.getRight() + dX, itemView.getTop(), itemView.getRight(), itemView.getBottom()); c.drawRect(background, paint); // 这里可以继续画删除图标,用 drawBitmap 或 drawText } super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); }

dX是条目相对原始位置的横向偏移,左滑时为负值,所以itemView.getRight() + dX就是背景矩形的左边界。actionState区分当前是滑动还是拖拽,拖拽状态下不要重复画背景,否则会盖住条目本身。super.onChildDraw必须调用,它负责把条目本身平移,不调用的话条目不会跟着手指动。画图标时注意dX的绝对值要超过图标宽度才画,否则图标会从条目边缘「挤」出来,视觉上很别扭。

3.2 拖拽时的条目抬升效果

拖拽过程中,被拖的条目默认只是平移,没有「拿起来」的感觉。工程里通过重写onSelectedChanged给选中的itemView加缩放和透明度:

@Override public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) { if (actionState != ItemTouchHelper.ACTION_STATE_IDLE && viewHolder != null) { // 拖拽或滑动开始时,给条目加一点视觉反馈 viewHolder.itemView.setScaleX(1.05f); viewHolder.itemView.setScaleY(1.05f); viewHolder.itemView.setAlpha(0.9f); } super.onSelectedChanged(viewHolder, actionState); }

actionState有三个值:IDLE、SWIPE、DRAG。非IDLE时说明手势已经开始,此时给itemView加缩放和透明度,松手后在clearView里恢复。注意onSelectedChanged在拖拽换位时也会反复回调,因为选中的ViewHolder会变,所以缩放设置要幂等,不要在这里做数据操作。clearView里恢复的代码:

@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.setAlpha(1f); }

clearView在手势彻底结束时调用,包括滑动删除后条目被移除的情况。这里只做视图状态复位,不要碰数据源,数据操作已经在onSwiped或onMove里做完了。

3.3 isLongPressDragEnabled 与滑动阈值

默认情况下,拖拽需要长按触发,这是isLongPressDragEnabled返回true的效果。如果想让某个手柄图标按下就能拖,可以返回false,然后在ViewHolder的手柄上设置OnTouchListener,手动调用ItemTouchHelper.startDrag(viewHolder)。工程里保留了长按拖拽,因为列表条目本身要响应点击,长按和点击不冲突。滑动阈值由getSwipeThreshold控制:

@Override public float getSwipeThreshold(@NonNull RecyclerView.ViewHolder viewHolder) { // 滑动超过条目宽度的 50% 才触发删除 return 0.5f; }

返回值是相对条目宽度的比例,默认 0.5。调小到 0.3 会更容易触发删除,调大到 0.7 则需要滑得更远。还有一个getSwipeEscapeVelocity控制快速滑动的最小速度,一般不用改。如果发现「轻轻一划就删了」,先检查这个阈值是不是被改过,再看onChildDraw里背景绘制的范围是否和阈值匹配,视觉和逻辑不一致会让用户误判。

3.4 数据源与视图一致性的保证

ItemTouchHelper只负责手势和动画,它不会动你的数据源。所有onMove和onSwiped里的数据操作都必须和notifyItemXxx配对,否则会出现「视图删了数据还在」或者「数据换了视图没动」的经典翻车。工程里ItemAdapter的两个方法这样写:

public void onItemMove(int from, int to) { Collections.swap(dataList, from, to); notifyItemMoved(from, to); } public void onItemDismiss(int position) { dataList.remove(position); notifyItemRemoved(position); // 删除后刷新后续条目的位置,避免复用错乱 notifyItemRangeChanged(position, dataList.size() - position); }

notifyItemMoved自带位移动画,notifyItemRemoved自带移除动画,这两个方法比notifyDataSetChanged更适合手势场景。notifyItemRangeChanged在删除后调用是为了让后续条目的position更新,尤其是条目里如果有基于position显示序号的逻辑,不刷新会看到序号错位。如果数据源是LiveData或数据库查询结果,注意在回调里操作的是内存列表,持久化要另开线程做,不要在onSwiped里直接写数据库,否则滑动会卡顿。

4. 避坑与排查:侧滑拖拽最容易翻车的五个点

4.1 滑动一次触发两次删除

现象是左滑删除一个条目,结果相邻条目也被删了,或者日志里onSwiped打印了两次。原因通常是ItemTouchHelper被重复attachToRecyclerView,比如在onResume里又创建了一次,或者在RecyclerView的Adapter重新设置后又挂了一次。每次attach都会注册一个触摸监听,手势会被多个ItemTouchHelper同时消费。解决办法是保证ItemTouchHelper只在初始化时创建一次,把它作为成员变量持有,attach前先判断是否已经挂过。如果确实需要重建,先itemTouchHelper.attachToRecyclerView(null)解绑再挂新的。

4.2 拖拽后点击事件错位

现象是拖拽排序完成后,点击某个条目打开详情,打开的却是另一个条目的内容。原因是onMove里只交换了数据源,但ViewHolder里缓存的position或者点击监听里闭包捕获的position没有更新。RecyclerView的ViewHolder会复用,getAdapterPosition()在动画期间可能返回NO_POSITION。解决方式是在点击监听里用holder.getBindingAdapterPosition()实时取位置,不要用构造时传入的position;onMove里交换数据后确保调用了notifyItemMoved,让RecyclerView更新内部位置映射。如果条目里有EditText这类焦点控件,拖拽后还要手动clearFocus,否则焦点会跟着ViewHolder跑到别的条目上。

4.3 侧滑背景画在条目下面看不见

现象是左滑时条目动了,但红色背景没出现,或者被条目完全盖住。原因是onChildDraw的绘制顺序问题:ItemTouchHelper默认在RecyclerView的onDraw阶段绘制,如果条目本身有不透明的背景,且你没有在super.onChildDraw之前画背景,背景就会被条目覆盖。正确顺序是先画背景矩形,再调用super.onChildDraw让条目平移。另外检查RectF的坐标是否用了itemView.getRight() + dX,如果写成itemView.getLeft() + dX,背景会画到条目左侧,左滑时正好在屏幕外,看起来就像没画。

4.4 滑动删除后列表出现空白或重复

现象是删除一个条目后,列表底部多出一个空白项,或者某个条目重复出现。原因是onSwiped里移除了数据但notifyItemRemoved的position用的是viewHolder.getAdapterPosition(),而在回调触发时这个值可能已经因为前一次操作变成了NO_POSITION。解决方式是在onSwiped开头先判断position == RecyclerView.NO_POSITION就直接返回,不要继续操作。另外如果数据源是Arrays.asList这种固定长度列表,remove会抛UnsupportedOperationException,要换成ArrayList。删除后补notifyItemRangeChanged也能缓解复用导致的显示错乱。

4.5 长按拖拽和条目长按事件冲突

现象是长按条目想拖拽,结果弹出了条目的长按菜单,或者拖拽完全没反应。原因是条目本身设置了setOnLongClickListener,它会和ItemTouchHelper的长按拖拽竞争。ItemTouchHelper的长按识别是在RecyclerView的触摸拦截里做的,如果条目先消费了长按,拖拽就不会启动。解决办法是如果条目需要长按菜单,就把拖拽改成手柄触发:isLongPressDragEnabled返回false,在手柄的OnTouchListener里ACTION_DOWN时调用startDrag。如果不需要长按菜单,直接去掉setOnLongClickListener,让ItemTouchHelper独占长按。

5. 二次开发:把侧滑拖拽改成你自己的业务形态

5.1 从「左滑删除」扩展到「双侧滑多动作」

很多业务不满足于左滑删除,而是左滑露出「删除」,右滑露出「置顶」。改造点在getMovementFlags里把swipeFlags改成LEFT | RIGHT,然后在onChildDraw里根据dX的正负分别画两种背景,在onSwiped里根据direction分流:

@Override public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { int position = viewHolder.getAdapterPosition(); if (position == RecyclerView.NO_POSITION) return; if (direction == ItemTouchHelper.LEFT) { adapter.onItemDismiss(position); // 左滑删除 } else if (direction == ItemTouchHelper.RIGHT) { adapter.onItemPin(position); // 右滑置顶 } }

onItemPin里把数据移到列表头部并notifyItemMoved,注意移动后原来的position会变,如果还有后续操作要用新的位置。双侧滑时getSwipeThreshold可以分别用getSwipeThreshold和getSwipeEscapeVelocity控制灵敏度,但阈值是全局的,不能按方向区分,需要更细的控制就得在onChildDraw里自己判断dX绝对值是否超过某个像素值,超过时改变背景颜色提示用户「松手即触发」。

5.2 拖拽排序后持久化顺序

拖拽只是改了内存列表,退出页面再进来顺序就还原了。持久化的时机建议放在clearView里,而不是onMove里,因为onMove在拖拽过程中会高频回调,每次都写存储会卡。clearView在手势结束时调用一次,此时数据已经稳定:

@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.setAlpha(1f); // 手势结束,持久化当前顺序 adapter.persistOrder(); }

persistOrder里把dataList的 id 顺序写到SharedPreferences或数据库,下次加载时按这个顺序排。注意clearView在滑动删除后也会调用,此时数据已经少了一条,持久化的是删除后的顺序,逻辑上正好一致。如果列表数据来自网络,持久化顺序只对本地展示有效,下次拉取后要按本地顺序重排,重排逻辑放在数据加载完成之后、setAdapter之前。

5.3 用 DiffUtil 替代 notifyItemXxx 的取舍

当列表数据量大、条目内容复杂时,notifyItemMoved加notifyItemRangeChanged的组合可能不够精细,容易出现闪烁。可以改用DiffUtil计算差异后统一提交,但要注意ItemTouchHelper的动画依赖notifyItemMoved的即时调用,如果拖拽过程中走DiffUtil异步计算,动画会断。我的习惯是拖拽和滑动期间仍然用notifyItemMoved/notifyItemRemoved保证动画流畅,等手势结束后再用一次DiffUtil做全量校正,两者不冲突。验证方法是拖拽十次以上观察是否有条目错位,再快速连续左滑删除五个条目看列表是否稳定,这两个场景能覆盖大部分复用问题。

5.4 一个我踩过的坑

早期做类似功能时,我在onSwiped里直接调用了adapter.notifyDataSetChanged(),想着「反正数据变了全刷一遍最保险」。结果滑动删除的动画完全消失,条目是「啪」一下跳没的,而且拖拽过程中如果触发全刷,正在拖的条目会直接回到原位,手势和视图脱节。从那以后我每次接ItemTouchHelper都强制自己只用notifyItemMoved和notifyItemRemoved,全刷只留给数据源整体替换的场景,并且替换前先解绑ItemTouchHelper。这个习惯帮我省掉了大量「动画诡异」的排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询