Skyline|小程序手势:让半屏弹窗更顺滑
在小程序页面开发中,我们经常用半屏弹窗来进来内容展示,例如:微信开放社区切换主页、加入购物车的选项页、文章留言区等等。 [图片] 常见的半屏弹窗展示逻辑是这样的: 打开弹窗:点击 “打开弹窗” 按钮展示弹窗关闭弹窗:点击“关闭按钮” or 遮罩层 关闭弹窗当我们想在半屏弹窗加一些交互动画时,可以监听节点的 touch 事件来做一些手势判断,进而处理拖拽事件。但是这种方式实现的滚动动画容易卡顿,出现延迟的情况,效果并不理想。 为了丰富小程序的交互体验,我们内置了一批手势组件,可以帮助开发者更好的实现交互动画的效果。 下图演示使用手势的半屏弹窗下拉效果与普通半屏下拉的对比。 当内部评论列表往下拉到顶部时,变为半屏的下拉,可直接下拉关闭弹窗。 [图片] 我们来看下这种操作是怎么实现的 在上面评论列表的半屏弹窗中会有一个 scroll-view 滚动组件,在 scroll-view 中会有滚动事件,当滚动到顶部时,我们希望有整个半屏的下拉事件。 所以我们需要在半屏的最外层放置一个拖动手势组件 pan-gesture-handler 由于拖动组件内部的 scroll-view 也是可以滚动的,所以这里需要进行一个手势协商的处理,就是什么条件下由哪个组件来响应手势。 当手势往下 ⬇️ 滚动时,此时判断内部 scroll-view 滚动条的位置 滚动条处于顶部:外层 pan-gesture-handler 响应滚动,此时半屏往下拖动至关闭半屏滚动条不处于顶部:内层 scroll-view 响应滚动,此时内部列表往上滚[图片] 当手势往上 ⬆️ 滚动时,此时判断半屏的位置 半屏不完全打开时:外层 pan-gesture-handler 响应滚动,此时半屏往上拖动至完全打开半屏半屏完全打开时:内层 scroll-view 响应滚动,此时内部列表往下滚[图片] 我们来看一下代码的实现,这里用到的手势组件 pan-gesture-handler(拖动时触发)和 vertical-drag-gesture-handler(纵向滑动时触发),手势组件有以下属性 on-gesture-event:手势回调事件should-response-on-move:是否响应当前手势的 move 阶段simultaneous-handlers:指定需要协商的手势是哪几个,下面演示表示 pan 和 scroll 协同触发。native-view:代理的原生节点,这里 scroll-view(scroll-y) 内有个 vertical-drag 手势,scroll-view 自身无法处理,需要被代理出来
...
接着,我们看看在页面 js 中怎么处理手势。 在手势处理的回调中因为会改变半屏的状态值,所以这里的回调函数采用 worklet 函数,worklet 函数运行在 UI 线程,使得小程序可以做到类原生动画般的体验。 // page.js
// shared 创建的变量为共享变量,可在 UI 线程和 JS 线程间同步
this.transY = wx.worklet.shared(1000)
this.scrollTop = wx.worklet.shared(0)
this.startPan = wx.worklet.shared(true)
// shouldPanResponse 和 shouldScrollViewResponse 用于 pan 手势和 scroll-view 滚动手势的协商
shouldPanResponse() {
'worklet'
return this.startPan.value
},
shouldScrollViewResponse(pointerEvent) {
'worklet'
// transY > 0 说明 pan 手势在移动半屏,此时 scroll-view 滚动不应生效
if (this.transY.value > 0) return false
const scrollTop = this.scrollTop.value
const { deltaY } = pointerEvent
// deltaY > 0 是往上滚动,scrollTop <= 0 是滚动到顶部边界,此时 pan 开始生效,scroll-view 滚动不生效
const result = scrollTop <= 0 && deltaY > 0
this.startPan.value = result
return !result
},
// pan 手势处理
handlePan(gestureEvent) {
'worklet'
if (gestureEvent.state === GestureState.ACTIVE) {
const curPosition = this.transY.value
const destination = Math.max(0, curPosition + gestureEvent.deltaY)
// 改变半屏的位置
this.transY.value = destination
}
// 其他手势状态的处理,如滚动结束时计算半屏处于打开还是关闭的状态
}
目前,同程旅行 已经上线了手势结合半屏的效果 体验路径:酒店查询 - 选择酒店 - 选择入住人 - 新增入住人 [图片] 普通半屏结合手势代码片段:https://developers.weixin.qq.com/s/lx0RH1mD7rGj 手势除了在普通半屏的应用之外,也可以实现分段式半屏。下面演示的分段式半屏比普通半屏的判断条件更多一些。 判断条件同普通半屏类似,根据手势方向 和 分段式半屏当前的位置来判断是响应分段式半屏还是内部列表,响应分段式半屏是改变到哪一个位置。 [图片] 这里与普通半屏不同的是我们还改变了地图的缩放级别(scale) 因为 worklet 函数是在 UI 线程运行的,当要改变 data 值时,需要通过 wx.worklet.runOnJS 调回 JS 线程。 // page.js
// 设置 map scale
// 运行在 JS 线程
setMapScale(scale) {
this.setData({ scale })
},
// worklet 函数,运行在 UI 线程
scrollTo(toValue) {
'worklet'
let scale = 18
if (toValue > screenHeight / 2) {
scale = 16
}
// 从 UI 线程调回 JS 线程
wx.worklet.runOnJS(this.setMapScale.bind(this))(scale)
this.transY.value = timing(toValue, { duration: 200 })
},
// 处理拖动半屏的手势
handlePan(gestureEvent) {
'worklet'
// 滚动半屏的位置
if (gestureEvent.state === GestureState.ACTIVE) {
// deltaY < 0,往上滑动
this.upward.value = gestureEvent.deltaY < 0
// 当前半屏位置
const curPosition = this.transY.value
// 只能在 [statusBarHeight, screenHeight] 之间移动
const destination = clamp(curPosition + gestureEvent.deltaY, statusBarHeight, screenHeight)
if (curPosition === destination) return
// 改变 transY,来改变半屏的位置
this.transY.value = destination
}
if (gestureEvent.state === GestureState.END || gestureEvent.state === GestureState.CANCELLED) {
if (this.transY.value <= screenHeight / 2) {
// 在上面的位置
if (this.upward.value) {
this.scrollTo(statusBarHeight)
} else {
this.scrollTo(screenHeight / 2)
}
} else if (this.transY.value > screenHeight / 2 && this.transY.value <= this.initTransY.value) {
// 在中间位置的时候
if (this.upward.value) {
this.scrollTo(screenHeight / 2)
} else {
this.scrollTo(this.initTransY.value)
}
} else {
// 在最下面的位置
this.scrollTo(this.initTransY.value)
}
}
},
分段式页面代码片段:https://developers.weixin.qq.com/s/fw0U31mI7bGf 半屏的交互除了在页面内实现,也能跨页面实现,如常见的下沉式半屏交互。其中,半屏效果与上述实现类似,而前一页面的下沉实现需要结合自定义路由 后面的文章中我们会介绍自定义路由结合手势怎么去实现下沉式半屏效果,不仅如此,还有很多类原生的页面切换效果都能通过自定义路由实现 [图片]