在移动端,侧滑菜单是一个很常用的组件(通常称作 Drawer,抽屉)。因为现在手机屏幕太大,点击角落的菜单按钮明显不如在屏幕中间滑动方便。
相比其他平台,小程序的组件库支持明显还不够完善,各个框架也还不太成熟。由于之前使用框架的过程中被各种神秘bug搞的头秃,还是用回了原生环境。
最近研究了一下如何在原生框架中实现滑动抽屉菜单效果,本来以为很麻烦,结果发现其实只需要几十行代码,而且可以类比实现很多灵活的效果。感觉现在网上相关资料较少,因此在此分享一下。除了文中贴出的代码块,也可以点击链接在小程序开发工具中预览效果、查看代码片段。这里实现了三种常见效果,先看一下动图,下面将一一讲解代码实现。
A 菜单在上层 | A2 菜单在上层,下层遮罩 | B 菜单在下层 |
---|---|---|
![]() |
![]() |
![]() |
手势控制菜单的原理很简单:小程序提供了一系列触摸手势触发的事件,包括触摸开始、移动、结束(touchstart, touchmove, touchend
)等等。在这些事件上绑定自定义的事件响应函数,即可实现根据手势打开关闭菜单的操作。
出于性能考虑,事件处理函数最好放在 WXS、而不是 JS 文件中。具体原理与小程序的运行环境有关,感兴趣的话可以去文末查看。WXS 是小程序的专用脚本语言(WXS 与 JS 的关系相当于 WXSS 与 CSS 的关系),语法和 JS 类似,有部分区别,比如:
module.exports
对外暴露wxs 文件和 wxml 文件中的基本写法如下:
// index.wxs function touchStart(e, ins) {} function touchMove(e, ins) {} function touchEnd(e, ins) {} module.exports = { touchstart: touchStart, touchmove: touchMove, touchend: touchEnd }
<wxs module="drawer" src="./index.wxs"></wxs> <view bindtouchstart="{{drawer.touchstart}}" bindtouchmove="{{drawer.touchmove}}" bindtouchend="{{drawer.touchend}}"> </view>
这是最常见的抽屉菜单样式之一,滑动主体内容不动,菜单在上层显示。首先写出基本的 HTML 结构和 CSS 样式(省略了一些美观方面的样式表):
<wxs module="drawer" src="./index.wxs"></wxs> <view> <view class="main" bindtouchstart="{{drawer.touchstart}}" bindtouchmove="{{drawer.touchmove}}" bindtouchend="{{drawer.touchend}}"> <view> 右滑显示侧边菜单 方案A </view> </view> <view class="drawer" data-drawerwidth="150"> <view class="drawer-item">drawerA</view> <view wx:for="{{[1, 2, 3]}}" class="drawer-item"> <text>menu item {{item}}</text> </view> </view> </view>
WXML 中的几个重点:
WXSS 没啥好说的,写在注释里了:
.main { height: 100vh; width: 100%; position: absolute; } .drawer { height: 100vh; width: 150px; position: absolute; transition: transform 0.4s ease; /* 位移使用transform实现,加个过渡动画更顺滑 */ left: -150px; /* width、偏移与WXML中的数值保持一致,初始状态隐藏菜单 */ }
wxs 函数有两个入参
event
是小程序事件对象,并在此基础上多了触发事件的组件的实例 event.instance
ownerInstance
是触发事件的组件的父组件(页面)的实例wxs 中组件实例是封装好的 ComponentDescriptor
对象,能够操作组件的 dataset、设置 style、class 等,对于交互动画基本够用了。更多用法可参考文档。
var wxsFunction = function(event, ownerInstance) { var instance = ownerInstance.selectComponent('.classSelector') // 返回组件的实例 instance.setStyle({ "font-size": "14px" // 支持rpx }) instance.getDataset() instance.setClass(className) return false // 不往上冒泡,相当于调用了同时调用了stopPropagation和preventDefault }
条件判断为主,逻辑没啥特别的,结合情景不难理解
let, const
声明变量,会报错var startmark = 0; var status = 0; // 菜单开闭状态 var JUDGEPOINT = 0.7; function touchStart(e, ins) { var pageX = (e.touches[0] || e.changedTouches[0]).pageX; startmark = pageX; } function touchMove(e, ins) { var pageX = (e.touches[0] || e.changedTouches[0]).pageX; var offset = pageX - startmark; var drawerComp = ins.selectComponent('.drawer'); var drawerWidth = drawerComp.getDataset().drawerwidth; if (offset > 0 && status == 0) { setCompTransX(drawerComp, Math.min(drawerWidth, offset)) } else if (offset < 0 && status == 1) { setCompTransX(drawerComp, Math.max(0, offset)) } } function touchEnd(e, ins) { var pageX = (e.touches[0] || e.changedTouches[0]).pageX; var offset = pageX - startmark; var drawerComp = ins.selectComponent('.drawer'); var drawerWidth = drawerComp.getDataset().drawerwidth; if (offset > 0 && status == 0) { if (offset < drawerWidth * JUDGEPOINT) { setCompTransX(drawerComp, 0); } else { setCompTransX(drawerComp, drawerWidth); status = 1; } } else if (offset < 0) { setCompTransX(drawerComp, 0); status = 0; } } function setCompTransX(comp, x) { comp.setStyle({ transform: 'translateX(' + x + 'px)', }) } module.exports = { touchstart: touchStart, touchmove: touchMove, touchend: touchEnd }
点击文首或文末链接在小程序开发工具中查看完整代码。
遮罩层只需要在菜单和主容器之间增加一个 view 即可:
<view class="main"></view> <view class="mask" data-maxopacity="0.6"></view> <view class="drawer" data-drawerwidth="150"></view>
样式中很重要的是这个 pointer-events
属性,设置为 none 之后点击动作会穿透这个 view 达到下层。因为遮罩层不像抽屉是处在画面以外的,它虽然透明度为0,但实际上一直覆盖在 .main 上方,如果不加这个属性,所有对 .main 的点击操作都会点到 .mask 上面,那不管是滑动还是其他按钮都无效了。
.mask { height: 100vh; width: 100%; position: fixed; transition: opacity 0.4s ease; opacity: 0; pointer-events: none; background-color: #548CA8; }
wxs 脚本也基本完全一致,只需要以相似的方法获取到 .mask 的实例以及 dataset 中的透明度参数,并在设置位移属性的同时设置遮罩层的透明度属性即可。
function setDrawer(x) { setCompTransX(drawerComp, x); maskComp.setStyle({ opacity: x / drawerWidth * maskOpacity, }) }
点击文首或文末链接在小程序开发工具中查看完整代码。
方案B 与方案A 的区别主要在于滑动时是主界面向右移动露出下层的菜单,其余各部分实现并无不同。这里只贴出主要差异的部分。
因为移动的是 .main 元素,因此把宽度配置数据放到了该元素的标签中,这样可以少获取一个组件实例。
<view class="drawer"></view> <view class="main" data-drawerwidth="150" bindtouchstart="{{drawer.touchstart}}" bindtouchmove="{{drawer.touchmove}}" bindtouchend="{{drawer.touchend}}"> </view>
transition 动画属性也放在 .main 中,.drawer 的偏移不需要了。
.main { height: 100vh; width: 100%; position: absolute; transition: transform 0.4s ease; } .drawer { height: 100vh; width: 150px; position: absolute; }
wxs 脚本中除了获取的组件不同外,连设置位移都不需要改。
function touchMove(e, ins) { var pageX = (e.touches[0] || e.changedTouches[0]).pageX; var offset = pageX - startmark; var mainComp = ins.selectComponent('.main'); var drawerWidth = mainComp.getDataset().drawerwidth; if (offset > 0 && status == 0) { setCompTransX(mainComp, Math.min(drawerWidth, offset)) } else if (offset < 0 && status == 1) { setCompTransX(mainComp, Math.max(0, offset)) } }
小程序在很多地方与 web 开发很像,但底层存在一些区别。网页中,渲染和脚本执行在同一个线程中执行(因此执行脚本可能会导致页面整个卡死);小程序在不同的线程中分别运行逻辑层(JS脚本)和渲染层(WXML和WXSS),线程间经由客户端(Native)进行通信。
因此,如果使用 JS 脚本响应事件,每次触发 touchmove 都会产生两次进程间通信(下图左所示),通信开销较大;同时“setData 渲染也会阻塞其它脚本执行”(文档这么说的,我也不知道为什么)。由于一次手势会触发巨量的 touchmove 事件,上述原因会造成动画的卡顿。
而 WXS 函数运行在视图层,不存在上述问题(下图右所示)。
以上就是原生小程序的几种抽屉菜单实现方法,希望对你有所帮助;对于文中存在的疏漏欢迎讨论指正。
点击链接可以在小程序开发工具中查看完整代码(使用小程序开发工具的代码片段分享,对开发工具版本有一定要求)。他这个分享代码片段有点玄学,如果直接打开失败,可以在登录后尝试在“项目-导入代码片段”中直接输入链接或链接最后一段ID。
参考资料:
小程序框架/视图层/事件系统/WXS 响应事件
官方 demo
小程序宿主环境