鸿蒙自定义弹窗(CustomDialog)的 8 种封装姿势
本文系统梳理鸿蒙 HarmonyOS NEXT 中自定义弹窗(CustomDialog)的 8 种封装姿势:基础装饰器封装、参数化封装、回调函数封装、@BuilderParam 内容注入、V2 状态驱动(MVVM)、promptAction 全局弹窗、DialogManager 单例管理器、DialogQueue 弹窗队列。每种姿势均提供完整 ArkTS V2 代码示例,并附横向对比表格,帮助开发
鸿蒙自定义弹窗(CustomDialog)的 8 种封装姿势
鸿蒙自定义弹窗(CustomDialog)的 8 种封装姿势
前言
在 HarmonyOS NEXT 应用开发中,弹窗是最高频的交互组件之一。从简单的确认框到复杂的业务表单,弹窗场景千变万化。ArkUI 提供了 @CustomDialog 装饰器和 promptAction.openCustomDialog 两条技术路线,在此基础上衍生出多种封装姿势。
本文系统梳理 8 种主流的 CustomDialog 封装方式,从最基础的装饰器用法到工程化的管理器模式,每种姿势都配有完整的 V2 规范代码示例。适合有一定 HarmonyOS 开发经验、希望让弹窗代码更优雅可复用的开发者阅读。
文章环境:HarmonyOS NEXT API 12+,ArkTS V2 状态管理规范,DevEco Studio 5.0+。
一、CustomDialog 核心机制解析
1.1 @CustomDialog 装饰器路线
@CustomDialog 装饰一个结构体,使其成为弹窗内容组件,通过 CustomDialogController 控制显隐。弹窗内容与宿主页面共享同一组件树,可以直接访问父组件的状态。
核心要点:
- 弹窗组件用
@CustomDialog修饰 - 宿主页面持有
CustomDialogController实例 - 参数通过
@Prop或@Link传递 - 关闭弹窗调用
controller.close()
1.2 promptAction.openCustomDialog 路线
promptAction.openCustomDialog 基于 @Builder 函数渲染弹窗内容,弹窗运行在独立的覆盖层,不依赖组件树。适合跨页面、全局弹窗等场景。
核心要点:
- 内容通过
@Builder函数描述 - 调用
promptAction.openCustomDialog展示 - 返回
dialogId,用closeCustomDialog(dialogId)关闭 - 支持在任意上下文(非 UI 组件内)调用
1.3 两条路线对比
| 对比项 | @CustomDialog | promptAction |
|---|---|---|
| 组件树依赖 | 依赖宿主组件 | 独立覆盖层 |
| 参数传递 | @Prop / @Link | 闭包捕获 |
| 跨页面调用 | 不支持 | 支持 |
| 动态内容 | @BuilderParam | @Builder |
| 状态同步 | 双向绑定 | 手动更新 |
| 适用场景 | 页面级弹窗 | 全局/服务级弹窗 |
二、姿势一:基础 @CustomDialog 封装
最标准的用法,适合简单的确认/取消场景。
2.1 弹窗组件
@CustomDialog
@ComponentV2
struct BasicConfirmDialog {
controller: CustomDialogController = new CustomDialogController({ builder: BasicConfirmDialog() })
@Param title: string = '提示'
@Param message: string = ''
@Event onConfirm: () => void = () => {}
@Event onCancel: () => void = () => {}
build() {
Column() {
Text(this.title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 12 })
Text(this.message)
.fontSize(14)
.fontColor('#666666')
.textAlign(TextAlign.Center)
.margin({ bottom: 24 })
Row({ space: 12 }) {
Button('取消')
.layoutWeight(1)
.backgroundColor('#F5F5F5')
.fontColor('#333333')
.onClick(() => {
this.onCancel()
this.controller.close()
})
Button('确认')
.layoutWeight(1)
.onClick(() => {
this.onConfirm()
this.controller.close()
})
}
}
.padding(24)
.width(300)
}
}
2.2 宿主页面调用
@Entry
@ComponentV2
struct BasicDialogPage {
private dialog: CustomDialogController = new CustomDialogController({
builder: BasicConfirmDialog({
title: '删除确认',
message: '确定要删除该条记录吗?删除后无法恢复。',
onConfirm: () => console.log('用户确认删除'),
onCancel: () => console.log('用户取消')
}),
alignment: DialogAlignment.Center,
cornerRadius: 16
})
build() {
Column() {
Button('打开基础弹窗').onClick(() => this.dialog.open())
}
.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
三、姿势二:参数化弹窗封装
将弹窗的样式、按钮文本、颜色等全部参数化,一个组件支撑多种业务场景。
3.1 参数接口定义
interface DialogConfig {
title: string
message: string
confirmText?: string
cancelText?: string
confirmColor?: ResourceColor
showCancel?: boolean
}
3.2 参数化弹窗组件
@CustomDialog
@ComponentV2
struct ConfigurableDialog {
controller: CustomDialogController = new CustomDialogController({ builder: ConfigurableDialog() })
@Param config: DialogConfig = { title: '', message: '' }
@Event onConfirm: () => void = () => {}
@Event onCancel: () => void = () => {}
build() {
Column() {
Text(this.config.title).fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 12 })
Text(this.config.message).fontSize(14).fontColor('#666666')
.textAlign(TextAlign.Center).margin({ bottom: 24 })
Row({ space: 12 }) {
if (this.config.showCancel !== false) {
Button(this.config.cancelText ?? '取消')
.layoutWeight(1).backgroundColor('#F5F5F5').fontColor('#333333')
.onClick(() => { this.onCancel(); this.controller.close() })
}
Button(this.config.confirmText ?? '确认')
.layoutWeight(this.config.showCancel !== false ? 1 : 0)
.width(this.config.showCancel !== false ? undefined : '100%')
.backgroundColor(this.config.confirmColor ?? '#007DFF')
.onClick(() => { this.onConfirm(); this.controller.close() })
}
}
.padding(24).width(300)
}
}
3.3 多场景复用
@Entry
@ComponentV2
struct ConfigurableDialogPage {
private deleteDialog: CustomDialogController = new CustomDialogController({
builder: ConfigurableDialog({
config: { title: '危险操作', message: '此操作将永久删除您的账号,请谨慎确认。',
confirmText: '确认删除', cancelText: '我再想想',
confirmColor: '#FF3B30', showCancel: true },
onConfirm: () => console.log('确认删除账号')
})
})
private alertDialog: CustomDialogController = new CustomDialogController({
builder: ConfigurableDialog({
config: { title: '温馨提示', message: '您的会员即将到期,请及时续费。',
confirmText: '立即续费', showCancel: false },
onConfirm: () => console.log('跳转续费')
})
})
build() {
Column({ space: 16 }) {
Button('危险操作弹窗').onClick(() => this.deleteDialog.open())
Button('仅确认弹窗').onClick(() => this.alertDialog.open())
}.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
四、姿势三:回调函数封装
将弹窗封装为独立工具方法,调用方只需传回调,无需感知控制器细节。
@CustomDialog
@ComponentV2
struct CallbackDialog {
controller: CustomDialogController = new CustomDialogController({ builder: CallbackDialog() })
@Param title: string = '提示'
@Param message: string = ''
@Event onResult: (confirmed: boolean) => void = () => {}
build() {
Column() {
Text(this.title).fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 12 })
Text(this.message).fontSize(14).fontColor('#666666')
.textAlign(TextAlign.Center).margin({ bottom: 24 })
Row({ space: 12 }) {
Button('取消').layoutWeight(1).backgroundColor('#F5F5F5').fontColor('#333333')
.onClick(() => { this.onResult(false); this.controller.close() })
Button('确认').layoutWeight(1)
.onClick(() => { this.onResult(true); this.controller.close() })
}
}.padding(24).width(300)
}
}
页面中使用,通过 onResult 统一处理确认/取消:
@Entry
@ComponentV2
struct CallbackDialogPage {
@Local resultText: string = '等待操作...'
private confirmDialog: CustomDialogController = new CustomDialogController({
builder: CallbackDialog({
title: '操作确认', message: '是否保存当前修改?',
onResult: (confirmed: boolean) => {
this.resultText = confirmed ? '已保存' : '已放弃'
}
})
})
build() {
Column({ space: 16 }) {
Text(this.resultText).fontSize(16)
Button('打开回调弹窗').onClick(() => this.confirmDialog.open())
}.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
五、姿势四:@BuilderParam 内容注入封装
将弹窗骨架(标题栏、按钮区)固定,内容区通过 @BuilderParam 由调用方自定义,实现最大灵活性。
5.1 弹窗骨架组件
@CustomDialog
@ComponentV2
struct SlotDialog {
controller: CustomDialogController = new CustomDialogController({ builder: SlotDialog() })
@Param title: string = '弹窗标题'
@BuilderParam content: () => void = this.defaultContent
@Event onConfirm: () => void = () => {}
@Event onCancel: () => void = () => {}
@Builder
defaultContent() {
Text('默认内容').fontSize(14).fontColor('#999')
}
build() {
Column() {
Text(this.title).fontSize(18).fontWeight(FontWeight.Bold)
.alignSelf(ItemAlign.Start).margin({ bottom: 16 })
this.content()
Divider().margin({ top: 20, bottom: 12 })
Row({ space: 12 }) {
Button('取消').layoutWeight(1).backgroundColor('#F5F5F5').fontColor('#333333')
.onClick(() => { this.onCancel(); this.controller.close() })
Button('确认').layoutWeight(1)
.onClick(() => { this.onConfirm(); this.controller.close() })
}
}.padding(24).width(340)
}
}
5.2 自定义内容注入示例
@Entry
@ComponentV2
struct SlotDialogPage {
@Local inputValue: string = ''
@Local selectedOption: number = 0
@Builder
feedbackContent() {
Column({ space: 12 }) {
TextInput({ placeholder: '请输入反馈内容', text: this.inputValue })
.onChange((val) => { this.inputValue = val })
Row({ space: 8 }) {
ForEach(['功能缺陷', '体验问题', '其他'], (item: string, index: number) => {
Text(item)
.padding({ horizontal: 12, vertical: 6 })
.backgroundColor(this.selectedOption === index ? '#007DFF' : '#F0F0F0')
.fontColor(this.selectedOption === index ? Color.White : '#333')
.borderRadius(16)
.onClick(() => { this.selectedOption = index })
})
}
}
}
private feedbackDialog: CustomDialogController = new CustomDialogController({
builder: SlotDialog({
title: '问题反馈',
content: this.feedbackContent.bind(this),
onConfirm: () => {
console.log(`反馈类型: ${this.selectedOption}, 内容: ${this.inputValue}`)
}
})
})
build() {
Column() {
Button('打开自定义内容弹窗').onClick(() => this.feedbackDialog.open())
}.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
六、姿势五:V2 状态驱动封装(MVVM)
引入 @ObservedV2 + @Trace 管理弹窗状态,弹窗内容响应数据变化,适合带校验的表单类弹窗。
6.1 弹窗状态模型
@ObservedV2
class UserFormState {
@Trace name: string = ''
@Trace phone: string = ''
@Trace nameError: string = ''
@Trace phoneError: string = ''
validate(): boolean {
let valid = true
if (this.name.trim().length === 0) {
this.nameError = '姓名不能为空'; valid = false
} else { this.nameError = '' }
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(this.phone)) {
this.phoneError = '请输入有效的手机号'; valid = false
} else { this.phoneError = '' }
return valid
}
reset() {
this.name = ''; this.phone = ''; this.nameError = ''; this.phoneError = ''
}
}
6.2 表单弹窗组件
@CustomDialog
@ComponentV2
struct UserFormDialog {
controller: CustomDialogController = new CustomDialogController({ builder: UserFormDialog() })
@Param formState: UserFormState = new UserFormState()
@Event onSubmit: (name: string, phone: string) => void = () => {}
build() {
Column() {
Text('用户信息').fontSize(18).fontWeight(FontWeight.Bold)
.alignSelf(ItemAlign.Start).margin({ bottom: 20 })
Column({ space: 16 }) {
Column({ space: 4 }) {
TextInput({ placeholder: '请输入姓名', text: this.formState.name })
.onChange((val) => { this.formState.name = val })
.borderColor(this.formState.nameError ? '#FF3B30' : '#E0E0E0')
if (this.formState.nameError) {
Text(this.formState.nameError).fontSize(12).fontColor('#FF3B30').alignSelf(ItemAlign.Start)
}
}
Column({ space: 4 }) {
TextInput({ placeholder: '请输入手机号', text: this.formState.phone })
.inputFilter('[0-9]').maxLength(11)
.onChange((val) => { this.formState.phone = val })
.borderColor(this.formState.phoneError ? '#FF3B30' : '#E0E0E0')
if (this.formState.phoneError) {
Text(this.formState.phoneError).fontSize(12).fontColor('#FF3B30').alignSelf(ItemAlign.Start)
}
}
}
Row({ space: 12 }) {
Button('取消').layoutWeight(1).backgroundColor('#F5F5F5').fontColor('#333333')
.onClick(() => { this.formState.reset(); this.controller.close() })
Button('提交').layoutWeight(1)
.onClick(() => {
if (this.formState.validate()) {
this.onSubmit(this.formState.name, this.formState.phone)
this.formState.reset()
this.controller.close()
}
})
}.margin({ top: 24 })
}.padding(24).width(320)
}
}
6.3 页面使用
@Entry
@ComponentV2
struct FormDialogPage {
@Local userFormState: UserFormState = new UserFormState()
private formDialog: CustomDialogController = new CustomDialogController({
builder: UserFormDialog({
formState: this.userFormState,
onSubmit: (name: string, phone: string) => {
console.log(`提交: 姓名=${name}, 手机号=${phone}`)
}
}),
autoCancel: false
})
build() {
Column() {
Button('打开表单弹窗').onClick(() => this.formDialog.open())
}.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
七、姿势六:promptAction.openCustomDialog 全局弹窗
不依赖组件树,可在任意上下文调用,适合工具类、服务层触发弹窗的场景。
import { promptAction } from '@kit.ArkUI'
class GlobalDialog {
private static dialogId: number = -1
static show(title: string, message: string,
onConfirm?: () => void, onCancel?: () => void): void {
const closeDialog = () => promptAction.closeCustomDialog(GlobalDialog.dialogId)
promptAction.openCustomDialog({
builder: () => {
Column() {
Text(title).fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 12 })
Text(message).fontSize(14).fontColor('#666666')
.textAlign(TextAlign.Center).margin({ bottom: 24 })
Row({ space: 12 }) {
Button('取消').layoutWeight(1).backgroundColor('#F5F5F5').fontColor('#333333')
.onClick(() => { onCancel?.(); closeDialog() })
Button('确认').layoutWeight(1)
.onClick(() => { onConfirm?.(); closeDialog() })
}
}
.padding(24).width(300).backgroundColor(Color.White).borderRadius(16)
},
alignment: DialogAlignment.Center,
maskColor: 'rgba(0,0,0,0.5)'
}).then((id) => { GlobalDialog.dialogId = id })
}
static dismiss(): void {
if (GlobalDialog.dialogId !== -1) {
promptAction.closeCustomDialog(GlobalDialog.dialogId)
GlobalDialog.dialogId = -1
}
}
}
任意位置一行调用:
GlobalDialog.show('网络异常', '当前网络不可用,请检查后重试。',
() => console.log('重试'),
() => console.log('取消')
)
八、姿势七:DialogManager 单例管理器封装
统一管理多个弹窗的显隐状态,避免 controller 散落各处,适合弹窗种类较多的复杂页面。
import { promptAction } from '@kit.ArkUI'
type DialogKey = 'confirm' | 'alert' | 'feedback' | 'userForm'
class DialogManager {
private static instance: DialogManager | null = null
private dialogIds: Map<DialogKey, number> = new Map()
static getInstance(): DialogManager {
if (!DialogManager.instance) {
DialogManager.instance = new DialogManager()
}
return DialogManager.instance
}
async open(key: DialogKey, builder: () => void): Promise<void> {
await this.close(key)
try {
const id = await promptAction.openCustomDialog({
builder, alignment: DialogAlignment.Center, maskColor: 'rgba(0,0,0,0.5)'
})
this.dialogIds.set(key, id)
} catch (err) {
console.error(`DialogManager open ${key} failed: ${err}`)
}
}
async close(key: DialogKey): Promise<void> {
const id = this.dialogIds.get(key)
if (id !== undefined) {
try { await promptAction.closeCustomDialog(id) } catch (_) {}
this.dialogIds.delete(key)
}
}
async closeAll(): Promise<void> {
for (const key of Array.from(this.dialogIds.keys())) {
await this.close(key)
}
}
isOpen(key: DialogKey): boolean { return this.dialogIds.has(key) }
}
页面中使用:
const dialogMgr = DialogManager.getInstance()
@Entry
@ComponentV2
struct DialogManagerPage {
@Local feedbackText: string = ''
@Builder
confirmBuilder() {
Column() {
Text('退出登录').fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 12 })
Text('确定要退出当前账号吗?').fontSize(14).fontColor('#666').margin({ bottom: 24 })
Row({ space: 12 }) {
Button('取消').layoutWeight(1).backgroundColor('#F5F5F5').fontColor('#333')
.onClick(() => dialogMgr.close('confirm'))
Button('退出').layoutWeight(1).backgroundColor('#FF3B30')
.onClick(() => { console.log('退出登录'); dialogMgr.close('confirm') })
}
}.padding(24).width(300).backgroundColor(Color.White).borderRadius(16)
}
build() {
Column({ space: 16 }) {
Button('退出确认弹窗').onClick(() => dialogMgr.open('confirm', this.confirmBuilder.bind(this)))
Button('关闭所有弹窗').onClick(() => dialogMgr.closeAll())
}.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
九、姿势八:弹窗队列封装
当多个业务事件同时触发弹窗时,通过队列保证弹窗逐一展示,避免叠加。
9.1 DialogQueue 实现
import { promptAction } from '@kit.ArkUI'
interface QueueItem {
builder: () => void
onClose?: () => void
}
class DialogQueue {
private static instance: DialogQueue | null = null
private queue: QueueItem[] = []
private currentDialogId: number = -1
private isShowing: boolean = false
static getInstance(): DialogQueue {
if (!DialogQueue.instance) { DialogQueue.instance = new DialogQueue() }
return DialogQueue.instance
}
enqueue(item: QueueItem): void {
this.queue.push(item)
if (!this.isShowing) { this.showNext() }
}
private async showNext(): Promise<void> {
if (this.queue.length === 0) { this.isShowing = false; return }
this.isShowing = true
const item = this.queue.shift()!
try {
const id = await promptAction.openCustomDialog({
builder: item.builder,
alignment: DialogAlignment.Center,
maskColor: 'rgba(0,0,0,0.5)',
onDidDisappear: () => {
item.onClose?.()
this.currentDialogId = -1
this.showNext()
}
})
this.currentDialogId = id
} catch (err) {
console.error(`DialogQueue showNext failed: ${err}`)
this.showNext()
}
}
closeCurrentAndNext(): void {
if (this.currentDialogId !== -1) {
promptAction.closeCustomDialog(this.currentDialogId)
}
}
clearQueue(): void { this.queue = [] }
}
9.2 使用示例
const dialogQueue = DialogQueue.getInstance()
@Entry
@ComponentV2
struct DialogQueuePage {
private makeDialogBuilder(index: number): () => void {
return () => {
Column() {
Text(`第 ${index} 个弹窗`).fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 12 })
Text('点击确认查看下一个弹窗').fontSize(14).fontColor('#666').margin({ bottom: 24 })
Button('确认').width('100%').onClick(() => dialogQueue.closeCurrentAndNext())
}
.padding(24).width(300).backgroundColor(Color.White).borderRadius(16)
}
}
build() {
Column({ space: 16 }) {
Button('触发 3 个连续弹窗')
.onClick(() => {
dialogQueue.enqueue({ builder: this.makeDialogBuilder(1) })
dialogQueue.enqueue({ builder: this.makeDialogBuilder(2) })
dialogQueue.enqueue({
builder: this.makeDialogBuilder(3),
onClose: () => console.log('所有弹窗已展示完毕')
})
})
}.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
十、8 种姿势横向对比
| 姿势 | 封装方式 | 复杂度 | 适用场景 |
|---|---|---|---|
| 基础 @CustomDialog | 装饰器 + 控制器 | ⭐ | 简单确认框 |
| 参数化封装 | @Prop 配置对象 | ⭐⭐ | 多场景复用一个组件 |
| 回调函数封装 | onResult 统一回调 | ⭐⭐ | 需要获取用户选择结果 |
| @BuilderParam 注入 | 插槽模式 | ⭐⭐⭐ | 内容高度自定义 |
| V2 状态驱动 | @ObservedV2 表单 | ⭐⭐⭐ | 表单类、带校验的弹窗 |
| promptAction 全局 | 工具方法封装 | ⭐⭐ | 服务层/工具类触发 |
| DialogManager | 单例管理器 | ⭐⭐⭐ | 页面弹窗种类多 |
| DialogQueue | 队列调度 | ⭐⭐⭐⭐ | 多弹窗并发保序展示 |
十一、常见问题
Q1:CustomDialogController 应该在哪里声明?
在宿主页面(@Entry 组件)内声明,不要放在子组件中。控制器依赖宿主的组件树,放错层级会导致弹窗无法正常显示。
Q2:弹窗内修改父组件状态为何不生效?
@Param 是单向绑定。如需双向同步,将状态提升到 @ObservedV2 对象,弹窗和页面共享同一个引用,@Trace 字段变更自动触发刷新。
Q3:promptAction 弹窗如何获取 UIContext?
在 EntryAbility.onWindowStageCreate 中通过 windowStage.getMainWindowSync().getUIContext() 获取并存储,再通过 UIContext.getPromptAction() 调用。
Q4:弹窗队列如何防止长时间不关闭导致堵塞?
在 enqueue 时携带 timeout 参数,showNext 内部用 setTimeout 超时后自动调用 closeCurrentAndNext()。
Q5:@BuilderParam 注入的内容能访问调用方状态吗?
可以。Builder 函数在调用方上下文中执行,通过闭包访问状态变量,使用 .bind(this) 确保 this 指向正确的组件实例。
总结
| 维度 | 推荐姿势 |
|---|---|
| 快速实现简单弹窗 | 姿势一(基础封装) |
| 一套代码多处复用 | 姿势二(参数化)+ 姿势三(回调) |
| 弹窗内容高度定制 | 姿势四(@BuilderParam 注入) |
| 复杂表单带实时校验 | 姿势五(V2 状态驱动) |
| 服务层/非 UI 层触发 | 姿势六(promptAction 全局) |
| 多弹窗统一管理 | 姿势七(DialogManager) |
| 多弹窗并发排队 | 姿势八(DialogQueue) |
HarmonyOS CustomDialog 的封装没有银弹,关键是根据业务场景选择合适的姿势。简单场景首选基础封装,业务复杂度上升时逐步引入状态驱动和管理器模式。合理组合本文介绍的 8 种姿势,可以覆盖日常 90% 以上的弹窗需求。
参考资料
更多推荐


所有评论(0)