系统默认弹窗只能满足最简单的确认/取消场景。本文用 @CustomDialog 装饰器构建三种完全不同的自定义弹窗——确认弹窗、输入弹窗、列表选择弹窗——涵盖弹窗数据回传、控制器生命周期、以及 CustomDialogController 的正确用法。


一、我们要做什么

一个页面 + 三个自定义弹窗:

  1. 确认弹窗 — 自定义按钮文字(“删除"替代"确定”)、自定义按钮颜色(红色警告色)、弹窗关闭后回传确认状态给页面
  2. 输入弹窗 — 弹窗内嵌 TextInput + 字数统计,输入内容校验通过后回传给页面
  3. 列表选择弹窗 — 弹窗内嵌选项列表,单选高亮,确定后将选中项回传给页面

页面顶部实时显示"当前标签"和"当前分类"——这两个值由弹窗回传的数据更新。三个交互点各自对应一种弹窗类型,覆盖了工作中最常见的三种弹窗模式。


二、@CustomDialog 基础

2.1 核心结构

@CustomDialog
struct ConfirmDialog {
  controller!: CustomDialogController;  // 必须,由框架注入
  title: string = '';
  message: string = '';
  onConfirm?: () => void;

  build() {
    Column() {
      Text(this.title)...
      Text(this.message)...
      Row() {
        Text('取消').onClick(() => this.controller.close())
        Text('确定').onClick(() => {
          this.onConfirm?.();
          this.controller.close();
        })
      }
    }
  }
}

@CustomDialog 装饰器标记的 struct 和普通 @Component 有三点不同:

  1. 必须有 controller 字段 — 类型 CustomDialogController,用于在弹窗内部调用 close() 关闭弹窗。字段名必须叫 controller,框架会通过它注入控制器引用。! 是 TypeScript 的 Definite Assignment Assertion,告诉编译器"这个字段在使用前一定会被赋值"——实际上由框架在弹窗打开时赋值。

  2. 不支持 @Entry — 不能独立作为页面入口,只能作为弹窗使用。

  3. 自带遮罩层 — 打开弹窗时自动显示半透明遮罩,点击遮罩是否关闭由 autoCancel 控制。

2.2 CustomDialogController

private confirmDialogCtrl!: CustomDialogController;

aboutToAppear(): void {
  this.confirmDialogCtrl = new CustomDialogController({
    builder: ConfirmDialog({
      controller: this.confirmDialogCtrl,
      title: '确认删除',
      message: '此操作不可撤销...',
      confirmText: '删除',
      confirmColor: AppColors.ERROR,
      onConfirm: (): void => {
        promptAction.showToast({ message: '已删除', duration: 1200 });
      }
    }),
    autoCancel: true,           // 点击遮罩是否关闭
    alignment: DialogAlignment.Center,  // 弹窗位置
  });
}

// 打开:
this.confirmDialogCtrl.open();

关键点:builder 中传入了 controller: this.confirmDialogCtrl——这看起来是循环引用,但实际上 builder 是一个懒求值函数。CustomDialogController 构造时只是保存了这个 builder 引用,直到 .open() 被调用时才真正执行 builder 函数创建弹窗视图。此时 this.confirmDialogCtrl 早已构造完成,不再是 null。

2.3 为什么写在 aboutToAppear 里?

控制器只能在组件的 aboutToAppear 或成员初始化器中创建。推荐 aboutToAppear,原因有二:

  • 成员初始化器的执行顺序不确定——如果多个控制器之间有依赖,用 aboutToAppear 可以控制顺序
  • aboutToAppear 中可以访问 this 的完整上下文,确保所有 @State 和字段已就绪

在这里插入图片描述

三、交互点1:确认弹窗

@CustomDialog
struct ConfirmDialog {
  controller!: CustomDialogController;
  title: string = '';
  message: string = '';
  confirmText: string = '确定';
  confirmColor: string = AppColors.PRIMARY;
  onConfirm?: () => void;

  build() {
    Column() {
      Text(this.title)...        // 弹窗标题
      Text(this.message)...      // 弹窗正文
      Divider()...

      Row() {
        Text('取消')             // 灰色文字,layoutWeight(1) 均分宽度
          .fontColor(AppColors.TEXT_TERTIARY)
          .onClick(() => this.controller.close())

        Divider().vertical(true) // 竖线分隔

        Text(this.confirmText)   // 确认按钮,颜色和文字可定制
          .fontColor(this.confirmColor)
          .fontWeight(FontWeight.Medium)
          .onClick(() => {
            this.onConfirm?.();
            this.controller.close();
          })
      }
    }
    .width('80%')
  }
}

调用示例:

this.confirmDialogCtrl = new CustomDialogController({
  builder: ConfirmDialog({
    controller: this.confirmDialogCtrl,
    title: '确认删除',
    message: '此操作不可撤销,确定要删除选中的记录吗?',
    confirmText: '删除',
    confirmColor: AppColors.ERROR,
    onConfirm: (): void => {
      promptAction.showToast({ message: '已删除', duration: 1200 });
    }
  }),
  autoCancel: true,
  alignment: DialogAlignment.Center,
});

为什么不用系统 promptAction.showDialog?因为系统弹窗不能改按钮颜色、不能加 Divider、不能自定义布局。这里的 confirmText: '删除'confirmColor: AppColors.ERROR 就是系统弹窗做不到的——系统弹窗的确认按钮永远是蓝色,无法为破坏性操作改为红色。

按钮分隔线是 Divider 组件——横线在上下按钮之间,竖线在左右按钮之间。系统弹窗没有这个细节,但自定义弹窗可以做到,视觉上更精致。


在这里插入图片描述

四、交互点2:输入弹窗

@CustomDialog
struct InputDialog {
  controller!: CustomDialogController;
  title: string = '';
  placeholder: string = '';
  maxLength: number = 20;
  onSubmit?: (value: string) => void;
  @State inputValue: string = '';

  build() {
    Column() {
      Text(this.title)...

      TextInput({ placeholder: this.placeholder, text: $$this.inputValue })
        .maxLength(this.maxLength)
        .backgroundColor(AppColors.BACKGROUND)
        .borderRadius(BorderRadius.MD)...

      Text(`${this.inputValue.length}/${this.maxLength}`)  // 字数统计
        .fontColor(AppColors.TEXT_DISABLED)

      Divider()...

      Row() {
        Text('取消').onClick(() => this.controller.close())
        Text('确定').onClick(() => {
          if (this.inputValue.trim().length > 0 && this.onSubmit) {
            this.onSubmit(this.inputValue.trim());
          }
          this.controller.close();
        })
      }
    }
  }
}

三个值得注意的设计:

1. @State inputValue 在弹窗内部独立管理 — 弹窗的 TextInput 绑定弹窗自己的 @State,不和页面共享状态。只有在点击"确定"时才通过 onSubmit 回调把值传给页面。这避免了"弹窗开着,页面上的值跟着变"的奇怪中间状态——用户取消了弹窗,值就没有被提交。

2. 字数统计this.inputValue.length 跟随 @State 实时更新。字数统计的显示位置在输入框下方、按钮上方,用的是右对齐——和 RegisterPage 的昵称字数统计保持一致的设计语言。

3. trim() 验证 — 空字符串或纯空格不能提交。trim() 之后再检查 length > 0,和前面文章的空消息守卫逻辑一致。

调用示例:

this.inputDialogCtrl = new CustomDialogController({
  builder: InputDialog({
    controller: this.inputDialogCtrl,
    title: '设置标签',
    placeholder: '请输入标签名称',
    maxLength: 10,
    onSubmit: (value: string): void => {
      this.tag = value;       // 回传给页面
      promptAction.showToast({ message: `标签已设为:${value}`, duration: 1200 });
    }
  }),
  autoCancel: true,
  alignment: DialogAlignment.Center,
});

五、交互点3:列表选择弹窗

class SelectOption {
  label: string;
  value: string;

  constructor(label: string, value: string) {
    this.label = label;
    this.value = value;
  }
}

@CustomDialog
struct SelectDialog {
  controller!: CustomDialogController;
  title: string = '';
  options: SelectOption[] = [];
  onSelect?: (value: string, label: string) => void;
  @State selectedValue: string = '';

  aboutToAppear(): void {
    if (this.options.length > 0) {
      this.selectedValue = this.options[0].value;  // 默认选中第一项
    }
  }

  build() {
    Column() {
      Text(this.title)...

      ForEach(this.options, (opt: SelectOption) => {
        Row() {
          Text(opt.label)...           // 选项文字
          // 自定义单选圆圈
          Row()
            .width(20).height(20)
            .borderRadius(10)
            .border({ width: ..., color: ... })
            .justifyContent(FlexAlign.Center)
        }
        .backgroundColor(this.selectedValue === opt.value ? '#F0F5FF' : Color.White)
        .onClick(() => { this.selectedValue = opt.value; })
      }, (opt: SelectOption) => opt.value)

      Divider()...
      Row() {
        Text('取消').onClick(() => this.controller.close())
        Text('确定').onClick(() => {
          const opt = this.options.find(o => o.value === this.selectedValue);
          if (opt && this.onSelect) {
            this.onSelect(opt.value, opt.label);
          }
          this.controller.close();
        })
      }
    }
  }
}

关键设计:

单选圆圈 — 用 Row + borderRadius(10) + border 画圆环。选中的圆环为蓝色描边 + 内部实心蓝点,未选中的为灰色描边。不用 Radio 组件而用自定义圆圈,是为了完全控制样式——Radio 组件的颜色和尺寸定制受限。

选中行背景backgroundColor(selectedValue === value ? '#F0F5FF' : Color.White) 给选中行淡蓝色底色,强化"当前选中"的视觉反馈。这和 iOS Settings 的选中单元格效果一致。

aboutToAppear 默认选中 — 弹窗打开时自动选中第一项,保证 selectedValue 永远有值。用户即使不选直接点"确定",也能提交第一项的结果。

调用示例:

this.selectDialogCtrl = new CustomDialogController({
  builder: SelectDialog({
    controller: this.selectDialogCtrl,
    title: '选择分类',
    options: [
      new SelectOption('技术文章', 'tech'),
      new SelectOption('实战教程', 'tutorial'),
      new SelectOption('产品动态', 'product'),
      new SelectOption('开发工具', 'tool'),
      new SelectOption('社区活动', 'event'),
    ],
    onSelect: (value: string, label: string): void => {
      this.category = label;
      promptAction.showToast({ message: `已选择:${label}`, duration: 1200 });
    }
  }),
  autoCancel: true,
  alignment: DialogAlignment.Center,
});

六、三个弹窗的共性模式

回顾三个弹窗,它们共享同一个架构模式:

@CustomDialog struct XxxDialog
  ├── controller!: CustomDialogController     ← 固定字段
  ├── 输入参数(title, message, options...)   ← 展示内容
  ├── @State 内部状态(输入值/选中项)          ← 弹窗内交互
  ├── 回调(onConfirm, onSubmit, onSelect)   ← 数据出口
  └── build()
       ├── 内容区域(Text, TextInput, ForEach)
       ├── Divider 横线分隔
       └── 按钮行(取消 + 确定)

这个模式可以复用到任何自定义弹窗:图片预览弹窗、筛选条件弹窗、日期选择弹窗……换内容区域和回调签名即可。


七、页面状态展示

@State tag: string = '未设置';
@State category: string = '未选择';

页面上方实时显示这两个值。弹窗的 onSubmit / onSelect 回调中更新这些 @State

// 输入弹窗回传
onSubmit: (value: string): void => {
  this.tag = value;
}

// 选择弹窗回传
onSelect: (value: string, label: string): void => {
  this.category = label;
}

值的显示有两种状态:

  • 未设置'未设置' / '未选择')→ 灰色(TEXT_DISABLED)
  • 已设置(弹窗回传的值)→ 蓝色(PRIMARY)+ 加粗

这个颜色切换让用户一眼就能看出"哪些设置还没改过"。


八、常见面试题 / 踩坑点

8.1 controller!! 是什么意思?

TypeScript 的 Definite Assignment Assertion。controller!: CustomDialogController 声明"这个字段在使用前一定会被赋值,不要报 TS2564 错误"。在 @CustomDialog 中,controller 由框架在弹窗打开时注入,不是通过构造函数传入,所以需要 ! 来告诉编译器。

8.2 为什么 builder 中传 controller: this.confirmDialogCtrl 不会空指针?

因为 builder 是懒求值的。new CustomDialogController({ builder: ..., ... }) 只是保存了 builder 函数引用,不会立即执行。执行发生在 .open() 调用时——此时控制器已构造完毕,this.confirmDialogCtrl 有值。所以传入 builder 的 controller 引用是有效的。

8.3 autoCancel: truefalse 怎么选?

  • 需要用户明确操作(删除确认、支付确认)→ autoCancel: false,防止误触遮罩关闭
  • 可跳过(筛选、提示说明)→ autoCancel: true,用户体验更好

本 Demo 三个弹窗都设为 true,因为它们都可跳过。如果是真实的删除确认弹窗,建议设为 false

8.4 为什么每个弹窗都要独立定义一个 @CustomDialog struct?

复用 vs 定制是弹窗设计的核心矛盾。把三种弹窗合并成一个"万能弹窗"需要无数的 if (type === 'input') / else if (type === 'select') 分支,代码难维护。

更好的实践是:基础结构复用(按钮行、Divider、宽度 80%、白色背景),内容区域各行其是。可以通过提取一个 @Builder dialogButtonRow(cancelAction, confirmAction) 减少按钮行的重复代码。

8.5 弹窗关闭后 @State 没更新?

检查回调中是否修改了 @State 字段。onSubmit / onSelect 回调在弹窗关闭前被调用——此时弹窗的视图还在,页面的视图也在。如果在回调中修改了页面的 @State,ArkUI 会在弹窗关闭后刷新页面。

但如果回调用的是箭头函数,里面的 this 指向弹窗自身而不是页面——需要确保回调在页面(调用方)中定义,如本 Demo 的 aboutToAppear 中定义回调。

8.6 DialogPage 的三个控制器能否合并为一个数组?

不能。CustomDialogController 是强类型绑定到具体的 @CustomDialog struct。ConfirmDialog 的控制器和 InputDialog 的控制器不是同一个类型——它们的 builder 接收不同的参数。所以需要三个独立的控制器字段。


九、运行方式

代码位于 dev/entry/src/main/ets/pages/DialogPage.ets

用 DevEco Studio 打开 dev/ 项目,首页点击"自定义弹窗 — 确认输入选择三合一"即可体验:

  1. 进入页面 → 顶部显示"标签:未设置"“分类:未选择”(灰色)
  2. 点击"确认弹窗" → 弹出红色"删除"按钮的确认弹窗 → 点击"删除" → Toast “已删除”,弹窗关闭
  3. 点击"输入弹窗" → 弹出输入框弹窗 → 输入"鸿蒙开发" → 字数显示 5/10 → 点击"确定" → Toast + 页面标签更新为"鸿蒙开发"(蓝色)
  4. 点击"选择弹窗" → 弹出选项列表 → 默认选中"技术文章" → 点击"产品动态"切换选中 → 点击"确定" → Toast + 页面分类更新为"产品动态"(蓝色)
  5. 弹窗打开时点击遮罩 → 弹窗关闭,不触发任何回调(autoCancel: true)
  6. 输入弹窗什么都不输点确定 → 不触发 onSubmit(trim 验证拦截)

十、扩展方向

  • 底部弹出弹窗 — 设置 alignment: DialogAlignment.Bottom,实现 iOS ActionSheet 风格的底部弹窗
  • 弹窗进出动画 — 自定义 open() / close() 的过渡动画(transition 属性)
  • 链式弹窗 — 确认弹窗关闭后自动打开下一个弹窗(在 onConfirm 回调中调用 nextCtrl.open()
  • 弹窗内表单校验 — 输入弹窗增加实时校验(如标签名不能重复),校验失败时"确定"按钮置灰不可点
  • 图片预览弹窗 — 点击缩略图 → 全屏弹窗显示大图,支持左右滑动切换
  • 筛选组合弹窗 — 多个筛选条件(类型 + 时间 + 排序)整合在一个弹窗中,确认后一次性回传所有条件
  • 弹窗嵌套 — 弹窗内按钮打开另一个弹窗(如"分享"弹窗 → "选择好友"弹窗),ArkUI 支持弹窗嵌套
Logo

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

更多推荐