引言

在移动应用的交互设计中,底部面板(Bottom Sheet)是最经典也最实用的 UI 模式之一。从 Apple Maps 的地点详情到 Spotify 的正在播放,从 Gmail 的邮件预览到微信的聊天信息——底部面板以一种不打断主流程的方式提供辅助信息或操作入口。

但"使用现成的面板组件"和"从零构建一个可拖拽面板"是完全不同层次的能力。前者是调用一个 API,后者意味着你掌握了手势识别、动画驱动、状态管理和物理反馈的综合运用。当一个产品经理要求"这个面板的阻尼感不对,再调一调",或者设计师要求"拖到一半松手时应该弹到最近的位置"——这时候,理解底层实现原理的开发者才能真正满足需求。

HarmonyOS NEXT 的 ArkUI 虽然提供了 bindSheet 等内置面板方案,但本文选择了一条"更难但更有价值"的道路:使用 PanGesture 手势识别 + animateTo 动画驱动 + 动态高度控制,从零构建一个三态可拖拽底部面板。面板有三种高度状态——折叠态(60vp,仅显示拖拽手柄)、半屏态(310vp,显示任务摘要)、全屏态(520vp,显示完整详情和操作按钮)——并通过手势拖拽在三种状态间自然过渡。

读完本文你将能够:

  • 使用 PanGesture 实现垂直拖拽手势识别
  • 结合拖拽偏移量实时驱动组件高度变化
  • 实现"松手吸附"逻辑——根据当前位置自动吸附到最近的停靠点
  • animateTo + Curve.EaseOut 实现平滑的状态切换动画
  • 理解拖拽交互中的状态机设计(空闲→拖拽中→动画回弹→空闲)

设计思路:为什么不用现成的 bindSheet?

ArkUI 自 API 10 起提供了 bindSheet 方法,可以快速实现底部弹出面板。但在以下场景中,自定义面板更有优势:

  1. 需要非标准停靠位置bindSheetSheetMode 只有 MEDIUM 和 LARGE 两种预设,而本文的面板需要自定义三个高度
  2. 需要实时响应拖拽bindSheet 的拖拽由系统控制,开发者无法在拖拽过程中获取实时偏移量来驱动其他 UI(如背景模糊程度、内容清单的同步缩放)
  3. 需要品牌化定制:手柄样式、面板圆角、阴影强度、背景色彩——自定义面板对每个像素有完全控制权
  4. 学习价值:理解面板的底层实现原理后,你可以将相同模式应用到侧边抽屉、音量滑块、亮度调节等任何需要拖拽交互的组件

核心 API:PanGesture 手势识别

PanGesture 是 ArkUI 中用于识别平移拖拽手势的 API。它的核心配置:

PanGesture({ fingers: 1, direction: PanDirection.Vertical, distance: 5 })

三个参数的含义:

  • fingers: 1:单指操作。这是面板拖拽的自然选择——双指拖拽面板不符合用户的心智模型
  • direction: PanDirection.Vertical:仅识别垂直方向的拖拽。如果你不做这个限制,用户在面板上斜向滑动可能触发手势,导致面板高度不稳定
  • distance: 5:最小识别距离为 5vp。小于 5vp 的微小移动被忽略,避免用户在面板上点击内容时误触发拖拽。这个值设为 5(而非默认的 0)是一个经验性的选择——太小会导致"点按变拖拽"的误触,太大会让拖拽反应迟钝

手势生命周期

PanGesture 提供四个回调阶段,与面板的状态机完美对应:

onActionStart  → 面板进入拖拽模式(记录起始高度,显示拖拽态手柄)
onActionUpdate → 面板实时跟随手指(更新高度,边界钳制)
onActionEnd    → 手指离开(计算最近停靠点,播放吸附动画)
onActionCancel → 手势被取消(如系统弹窗打断,回弹到起始位置)

三态高度设计

面板的三个停靠高度不是随意设定的,而是根据内容的信息层级来决定的:

private collapsedH: number = 60;   // 折叠态:仅手柄
private halfH: number = 310;       // 半屏态:标题 + 描述
private fullH: number = 520;       // 全屏态:标题 + 描述 + 子任务 + 操作按钮
状态 高度 可见内容 用户意图
折叠态 60vp 拖拽手柄 “我在看任务列表,不需要详情”
半屏态 310vp 标题 + 描述摘要 “我想快速了解这个任务”
全屏态 520vp 标题 + 描述 + 子任务 + 操作按钮 “我要处理这个任务”

三种状态对应了三个不同的用户意图层次。这是渐进式信息披露(Progressive Disclosure)设计原则的体现——不一次性展示所有信息,而是让用户通过简单的上滑手势逐层深入。

核心实现一:拖拽跟随

手势更新的回调中,将手指的垂直偏移量转换为面板高度变化:

.gesture(
  PanGesture({ fingers: 1, direction: PanDirection.Vertical, distance: 5 })
    .onActionStart(() => {
      this.panelStartHeight = this.panelHeight;  // 记录拖拽起始高度
      this.isDragging = true;                     // 进入拖拽态
    })
    .onActionUpdate((event: GestureEvent) => {
      let newH = this.panelStartHeight - event.offsetY;  // 核心公式
      // 边界钳制
      if (newH < this.collapsedH) {
        newH = this.collapsedH;
      }
      if (newH > this.fullH) {
        newH = this.fullH;
      }
      this.panelHeight = newH;
    })
    .onActionEnd(() => {
      this.isDragging = false;
      this.snapPanel();  // 吸附到最近停靠点
    })
)

核心公式解析

let newH = this.panelStartHeight - event.offsetY;

这个公式是整个拖拽系统的核心。理解它需要搞清楚 ArkUI 中 event.offsetY 的符号约定:

  • 手指向下滑动offsetY 为正值(手指离开起始点向下移动)
  • 手指向上滑动offsetY 为负值(手指离开起始点向上移动)

因此:

  • 手指向上滑(offsetY = -100)→ newH = startH - (-100) = startH + 100 → 面板变高(展开)
  • 手指向下滑(offsetY = 80)→ newH = startH - 80 → 面板变矮(收起)

这与用户的直觉一致:向上拖动面板 = 展开查看更多内容。

边界钳制

两行 if 语句确保面板高度不会超出物理范围:

if (newH < this.collapsedH) newH = this.collapsedH;  // 不能比折叠态更矮
if (newH > this.fullH) newH = this.fullH;             // 不能比全屏态更高

如果你去掉这些钳制,用户可能会把面板拖到负数高度(面板消失)或拖到屏幕外(超过全屏)。边界钳制给了拖拽一个"硬墙"——拖到极限后即使手指继续移动,面板也不再跟随。

更高级的实现可以在边界处加入"阻尼"效果:到达边界后手指再移动 100vp,面板只跟随 30vp(类似 iOS 的橡皮筋效果)。但这需要更复杂的手势数学,本文保持简洁。
在这里插入图片描述
在这里插入图片描述

核心实现二:松手吸附

当用户松手时,面板不会停留在任意高度,而是自动吸附到最近的三个停靠点之一:

snapPanel(): void {
  let targets: number[] = [this.collapsedH, this.halfH, this.fullH];
  let best = targets[0];
  let bestDist = Math.abs(this.panelHeight - targets[0]);

  for (let i = 1; i < targets.length; i++) {
    let dist = Math.abs(this.panelHeight - targets[i]);
    if (dist < bestDist) {
      bestDist = dist;
      best = targets[i];
    }
  }

  this.animatePanelTo(best);
}

animatePanelTo(target: number): void {
  animateTo({ duration: 280, curve: Curve.EaseOut }, () => {
    this.panelHeight = target;
  });
}

吸附算法很简单——遍历三个停靠点,找到与当前高度距离最近的那个。例如,如果松手时高度是 200vp,与 60 距离 140,与 310 距离 110,与 520 距离 320——最近的是 310,吸附到半屏态。

animateTo 驱动高度从当前值平滑过渡到目标值,280ms 的时长和 EaseOut 曲线创造了"面板吸附到位"的自然物理感——开始快,结束慢,就像物体被磁铁吸过去。

为什么不用弹簧动画?

Curve.Friction(替代已废弃的 Curve.Spring)可以创建更真实的物理弹簧效果。但对于面板吸附,弹簧动画有一个缺点:如果计算不当,弹簧的"过冲"(overshoot)会让面板短暂超出目标停靠点再弹回——这在某些设计中是风格化的选择,但在本文的实用场景中,EaseOut 的"快速接近、平稳停止"更符合用户对"吸附"的心智预期。

核心实现三:点击选择联动

除了拖拽面板,用户还可以点击任务列表中的任务来展开面板:

selectTask(idx: number): void {
  this.selectedIdx = idx;
  if (this.panelHeight < this.halfH) {
    this.animatePanelTo(this.halfH);
  }
}

关键细节:点击选择只在面板处于折叠态(高度 < 半屏高度)时自动展开。如果面板已经处于半屏或全屏状态,切换任务不会改变面板高度——因为用户可能正在浏览不同任务的详情,保持面板当前高度是合理的。

这个逻辑体现了"尊重用户当前视图"的设计原则:不要因为内部数据变化而突然改变用户正在操作的界面的物理状态。

核心实现四:条件内容渲染

面板内容根据当前高度渐进显示:

if (this.selectedIdx >= 0 && this.panelHeight > this.collapsedH + 20) {
  Column() {
    // 标题和描述(半屏时即可见)
    Text(this.tasks[this.selectedIdx].title)...
    Text(this.tasks[this.selectedIdx].description)...

    // 子任务和操作按钮(仅全屏时可见)
    if (this.panelHeight > this.halfH + 30) {
      Text('子任务')...
      ForEach(subtasks)...
      Row() {
        Button('编辑任务')...
        Button('标记完成')...
      }
    }
  }
}

这里用了一个微妙的设计:阈值不是精确的停靠点高度(60、310、520),而是比停靠点略高一点的值(80、340)。原因是:

  • 当面板动画吸附到半屏(310vp)时,内容立即出现,没有"高度够了但内容还没出来"的延迟感
  • 当用户拖拽面板刚好超过半屏一点点时,子任务不会闪现——需要明确地拖到更高位置才显示

这种"滞后阈值"设计在物理世界中也有对应——就像抽屉需要拉出一段距离后里面的东西才能被拿到。

视觉反馈:拖拽态与闲置态

面板在不同状态下呈现不同的视觉风格:

闲置态

// 手柄颜色:浅灰
.backgroundColor('#E0E0E0')
// 面板阴影:轻度
.shadow({ radius: 8, color: '#00000020', offsetX: 0, offsetY: -2 })
// 面板背景:白色
.backgroundColor('#FFFFFF')

拖拽态

// 手柄颜色:蓝色(暗示"激活")
.backgroundColor('#1677FF')
// 面板阴影:加强(产生"抬升"感)
.shadow({ radius: 20, color: '#00000020', offsetX: 0, offsetY: -2 })
// 面板背景:微蓝(与手柄呼应)
.backgroundColor('#FAFAFE')

拖拽态的手柄变为蓝色,阴影半径从 8 增加到 20——在视觉上创造"面板被手指拎起来了"的立体感。这是 Material Design 中"表面抬升"(Surface Elevation)理念的直接应用:被操作的对象应该看起来更接近用户(更大的阴影 = 更短的投影距离 = 更接近屏幕表面)。

任务列表的左侧选中指示器

点击选中任务时,列表项左侧出现蓝色竖线:

.border({
  width: { left: idx === this.selectedIdx ? 3 : 0 },
  color: idx === this.selectedIdx ? '#1677FF' : '#FFFFFF'
})

同时背景切换为浅蓝色(#F8FAFF),与面板内容呼应。这个视觉关联帮助用户建立"列表项 ← → 面板内容"的对应关系。

完整页面结构

Column(根容器,高度 100%)
├── Header(标题栏:任务列表 + 任务总数)
├── Scroll(任务列表,layoutWeight(1) 填充剩余空间)
│   └── Column
│       └── ForEach:任务列表项
│           ├── 左侧蓝色选中指示器(条件渲染)
│           ├── 任务标题 + 元信息(优先级、负责人、截止日期)
│           └── 右侧箭头 ›
└── Column(可拖拽面板,高度 = panelHeight)
    ├── Row(拖拽手柄,PanGesture 绑定)
    └── Column(面板内容,条件渲染)
        ├── 任务标题
        ├── 任务描述
        ├── 子任务列表(全屏态可见)
        └── 操作按钮行(全屏态可见)

常见问题与解决方案

问题一:拖拽面板时任务列表跟着滚动

原因:任务列表的 Scroll 组件和面板的 PanGesture 同时响应手指移动,形成手势冲突。

解决:PanGesture 绑定在面板的手柄区域(28vp 高度的 Row),而 Scroll 覆盖的是任务列表区域。两者的触摸区域不重叠,手势互不干扰。如果手柄区域太小导致触控不灵敏,可以增大手柄的 .height().padding() 而不改变视觉手柄的大小。

问题二:animateTo 在拖拽过程中不生效

原因onActionUpdate 中直接赋值 this.panelHeight = newH,没有使用 animateTo——这是故意为之。拖拽跟随必须直接赋值以保持 1:1 的手指跟随感。如果在 onActionUpdate 中使用动画,面板会滞后于手指,产生"不跟手"的感觉。

解决:拖拽中用直接赋值(60fps 实时响应),松手后用 animateTo(平滑吸附)。两者各司其职。

问题三:点击任务后面板没有展开

原因:面板当前已经处于半屏或全屏高度(≥ 310vp),selectTask 中的 if (panelHeight < halfH) 条件不满足。

解决:这是预期行为。如果用户在半屏状态下切换任务,面板保持当前高度。"双击展开"可以作为额外的功能——同一任务点击两次后展开到全屏。

问题四:PanGesture 精度误差导致面板微微抖动

原因distance: 5 设置过小,手指的微小抖动被识别为手势偏移。

解决:将 distance 从 5 增加到 8-10。但这会让拖拽不够灵敏。更好的方案是在 onActionUpdate 中加入"死区"——偏移量小于 2vp 时忽略更新。

进阶扩展方向

1. 橡皮筋阻尼

在边界处(collapsed 以下或 full 以上)应用阻尼公式:newH = bound + (newH - bound) * 0.3。用户拖到边界后还能继续拖一点点,松手后弹回——这就是 iOS 风格的橡皮筋效果。

2. 速度感知吸附

onActionEnd 中,除了当前高度,还考虑手指离开时的速度(event.velocityY)。如果用户快速上滑(速度 > 阈值),即使当前高度更接近半屏,也应该吸附到全屏——因为用户的"意图"是想快速展开。

3. 面板遮罩联动

面板展开时,在任务列表上方覆盖一个半透明遮罩,点击遮罩收起面板。遮罩的透明度根据面板高度动态计算:opacity = (panelHeight - collapsedH) / (fullH - collapsedH) * 0.5

4. 多面板嵌套

在面板内部再嵌套一个可拖拽的子面板——例如"编辑子任务"面板从全屏面板中再向上滑出。需要管理多层手势优先级。

5. 无障碍适配

为面板的拖拽手柄添加无障碍标签(.accessibilityText('拖拽调整面板高度')),让视障用户通过辅助功能也能操作面板。

总结

本文通过从零构建一个三态可拖拽底部面板,深入讲解了 HarmonyOS ArkUI 中手势交互和动画系统的综合应用。核心要点如下:

  1. PanGesture 手势识别fingersdirectiondistance 三个参数精确控制手势行为
  2. 拖拽跟随公式newH = startH - offsetY,手指上滑(负 offsetY)→ 面板变高,手指下滑(正 offsetY)→ 面板变矮
  3. 边界钳制if (newH < min) newH = min; if (newH > max) newH = max; 限制面板范围
  4. 松手吸附:计算到三个停靠点的距离,选择最近的目标,animateTo 动画过渡
  5. 手势状态管理onActionStart(记录起始值)→ onActionUpdate(直接赋值跟随)→ onActionEnd(吸附动画 + 状态重置)
  6. 渐进式内容呈现:折叠态(0内容)→ 半屏态(标题+描述)→ 全屏态(+子任务+按钮)
  7. 视觉反馈:拖拽态手柄变色、阴影加强,创造"拎起来"的物理感

自定义面板的构建过程实际上是"手势→动画→状态"三者协同的微型交响乐。掌握这个模式后,你已经具备了构建任何自定义拖拽交互的能力——无论是音量滑块、亮度调节、侧边栏抽屉、还是创新性的新型交互界面。

在某种意义上,拖拽是移动端唯一真正需要"从零实现"的交互模式。点击、长按、滑动——系统已经做了足够好的默认处理。但拖拽需要开发者自己管理坐标系、计算偏移量、处理边界、调度动画。这是一种值得花时间精通的能力,因为它是区分"普通应用"和"有质感的应用"的关键技能之一。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐