一、引言

在移动端的交互设计中,“底部弹出层”(Bottom Sheet)已经成为仅次于对话框(Dialog)和气泡弹窗(Popup)的第三大"浮层交互"模式。从 iOS 的 UISheetPresentationController 到 Material Design 的 BottomSheet,再到各类 App 中的分享面板、地址选择器、筛选面板——底部弹出层以其"从底部自然滑出、保留部分背景可见、支持拖拽手势"的特性,成为了用户体验设计中不可或缺的一环。

与 Dialog 相比,Bottom Sheet 不会完全打断用户的注意力——它保留了约 1/3 到 1/2 的背景可见,让用户始终知道"我在哪里"。与 Popup 相比,Bottom Sheet 可以承载更复杂的内容(列表、表单、多层级导航),而不仅仅是快捷操作菜单。它像是一种"轻量级的二级页面"——足够完成复杂任务,又足够轻便,不会带来页面跳转的认知负担。

在 HarmonyOS NEXT 中,ArkUI 提供了原生的 bindSheet API 来实现底部半模态弹出层。通过绑定到任意组件的 bindSheet 属性,配合 SheetSize 高度枚举(MEDIUM / LARGE / AUTO)、拖拽手柄(dragBar)、显示/隐藏回调,开发者可以轻松地为自己的页面添加底部弹出层交互。

本文将通过一个完整的**“地址选择器”**实战案例,利用 Stack 叠加层 + 偏移动画技术,从零构建一个底部半模态弹出层,深入解析其核心架构——遮罩层、弹出面板、拖拽手柄、多级联动和高度切换。同时,我们会与原生 bindSheet API 进行对比分析,帮助你理解何时使用原生组件、何时选择自定义实现。阅读完本文,你将能够:

  • 掌握底部弹出层的 UI 架构(遮罩 + 面板 + 动画)
  • 实现拖拽区域点击切换面板高度的交互
  • 在弹出层内部实现多级内容导航(省→市→区三级联动)
  • 通过 @State 驱动面板的显示/隐藏状态
  • 理解 bindSheet 原生 API 与自定义实现的各自优势

二、底部弹出层的核心架构

2.1 三层叠加模型

一个完整的底部弹出层由三个视觉层次组成:

┌────────────────────────────┐
│       主内容区              │  ← 底层:页面的正常内容
│   (选择按钮 + 当前选择)    │
│                            │
├────────────────────────────┤
│  半透明遮罩 (#00000055)    │  ← 中层:阻止背景交互 + 暗示背景不可操作
├────────────────────────────┤
│  ┌──────────────────────┐  │
│  │  ━━━ 拖拽手柄         │  │
│  │  选择省份             │  │  ← 顶层:白色圆角面板
│  │  · 北京市        ›   │  │     承载弹出层内容
│  │  · 上海市        ›   │  │
│  │  · 广东省        ›   │  │
│  │  · 浙江省        ›   │  │
│  └──────────────────────┘  │
└────────────────────────────┘

在 ArkUI 中,我们使用 Stack 叠加层 + .overlay() 来实现这个三层架构:

// 主页面结构
Column() {
  // ... 主内容 ...
}
.overlay(this.showSheet ? this.sheetOverlay() : null)

this.showSheetfalse 时,overlay 内容为 null,页面正常显示。当为 true 时,overlay 渲染一个全屏的 Column,内部包含半透明遮罩和底部弹出面板。

2.2 半透明遮罩:背景的"失焦"效果

遮罩层有两个核心职责:阻止用户与背景页面交互、通过半透明黑色暗示"背景暂时不可用"。

Column()
  .width('100%')
  .layoutWeight(1)
  .backgroundColor('#00000055')
  .onClick(() => { this.showSheet = false; })

这里 #00000055 表示黑色 33% 不透明度(0x55 / 0xFF ≈ 33%)。为什么是 33% 而不是更高的值?因为底部弹出层的设计哲学是"不完全遮挡背景"——用户需要隐约看到背景内容,以维持空间方向感。通常的取值范围是 30%-50%:

  • 30%:轻量弹出,背景内容仍然可辨识(适合教程引导类弹出)
  • 40%:标准弹出,背景朦胧但可见(适合选择器、筛选面板)
  • 50%+:重度弹出,背景几乎不可见(适合确认类弹出,接近 Dialog)

遮罩层的 onClick 绑定到关闭逻辑——点击遮罩区域(即弹出面板之外的任意位置)关闭弹出层。这是符合用户直觉的标准行为。

2.3 弹出面板:圆角白色容器

弹出面板是一个固定在底部的 Column,通过圆角和阴影与遮罩层区分开:

Column() {
  // 拖拽手柄 + 标题 + 选项列表
}
.width('100%')
.height(this.sheetHeight)       // 动态高度:420vp(中等)或 640vp(完整)
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 16, topRight: 16 })  // 仅顶部圆角
.shadow({ radius: 16, color: '#00000020' })  // 柔和的升起阴影

关键设计细节:

  1. 仅顶部圆角topLeft: 16, topRight: 16):底部保持直角,与屏幕底部对齐。如果四个角都设置圆角,面板底部会与屏幕底部之间露出遮罩层,显得不完整。
  2. 升起阴影shadow):radius: 16 配合 color: '#00000020'(黑色 12.5% 不透明度),在面板上方产生一个柔和的阴影,增强"面板浮在背景之上"的立体感。
  3. 动态高度this.sheetHeight 是一个 @State 变量,初始为 420vp(MEDIUM_HEIGHT),用户点击拖拽手柄后切换为 640vp(LARGE_HEIGHT),再点击再切回。

2.4 拖拽手柄:视觉暗示 + 交互入口

拖拽手柄是 Bottom Sheet 最具标志性的视觉元素:

Row() {
  Row()
    .width(36)
    .height(4)
    .borderRadius(2)
    .backgroundColor('#D0D0D8')
}
.width('100%')
.height(28)
.justifyContent(FlexAlign.Center)
.onClick(() => {
  // 在 MEDIUM 和 LARGE 高度之间切换
  if (this.sheetHeight === this.MEDIUM_HEIGHT) {
    this.sheetHeight = this.LARGE_HEIGHT;
  } else {
    this.sheetHeight = this.MEDIUM_HEIGHT;
  }
})

这个设计包含两层信息:

  • 视觉暗示:36×4vp 的灰色圆角条暗示"这里有东西可以拖拽"。它的尺寸和颜色经过精心设计——太粗会显得笨重,太细会被忽略;颜色太深会过于显眼,太浅则不可见。
  • 交互入口onClick 在 MEDIUM(420vp,约半屏)和 LARGE(640vp,近全屏)之间切换。用户点击手柄 → 面板高度变化 → 看到更多或更少内容。

在生产环境中,理想的做法是支持真正的手势拖拽(通过 gesture API 响应 PanGesture),而非仅支持点击切换。但点击切换的实现更简单、更可靠,且同样能完成"展示更多/更少信息"的功能目标。对于地址选择器这种内容量可控的场景,点击切换已经足够实用。
在这里插入图片描述

三、实战:地址选择器

3.1 页面整体设计

我们的地址选择器围绕"省→市→区三级联动"的场景设计,模拟电商 App 中常见的"选择收货地址"功能:

主页面区域

  1. 顶部标题栏(深色背景,“📍 地址选择器”)
  2. 当前选择展示卡(显示已选地址,支持一键清除)
  3. "选择收货地址"按钮
  4. 功能说明卡片

底部弹出层(通过 overlay 渲染):

  1. 半透明遮罩(点击关闭)
  2. 白色圆角面板(动态高度 420 / 640)
  3. 拖拽手柄(点击切换高度)
  4. 标题行(返回箭头 + 当前层级标题 + 关闭按钮)
  5. 选项列表(自适应三级深度)

3.2 三级联动地址数据

地址数据使用树形结构 AddressNode

interface AddressNode {
  name: string;
  children?: AddressNode[];
}

const REGIONS: AddressNode[] = [
  {
    name: '广东省',
    children: [
      { name: '广州市', children: [{ name: '天河区' }, { name: '越秀区' }, { name: '海珠区' }] },
      { name: '深圳市', children: [{ name: '南山区' }, { name: '福田区' }, { name: '罗湖区' }] },
      { name: '珠海市', children: [{ name: '香洲区' }, { name: '斗门区' }] },
    ]
  },
  // ... 北京市、上海市、浙江省、江苏省、四川省
];

关键设计:

  • children? 字段是可选的——区/县层级没有 children(叶子节点),省/市层级有 children(分支节点)。
  • 北京和上海是直辖市,children 直接是区(跳过了市一级)。
  • 广东省、浙江省等有省→市→区三级。
  • 数据结构天然支持不同深度的地址层级,无需硬编码层级关系。

3.3 面板内的多级导航

弹出层内部的多级导航是整个 Demo 最复杂的部分。当用户在"省份列表"中点击"广东省"时:

  1. selectedProvince 更新为 “广东省”
  2. sheetLevel 更新为 { title: '广东省', items: ['广州市', '深圳市', '珠海市'] }
  3. 面板标题从"选择省份"变为"广东省"
  4. 返回箭头出现(因为不再是第一级)
  5. 选项列表重新渲染为城市列表

核心逻辑在 handleItemClick 中:

handleItemClick(item: AddressNode): void {
  if (item.children && item.children.length > 0) {
    // 有子节点 → 进入下一级
    const hasGrandchildren = item.children.some(
      (c: AddressNode) => c.children && c.children.length > 0
    );
    // ... 更新 sheetLevel 和 selectedProvince/selectedCity
  } else {
    // 叶子节点 → 选中并关闭面板
    this.selectedDistrict = item.name;
    this.showSheet = false;
  }
}

判断逻辑分为两条路径:

  • 分支节点:有 children → 面板导航到下一级,展开子节点列表
  • 叶子节点:无 children → 选中该节点,关闭面板,更新主页面的地址显示

goBack() 方法负责返回上一级:

goBack(): void {
  if (this.selectedDistrict) {
    this.selectedDistrict = '';
    // 回到城市列表...
  }
  if (this.selectedCity) {
    this.selectedCity = '';
    // 回到省份列表...
  }
  // ...
}

返回逻辑按照"区→市→省"的顺序逐级回退,sheetLevel 的 title 和 items 依次还原。

3.4 选中状态的传递

底部弹出层与主页面的数据传递完全通过 @State 变量完成:

底部弹出层            主页面
──────────          ──────────
选中 "广东省"  →  selectedProvince = "广东省"
选中 "深圳市"  →  selectedCity = "深圳市"
选中 "南山区"  →  selectedDistrict = "南山区"
                  getSelectedAddress() → "广东省 · 深圳市 · 南山区"

当三级地址都选好后,getSelectedAddress() 将它们用 · 连接展示。如果地址为空,显示"请选择收货地址"。清除按钮(✕)将所有三个变量重置为空字符串。

这种"共享 @State 变量"的数据流是声明式 UI 最自然的通信模式——不需要事件总线、不需要回调函数、不需要参数传递。底部弹出层直接修改 selectedProvince / selectedCity / selectedDistrict,主页面自动响应变化。

四、与 Dialog 和 Popup 的对比

在之前的文章中,我们探讨了 CustomDialog(自定义弹窗,#14)和 bindPopup(气泡弹窗,#37)。底部弹出层与它们相比,各有最合适的场景:

特性 CustomDialog bindPopup Bottom Sheet
出现位置 屏幕正中 组件附近 屏幕底部
背景遮罩 全遮(通常 50%+) 无遮罩 半遮(30-40%)
内容复杂度 中等(表单、确认) 简单(菜单、列表) 复杂(多级导航、长列表)
用户操作成本 高(必须处理) 低(轻量) 中(可拖拽关闭)
空间占用 屏幕中央,约 60% 组件旁,约 30% 屏幕底部,40-80%
最佳场景 确认操作、输入信息 提供选项、快捷操作 选择器、筛选面板、详情

简单来说:

  • Dialog:用户必须做出选择才能继续——“确认删除?”、“输入名称”
  • Popup:用户在某个组件附近需要一个快捷操作——“排序方式”、“筛选条件”
  • Bottom Sheet:用户需要一个辅助性的、可中途退出的选择——“选择地址”、“查看详情”、“更多选项”

底部弹出层(Bottom Sheet)的独特价值在于它的"低侵入性"。它不会像 Dialog 那样完全覆盖整个屏幕,也不会像 Popup 那样空间有限。它是一种"折中方案"——兼顾了内容的复杂度和交互的轻便性。
在这里插入图片描述

五、原生 bindSheet API vs 自定义实现

HarmonyOS NEXT 提供了原生的 bindSheet API,可以直接绑定到任何组件上:

// 原生方式(示意)
.bindSheet($$this.isShow, {
  height: SheetSize.MEDIUM,
  dragBar: true,
  showClose: true,
  builder: this.sheetContent
})

原生 bindSheet 的优势:

  • 内置手势:自动支持拖拽展开/收起,无需手动处理手势
  • 标准动画:使用系统级弹簧动画,曲线经过精心调校
  • 无障碍支持:与系统无障碍服务集成,自动获得屏幕阅读器支持
  • 代码量少:只需几行配置代码

自定义实现的优势:

  • 完全可控:面板的每个细节(圆角、阴影、动画曲线、高度值)都可以自由定制
  • 逻辑灵活:面板内容可以是任意复杂的组件树(如我们的三级地址导航)
  • 学习价值:深入理解底部弹出层的实现原理,为理解原生 API 打好基础
  • 无版本限制:不依赖特定 API 版本

在实践中,推荐的做法是:

  1. 优先使用原生 bindSheet:如果只是简单的底部弹出(如操作菜单、筛选面板),原生 API 简单可靠
  2. 考虑自定义实现:如果弹出层需要复杂的多级导航、自定义动画、或需要与页面进行复杂的数据交互,自定义实现提供更大的灵活性

本文选择自定义实现,正是因为它能完整展示底部弹出层的底层原理——遮罩层为什么是半透明?拖拽手柄的尺寸为什么是 36×4vp?面板的圆角为什么只设置顶部?——理解了这些"为什么",你才能在需要时自信地选择原生 API,并在需要定制时从容地写出自己的实现。

六、完整代码结构

页面组件树:

BottomSheetPage
├── Column(主页面)
│   ├── Row(标题栏:"📍 地址选择器")
│   ├── Column(当前选择展示区)
│   │   ├── Text("收货地址"标签)
│   │   └── Row(地址文本 + ✕ 清除按钮)
│   ├── Column(按钮区)
│   │   ├── Button("选择收货地址" → 打开弹出层)
│   │   └── Text(提示文本)
│   └── Column(功能说明卡)
│
└── [overlay] sheetOverlay(条件渲染)
    ├── Column(半透明遮罩,点击关闭)
    └── Column(底部弹出面板,动态高度)
        ├── Row(拖拽手柄 ━━━)
        ├── Row(←返回 + 标题 + 关闭按钮)
        ├── Divider(分隔线)
        └── Scroll > Column
            └── ForEach(选项列表,每项带 › 箭头指示)

代码约 260 行,核心聚焦底部弹出层的架构设计、多级导航逻辑、状态管理和与原生 API 的对比分析。

七、总结

本文以地址选择器为应用场景,从零构建了一个底部半模态弹出层(Bottom Sheet),深入解析了其核心架构、多级导航和状态管理。

回顾本文覆盖的核心要点:

  1. 底部弹出层的三层架构:遮罩层(半透明黑色,阻止背景交互)、弹出面板(白色圆角容器,承载内容)、拖拽手柄(视觉暗示 + 高度切换入口)。三个层次通过 .overlay() 附加到主页面之上。

  2. 遮罩层的设计考量:30%-40% 不透明度是最佳区间,既阻止背景交互,又保留空间方向感。遮罩层绑定 onClick 关闭逻辑,提供自然的关闭路径。

  3. 弹出面板的视觉设计:仅顶部圆角(topLeft: 16, topRight: 16)确保面板与屏幕底部无缝衔接;升起阴影(radius: 16, color: '#00000020')增强立体感;动态高度在 MEDIUM(420vp)和 LARGE(640vp)之间切换,适应不同内容量。

  4. 拖拽手柄的双重功能:36×4vp 的灰色圆角条既是"这里可以交互"的视觉暗示,又是"点击切换高度"的交互入口。简约而不简单——尺寸和颜色经过精细平衡,既可见又不过分突出。

  5. 弹出层内的多级导航:通过 sheetLevel 状态追踪当前导航层级(title + items),handleItemClick 处理"进入下一级"和"选中并关闭"两条路径,goBack 处理"返回上一级"。整个导航逻辑完全在弹出层内部完成,无需额外的页面跳转。

  6. 状态驱动的数据流selectedProvinceselectedCityselectedDistrict 三个 @State 变量被弹出层和主页面共享——弹出层修改它们,主页面通过 getSelectedAddress() 响应变化。这是声明式 UI 最自然的跨组件通信模式。

  7. 与 Dialog 和 Popup 的定位差异:Dialog 要求用户做出选择(高侵入),Popup 提供快捷操作(轻量),Bottom Sheet 承载复杂选择但保留背景可见(低侵入)。三者各有定位,了解它们的差异有助于在正确的场景使用正确的组件。

底部弹出层是现代移动应用中最优雅的交互模式之一。它不是"省事的 Dialog"或"加大的 Popup"——它有自己独特的设计哲学:保留背景可见(维持空间感)、从底部自然滑出(符合拇指热区)、支持拖拽切换高度(灵活适应内容量)。当你需要让用户做出一个"辅助性而非决定性"的选择时——选地址、筛条件、看详情——不要弹出一个 Dialog 挡住整个屏幕,用一个 Bottom Sheet,让选择变得轻便而优雅。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐