news 2026/11/4 2:01:13

Android侧滑删除菜单实现:基于RecyclerView自定义SlidingMenuLayout

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android侧滑删除菜单实现:基于RecyclerView自定义SlidingMenuLayout

我接手过不少项目,只要列表里有“删除”这个操作,产品基本都会提一句:“长按太隐蔽了,做个左滑删除呗。”如果只是滑一下整行消失,RecyclerView自带的Swipe就够了。但要是产品图里画的是“左滑露出一个红色删除按钮,点一下才真正删除”,事情就从“能用”变成了“要自己动手”。

这篇文章就聊我最近一次实现“侧滑点击删除菜单”的完整过程:为什么不用现成的滑动删除组件,自研一个SlidingMenuLayout时要怎么搭布局、怎么写触摸事件、怎么处理列表里的互斥和复用,最后还有一份可以直接跑通的最小示例。适合那些不愿意为了一个按钮引一大堆依赖、想彻底搞懂侧滑原理的Android开发者。

1. 为什么不用现成的“滑动删除”而要自己写容器

1.1 三套现成方案的真实体验

我先按“能不能满足需求”过了一遍现有的做法,简单列个对比:

方案交互形态是否支持“滑出后停在半路,露出按钮”额外成本
RecyclerView自带的Swipe删除整条item直接滑出并移除不支持,滑出去就触发回调最少,但完全不符合需求
ItemTouchHelper + 自定义Swipe也是滑出回调,可做部分偏移理论上能通过自定义onChildDraw实现半开,但逻辑很绕中,代码侵入强
各类开源侧滑库支持露按钮、支持多菜单支持,但功能多、配置重高,还要应付依赖版本冲突

系统自带的Swipe交互设计初衷是“快速移除”,它把整个滑动过程当成一种手势确认。而我们需要的其实是“滑动后暂停,让用户再看一眼按钮再确认”,这两种交互在状态模型上根本不一样,强行在自带Swipe上做半开状态,要么改源码,要么把onChildDraw写得面目全非。

至于开源库,功能是很全,但我那个项目本身已经有一堆复杂依赖,库之间版本经常打架。而且这些库往往自带布局层级和自定义属性,接入后想和现有主题样式完全兼容,还得再做一层封装,越包越厚。我统计了一下,核心需求其实就一句话:**内容左移一点,露出右侧固定宽度的删除按钮。**这种场景自己写一个自定义ViewGroup,其实比引库更快。

1.2 我确定的自研边界

一开始我就给这个控件划了边界:只做“水平滑动露出右侧操作区”,操作区放几个按钮、按钮点击后干什么,都交给外部。不做的功能包括:多方向侧滑、长按拖拽、自动定位到某个菜单项、滑动距离百分比回调等。这些看起来酷炫,但多数时候用不上,反而会把触摸逻辑搞复杂。

自研的收益有两个:第一,布局完全由自己控制,菜单宽度、背景、动画时长都能直接写在XML里;第二,依赖为零,随便塞进哪个项目都不会污染现有环境。

1.3 整体模型:内容与菜单一起平移

我最开始想的是“菜单从右侧滑进来”,后来发现更好的模型是“内容和菜单作为两个整体,一起向左平移一个菜单宽度”。

举个例子方便理解:你有一张A4纸,纸下压着一张卡片露出一点边。菜单就是那张卡片,刚开始完全藏在纸底下。要露出菜单,只需要把整张纸(包括内容区域)向左推一个卡片的宽度,卡片相对纸的位置始终不变,只是被露出来的部分变多了。

对应到代码里就是:内容View占满整个容器,菜单View布局在容器右边界之外,打开菜单时给两者同时设置translationX = -menuWidth。整个过程只改一个变量,不需要重新measure和layout,性能开销小,视觉上也自然。

2. SlidingMenuLayout 的布局模型:菜单先藏在右边界外

2.1 为什么要用“整体左移”而不是“菜单滑入”

我尝试过另一种做法:菜单初始位置在容器内部右侧,通过菜单自身的translationX把它从右往左移出来。这样做的最大问题是,内容View必须盖在菜单上面才能让菜单默认不可见,而View的绘制顺序又会让菜单先绘制却被内容挡住,一旦菜单区域需要处理点击,事件优先级就会变得很别扭。

整体左移避免了这个问题。菜单的默认位置在容器右边界之外,父容器裁剪之后它天然不可见;打开时整个槽位一起左移,菜单就进入可见区域。关闭时,再一起移回来。事件分发只需要关注“当前在哪个状态”,不需要考虑菜单是否遮挡内容的问题。

2.2 measure 和 layout 的关键代码

先看核心布局代码:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = MeasureSpec.getSize(widthMeasureSpec) val height = MeasureSpec.getSize(heightMeasureSpec) if (childCount == 0) { setMeasuredDimension(0, 0) return } // 第一个子View:内容区域,占满整个容器 val content = getChildAt(0) content.measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) ) setMeasuredDimension(width, content.measuredHeight) // 第二个子View:菜单区域,宽度由自身LayoutParams决定 if (childCount > 1) { val menu = getChildAt(1) val lp = menu.layoutParams val menuWidthSpec = when (lp.width) { LayoutParams.MATCH_PARENT -> MeasureSpec.makeMeasureSpec(width * 4 / 5, MeasureSpec.EXACTLY) LayoutParams.WRAP_CONTENT -> MeasureSpec.makeMeasureSpec(width / 2, MeasureSpec.AT_MOST) else -> MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY) } menu.measure( menuWidthSpec, MeasureSpec.makeMeasureSpec(content.measuredHeight, MeasureSpec.EXACTLY) ) menuWidth = menu.measuredWidth } }

有几个细节需要解释一下。菜单高度我强制用内容的测量高度,这保证了菜单和内容在同一个item里视觉上是一体的。菜单宽度如果写MATCH_PARENT,会让它几乎占满整个父容器,侧滑就失去意义,所以我把它限制成容器宽度的五分之四,算是一种兜底。

onLayout更简单:

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { if (childCount == 0) return val content = getChildAt(0) content.layout(0, 0, measuredWidth, measuredHeight) if (childCount > 1) { val menu = getChildAt(1) // 初始位置在容器右侧之外,靠整体左移露出 menu.layout(measuredWidth, 0, measuredWidth + menu.measuredWidth, measuredHeight) } }

注意菜单的layout区域是从measuredWidth开始的,也就是完全在父容器外面。此时父容器默认的clipChildren是true,所以用户看不到它。这不是bug,是我们的设计起点。

2.3 状态机设计

侧滑菜单的难点不在布局,而在“任何时刻你都要知道当前处于什么状态”。我定义了四个状态:

状态含义offset值
STATE_CLOSED完全关闭0
STATE_OPENED完全打开-menuWidth
STATE_DRAGGING手指正在拖动介于两者之间
STATE_SETTLING松手后动画回弹中动态变化

所有状态的变化都收敛到一个方法里,而不是在触摸事件里到处散落赋值:

private fun setOffset(newOffset: Float) { val clamped = newOffset.coerceIn(-menuWidth.toFloat(), 0f) offset = clamped contentView.translationX = clamped menuView.translationX = clamped val newState = when { clamped == 0f -> STATE_CLOSED clamped == -menuWidth.toFloat() -> STATE_OPENED else -> STATE_DRAGGING } if (newState != state) { state = newState onMenuStateChange?.invoke(state) } }

把状态和偏移量绑在一起是后来才养成的习惯。一开始我试过在onTouchEvent里手写状态切换,结果每次加一个分支就要补一堆状态判断,代码越来越难读。现在所有入口都走setOffset,外部回调、UI刷新都统一在这一处触发。

3. 触摸事件三策略:关闭态拖动、打开态点击关闭、垂直手势放行

3.1 onInterceptTouchEvent 的分支逻辑

侧滑容器的触摸处理是整篇文章最核心的部分,也是我最开始写崩溃的地方。RecyclerView本身就会拦截垂直滚动,我们的控件要从它的孩子手里抢水平滑动,靠的是onInterceptTouchEvent的返回值。

我的设计分三种情况:

  • 菜单关闭时:只有明显的水平滑动才拦截,其他情况一律放行,让内容布局正常处理点击。
  • 菜单打开时,手指点在内容区域:
    • 垂直滑动大于水平滑动:不拦截,交给RecyclerView滚动;
    • 水平滑动大于垂直滑动:拦截,进入拖动关闭流程;
    • 没有明显移动:等到ACTION_UP时再拦截一次,用来实现“点击内容区关闭菜单”。
  • 菜单打开时,手指点在菜单区域:不拦截,让删除按钮自己处理点击。

具体的拦截判定代码:

override fun onInterceptTouchEvent(event: MotionEvent): Boolean { if (childCount < 2) return super.onInterceptTouchEvent(event) when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { downX = event.x downY = event.y lastX = event.x lastY = event.y isDragged = false velocityTracker?.recycle() velocityTracker = VelocityTracker.obtain().apply { addMovement(event) } // 如果正在回弹动画,直接抢手势,避免触摸和动画打架 if (state == STATE_SETTLING) { scroller.forceFinished(true) return true } return false } MotionEvent.ACTION_MOVE -> { val dx = event.x - downX val dy = event.y - downY if (state == STATE_OPENED) { val touchOnContent = event.x < width - menuWidth if (touchOnContent) { // 垂直拖动放行给RecyclerView if (Math.abs(dy) > touchSlop && Math.abs(dy) > Math.abs(dx)) { return false } // 水平拖动自己拦截 if (Math.abs(dx) > touchSlop && Math.abs(dx) > Math.abs(dy)) { isDragged = true return true } } } else { if (Math.abs(dx) > touchSlop && Math.abs(dx) > Math.abs(dy)) { isDragged = true return true } } return false } MotionEvent.ACTION_UP -> { // 菜单打开时点击内容区,拦截UP,阻止点击事件穿透到内容布局 if (state == STATE_OPENED && !isDragged && event.x < width - menuWidth) { return true } return false } } return super.onInterceptTouchEvent(event) }

这里有个关键点:ACTION_DOWN阶段即使菜单已经打开,我也选择不拦截。这样垂直滑动手势才能顺畅地交给RecyclerView,下拉刷新、列表滚动都不会被我们的容器卡住。

3.2 onTouchEvent 里的拖动和惯性判定

事件一旦被拦截,后续事件就会进入onTouchEvent。这里核心任务是三件事:跟踪手指移动、更新offset、抬手时决定回弹到哪个目标。

override fun onTouchEvent(event: MotionEvent): Boolean { if (childCount < 2) return false velocityTracker?.addMovement(event) when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { lastX = event.x lastY = event.y if (state == STATE_SETTLING) { scroller.forceFinished(true) } return true } MotionEvent.ACTION_MOVE -> { val dx = event.x - lastX lastX = event.x lastY = event.y if (state == STATE_SETTLING) { scroller.forceFinished(true) } setOffset((offset + dx).coerceIn(-menuWidth.toFloat(), 0f)) return true } MotionEvent.ACTION_UP -> { if (state == STATE_OPENED && !isDragged && event.x < width - menuWidth) { // 点击内容区,关闭菜单 smoothClose() velocityTracker?.recycle() velocityTracker = null return true } velocityTracker?.computeCurrentVelocity(1000) val velocityX = velocityTracker?.xVelocity ?: 0f val target = when { velocityX < -minimumFlingVelocity -> -menuWidth.toFloat() velocityX > minimumFlingVelocity -> 0f offset < -menuWidth / 2f -> -menuWidth.toFloat() else -> 0f } smoothTo(target) velocityTracker?.recycle() velocityTracker = null return true } MotionEvent.ACTION_CANCEL -> { smoothTo(if (offset < -menuWidth / 2f) -menuWidth.toFloat() else 0f) velocityTracker?.recycle() velocityTracker = null return true } } return super.onTouchEvent(event) }

抬手时的目标判定我采用“速度优先,位置兜底”的策略:只要向左挥动的速度达到系统最小fling速度,就打开菜单;向右挥动就关闭。如果速度不够,再看当前偏移量是否超过菜单宽度的一半。这套逻辑符合直觉:用户快速右滑是想把列表恢复原样,而不一定非要滑过一半。

3.3 最容易踩的坑:onTouchEvent 不一定先收到Down

这个坑我改了三次才明白。正常情况下,一个View的onTouchEvent是先收到ACTION_DOWN,再收到ACTION_MOVE。但这里不一样:我们在ACTION_MOVE或ACTION_UP阶段才拦截事件,拦截后传给自己的onTouchEvent,第一个事件可能直接就是MOVE或UP。如果没有提前初始化lastX、lastY、velocityTracker,第一次移动就会跳变一大段,要么菜单乱甩,要么直接飞出去。

解决办法就是在onInterceptTouchEvent的ACTION_DOWN里把lastX、lastY、velocityTracker全部初始化,保证即使后面拦截的是MOVE,也有正确的起点数据。

另一个容易踩的坑是“打开菜单后点击内容区关闭”这件事。我一开始在内容布局的根View上设置OnClickListener,里头判断菜单是否打开。但实际项目里内容布局可能自己也有点击需求,两个点击事件叠在一起,逻辑就绕了。后来我改成在ACTION_UP时统一拦截,让内容区收不到这次点击,再由我这边执行关闭,这样就干净地阻断了事件穿透。

3.4 回弹动画:Scroller 和 settleTarget

动画我用的是Scroller,不用属性动画。原因很简单:Scroller配合computeScroll在UI线程的绘制循环里走,RecyclerView滚动时不会额外创建属性动画对象,控制也更直接。

private var settleTarget = 0f override fun computeScroll() { if (scroller.computeScrollOffset()) { setOffset(scroller.currX.toFloat()) postInvalidateOnAnimation() } else if (state == STATE_SETTLING) { setOffset(settleTarget) state = if (settleTarget == 0f) STATE_CLOSED else STATE_OPENED onMenuStateChange?.invoke(state) } } private fun smoothTo(target: Float) { settleTarget = target val startX = offset.toInt() val dx = (target - offset).toInt() val duration = (Math.abs(dx) * 0.4f).toInt().coerceIn(150, 300) scroller.startScroll(startX, 0, dx, 0, duration) postInvalidateOnAnimation() } private fun smoothOpen() { smoothTo(-menuWidth.toFloat()) } private fun smoothClose() { smoothTo(0f) }

有人可能会问,在setOffset里已经把状态重置了,为什么computeScroll末尾还要再设置一次?因为动画过程中最后一帧可能因为取整误差停在-menuWidth + 1这样的值,如果只靠setOffset判断,状态会卡在DRAGGING。把target兜底设一次,就能保证动画结束一定落在预期的状态上。

4. 多条目互斥、点击删除与列表滚动的协作

4.1 菜单互斥的Adapter回调设计

单条item的侧滑好写,真正复杂的是多条item之间的联动。我要求同一时刻只有一个菜单能处于打开状态,否则页面上到处是红色删除按钮,视觉太乱了。

我在SlidingMenuLayout里预留了一个状态回调:

var onMenuStateChange: ((state: Int) -> Unit)? = null

Adapter里在onBindViewHolder时绑定这个回调,如果状态变成OPENED,就通知Adapter关掉之前打开的菜单:

holder.binding.slidingMenu.onMenuStateChange = { state -> if (state == SlidingMenuLayout.STATE_OPENED) { closeOtherMenus(bindingAdapterPosition, holder.itemView) } }

closeOtherMenus的实现方式有两种。一种是遍历RecyclerView当前可见的child,找到所有SlidingMenuLayout,如果不是当前这个就执行smoothClose。另一种是维护一个变量记住当前打开菜单的position。我推荐第一种,因为它不依赖Adapter内部的数据状态,RecyclerView帮你过滤好了可见范围:

private fun closeOtherMenus(position: Int, currentView: View) { for (i in 0 until recyclerView.childCount) { val child = recyclerView.getChildAt(i) if (child == currentView) continue val slidingLayout = child.findViewById<SlidingMenuLayout>(R.id.slidingMenu) if (slidingLayout?.isOpen == true) { slidingLayout.smoothClose() } } }

4.2 列表滚动和复用时的强制复位

菜单打开时如果用户滚动列表,打开的菜单还停留在item上,跟着列表一起滚到屏幕外,再滚回来时画面就乱了。所以必须监听RecyclerView的滚动状态,一旦开始滚动,就关闭所有菜单:

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState != RecyclerView.SCROLL_STATE_IDLE) { closeAllMenus() } } })

然后是ViewHolder复用。RecyclerView的item滑出屏幕后会进入回收池,再次显示时onBindViewHolder会重新绑定数据,但View本身可能还停留在打开菜单的平移状态。所以onBindViewHolder里必须重置:

holder.binding.slidingMenu.reset()

reset方法的实现也有讲究,不能只把offset归零,还要把正在跑的Scroller动画停掉:

fun reset() { if (!scroller.isFinished) { scroller.abortAnimation() } setOffset(0f) onMenuStateChange = null }

把回调置空是为了防止复用过程中旧的回调触发误操作,绑定新holder时再设置新的回调即可。

4.3 点击删除按钮的完整交互闭环

删除逻辑放在菜单里的按钮点击事件中。这里有个顺序问题:如果直接执行removeAt(position)并notifyItemRemoved(position),item移除动画执行时,菜单还处于打开状态,视觉上会比较突兀。我的做法是:点击删除时先立即关闭所有其他菜单,让当前item保持打开状态,通过notifyItemRemoved交给ItemAnimator处理。

binding.btnDelete.setOnClickListener { val position = bindingAdapterPosition if (position == RecyclerView.NO_POSITION) return@setOnClickListener closeAllMenus() items.removeAt(position) notifyItemRemoved(position) openPosition = -1 }

还有一个容易忽略的细节:删除后position全部往前挪了一位,之前记录的openPosition如果不重置,下一次绑定就会错位。所以删除后一定要把openPosition置为-1。

5. 直接可用的小demo:布局、Adapter和接入

5.1 item布局XML

下面是这个侧滑菜单的完整item布局,SlidingMenuLayout作为item根布局,第一个子View是内容,第二个子View是菜单:

<com.yourpackage.SlidingMenuLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/slidingMenu" android:layout_width="match_parent" android:layout_height="wrap_content"> <!-- 内容区 --> <LinearLayout android:id="@+id/content" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:background="@color/white" android:padding="16dp"> <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" tools:text="标题" /> <TextView android:id="@+id/subtitle" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#666" tools:text="副标题描述" /> </LinearLayout> <!-- 侧滑菜单区 --> <TextView android:id="@+id/btnDelete" android:layout_width="88dp" android:layout_height="match_parent" android:background="@color/delete_red" android:gravity="center" android:text="删除" android:textColor="@color/white" android:textSize="15sp" /> </com.yourpackage.SlidingMenuLayout>

菜单高度之所以可以写成match_parent,是因为我在logic里已经强制按内容高度测量了,宽度用固定值88dp,比较适合“删除”这类单一操作按钮。如果要做“删除+收藏”等多按钮,把第二个子View换成横向LinearLayout就行。

5.2 完整SlidingMenuLayout代码

我把上面几个章节的代码片段拼成完整类,可以直接复制到项目里用:

class SlidingMenuLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { companion object { const val STATE_CLOSED = 0 const val STATE_OPENED = 1 const val STATE_DRAGGING = 2 const val STATE_SETTLING = 3 } private var state = STATE_CLOSED private var offset = 0f private var settleTarget = 0f private var menuWidth = 0 private val scroller = Scroller(context) private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private val minimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity private var downX = 0f private var downY = 0f private var lastX = 0f private var lastY = 0f private var isDragged = false private var velocityTracker: VelocityTracker? = null var onMenuStateChange: ((state: Int) -> Unit)? = null private val contentView: View get() = getChildAt(0) private val menuView: View get() = getChildAt(1) val isOpen: Boolean get() = state == STATE_OPENED override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = MeasureSpec.getSize(widthMeasureSpec) val height = MeasureSpec.getSize(heightMeasureSpec) if (childCount == 0) { setMeasuredDimension(0, 0) return } val content = getChildAt(0) content.measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) ) setMeasuredDimension(width, content.measuredHeight) if (childCount > 1) { val menu = getChildAt(1) val lp = menu.layoutParams val menuWidthSpec = when (lp.width) { LayoutParams.MATCH_PARENT -> MeasureSpec.makeMeasureSpec(width * 4 / 5, MeasureSpec.EXACTLY) LayoutParams.WRAP_CONTENT -> MeasureSpec.makeMeasureSpec(width / 2, MeasureSpec.AT_MOST) else -> MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY) } menu.measure( menuWidthSpec, MeasureSpec.makeMeasureSpec(content.measuredHeight, MeasureSpec.EXACTLY) ) menuWidth = menu.measuredWidth } } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { if (childCount == 0) return val content = getChildAt(0) content.layout(0, 0, measuredWidth, measuredHeight) if (childCount > 1) { val menu = getChildAt(1) menu.layout(measuredWidth, 0, measuredWidth + menu.measuredWidth, measuredHeight) } } override fun onInterceptTouchEvent(event: MotionEvent): Boolean { if (childCount < 2) return super.onInterceptTouchEvent(event) when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { downX = event.x downY = event.y lastX = event.x lastY = event.y isDragged = false velocityTracker?.recycle() velocityTracker = VelocityTracker.obtain().apply { addMovement(event) } if (state == STATE_SETTLING) { scroller.forceFinished(true) return true } return false } MotionEvent.ACTION_MOVE -> { val dx = event.x - downX val dy = event.y - downY if (state == STATE_OPENED && event.x < width - menuWidth) { if (Math.abs(dy) > touchSlop && Math.abs(dy) > Math.abs(dx)) { return false } if (Math.abs(dx) > touchSlop && Math.abs(dx) > Math.abs(dy)) { isDragged = true return true } } else { if (Math.abs(dx) > touchSlop && Math.abs(dx) > Math.abs(dy)) { isDragged = true return true } } return false } MotionEvent.ACTION_UP -> { if (state == STATE_OPENED && !isDragged && event.x < width - menuWidth) { return true } return false } } return super.onInterceptTouchEvent(event) } override fun onTouchEvent(event: MotionEvent): Boolean { if (childCount < 2) return false velocityTracker?.addMovement(event) when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { lastX = event.x lastY = event.y if (state == STATE_SETTLING) { scroller.forceFinished(true) } return true } MotionEvent.ACTION_MOVE -> { val dx = event.x - lastX lastX = event.x lastY = event.y if (state == STATE_SETTLING) { scroller.forceFinished(true) } setOffset((offset + dx).coerceIn(-menuWidth.toFloat(), 0f)) return true } MotionEvent.ACTION_UP -> { if (state == STATE_OPENED && !isDragged && event.x < width - menuWidth) { smoothClose() velocityTracker?.recycle() velocityTracker = null return true } velocityTracker?.computeCurrentVelocity(1000) val velocityX = velocityTracker?.xVelocity ?: 0f val target = when { velocityX < -minimumFlingVelocity -> -menuWidth.toFloat() velocityX > minimumFlingVelocity -> 0f offset < -menuWidth / 2f -> -menuWidth.toFloat() else -> 0f } smoothTo(target) velocityTracker?.recycle() velocityTracker = null return true } MotionEvent.ACTION_CANCEL -> { smoothTo(if (offset < -menuWidth / 2f) -menuWidth.toFloat() else 0f) velocityTracker?.recycle() velocityTracker = null return true } } return super.onTouchEvent(event) } override fun computeScroll() { if (scroller.computeScrollOffset()) { setOffset(scroller.currX.toFloat()) postInvalidateOnAnimation() } else if (state == STATE_SETTLING) { setOffset(settleTarget) state = if (settleTarget == 0f) STATE_CLOSED else STATE_OPENED onMenuStateChange?.invoke(state) } } private fun setOffset(newOffset: Float) { val clamped = newOffset.coerceIn(-menuWidth.toFloat(), 0f) offset = clamped contentView.translationX = clamped menuView.translationX = clamped val newState = when { clamped == 0f -> STATE_CLOSED clamped == -menuWidth.toFloat() -> STATE_OPENED else -> STATE_DRAGGING } if (newState != state) { state = newState onMenuStateChange?.invoke(state) } } private fun smoothTo(target: Float) { settleTarget = target val startX = offset.toInt() val dx = (target - offset).toInt() val duration = (Math.abs(dx) * 0.4f).toInt().coerceIn(150, 300) scroller.startScroll(startX, 0, dx, 0, duration) postInvalidateOnAnimation() } private fun smoothOpen() = smoothTo(-menuWidth.toFloat()) private fun smoothClose() = smoothTo(0f) fun reset() { if (!scroller.isFinished) { scroller.abortAnimation() } setOffset(0f) onMenuStateChange = null } }

5.3 Adapter 与 RecyclerView 接入

Adapter里要做的就三件事:绑定数据时重置侧滑状态、注册菜单状态回调、处理删除按钮点击。核心代码如下:

class SimpleAdapter(private val items: MutableList<String>) : RecyclerView.Adapter<SimpleAdapter.VH>() { private lateinit var recyclerView: RecyclerView class VH(val binding: ItemSimpleBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val binding = ItemSimpleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return VH(binding) } override fun onBindViewHolder(holder: VH, position: Int) { val slidingMenu = holder.binding.slidingMenu slidingMenu.reset() slidingMenu.onMenuStateChange = { state -> if (state == SlidingMenuLayout.STATE_OPENED) { closeOtherMenus(holder.bindingAdapterPosition, holder.itemView) } } holder.binding.title.text = items[position] holder.binding.btnDelete.setOnClickListener { val pos = holder.bindingAdapterPosition if (pos == RecyclerView.NO_POSITION) return@setOnClickListener closeAllMenus() items.removeAt(pos) notifyItemRemoved(pos) } } override fun getItemCount() = items.size override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { super.onAttachedToRecyclerView(recyclerView) this.recyclerView = recyclerView recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState != RecyclerView.SCROLL_STATE_IDLE) { closeAllMenus() } } }) } private fun closeAllMenus() { if (!::recyclerView.isInitialized) return for (i in 0 until recyclerView.childCount) { val child = recyclerView.getChildAt(i) child.findViewById<SlidingMenuLayout>(R.id.slidingMenu)?.smoothClose() } } private fun closeOtherMenus(position: Int, currentView: View) { if (!::recyclerView.isInitialized) return for (i in 0 until recyclerView.childCount) { val child = recyclerView.getChildAt(i) if (child == currentView) continue child.findViewById<SlidingMenuLayout>(R.id.slidingMenu)?.smoothClose() } } }

接入RecyclerView的部分和普通列表没有任何区别,不需要额外的LayoutManager配置,也不需要设置ItemTouchHelper。

6. 与下拉刷新的接缝处理与性能建议

6.1 SwipeRefreshLayout共存逻辑

这个侧滑容器最怕的其实是外层套了一个下拉刷新组件。我的处理思路是:菜单关闭时,所有水平滑动都自己拦截,垂直方向完全放行,所以下拉刷新只在菜单关闭时正常触发。菜单打开时,如果用户手指在内容区做垂直滑动,onInterceptTouchEvent返回false,事件会正常向上传递给RecyclerView,再由RecyclerView和外层的SwipeRefreshLayout协作判断是否下拉。

我实际测试下来,只要不刻意在打开菜单时对垂直场景做“抢事件”的骚操作,两者就不会冲突。真正需要额外处理的反而是打开状态下点击内容区关闭菜单的那一下,要确保SwipeRefreshLayout不会把这次点击误判成下拉起点。实测中SwipeRefreshLayout的拦截阈值要大于touchSlop,普通点击不会触发,所以问题不大。

6.2 性能关注点

侧滑交互期间所有变化都只是translationX的变化,不需要重新测量,也不需要重新布局,这个模型在性能上天然有优势。要注意的是别在拖动过程中频繁调用requestLayout,比如有些人会在setOffset里顺手改菜单宽度,这会让RecyclerView在一帧内做大量measure,列表就会卡。

另外,velocityTracker在每个ACTION_DOWN都重新obtain,在ACTION_UP或ACTION_CANCEL里recycle,不要遗漏。如果不recycle,列表快速滑动时会有少量内存波动,反复下来还是能感受到卡顿的。

如果列表项特别多,建议在onCreateViewHolder里给菜单按钮设置一个公共的OnClickListener,而不是每绑定一次就在onBindViewHolder里重新setOnClickListener。不过考虑到可读性,我上面的示例选择了直接在onBind里设置,实际项目里可以根据性能要求再优化。

6.3 我最终保留的几个边界处理

有几个场景我在测试时反复翻车,最后用很朴素的办法兜住了。

第一个是菜单正在回弹时再次触摸。比如用户快速甩了一下菜单,动画还没停,又用手指按上来了。这时候如果不处理,Scroller和手指会互相抢translationX,画面抖动。我在ACTION_DOWN里判断了STATE_SETTLING,如果正在动画就直接forceFinished,让手指接管。

第二个是RecyclerView复用导致menuWidth为0。第一屏绑定后onMeasure一定会执行,menuWidth会有值。但如果在onLayout里menu尚未measure,或者ViewHolder被异常复用,menuWidth就等于0,setOffset的coerce范围会变成0,菜单就永远打不开。我在reset里通过setOffset(0f)已经保证了初始状态,但在极端情况下还是建议在setOffset里加一行防御:

private fun setOffset(newOffset: Float) { if (menuWidth <= 0) return ... }

第三个是删除后position错位。这个问题在前面已经说过了,重点是删除后重置openPosition,同时因为onBindViewHolder会执行reset,其他item的菜单状态自然就复位了。

这套实现我后来用了好几个项目,虽然代码不长,但把“侧滑出菜单”这个交互从“引别人的库”变成了“完全可控的自己的东西”。遇到产品临时改交互,比如改成双按钮或者增加滑动阻尼,只需要改布局和setOffset的范围判断,不用再跑去翻库的内部实现。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 16:58:08

Python字典有序性演变:从哈希表无序到插入顺序规范化

1. 从“运行稳定”到“顺序不对”&#xff1a;一个让我重读源码的经历先说个我自己遇到的真实场景。早几年写代码&#xff0c;排查一个缓存刷新异常&#xff0c;日志里看到两组字典打印结果不一致&#xff0c;第一反应是“字典本来就无序&#xff0c;你别拿顺序说事”。结果后来…

作者头像 李华
网站建设 2026/10/25 9:09:40

中文分词实践:从词典匹配到隐马尔可夫模型的完整搭建指南

1. 从零做分词这件事&#xff0c;到底图什么先跟你说句掏心窝的话&#xff1a;市面上现成的中文分词工具一抓一大把&#xff0c;安装一个再调几行参数就能用&#xff0c;为什么还要自己动手搭一遍&#xff1f;我的理解是&#xff0c;分词是NLP入门路上最避不开、也最能帮你建立…

作者头像 李华
网站建设 2026/10/24 2:49:37

数据预处理与特征工程实战指南:从清洗到编码的完整方法论

开头写完了&#xff0c;继续往下写主体。1. 数据预处理的核心思路与常见误区1.1 预处理的优先级为什么高于模型调参我先说一个判断&#xff1a;数据预处理做得好不好&#xff0c;直接决定模型效果的上限&#xff0c;调参只是在逼近这个上限。这个道理很多人都听过&#xff0c;但…

作者头像 李华
网站建设 2026/10/26 22:14:03

DLSS Swapper 使用指南:4 步完成游戏内 DLSS 版本切换与回滚

DLSS Swapper 使用指南&#xff1a;4 步完成游戏内 DLSS 版本切换与回滚 【免费下载链接】dlss-swapper 项目地址: https://gitcode.com/GitHub_Trending/dl/dlss-swapper DLSS Swapper 用于替换游戏里已有的 DLSS、FSR 或 XeSS 的 DLL&#xff08;游戏依赖的技术模块文…

作者头像 李华
网站建设 2026/10/27 5:35:33

SEO快速见效实操指南:从收录到排名提升的完整路径

做SEO的人&#xff0c;多多少少都经历过这种状态&#xff1a;花了不少时间改标题、调内链、更新文章&#xff0c;后台数据一个多月纹丝不动&#xff0c;看着同行涨流量涨得欢&#xff0c;自己连收录都磕磕绊绊。有人开始怀疑这条路走不通&#xff0c;有人转头研究各种偏门玩法。…

作者头像 李华
网站建设 2026/10/26 23:19:12

1-4-3-ChatGPT辅助学习与课程提示词

title: 把 ChatGPT 用成开发工具:提问八法与项目级提示词 lecture: 1-4-3 chapter: 1 section: 4 series: AI Agent 全栈开发工程师学习笔记 tags: [Prompt, ChatGPT, 自定义指令, 提示词模板] 把 ChatGPT 用成开发工具:提问八法与项目级提示词 同一个问题,回复质量能差一…

作者头像 李华