资讯详情 uni-app左滑列表组件选型与自研实践:从手势识别到多端兼容
📅 2026/10/15 18:22:20
先说结论uni-app 里做左滑操作列表最稳妥的路线其实是自己写。官方组件能覆盖 80% 的基础场景但真到了要同时适配小程序、App、H5并且还想控制手势手感、按钮布局、展开互斥这些细节时抄作业往往会改成一场灾难。这篇文章记录的是我从选型到自研一个通用左滑组件的完整过程包含可直接复用的代码、多端兼容的细节以及我在真实项目里踩过的四个坑。如果你正在做一个消息列表、订单列表这种带置顶/删除/标记操作的页面这篇文章能帮你省掉不少来回折腾的时间。1. 为什么别急着写代码三种实现路线的取舍分析第一次做这个需求我第一反应就是找现成方案。当时摆在我面前的大概有三条路官方扩展组件、插件市场里的第三方组件、自己从手势开始写。三条路我都试过这里直接说结论。1.1 官方 uni-swipe-action 能省多少事官方组件在 uni_modules 里直接引入就能用结构大概是外层包一个uni-swipe-action列表项用uni-swipe-action-item按钮配置通过options传进去。uni-swipe-action uni-swipe-action-item v-for(item, index) in list :keyitem.id :right-optionsoptions clickonClick view classlist-content{{ item.title }}/view /uni-swipe-action-item /uni-swipe-action好处很明显不用管手势识别、不用管动画回弹、也不用管按钮宽度适配。如果业务就是两条按钮固定颜色不搞花样这套方案半小时就能跑起来。但它的短板也很致命。第一按钮样式定制非常受限想加渐变、异形圆角、自定义图标都得去覆写它内部的多层嵌套结构改着改着就变成 fork 源码。第二展开互斥和点击外部关闭的逻辑并不总是符合预期。第三在某些小程序端它内部用了定位布局和 fixed 弹层叠在一起时偶尔会出现层级遮挡。这些坑在文档里都不会写只有真机上被坑过才知道。1.2 第三方插件看起来很香但多端适配是个赌局插件市场里搜左滑能找到不少 star 很高的组件功能比官方还全支持左右双向滑、多级菜单、甚至长按拖拽。但我的体验是大部分这类插件的真机测试报告只覆盖了一两个端你在微信小程序上跑得顺不代表百度小程序、支付宝小程序、App 上也顺。我试过一个组件功能确实强大但它的滑动逻辑是拿scroll-view做的在 App 端和 H5 端的手感完全不一样最后还得改源码。第三方组件一旦需要改源码你就失去了跟随作者升级的资格后面作者修 bug 发新版本你都得手动合代码。对多端发布的团队来说这其实是隐性成本。1.3 我的选择从手势开始自研自研最大的好处是可控。整个交互拆开无非是三件事识别手指横滑、计算位移、松手时决定展开还是回弹。这三件事加起来不过几百行代码但你能精确控制每个环节。拖动手感可以自己调是全程跟随手指还是加一点阻尼。按钮区可以随便设计两个按钮、三个按钮带图标、带文案、带分隔线都行。多端兼容问题自己在代码里逐个处理而不是去读别人组件源码猜它做了什么。我用自研方案落地过一个订单列表功能包含左滑出现置顶和删除两个按钮、展开时点击页面其他地方自动关闭、删除后有动画回收。整个组件只占一个 vue 文件最后在微信小程序、App、H5 三端都跑得挺稳。这里给个选型建议如果你能确定项目只在单一平台运行、样式也很普通官方组件确实快但只要提到多端适配和自定义交互这两个词自研基本是唯一稳的路线。2. 左滑交互的底层原理从手指坐标到位移状态机不管用什么方案左滑操作的底层逻辑都是同一套。理解这套逻辑后面看代码、调 bug 都会清晰很多。2.1 一次完整滑动手势的数据链路一次左滑从手指按下到抬起经历了touchstart - touchmove... - touchend三个事件周期。touchstart负责记录起点坐标。touchmove会在手指移动过程中触发很多次每次都能拿到当前的clientX和clientY和起点相减就得到了水平和垂直方向的位移增量const dx touch.clientX - this.startX; const dy touch.clientY - this.startY;这里要注意一个很容易忽略的点用户手指很少严格水平移动。哪怕是滑下面的操作按钮手指也会带一点上下偏移。所以必须在第一次位移超过阈值时进行一次方向锁定if (Math.abs(dx) 8 Math.abs(dx) Math.abs(dy)) { // 判定为横向滑动锁定方向 this.horizontal true; } else if (Math.abs(dy) 8 Math.abs(dy) Math.abs(dx)) { // 判定为纵向滚动本次触摸不再处理横滑 this.horizontal false; }方向锁定的意义在于一旦判定为横滑后面不管手指怎么抖都只处理水平位移不跟垂直方向产生冲突反过来如果判定为纵向滚动就立即把这次触摸标记为非横滑后面的touchmove都直接忽略把滚动还给页面或滚动容器。2.2 位移范围、吸附阈值和速度检测横滑过程中的位移不是无限增大的。按钮区总宽是actionsWidth列表内容最左只能移到-actionsWidth的位置最右只能回到0。所以每一帧都要做一次约束let next this.startOffset dx; next Math.max(-this.actionsWidth, Math.min(0, next)); this.offset next;clamp很重要。不做约束手指往左滑时内容会被拖出屏幕外按钮区露出过头松手后还要再纠偏观感非常差。松手时的吸附判断比较简单一个阈值足够当前位移超过按钮总宽的一半就展开否则就回弹。但这只对慢速滑动友好。如果用户快速甩动但只滑了三分之一按阈值判断应该回弹但用户的手势意图明显是想展开。我后来在项目里加了速度检测用最后一个touchmove和倒数第二个touchmove之间的位移差除以时间差得出瞬时速度。松手时如果速度绝对值大于某个值直接忽略距离阈值按手势方向展开或关闭const velocity (lastOffset - prevOffset) / (now - lastTime); if (Math.abs(velocity) 0.5) { target velocity 0 ? -this.actionsWidth : 0; } else { target this.offset -this.actionsWidth / 2 ? -this.actionsWidth : 0; }加了速度判断之后列表项的滑动手感明显更通透用户快速刷列表时不会出现看着要打开却弹回去的别扭感。2.3 用状态机管理展开、收起和过渡期开始写代码之前我建议先用一个简单的状态机把交互阶段定义清楚状态含义触发时机CLOSED初始闭合状态位移为 0页面加载、点击外部区域时DRAGGING手指拖动中位移实时跟随touchmove 判定横滑后SETTLING松手后向目标位置过渡touchend 之后动画期间OPENED展开状态按钮完全露出动画结束位移达到按钮总宽为什么需要状态机因为拖动、动画、展开三个时期对手势的处理策略完全不一样。动画过程中如果用户再次 touchstart应该直接忽略否则会看到列表项在动画中被二次拖动状态直接乱掉。有了显式的animating状态这类边界条件就变成一行判断的事。transform而不是用left或margin-left是因为transform触发的是合成器动画不触发布局重排。在小程序端和 H5 端这直接决定了滑动跟不跟手。3. 从零落地一个通用组件SwipeActionItem 的完整实现有了原理打底写代码就顺了。我实际用的组件叫SwipeActionItem.vue支持任意个按钮、按钮宽度自定义、展开互斥、点击外部关闭几百行以内搞定。3.1 组件的模板结构左滑组件的核心是两层结构底部是操作按钮区顶部是内容区。内容区盖住按钮区用户左滑时内容区往左平移按钮区从右侧露出来。template view classswipe-item !-- 操作按钮区域 -- view classswipe-item__actions :style{ width: actionsWidth px } view v-for(action, index) in actions :keyaction.text index classswipe-item__action :style{ width: action.width px, backgroundColor: action.bgColor || #ff4d4f, color: action.color || #ffffff } click.stoponAction(index) {{ action.text }} /view /view !-- 内容区域 -- view classswipe-item__content :class{ swipe-item__content--anim: animating } :style{ transform: translateX( offset px) } touchstartonTouchStart touchmoveonTouchMove touchendonTouchEnd touchcancelonTouchEnd slot / /view /view /template按钮区绝对定位在右侧宽度由 JS 根据 actions 配置计算出来。内容区相对定位z-index高于按钮区保证闭合状态下按钮区完全被盖住视觉上只有一条干净的列表。关键的样式部分.swipe-item { position: relative; overflow: hidden; background: #ffffff; } .swipe-item__actions { position: absolute; top: 0; right: 0; height: 100%; display: flex; flex-direction: row; align-items: stretch; } .swipe-item__action { display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; } .swipe-item__content { position: relative; z-index: 2; background: #ffffff; transition: none; } .swipe-item__content--anim { transition: transform 0.25s ease; }这里有两个细节值得注意。第一动作按钮一定要flex-shrink: 0否则按钮一多flex 布局会把它们压缩变形第二过渡动画的 class 和拖动状态是分离的拖动过程中不给过渡松手后才加。如果全程开着transition手指每移动一帧列表都会朝目标方向追过去手感会变得特别迟钝。3.2 触摸事件与位移计算接下来是组件的灵魂——触摸事件处理。我用的还是 Options API 的写法uni-app 主力项目大部分仍是 Vue2 语法逻辑比较直观。Vue3 项目把 data 和 methods 搬到 setup 里就行。export default { name: SwipeActionItem, props: { actions: { type: Array, default: () [] }, active: { type: Boolean, default: false } }, data() { return { offset: 0, startX: 0, startY: 0, startOffset: 0, actionsWidth: 0, dragging: false, horizontal: false, animating: false, lastMoveTime: 0, lastOffset: 0 }; }, watch: { active(val) { // 父组件通知收起时如果当前项处于打开状态就归位 if (!val this.offset ! 0) { this.close(); } } }, mounted() { this.actionsWidth this.actions.reduce( (sum, item) sum (item.width || 80), 0 ); }, methods: { onTouchStart(e) { if (this.animating) return; const touch e.touches[0]; this.startX touch.clientX; this.startY touch.clientY; this.dragging false; this.horizontal false; this.lastOffset this.offset; this.lastMoveTime Date.now(); }, onTouchMove(e) { if (this.animating) return; const touch e.touches[0]; const dx touch.clientX - this.startX; const dy touch.clientY - this.startY; if (!this.dragging) { if (Math.abs(dx) 8 Math.abs(dx) Math.abs(dy)) { this.dragging true; this.horizontal true; this.startOffset this.offset; } else if (Math.abs(dy) 8 Math.abs(dy) Math.abs(dx)) { this.horizontal false; } if (!this.dragging) return; } let next this.startOffset dx; next Math.max(-this.actionsWidth, Math.min(0, next)); this.offset next; this.lastOffset next; this.lastMoveTime Date.now(); }, onTouchEnd() { if (!this.horizontal || this.animating) return; const shouldOpen this.offset -this.actionsWidth * 0.5; this.dragging false; this.horizontal false; this.animateTo(shouldOpen ? -this.actionsWidth : 0); this.$emit(toggle, { open: shouldOpen }); }, animateTo(target) { this.animating true; this.offset target; setTimeout(() { this.animating false; }, 260); }, close() { this.animateTo(0); this.$emit(toggle, { open: false }); }, onAction(index) { this.$emit(action, index); } } };这段代码里startOffset的语义值得强调它记录的是本次拖动开始前列表项的位移而不是 0。这样做的原因是列表项可能正处于展开状态用户再次横向拖动时位移要基于当前状态继续累加而不是从头算否则手指按下去的一瞬间列表会先跳回闭合位置。3.3 父页面中的展开互斥一个列表同时只有一个项能处于打开状态这是左滑交互的默认规则。我通过父页面维护一个activeIndex实现template view classorder-list clickonListClick SwipeActionItem v-for(item, index) in list :keyitem.id :actionsactions :activeactiveIndex index toggleonToggle(index, $event) actiononAction(index, $event) view classorder-item text classorder-item__title{{ item.title }}/text text classorder-item__status{{ item.status }}/text /view /SwipeActionItem /view /templateexport default { data() { return { activeIndex: -1, actions: [ { text: 置顶, width: 72, bgColor: #faad14 }, { text: 删除, width: 72, bgColor: #ff4d4f } ] }; }, methods: { onToggle(index, { open }) { this.activeIndex open ? index : -1; }, onAction(index, actionIndex) { // actionIndex: 0 表示置顶1 表示删除 if (actionIndex 0) { this.handleStick(index); } else { this.handleDelete(index); } }, onListClick() { this.activeIndex -1; } } };互斥逻辑其实就两行某个项toggle打开时把它的 index 记录到activeIndex另一个项要打开时原来那个项的activeprop 会变成false进而触发组件内部的watch自动收起。这个方案不需要组件之间互相通信也不需要用全局事件总线干净利落。删除操作配合一个简单的列表动画体验会完整不少。置顶操作用splice把当前项移到数组头部列表会自动重新渲染展开状态也会因为activeIndex失效而自动关闭。handleDelete(index) { this.list.splice(index, 1); this.activeIndex -1; }, handleStick(index) { const [item] this.list.splice(index, 1); this.list.unshift(item); this.activeIndex -1; }4. 多端适配实录rpx 换算、滚动冲突与真机细节组件跑起来之后最花时间的是三端真机调试。这里把我在小程序、H5、App 上遇到的多端差异一次性说清楚。4.1 按钮宽度必须统一换算成 px触摸事件拿到的clientX、位移量都是 px而很多人在开发时习惯用 rpx 写按钮宽度。如果按钮宽度直接写80rpx组件内部计算的actionsWidth是 80那么在小屏手机上左滑距离和按钮实际宽度就会对不上松手后要么按钮露不全要么露出了一部分空白。正确做法是把配置宽度统一转成 px。uni.upx2px()可以做这个换算但有的端支持不完整。我项目里用的是自己算const sysInfo uni.getSystemInfoSync(); const factor sysInfo.windowWidth / 750; const pxWidth (rpxValue) rpxValue * factor;在组件的 mounted 里把每个按钮的宽全部转一遍再累加。按钮的实际渲染宽度也直接使用 px保证位移计算和样式渲染用的是同一套数字。4.2 纵向滚动和横向滑动到底怎么和平相处一开始我总担心 touch 事件会干扰页面竖向滚动后来实测发现只要方向锁定逻辑写得对纵向滚动基本不会被破坏。用户纵向滑动时第一次位移检测就会判定为竖向组件直接放弃本次触摸横向滑动时滚动容器的纵向滚动也不会被触发。真正需要小心的是 iOS 下的橡皮筋效果。列表滚到底部用户再往下拉或者横滑时整个页面会跟着弹性回弹。H5 端可以通过在容器上添加overscroll-behavior: contain来缓解.swipe-page { overscroll-behavior: contain; }微信小程序 iOS 端也有类似问题但没有统一的 CSS 方案我的做法是左滑列表所在的页面尽量使用scroll-view包裹并且给scroll-view设置scroll-y。实测在scroll-view里横向手势和纵向滚动的冲突要比页面原生滚动小很多。还有一个容易踩的坑不要在模板上直接写touchmove.prevent。.prevent是静态的一旦写了列表项的纵向滚动全会被拦截用户会明显感到页面划不动。正确的处理是只在horizontal判定成立后才在事件对象上阻止默认行为onTouchMove(e) { if (this.horizontal e.cancelable) { e.preventDefault(); } // ... 后续位移计算 }H5 端这么做没问题。小程序端个别平台对 preventDefault 的支持不是完全一致真机测试时需要重点验证的表现是左滑操作时页面是否跟随手指左右抖动。4.3 App 端和小程序端的性能差异App 端和 H5 端性能通常不是瓶颈transform 位移基本跟手。小程序端需要特别注意逻辑层和渲染层是分离的每一次setData都要跨层传输数据拖动过程中如果每一帧都触发 setData低端安卓机上就会出现明显的掉帧。我给小程序端做了三件事拖动中的位移不做小数运算Math.round归整。位移变化极小小于 2px时直接跳过更新减少 setData 次数。拖动过程中不加任何 transform 过渡靠原生合成器跟手。实测这三项优化下来中端安卓机的滑动流畅度提升还是很明显的。如果列表项非常多且要求丝滑可以考虑 App 端使用renderjs把触摸计算放到视图层小程序端则要配合虚拟列表来控量。4.4 组件点击与外部点击关闭的顺序问题小程序端有个特殊现象点击操作按钮时按钮的click事件和内容区的click事件有可能会先后触发。如果内容区已经在展开状态点击按钮的位置又恰好落在内容区边缘就会出现按钮回调执行了但列表项又被误关闭的情况。我的做法是按钮区的click必须加.stop修饰符阻止事件冒泡到外层列表容器。同时在按钮回调里不依赖activeIndex的变化而是直接处理业务逻辑最后手动把activeIndex置为 -1。这样即使事件时序有偏差也不会出现业务逻辑被重复执行的情况。5. 复盘真实项目里踩过的四个坑排查思路与修复方案最后分享四个我实际项目中遇到的边界问题。这些坑都不在组件文档里很可能在你写完第一版后突然冒出来。5.1 展开后点击列表空白处列表项不关闭现象某个列表项左滑展开后点另一个列表项或者点页面空白之前展开的项纹丝不动。排查思路组件只关注自己内部的手势和点击点击发生在别的区域时当前项根本收不到任何事件。问题本质是收起状态由谁通知的机制没设计好。修复方案在列表外层容器加一个click统一把所有项的activeIndex置为 -1。这样点任何非按钮区域所有项都会通过watch收到active false自动执行收起动画。注意按钮区的.stop必须加否则点击按钮时事件冒泡到容器会先把展开的项收起来按钮回调再执行业务上就会出现功能正常但视觉跳变的怪问题。5.2 左侧第一项总是粘着一条背景色怎么都消不掉现象列表初始加载时右侧偶尔会露出一条几像素宽的色块看起来像按钮区漏出来了。左滑一点再回弹后反而正常。排查思路按钮区是绝对定位子按钮宽度是用配置算出来的。问题大概率出在actionsWidth的计算时机上。如果 mounted 里直接累加但按钮区已经在模板中渲染过一次就会出现一帧按钮宽度还是默认值的画面。修复方案mounted 里用this.$nextTick包一层等渲染完成后再计算actionsWidth。同时给按钮区显式设置宽度不要让它依赖 flex 布局隐式撑开。这样初始状态下按钮区始终被压在当前项内容区底下不会有一瞬间的漏色。mounted() { this.$nextTick(() { this.actionsWidth this.actions.reduce( (sum, item) sum this.toPx(item.width || 80), 0 ); }); }5.3 快速连滑的时候列表项卡在半开半关的中间态现象用户快速把列表项滑开马上又往回滑有时候列表项停在中间位置既没有完全展开也没有完全回弹。排查思路问题出在动画状态和手势状态的衔接上。动画期间animating为 true新的 touchstart 被忽略但如果上一次动画的定时器还没结束动画目标又被新的状态覆盖位移就会在多个目标值之间互相覆盖最后停在某个中间值。修复方案动画期间强制忽略手势只是第一步。还要在每次animateTo之前清理之前的定时器用一个animationTimer保存句柄组件卸载时也要清理。这样不管用户怎么快速连滑每次松手后只有一个最新的目标位移在生效不会再互相干扰。5.4 低端安卓机上滑动一卡一卡iOS 上反而流畅现象同样一段代码iPhone 上跟手低端安卓机上明显掉帧尤其是按钮区背景色比较鲜艳时视觉上更明显。排查思路小程序端频繁 setData 是主因其次是按钮区带背景色导致绘制面积变大。性能瓶颈不在逻辑代码而在渲染通道的传输开销。修复方案除了前面提到的位移归整、阈值跳过我还把所有按钮的背景色从动态绑定改为静态样式类。如果按钮只有固定的两三种颜色优先写成 class 而不是行内 style减少样式计算量。针对低端机再把滑动阈值从 8px 调到 12px减少误触发的概率实测平滑度改善很明显。最后一点个人体会左滑交互看起来是个小功能真正落地要考虑的问题其实很多手势方向锁定、位移约束、吸附阈值、动画时机、展开互斥、多端差异每一项都值得单独验证。我现在的选型标准很简单如果只是普通删除操作官方组件覆写样式足够但凡涉及多个操作按钮、复杂业务状态、或者要跨三端发布我依然会回到自研方案上。组件代码我一直在持续用后来给按钮加了角标、再后来支持了右侧双按钮不同宽度都是在原有架构上加配置项的事。希望这篇梳理能帮你少走点弯路一次把左滑列表做明白。