哈罗,这里是cc。前面几篇我们把"页面怎么搭、怎么跳、怎么导航、底部 Tab 怎么排"都讲完了(第16~18篇)。但一个像样的 App 还缺一类东西:
不打断主流程也能跟用户交互的浮层——弹窗和菜单。
比如:删东西前弹个确认框、长按一条消息弹出"复制/转发/删除"、点个按钮从底部升起一个选择器、在输入框旁边冒个小气泡提示。这些在鸿蒙里分散在几套 API 里,初学者很容易搞混"该用哪个"。今天统一捋一遍。
这篇会和第13~15篇的状态管理(@Prop/@Link/@ObjectLink/AppStorage)、第17篇的 Navigation 串起来——弹窗要传数据、要回传结果,底层还是那套逻辑。
虽然都叫"浮层",但两件事定位完全不同:
AlertDialogActionSheet / CustomDialog / bindPopup | bindMenubindContextMenu / Menu 组件 |
一句话记忆:
要用户"做决定"用弹窗,要用户"选操作"用菜单。
下面分别讲。
最轻量的弹窗,不用自己写组件,直接 AlertDialog.show(...) 一行弹出来。适合"确定/取消"这种二选一确认。
@Entry@Componentstruct AlertDemo {@State count: number = 0build() {Column() {Text(`当前数量:${this.count}`).fontSize(20).margin(20)Button('删除一项').onClick(() => {AlertDialog.show({title: '确认删除',subtitle: '此操作不可恢复', // API 11+ 支持副标题message: '删除后数据将无法找回,确定吗?',autoCancel: true, // 点遮罩/返回键能否关闭alignment: DialogAlignment.Center,primaryButton: {value: '取消',action: () => {console.log('用户取消了')}},secondaryButton: {value: '删除',fontColor: '#E8380D', // 危险操作标红action: () => {this.count += 1console.log('用户确认删除')}}})})}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}

几个关键点先记住:
AlertDialog.show(...) 是静态方法,直接调,不用 new、不用 controller。primaryButton = 左/次按钮,secondaryButton = 右/主按钮。两个按钮的 action 里写各自的回调。title,用 message + 两个按钮的简写形式 confirm:// 更短的"确定"弹窗AlertDialog.show({message: '保存成功',confirm: {value: '好的',action: () => {}}})
onWillDismiss 在弹窗即将消失时触发,可以决定是否真的关掉。比如"编辑未保存,点了返回也要拦下来"——和第17篇 NavDestination 的 onBackPressed 是同一类思路:
AlertDialog.show({title: '退出编辑',message: '有内容未保存',autoCancel: true,primaryButton: { value: '继续编辑', action: () => {} },secondaryButton: { value: '仍要退出', action: () => {} },onWillDismiss: (action: DismissDialogAction) => {// reason 可能是:PRESS_BACK(系统返回)/ TOUCH_OUTSIDE(点遮罩)/ CLOSE_BUTTON / ENTERif(action.reason === DismissReason.PRESS_BACK) {// 想拦就拦:不调 action.dismiss() 就不关;想放行就调一下action.dismiss()}}})
onWillDismiss里不调action.dismiss(),弹窗就不会关——这是做"未保存拦截"的开关。
AlertDialog 适合二选一。如果有一串操作(比如「分享到:微信/朋友圈/复制链接/取消」),用 ActionSheet——它从底部升起,列一排选项:
Button('分享').onClick(() => {ActionSheet.show({title: '分享到',message: '选择分享方式',autoCancel: true,confirm: {value: '取消',action: () => {}},cancel: () => {console.log('用户点了取消/遮罩')},sheets: [{title: '微信好友',action: () => { console.log('分享到微信') }},{title: '朋友圈',action: () => { console.log('分享到朋友圈') }},{title: '复制链接',action: () => { console.log('已复制') }}]})})

sheets 是一个数组,每一项 { title, action },点哪个就执行哪个 action。confirm 是底部的"取消"兜底按钮。
AlertDialog 和 ActionSheet 都是"开箱即用、不能改长相"的快捷弹窗。一旦你想自定义内部布局(比如做一个登录弹窗、一个带图的评分弹窗),就得上 CustomDialog。
CustomDialog 是弹窗的"完全体":你自己的 @Component 想怎么画就怎么画,弹出的位置、遮罩、动画都能控。核心是两个角色:
@CustomDialog:装饰器,标在弹窗组件上。CustomDialogController:弹窗控制器,负责 open() / close(),在页面组件里 new 出来。// 1)先写弹窗组件,@CustomDialog 装饰@CustomDialogstruct TipDialog {// 控制器由框架自动注入,弹窗里声明即可,关闭时直接用controller?: CustomDialogControllerbuild() {Column() {Text('这是一个自定义弹窗').fontSize(20).margin({ bottom: 16 })Button('关闭').onClick(() => {this.controller?.close() // 关弹窗})}.padding(24).backgroundColor(Color.White).borderRadius(12)}}// 2)页面里 new 一个 Controller,挂上 builder@Entry@Componentstruct CustomDemo {// ★ Controller 是普通属性,不要加 @State(官方不推荐放 @State)dialogController: CustomDialogController = new CustomDialogController({builder: TipDialog(), // 指向上面的弹窗组件alignment: DialogAlignment.Center,autoCancel: true, // 点遮罩关闭maskColor: 'rgba(0,0,0,0.4)', // 遮罩颜色offset: { dx: 0, dy: 0 }})build() {Column() {Button('打开弹窗').onClick(() => {this.dialogController.open() // 开弹窗})}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}

三个关键点:
@CustomDialog 装饰;页面里用 new CustomDialogController({ builder: XXX() }) 持有它。this.dialogController.open(),关闭在弹窗内部调 this.controller?.close()——controller 由框架自动注入(弹窗里声明 controller?: CustomDialogController 即可,不用手动传)。autoCancel / maskColor / alignment 控制"点外面关不关、遮罩多深、弹在哪"。弹窗要显示页面里的值,直接在 builder 里传参,和普通的自定义组件传参一模一样:

@CustomDialogstruct TipDialog {controller?: CustomDialogControllermsg: string = '' // 接页面传进来的值(普通属性,由 builder 传入)build() {Column() {Text(this.msg).fontSize(18).margin({ bottom: 16 })Button('关闭').onClick(() => this.controller?.close())}.padding(24)}}@Entry@Componentstruct CustomDemo2 {@State tip: string = '你有一条新消息'dialogController!: CustomDialogControlleraboutToAppear() {// 关键:在 aboutToAppear 里用最新状态重建 controller 的 builderthis.dialogController = new CustomDialogController({builder: TipDialog({ msg: this.tip }),alignment: DialogAlignment.Center})}build() {Column() {Button('打开弹窗').onClick(() => this.dialogController.open())}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}
一个常见坑:
CustomDialogController的builder只在new那一刻定一次。上面CustomDemo2在aboutToAppear里new时,把当时的tip拷成快照塞进弹窗;之后页面tip变了,弹窗内容不会自动刷新。想要"动态值",就在打开前重新new一次 Controller,把最新值传进去(别指望页面状态变了弹窗自动跟着变)。
弹窗里用户选了什么、输了什么,页面要拿到。三种常见手法,从简到繁:
姿势 A:回调函数(最简单)——给弹窗传一个函数属性,弹窗里调它把结果抛回去:
@CustomDialogstruct InputDialog {controller?: CustomDialogController@State text: string = ''onConfirm?: (value: string) => void // 回调属性build() {Column() {TextInput({ placeholder: '请输入', text: this.text }).onChange((v) => { this.text = v })Button('确定').margin({ top: 12 }).onClick(() => {this.onConfirm?.(this.text) // 把结果抛回页面this.controller?.close()})}.padding(24)}}@Entry@Componentstruct PageA {@State result: string = ''dialogController!: CustomDialogControlleraboutToAppear() {this.dialogController = new CustomDialogController({builder: InputDialog({onConfirm: (v: string) => { this.result = v } // 接回结果})})}build() {Column() {Text(`结果:${this.result}`).fontSize(18).margin(16)Button('输入点东西').onClick(() => this.dialogController.open())}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}

姿势 B:实时同步(onChange 回调)——如果希望"弹窗里每改一下,页面立刻跟着变",把 @Link 换成 onChange 回调即可。注意:@Link 通过 CustomDialogController 在不少版本会失效(报错 is not callable 或绑定不上),生产里弹窗双向同步用回调最稳。
@CustomDialogstruct LinkDialog {controller?: CustomDialogControllervalue: string = '' // 普通属性收初始值onChange?: (v: string) => void // 回调:把每次变化抛回页面build() {Column() {TextInput({ text: this.value }).onChange((v) => {this.value = vthis.onChange?.(v) // 本地显示 + 实时通知页面})Button('关闭').onClick(() => this.controller?.close())}.padding(24)}}// 页面@Entry@Componentstruct PageB {@State name: string = 'cc'dialogController: CustomDialogController = new CustomDialogController({builder: LinkDialog({value: this.name, // 初始值onChange: (v: string) => { this.name = v } // 弹窗里改 → 页面 name 实时同步})})build() {Column() {Text(`结果:${this.name}`).fontSize(18).margin(16)Button('输入点东西').onClick(() => this.dialogController.open())}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}
效果和
@Link一样"弹窗改、页面同步",但走回调,编译 100% 过、各版本通用。@Link真正的双向绑定玩法见第14篇(普通父子组件场景);用在CustomDialog上受弹窗渲染机制限制,不推荐。

姿势 C:AppStorage 中转——弹窗和页面都没有直接层级关系时(比如跨组件),用 AppStorage / @StorageLink 当公共黑板(第15篇讲过)。适合"登录弹窗改了全局登录态"这类场景。
CustomDialogController 还能控制长相:
this.dialogController = new CustomDialogController({builder: MyDialog(),alignment: DialogAlignment.Bottom, // 贴底部(做底部抽屉/选择器常用)offset: { dx: 0, dy: 0 },customStyle: true, // true = 去掉默认卡片白底,自己完全控制布局gridCount: 4, // 弹窗宽度占几格(默认按栅格)maskColor: 'rgba(0,0,0,0.3)'})
做"底部升起的选择面板"就把 alignment 设 Bottom + customStyle: true,里面放自己的 Column 即可。
CustomDialog 是"重"弹窗,带遮罩、居中或贴底。有些场景你只想在某个按钮旁边冒个小气泡提示(比如「已复制」「这里填错了」),用 bindPopup 更轻:
@Entry@Componentstruct PopupDemo {@State showTip: boolean = falsebuild() {Column() {Button('点我').onClick(() => { this.showTip = !this.showTip })// 第一个参数是"是否显示",true 就冒出来.bindPopup(this.showTip, {message: '这是一条气泡提示', // 纯文字气泡placement: Placement.Bottom, // 相对于按钮的位置showInSubWindow: false,onStateChange: (e) => {// e.isVisible 变化时回调,可同步本组件的 showTip 状态if (!e.isVisible) { this.showTip = false }}})}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}

想要自定义气泡内容(不只是文字),把 message 换成 builder:
@BuilderpopupBuilder() {Column() {Text('自定义气泡').fontSize(14)Button('知道了').onClick(() => { this.showTip = false })}.padding(12).backgroundColor(Color.White).borderRadius(8)}// 使用.bindPopup(this.showTip, {builder: this.popupBuilder,placement: Placement.Top,mask: true, // 是否带遮罩onStateChange: (e) => { if (!e.isVisible) this.showTip = false }})

bindPopup 和 CustomDialog 的区别一句话:
bindPopup是"长"在组件身上的小气泡;CustomDialog是独立的一整层弹窗。
菜单分两种触发方式:
bindMenu:点一下组件,弹出菜单(比如右上角「⋯」)。bindContextMenu:长按 / 右键弹出(比如长按列表项)。最省事——直接给一个 MenuElement 数组:
@Entry@Componentstruct MenuDemo {@State showMenu: boolean = falsebuild() {Column() {Button('⋯').bindMenu(this.showMenu,[{ value: '复制', action: () => { console.log('复制') } },{ value: '转发', action: () => { console.log('转发') } },{value: '删除',action: () => { console.log('删除') }}],{ // ③ MenuOptions 对象(不是裸回调)onDisappear: () => { this.showMenu = false } // 菜单消失时把状态重置回 false}).onClick(() => { this.showMenu = true }) // 点按钮=显示}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}

三个参数:① 是否显示的 @State 状态变量(isShow)② 菜单项数组(每项 { value, action })③ MenuOptions 对象,用 onDisappear: () => void 在菜单关闭时把 showMenu 重置回 false(它是无参回调,不是 (isVisible) => void),否则下次点不开。
bindMenu 的数组形式只能放文字项。想要图标、分组、子菜单这种更丰富的菜单,用 bindContextMenu + 自己写 Menu/MenuItem 的 @Builder:
@Entry@Componentstruct ContextMenuDemo {@BuilderctxMenuBuilder() {Menu() {MenuItem({ startIcon: $r('app.media.startIcon'), content: '复制', labelInfo: 'Ctrl+C' }).onClick(() => { console.log('复制') })MenuItem({ content: '粘贴' }).onClick(() => { console.log('粘贴') })// 分组MenuItemGroup({ header: '更多' }) {MenuItem({ content: '转发' }).onClick(() => {})MenuItem({ content: '收藏' }).onClick(() => {})}}}build() {Column() {Text('长按我试试').fontSize(20).padding(20).bindContextMenu(this.ctxMenuBuilder, // ① content:直接传 @Builder(不是 boolean)ResponseType.LongPress, // ② responseType:长按触发(也可 ResponseType.RightClick 右键){})}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}
Menu 组件里 MenuItem 能带 startIcon(图标)、content(文字)、labelInfo(右侧快捷键提示),还能用 MenuItemGroup 分组。长按列表项弹这种菜单,体验就很接近系统了。


鸿蒙把常用的"选择器"也做成了"一行弹窗"的 API,不用自己拼 DatePicker:
// 日期选择DatePickerDialog.show({start: new Date('2000-01-01'),end: new Date('2100-12-31'),selected: new Date(),onAccept: (value: DatePickerResult) => {console.log(`选了:${value.year}-${value.month+1}-${value.day}`)},onCancel: () => {},onChange: (value: DatePickerResult) => {}})// 时间选择TimePickerDialog.show({selected: new Date(),onAccept: (value: TimePickerResult) => {console.log(`选了:${value.hour}:${value.minute}`)},onCancel: () => {}})// 文本选择(比如选城市)TextPickerDialog.show({range: ['北京', '上海', '广州', '深圳'],selected: 0,onAccept: (value: TextPickerResult) => {console.log(`选了:${value.value}`)},onCancel: () => {}})
这些 xxxDialog.show(...) 和 AlertDialog.show 一样是静态方法,一行唤起,省去自己写 CustomDialog 的麻烦。另外注意月份要加+1,月份计数是从0开始的

弹窗/菜单不是"页面",这点要心里有数:
弹窗是"盖"在当前页面上的浮层,不进 NavPathStack(接第17篇)。所以你从首页弹个 CustomDialog,再切 Tab、再切回来,那个弹窗默认已经没了——它跟某个具体页面的 UI 绑在一起,不是导航栈里的一页。
在 Navigation 的子页面里弹窗,照常弹。CustomDialogController 在子页面组件里 new 即可,和首页没区别。但要注意:子页面被 pop 出栈时,它上面的弹窗也跟着没了——这通常是符合预期的。
回传结果,底层还是状态管理那套(接第13~15篇):
@Link 双向绑定(最简单;但弹窗里别用@Link,见踩坑#7)。AppStorage / @StorageLink。onPop 回调(Navigation 跳转回传,第17篇讲过)或弹窗的回调函数属性(本文 4.3 姿势 A)。onWillDismiss / onBackPressed 是同类拦截机制:弹窗想"未保存别关"用 onWillDismiss,Navigation 子页想拦截返回用 onBackPressed。思路一致——都是"即将消失时给你一次否决权"。
记一句总纲:
弹窗/菜单解决"浮层交互",数据进出解决"和页面通信",两者用状态管理或回调桥接,和导航栈是两套独立机制。
通用
AlertDialog / ActionSheet / PickerDialog 都是静态 show():直接 AlertDialog.show(...),不要 new。它们是"一次性"弹窗,没有 controller。CustomDialog + CustomDialogController:AlertDialog 改不了内部长相。CustomDialogController 别放 @State:官方建议作为普通属性即可;放 @State 可能引发多余刷新甚至异常。controller 由框架自动注入:在 @CustomDialog 组件里声明 controller?: CustomDialogController 即可,页面 new CustomDialogController({ builder: XXX() }) 时框架会把同一个 controller 自动绑定进去,弹窗里 this.controller?.close() 就能关掉它——不用(也不能)在 builder 里手动传 controller(如 4.1 的 builder: TipDialog() 就没传)。autoCancel:忘了设 autoCancel: true,用户点遮罩关不掉,体验很差。传值相关
CustomDialog 的 builder 只在 new 时定一次:想让弹窗显示"最新动态值",在打开前(或 aboutToAppear)重新 new 一个 Controller 把新值传进去,别指望页面状态变了弹窗自动变。CustomDialog 里做双向同步用回调 / @StorageLink 最稳:@Link 通过 CustomDialogController.builder 在不少版本会失效(报错 is not callable 或绑定不上),别依赖它;真正用 @Link 双向绑定见第14篇(普通父子组件场景)。@State 在弹窗里存:弹窗销毁后它的 @State 也没了;要回传就走回调 / @StorageLink / AppStorage(@Link 仅限普通父子组件,弹窗里别依赖,见#7)。菜单相关
bindMenu 受控模式要手动同步:MenuOptions 的 onDisappear: () => void 里把 showMenu 改回 false,否则菜单关掉后 showMenu 还是 true,下次点按钮不弹(onDisappear 是无参回调,别写成 (isVisible) => void)。bindMenu 数组形式只能文字项:要图标/分组/子菜单,改用 bindContextMenu + Menu/MenuItem 的 @Builder。ResponseType.LongPress:想右键触发用 ResponseType.RightClick,别写错。Picker
DatePickerDialog 的 onAccept 拿的是 DatePickerResult:里面有 year/month/day 字段,不是直接 Date 对象,别对着 value 当 Date 用。ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line┌─────────────────────────────────────────────────────────┐│ 浮层三件套(弹窗 + 菜单) ││ ││ ① 快捷弹窗(静态 show,不能改长相) ││ ├─ AlertDialog 确认/取消 二选一 ││ ├─ ActionSheet 底部一排操作(多选一) ││ └─ Date/Time/TextPickerDialog 日期/时间/文本选择 ││ ││ ② 自定义弹窗(@CustomDialog + Controller,最灵活) ││ └─ CustomDialogController.open() / .close() ││ ├─ 传进:builder 参数(动态值记得重新 new) ││ └─ 传出:回调 / @StorageLink / AppStorage ││ ││ ③ 轻量浮层 ││ ├─ bindPopup 长在某个组件旁的小气泡 ││ ├─ bindMenu 点击组件弹菜单(文字项) ││ └─ bindContextMenu 长按/右键弹菜单(Menu组件自定义)││ ││ 关键关系: ││ · 浮层不进 NavPathStack(接第17篇) ││ · 数据进出靠状态管理/回调(接第13~15篇) ││ · onWillDismiss ≈ NavDestination.onBackPressed(拦截) │└─────────────────────────────────────────────────────────┘
选型速记:
AlertDialog | |
ActionSheet | |
DatePickerDialogTimePickerDialog / TextPickerDialog | |
CustomDialog | |
bindPopup | |
bindMenu | |
bindContextMenuMenu |
留言区见~