Taro 小程序弹窗抽屉的返回手势适配

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
Taro微信小程序PageContainer交互适配

我在 Taro 小程序里做了一个从底部弹出的表单抽屉。它看起来和普通页面差不多:顶部有标题和关闭图标,中间是表单,底部是操作按钮。

实际使用时有两个问题。右上角的关闭图标很难点中;用户想退出抽屉时,下意识使用右滑返回或安卓返回键,结果不是关闭抽屉,而是回到上一页,有时甚至直接离开小程序。

这不是动画细节。抽屉在视觉上已经进入新的界面层级,导航栈却不知道它的存在。

看得见的图标不等于点得到

原来的关闭按钮只有一个 × 字符和少量内边距:

<Text className="text-[40rpx] p-[10rpx]" onClick={onClose}>
  ×
</Text>

在 750rpx 设计宽度下,40rpx 大约是 20px。字符本身很醒目,但可点击区域仍然偏小。用户单手操作时,拇指很难准确落在字符附近。

我把图标大小和触控热区分开处理。字符不必继续放大,外层按钮负责提供至少约 44px 的点击面积:

<View
  className="w-[88rpx] h-[88rpx] rounded-full flex items-center justify-center bg-gray-100 text-gray-500 active:bg-gray-200 active:text-gray-700"
  role="button"
  aria-label="关闭"
  onClick={onClose}
>
  <Text className="text-[40rpx] leading-none">×</Text>
</View>

88rpx 在常见的 375px 宽度设备上约为 44px。按钮有稳定的圆形底色,按下后颜色变化,不需要把 × 做得很粗才能让人注意到它。

如果标题栏空间紧张,可以用负外边距调整视觉占位,但不要缩小实际热区。触控区域越靠近屏幕边缘,越不应该要求用户精确点击。

普通遮罩层接不住系统返回

我一开始使用的是常见写法:一个固定定位的遮罩层,加一个位于底部的内容容器。

<View className="fixed inset-0 z-[2000] flex items-end bg-black/60">
  <View className="h-[85vh] w-full rounded-t-[48rpx] bg-white">
    {children}
  </View>
</View>

这种结构可以处理点击遮罩和点击关闭按钮,却不会改变小程序页面栈。右滑返回、安卓物理返回键和 Taro.navigateBack() 仍然作用于当前页面,所以页面会先被关闭。

onUnload 也解决不了这个问题。等它执行时,页面已经开始离开,拿它关闭 React 状态太晚了。

PageContainer 是小程序提供的“假页”

微信小程序的 page-container 就是为半屏弹窗和页内子页面准备的。Taro 已将它封装为 PageContainer

当页面里存在打开的 PageContainer 时,以下返回动作会先关闭容器:

  • iOS 右滑返回。
  • 安卓物理返回键。
  • 调用 navigateBack

组件可以这样写:

import { PageContainer, View } from "@tarojs/components";

interface DrawerProps {
  show: boolean;
  onClose: () => void;
}

export default function PublishDrawer({ show, onClose }: DrawerProps) {
  return (
    <PageContainer
      show={show}
      duration={300}
      zIndex={2000}
      overlay
      position="bottom"
      round
      closeOnSlideDown={false}
      overlayStyle="background-color: rgba(0, 0, 0, 0.6);"
      customStyle="width: 100%; height: 85vh; background-color: transparent;"
      onBeforeLeave={onClose}
      onClickOverlay={onClose}
    >
      <View
        className="h-full w-full rounded-t-[48rpx] bg-white"
        catchMove
      >
        {/* 抽屉内容 */}
      </View>
    </PageContainer>
  );
}

这里的 onBeforeLeave 很重要。用户通过系统返回关闭原生容器时,React 中的 show 也要同步变为 false。否则原生层已经关闭,业务状态却还认为抽屉处于打开状态,下一次点击入口可能无法正常显示。

点击右上角按钮、点击遮罩和系统返回最好都调用同一个 onClose。这个函数应当可以重复调用,只负责把 show 设为 false,不要在里面安排只能执行一次的副作用。

不要再叠一套遮罩和进入动画

PageContainer 已经提供这些能力:

  • overlayoverlayStyle 控制遮罩。
  • position="bottom" 控制弹出方向。
  • duration 控制进入和退出时长。
  • round 提供圆角容器语义。

如果内部还保留一套 fixed inset-0 遮罩和 animate-slide-up,可能出现双层黑色、重复动画或层级错乱。迁移时应让 PageContainer 管外层过渡,抽屉内容只负责自身布局。

原来放在底部的安全区也要保留:

<View className="pb-[calc(40rpx+env(safe-area-inset-bottom))]">
  {/* 底部按钮 */}
</View>

catchMove 可以避免用户滑动抽屉内容时带动后面的页面。对于内部有 ScrollView 的表单,需要确认滚动仍然发生在内容区,不要把整个抽屉变成无法滚动的固定层。

一个页面只能有一个 PageContainer

这个组件有一个容易忽略的限制:当前页面最多只能存在一个 PageContainer。抽屉里如果还要打开协议、选择器或详情弹窗,不能继续嵌套第二个。

我会让最外层抽屉持有这一个“假页”,内部弹窗继续用普通状态控制。此时还要明确返回层级:如果内部弹窗打开时用户按返回,是关闭内部弹窗,还是关闭整个抽屉。PageContainer 默认处理的是整个容器,不会自动理解 React 里的第二层弹窗。

简单页面可以接受返回时关闭整个抽屉。长表单或已经输入较多内容的场景,则应补上离开确认,且关闭按钮、遮罩和系统返回必须走同一套确认逻辑,不能让返回手势绕过它。

不适合这个问题的几个 API

我排查时还看了几个名字相近的能力:

  • disableSwipeBack 只是试图禁用页面右滑,不会把返回动作变成关闭抽屉,而且微信客户端 7.0.5 以后页面配置中的这个属性已经不再生效。
  • enableAlertBeforeUnload 用来在页面离开前弹出确认提示,适合防止误退出,不适合把返回动作无感转换成关闭抽屉。
  • onUnload 只能得知页面正在销毁,无法在正确时机保留页面。
  • 自己模拟页面历史会让状态、动画和真实页面栈混在一起,没有必要。

对于半屏弹窗,PageContainer 的语义最贴近用户看到的界面:它不是一个真实新页面,但返回行为像页面一样有层级。

验证不能只点关闭按钮

开发者工具里至少要检查这些路径:

  1. 点击右上角关闭按钮,抽屉关闭且页面保留。
  2. 点击遮罩,结果与关闭按钮一致。
  3. iOS 真机右滑,先关闭抽屉,不返回上一页。
  4. 安卓真机按系统返回键,先关闭抽屉。
  5. 抽屉关闭后再次打开,状态正常。
  6. 表单滚动时,后面的页面不跟随移动。
  7. 内部弹窗打开时,返回层级符合产品预期。

微信开发者工具能验证大部分逻辑,但右滑手势和安卓物理返回仍应上真机。这个问题本来就是由用户的肌肉记忆触发的,只检查鼠标点击,很容易以为已经处理完整。

参考资料