鸿蒙自定义弹窗(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% 以上的弹窗需求。

参考资料

Logo

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

更多推荐