鸿蒙ArkUI实战:自定义弹窗与数据回传
本文介绍了使用 @CustomDialog 装饰器实现三种常见的自定义弹窗交互模式:确认弹窗(支持自定义按钮样式)、输入弹窗(带字数统计和输入校验)、列表选择弹窗(单选回传)。通过 CustomDialogController 实现弹窗生命周期控制,重点讲解了循环引用问题的解决方案(利用懒求值特性)以及控制器的初始化时机选择(推荐在 aboutToAppear 中创建)。每种弹窗都演示了数据回传机
系统默认弹窗只能满足最简单的确认/取消场景。本文用
@CustomDialog装饰器构建三种完全不同的自定义弹窗——确认弹窗、输入弹窗、列表选择弹窗——涵盖弹窗数据回传、控制器生命周期、以及CustomDialogController的正确用法。
一、我们要做什么
一个页面 + 三个自定义弹窗:
- 确认弹窗 — 自定义按钮文字(“删除"替代"确定”)、自定义按钮颜色(红色警告色)、弹窗关闭后回传确认状态给页面
- 输入弹窗 — 弹窗内嵌 TextInput + 字数统计,输入内容校验通过后回传给页面
- 列表选择弹窗 — 弹窗内嵌选项列表,单选高亮,确定后将选中项回传给页面
页面顶部实时显示"当前标签"和"当前分类"——这两个值由弹窗回传的数据更新。三个交互点各自对应一种弹窗类型,覆盖了工作中最常见的三种弹窗模式。
二、@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 有三点不同:
-
必须有
controller字段 — 类型CustomDialogController,用于在弹窗内部调用close()关闭弹窗。字段名必须叫controller,框架会通过它注入控制器引用。!是 TypeScript 的 Definite Assignment Assertion,告诉编译器"这个字段在使用前一定会被赋值"——实际上由框架在弹窗打开时赋值。 -
不支持
@Entry— 不能独立作为页面入口,只能作为弹窗使用。 -
自带遮罩层 — 打开弹窗时自动显示半透明遮罩,点击遮罩是否关闭由
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: true 和 false 怎么选?
- 需要用户明确操作(删除确认、支付确认)→
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/ 项目,首页点击"自定义弹窗 — 确认输入选择三合一"即可体验:
- 进入页面 → 顶部显示"标签:未设置"“分类:未选择”(灰色)
- 点击"确认弹窗" → 弹出红色"删除"按钮的确认弹窗 → 点击"删除" → Toast “已删除”,弹窗关闭
- 点击"输入弹窗" → 弹出输入框弹窗 → 输入"鸿蒙开发" → 字数显示 5/10 → 点击"确定" → Toast + 页面标签更新为"鸿蒙开发"(蓝色)
- 点击"选择弹窗" → 弹出选项列表 → 默认选中"技术文章" → 点击"产品动态"切换选中 → 点击"确定" → Toast + 页面分类更新为"产品动态"(蓝色)
- 弹窗打开时点击遮罩 → 弹窗关闭,不触发任何回调(autoCancel: true)
- 输入弹窗什么都不输点确定 → 不触发 onSubmit(trim 验证拦截)
十、扩展方向
- 底部弹出弹窗 — 设置
alignment: DialogAlignment.Bottom,实现 iOS ActionSheet 风格的底部弹窗 - 弹窗进出动画 — 自定义
open()/close()的过渡动画(transition属性) - 链式弹窗 — 确认弹窗关闭后自动打开下一个弹窗(在
onConfirm回调中调用nextCtrl.open()) - 弹窗内表单校验 — 输入弹窗增加实时校验(如标签名不能重复),校验失败时"确定"按钮置灰不可点
- 图片预览弹窗 — 点击缩略图 → 全屏弹窗显示大图,支持左右滑动切换
- 筛选组合弹窗 — 多个筛选条件(类型 + 时间 + 排序)整合在一个弹窗中,确认后一次性回传所有条件
- 弹窗嵌套 — 弹窗内按钮打开另一个弹窗(如"分享"弹窗 → "选择好友"弹窗),ArkUI 支持弹窗嵌套
更多推荐
所有评论(0)