基于 API 12+,四种弹窗模式深度对比 + 踩坑实录,附 DialogDemo 完整代码走读


写在前面

做鸿蒙项目到中期,弹窗这件事开始变得微妙。起初一个 AlertDialog.show() 就够用了,后来产品说要加个输入框,再后来设计师画了个底部弹出的操作面板,再再后来又来个全屏的编辑页——但不想用 Navigation 跳转,说体验太重。

我一开始的思路很简单:弹窗嘛,不就是一个遮罩层加个内容区?自己写个 @Builder 配合 @State 控制显隐不就完了?结果写了两周发现自己造的轮子在转场动画、手势交互、键盘避让上全是 bug,回头一看 ArkUI 已经提供了四种弹窗方案,该用哪个、怎么用、哪里有坑,这篇文章一口气讲清楚。
请添加图片描述


一、AlertDialog:最省心的确认弹窗

AlertDialog 是四种方案里最简单的。不需要声明控制器,不需要 Builder,一行 AlertDialog.show() 搞定。适合的场景很明确:只需要用户确认或取消,不需要自定义 UI

DialogDemo 里的用法是这样的:

AlertDialog.show({
  title: '提示',
  message: '这是一个AlertDialog示例,确认要继续操作吗?',
  primaryButton: {
    value: '取消',
    action: () => {
      this.dialogResult = '取消'
      this.addLog('AlertDialog: 取消')
    }
  },
  secondaryButton: {
    value: '确认',
    action: () => {
      this.dialogResult = '确认'
      this.addLog('AlertDialog: 确认')
    }
  }
})

这东西的优点是零配置——不需要提前声明,不需要管理生命周期,调用就弹,点完就消失,系统帮你处理一切。缺点也很明显:你没法改布局,没法加输入框,按钮文案和个数都受限。产品要是说"弹窗里加个勾选框",AlertDialog 就直接歇菜了。

有一点值得注意:AlertDialog 在 UIExtensionComponent 里不生效。这不是 bug,是系统限制——UIExtension 的上下文不具备弹出系统级弹窗的能力。如果你在卡片或 Extension 里需要弹窗提示,得走 CustomDialog 或自己画。

另一个细节是按钮顺序。primaryButton 在左侧,secondaryButton 在右侧,这个位置跟 Android 的 AlertDialog 是反的(Android 的 positive button 在右)。团队里安卓转过来的同事刚开始老点错,后来统一了规范才好点。如果你觉得别扭,可以在 UI 规范里注明"左侧取消、右侧确认",至少让团队认知一致。

AlertDialog 的 alignment 参数可以控制弹窗位置,默认居中,设成 DialogAlignment.BOTTOM 可以从底部弹出。但说实话,底部弹出的交互更适合用 bindSheet 来做,AlertDialog 底部弹出的动画和遮罩逻辑都不如 bindSheet 自然。
请添加图片描述


二、CustomDialog:能写自定义 UI 的弹窗

AlertDialog 搞不定的事情,CustomDialog 基本都能搞定。它的核心思路是:用 @CustomDialog 装饰器声明一个自定义组件,然后在父组件里通过 CustomDialogController 控制它的打开和关闭。

DialogDemo 里的 NameDialog 就是一个典型用法——弹窗里有个输入框让用户填姓名:

@CustomDialog
struct NameDialog {
  controller: CustomDialogController
  onConfirm: (name: string) => void = (_name: string) => {}
  @State inputName: string = ''

  build() {
    Column() {
      Text('请输入姓名')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 16 })
      TextInput({ placeholder: '姓名', text: this.inputName })
        .onChange((value: string) => {
          this.inputName = value
        })
        .width('80%')
        .margin({ bottom: 24 })
      Row() {
        Button('取消')
          .onClick(() => {
            this.controller.close()
          })
          .backgroundColor('#999')
          .fontColor(Color.White)
        Button('确认')
          .onClick(() => {
            this.onConfirm(this.inputName)
            this.controller.close()
          })
      }
      .justifyContent(FlexAlign.SpaceEvenly)
      .width('100%')
    }
    .padding(24)
  }
}

然后在父组件 DialogDemo 里这样声明和使用:

private nameDialogController: CustomDialogController = new CustomDialogController({
  builder: NameDialog({
    onConfirm: (name: string) => {
      this.customDialogResult = name
      this.addLog('CustomDialog返回: ' + name)
    }
  })
})

点击按钮打开:

Button('显示自定义弹窗')
  .onClick(() => {
    this.nameDialogController.open()
    this.addLog('CustomDialog打开')
  })

请添加图片描述

看起来很 straightforward,对吧?但我在这上面栽过一个大跟头。

坑:CustomDialogController 在 aboutToDisappear 后调用会崩溃

这个坑真的坑了我一整天。场景是这样的:有个页面 A,里面声明了 CustomDialogController,页面 A 被 Navigation pop 掉之后,某个异步回调还在持有这个 controller 的引用,然后调用了 open() —— 直接崩溃,报错是类似 “CustomDialogController is not valid” 的错误。

原因说起来不复杂:CustomDialogController 的生命周期跟它所在的组件绑定。组件销毁了,controller 就失效了,再调用就崩溃。但问题在于,异步场景下这个时序很难保证。比如你弹出了 CustomDialog,用户在弹窗里点了确认,确认逻辑里发了个网络请求,网络请求回来之后你想再弹一个提示弹窗——但此时组件可能已经被 pop 了。

我的解决方案比较粗暴但有效:在调用 open() 之前检查组件是否还活着。做法是在组件里加一个标志位:

@State private isAlive: boolean = true

aboutToDisappear() {
  this.isAlive = false
}

// 弹窗调用处
someAsyncCallback() {
  if (!this.isAlive) return
  this.nameDialogController.open()
}

还有一个相关的坑:CustomDialogController 必须作为组件的成员变量声明,不能在方法里临时 new 一个。如果你写成:

// 错误写法!
onClick(() => {
  let ctrl = new CustomDialogController({ builder: MyDialog() })
  ctrl.open()
})

这种写法在某些版本上会直接不显示,或者显示了但关闭后崩溃。原因还是生命周期——局部变量的 controller 在方法执行完就可能被回收,弹窗还没关就找不到了。必须在组件级别声明 controller,这是硬性规则。

关于数据回传,DialogDemo 里用的是回调函数的方式(onConfirm 参数)。另一种方式是用 AppStorageLocalStorage 做中转,但我个人更偏好回调——逻辑更内聚,不用在全局状态里加临时变量。如果你的弹窗需要回传的数据特别复杂(比如一整个表单对象),回调参数直接传对象就行,ArkTS 里不存在序列化的问题。

CustomDialog 还有个 autoCancel 参数,默认 true,点遮罩层能关闭。如果你的弹窗是那种"必须让用户做出选择"的场景(比如同意用户协议),记得设成 false,否则用户一碰遮罩就关了,你啥也没拿到。


三、bindSheet:半模态,这个我用得最多

半模态(Bottom Sheet)在移动端设计里是个高频交互模式:分享面板、操作选择、筛选器、评论输入……几乎每个 App 都有一堆这种场景。HarmonyOS NEXT 提供的 bindSheet 是目前四种弹窗方案里我用得最多的。

DialogDemo 里的 sheetBuilder 展示了一个典型的操作选择面板:

@Builder
sheetBuilder() {
  Column() {
    Text('选择操作')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .margin({ top: 20, bottom: 12 })
    List() {
      ForEach(this.sheetItems, (item: SheetItem) => {
        ListItem() {
          Row() {
            Text(item.icon)
              .fontSize(24)
              .margin({ right: 12 })
            Text(item.name)
              .fontSize(16)
          }
          .width('100%')
          .padding({ left: 20, right: 20, top: 14, bottom: 14 })
          .onClick(() => {
            this.sheetResult = item.name
            this.isSheetShow = false
            this.addLog('Sheet选择: ' + item.name)
          })
        }
      }, (item: SheetItem) => item.name)
    }
    .divider({ strokeWidth: 1, color: '#eeeeee' })
    .width('100%')
  }
  .width('100%')
}

绑定到组件上:

.bindSheet(this.isSheetShow, this.sheetBuilder, {
  showClose: true,
  onWillDisappear: () => {
    this.addLog('Sheet即将消失')
  }
})

打开方式就是改 isSheetShowtrue

Button('显示半模态Sheet')
  .onClick(() => {
    this.isSheetShow = true
    this.addLog('Sheet打开')
  })

相比 CustomDialog 的 controller 模式,bindSheet 的状态驱动方式更符合声明式 UI 的思路——改个布尔值就弹出/关闭,不需要手动管理 controller 的生命周期,不用担心组件销毁后 controller 失效的问题。就这一点,我就更倾向于用 bindSheet 而不是 CustomDialog。

坑:SheetSize.FIT_CONTENT 在内容未渲染时高度为 0

这个坑非常隐蔽。bindSheetSheetSize 枚举有 MEDIUM(半屏)、LARGE(大半屏)、FIT_CONTENT(自适应内容高度)三个值。FIT_CONTENT 听起来很美好——内容多高 Sheet 就多高,不多不少。但实际用起来你会发现,有时候 Sheet 弹出来就一条缝,高度几乎为 0。

问题出在时序上:FIT_CONTENT 是在 Sheet 弹出的瞬间测量内容高度的。如果你的内容里有异步加载的图片、条件渲染的区域、或者 @State 驱动的列表数据还没填充完,那测量的时候内容就是空的,高度自然就是 0。Sheet 弹出之后数据到了、内容撑开了,但高度不会重新计算——系统不会给你重新走一遍布局测量。

我遇到的具体场景是:Sheet 里放了一个列表,列表数据是从网络请求回来的。请求没到的时候列表为空,Sheet 高度就是 0;请求到了之后列表有了数据,但 Sheet 还是那条缝。

解决思路有两个:

方案 A:不用 FIT_CONTENT,用 MEDIUM 或 LARGE。既然自适应靠不住,那就给个固定比例。MEDIUM 大概是屏幕一半,LARGE 大概是屏幕 90%,大多数场景够用了。这是最省事的做法。

方案 B:数据准备好了再弹 Sheet。把 isSheetShow = true 放在网络请求的回调里,确保数据到了再弹出。这样 FIT_CONTENT 测量的时候内容已经是完整的,高度就正确了。我现在的项目里基本都用这个方案——反正你数据没到弹个空 Sheet 也没意义。

还有一个跟高度相关的细节:bindSheet 有个 detents 参数,可以设置多个高度档位,用户可以上下拖动在不同高度之间切换。如果你之前用 Android 的 BottomSheetBehavior 做过类似功能,你会觉得这个 API 设计得很舒服。但 detentsFIT_CONTENT 不要同时用——两者在测量逻辑上会打架,我试过,表现不可预期。

关于拖拽关闭

bindSheet 默认支持往下拖拽关闭,这是半模态交互的标配。但 SheetOptions 里并没有一个 draggable 属性让你控制是否可拖拽——至少在 API 12 这个版本没有。如果你需要禁止用户拖拽关闭(比如弹出的 Sheet 里有个表单,怕用户误触),目前没有官方 API 能做到这一点。

网上有人建议用 onWillDismiss 回调来拦截,但这个回调只能拦截"即将关闭"的行为,不能从根本上禁止拖拽手势。实际效果是:用户拖了,Sheet 跟着手移动了,松手之后 onWillDismiss 被调用,你可以选择不关——但拖拽的视觉反馈已经发生了,体验很割裂。

我的临时方案是把 showClose 设为 false(隐藏右上角关闭按钮),然后在 Sheet 内容里放一个明确的"关闭"或"取消"按钮,引导用户用按钮关闭而不是拖拽。不算完美,但至少不会出现"填了一半表单被误触关闭"的问题。期待后续版本加上 draggable 属性。

bindSheet 的生命周期回调比较丰富:onWillAppearonAppearonWillDisappearonDisappear 四个阶段都有。DialogDemo 里用了 onWillDisappear 来记录日志。实际项目中我主要用 onAppear 来做焦点设置(比如弹出评论输入 Sheet 时自动聚焦输入框),用 onDisappear 来做状态清理。
请添加图片描述


四、bindContentCover:全屏模态,轻量级"页面跳转"

bindContentCover 是四种方案里最"重"的——它弹出的是全屏内容,视觉上就是一个新页面,但交互上还是弹窗模式。适合的场景是:需要全屏展示的复杂内容,但又不想走 Navigation 路由

DialogDemo 里的 coverBuilder 做了一个带导航栏的全屏内容页:

@Builder
coverBuilder() {
  Column() {
    Row() {
      Text('< 返回')
        .fontSize(18)
        .fontColor(Color.White)
        .onClick(() => {
          this.isCoverShow = false
          this.addLog('ContentCover关闭')
        })
      Blank()
      Text('全屏内容')
        .fontSize(18)
        .fontColor(Color.White)
        .fontWeight(FontWeight.Bold)
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .backgroundColor('#1976D2')
    .alignItems(VerticalAlign.Center)

    Scroll() {
      Column() {
        Text('这是一个全屏模态页面')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 40, bottom: 16 })
        Text('bindContentCover适用于需要全屏展示的复杂内容,\n如详情页、编辑页等场景。')
          .fontSize(15)
          .fontColor('#666666')
          .textAlign(TextAlign.Center)
          .margin({ bottom: 32 })
        Button('确认选择')
          .width('60%')
          .onClick(() => {
            this.coverResult = '已确认全屏内容'
            this.isCoverShow = false
            this.addLog('ContentCover确认: 已确认全屏内容')
          })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
    }
    .layoutWeight(1)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5F5F5')
}

绑定方式跟 bindSheet 类似:

.bindContentCover(this.isCoverShow, this.coverBuilder, {
  modalTransition: ModalTransition.DEFAULT
})

ModalTransition.DEFAULT 会有一个从底部滑上来的转场动画,跟 Navigation 的 push 动画很像。还有 NONE(无动画,直接出现)和 FULL_SCALE(缩放动画)可选。

坑:bindContentCover 里无法直接操作父组件状态

这个坑跟 ArkUI 的组件作用域设计有关。bindContentCover 弹出的内容虽然在同一个组件文件里写的 @Builder,但它运行在独立的组件作用域中。这意味着你在 coverBuilder 里用 this.xxx 访问父组件的 @State 变量时,只能读,不能写

等等,你可能会说:DialogDemo 里明明写了 this.coverResult = '已确认全屏内容'this.isCoverShow = false,这不就是在改父组件状态吗?确实能改,但这里面有个微妙的地方——能改是因为 coverBuilder 里引用的 this 仍然是父组件的实例。ArkUI 的 @Builder 函数里的 this 指向取决于调用上下文,当 @Builder 被用作 bindContentCover 的参数时,this 仍然指向宿主组件。

但如果你在 coverBuilder 里又嵌套了一个自定义组件,而这个自定义组件想改父组件的状态,那就不行了——它拿不到父组件的 this。这种情况下需要用 @Link 或回调函数来桥接。

实际项目里我踩的具体场景是:coverBuilder 里放了一个编辑表单组件 EditForm,表单提交后需要把编辑结果回传给父组件并关闭 ContentCover。解决方案是在 EditForm 里声明一个 onSave 回调:

@Component
struct EditForm {
  @State formData: FormData = new FormData()
  onSave: (data: FormData) => void = (_data: FormData) => {}

  build() {
    // ... 表单UI ...
    Button('保存')
      .onClick(() => {
        this.onSave(this.formData)
      })
  }
}

然后在 coverBuilder 里:

EditForm({
  onSave: (data: FormData) => {
    this.editResult = data
    this.isCoverShow = false
  }
})

这样做虽然多了一层回调包装,但逻辑清晰,也不违背组件间通信的规范。

另一个值得说的点是:bindContentCover 虽然视觉上像页面跳转,但它不走 Navigation 的路由栈。这意味着用户按物理返回键的时候,行为可能跟预期不同——Navigation 栈里的页面会被 pop,而不是关闭 ContentCover。如果你希望返回键能关闭 ContentCover,需要自己监听返回事件。不过话又说回来,如果你需要完整的返回键支持、路由栈管理、跨页面状态共享,那就不应该用 bindContentCover,直接用 Navigation 才是正道。bindContentCover 的定位是"轻量级全屏弹窗",不是"替代 Navigation"。


五、四种弹窗方案横向对比

表格说话:

维度 AlertDialog CustomDialog bindSheet bindContentCover
适用场景 简单确认/警告 复杂表单/自定义UI 半屏选择/操作面板 全屏详情/编辑页
自定义程度 低(只能改文案和按钮) 中(自由布局,但尺寸受限) 高(完整Builder) 高(完整Builder)
交互模式 模态阻塞 模态阻塞 半模态,可拖拽 全屏模态
生命周期 系统管理,无需手动 手动管理controller 手势驱动+回调 转场动画+回调
数据回传 按钮action回调 回调函数 回调函数 回调函数
打开方式 AlertDialog.show() controller.open() isShow = true isShow = true
关闭方式 自动/按钮 controller.close() 拖拽/设false 设false/按钮
是否阻塞页面 否(半屏可见下层) 是(全屏覆盖)
转场动画 淡入淡出 淡入淡出 底部滑入 可选三种
键盘避让 系统处理 需手动配置 系统处理 系统处理

选型决策其实很简单,我在项目里总结了一个判断流程:

需要弹窗提示?
├─ 只需确认/取消? → AlertDialog
├─ 需要自定义UI?
│   ├─ 占半屏以内? → CustomDialog
│   ├─ 底部操作面板? → bindSheet
│   └─ 全屏内容? → bindContentCover
└─ 不确定? → 优先 bindSheet

为什么"不确定就选 bindSheet"?因为半模态是移动端最通用的交互模式——它不阻塞整个页面,用户能看到上下文,操作成本低,关起来方便。我自己项目里四种弹窗的使用比例大概是 AlertDialog 10%、CustomDialog 15%、bindSheet 55%、bindContentCover 20%。bindSheet 占了大头。


六、DialogDemo 完整代码走读

把 DialogDemo 的代码串一遍,看看四种弹窗是怎么在一个页面里和平共处的。

6.1 状态声明

页面里维护了四种弹窗各自的结果状态,加上一个日志文本和两个控制 bindSheet / bindContentCover 显隐的布尔值:

@State dialogResult: string = '未操作'
@State customDialogResult: string = '未操作'
@State sheetResult: string = '未操作'
@State coverResult: string = '未操作'
@State logText: string = ''
@State activeTab: number = 0
@State isSheetShow: boolean = false
@State isCoverShow: boolean = false

注意 CustomDialog 的 controller 不是 @State 变量,而是 private 成员——因为 CustomDialogController 不是可观察的对象,加了 @State 反而可能引发不必要的重渲染。

6.2 页面布局

整个页面用了 Tabs 分两个 Tab:“实战演示"和"对比指南”。演示 Tab 里依次展示四种弹窗的触发按钮和结果文本,指南 Tab 里是对比表格和踩坑提醒。

build 方法的核心结构:

build() {
  Column() {
    // 顶部导航栏
    Row() { /* ... */ }
      .width('100%')
      .height(56)
      .backgroundColor('#1976D2')

    Tabs({ index: this.activeTab }) {
      TabContent() { this.demoTab() }
        .tabBar('实战演示')
      TabContent() { this.guideTab() }
        .tabBar('对比指南')
    }
    .layoutWeight(1)
  }
  .width('100%')
  .height('100%')
  .bindSheet(this.isSheetShow, this.sheetBuilder, { /* ... */ })
  .bindContentCover(this.isCoverShow, this.coverBuilder, { /* ... */ })
}

bindSheet 和 bindContentCover 都绑在最外层 Column 上——这是推荐的做法,绑在根容器上可以确保弹窗的遮罩层覆盖整个页面。如果绑在某个子组件上,遮罩可能只覆盖那个子组件的区域。

6.3 对比数据

DialogDemo 里用接口定义了对比表的数据结构:

interface CompareItem {
  type: string
  scenario: string
  customLevel: string
  interactMode: string
  lifecycle: string
}

然后初始化数据:

private compareData: CompareItem[] = [
  { type: 'AlertDialog', scenario: '简单确认/警告', customLevel: '低', interactMode: '模态阻塞', lifecycle: '系统管理' },
  { type: 'CustomDialog', scenario: '复杂表单/自定义UI', customLevel: '中', interactMode: '模态阻塞', lifecycle: '手动管理' },
  { type: 'bindSheet', scenario: '半屏选择/操作面板', customLevel: '高', interactMode: '半模态', lifecycle: '手势驱动' },
  { type: 'bindContentCover', scenario: '全屏详情/编辑页', customLevel: '高', interactMode: '全屏模态', lifecycle: '转场动画' }
]

指南 Tab 里用 ForEach 渲染这个表格,虽然手动写 Column + Row 的表格不如 DataGrid 组件优雅,但 ArkUI 目前没有 DataGrid,这是唯一的方式。

6.4 日志系统

DialogDemo 里有个 addLog 方法,给每种弹窗的操作都打上时间戳日志,方便观察交互时序:

private addLog(msg: string): void {
  let now: Date = new Date()
  let h: number = now.getHours()
  let m: number = now.getMinutes()
  let s: number = now.getSeconds()
  let time: string = h.toString() + ':' + (m < 10 ? '0' : '') + m.toString() + ':' + (s < 10 ? '0' : '') + s.toString()
  this.logText = '[' + time + '] ' + msg + '\n' + this.logText
}

这个日志在调试弹窗生命周期时非常有用——你可以看到弹窗什么时候打开、什么时候关闭、回调在什么时序触发。特别是调试 bindSheet 的 onWillDisappear 时,日志能帮你确认回调的触发时机是在拖拽手势结束之后、还是 Sheet 动画结束之后(答案是前者)。


七、实战经验总结

什么时候该用哪种弹窗

写了一年多鸿蒙项目,我对四种弹窗的偏好已经比较明确了:

AlertDialog 只用于系统级确认——删除数据、退出登录、授权提示。其他场景不用,因为没法定制 UI。

CustomDialog 用于需要自定义布局但不需要太大面积的场景——带输入框的确认弹窗、选择日期的日历弹窗、评分弹窗。但要注意 controller 的生命周期管理,异步场景下容易出问题。

bindSheet 是我的主力选择——操作面板、筛选器、评论输入、图片选择、分享面板,通通用 bindSheet。它的手势交互是系统级的,流畅度比自己实现要好得多。唯一需要注意的是 FIT_CONTENT 的高度测量问题。

bindContentCover 只在"全屏展示但不想走路由"的场景下使用——比如图片预览、临时编辑页、大表单填写。如果你发现 ContentCover 里开始写复杂的生命周期逻辑和路由守卫,那就该考虑换成 Navigation 了。

几个容易忽略的细节

  1. CustomDialog 的 controller 必须在组件成员变量里声明,不能在方法里 new。这不是建议,是强制要求。违反了要么不显示,要么崩溃。

  2. bindSheet 的 FIT_CONTENT 有渲染时序问题,数据没准备好就弹出来的话高度会是 0。要么用 MEDIUM/LARGE 固定比例,要么等数据到了再弹。

  3. bindContentCover 不走 Navigation 路由栈,返回键行为需要自己处理。别把它当页面跳转用。

  4. SheetOptions 没有 draggable 属性,没法禁止拖拽关闭。如果业务上需要强制用户操作完才能关闭,目前只能通过 UI 引导(放明确的关闭按钮、隐藏关闭图标)来缓解。

  5. AlertDialog 在 UIExtensionComponent 里不生效,Extension 场景下要用 CustomDialog 或其他方案替代。

  6. 弹窗里的组件树是独立的,尤其是 bindSheet 和 bindContentCover 的 Builder 内容,它们跟父组件之间的状态同步需要通过回调或 @Link 来桥接,不要想当然地认为 this 能穿透一切。

我踩过的最离谱的坑

最离谱的一个是 CustomDialogController 崩溃——不是在开发阶段崩溃,是上线之后用户反馈偶现崩溃。复现条件是:用户快速操作,弹窗弹出 → 页面被 pop → 弹窗关闭回调触发 → controller 已经失效 → 崩溃。开发阶段很难复现因为你的操作速度不够快、网络延迟不够大。后来加了 isAlive 标志位才彻底解决。

第二离谱的是 bindSheet 的 FIT_CONTENT 高度为 0。当时我在 Sheet 里放了一个 LazyForEach 列表,数据是页面 aboutToAppear 时请求的。本地开发时网络很快,请求几乎瞬间完成,所以 Sheet 弹出时数据已经有了,高度正常。上了测试环境网络延迟 200ms,Sheet 弹出时数据还没到,高度变成 0。更恶心的是有些数据到了之后 Sheet 高度能自动修正,有些又不能——跟列表项的复杂度有关。最后统一改成了 MEDIUM,问题消失。


八、写在最后

弹窗这种东西,单个看都不复杂,但四种方案各有各的设计理念和 API 风格,混着用的时候容易踩坑。我建议项目里统一规范:哪种场景用哪种弹窗,提前定好,别让不同开发者在同一个项目里各用各的。

DialogDemo 虽然代码量不大,但把四种弹窗的核心用法和坑点都覆盖了。建议你把 Demo 跑起来,每种弹窗都操作几遍,特别关注一下 bindSheet 的拖拽关闭行为和 bindContentCover 的返回键行为——这两个是线上最容易出交互问题的地方。

如果你正在做鸿蒙项目的弹窗模块,希望这篇文章能帮你少踩几个坑。那些坑我替你踩过了,教训就是:别信文档里"自适应高度"这种美好描述,跑起来看实际表现才是真的

Logo

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

更多推荐