鸿蒙 HarmonyOS 6 | ArkUI (08):弹窗与覆盖 CustomDialog、Toast 与 Popup
在鸿蒙 HarmonyOS 6 中,ArkUI 为我们提供了极其强大的弹窗定制能力。无论是转瞬即逝的 Toast,还是完全自定义的 CustomDialog,亦或是指向性明确的 Popup 气泡,我们都可以像搭积木一样,用声明式的代码构建出既美观又灵动的交互体验。
前言
在一个优秀的应用设计中,界面不仅仅是平铺直叙的展示,更需要有层级感。当用户点击删除按钮时,我们需要一个确认框来防止误触;当后台数据加载完成时,我们需要一个轻量的提示告诉用户 好了 ;当用户对某个晦涩的功能图标感到困惑时,我们需要一个气泡弹窗来解释它的含义。这些浮在主界面之上的交互层,我们统称为 覆盖物(Overlays)。
在早期的开发中,很多工程师习惯直接使用系统原生的 AlertDialog,那种灰底黑字的弹窗虽然功能健全,但在如今这个颜值为王的时代,它打断了用户的情绪流,也破坏了应用的整体设计语言。
在鸿蒙 HarmonyOS 6 中,ArkUI 为我们提供了极其强大的弹窗定制能力。无论是转瞬即逝的 Toast,还是完全自定义的 CustomDialog,亦或是指向性明确的 Popup 气泡,我们都可以像搭积木一样,用声明式的代码构建出既美观又灵动的交互体验。

一、 轻量级反馈与上下文气泡
在进入复杂的弹窗之前,我们先解决最基础的反馈需求。当用户复制了一段文本,或者刷新列表成功时,我们不需要让用户进行任何操作,只需要给出一个朕已阅的信号。这就是 Toast。在 API 20 中,系统将这类交互统一收敛到了 promptAction 模块下。我们不再像以前那样去寻找 Window 实例,而是直接调用 promptAction.showToast。这个 API 非常纯粹,它接受一个显示时长、一条消息文本,以及一个可选的位置参数。但在实战中,建议尽量保持 Toast 的简洁,不要试图在里面塞入过多的文字。它应该像一阵风,来过,被看到,然后消失。
如果说 Toast 是全局的广播,那么 Popup 气泡就是点对点的悄悄话。CustomDialog 是一种模态交互,它会给背景加上遮罩,强迫用户聚焦。但有时候,我们并不想打断用户的操作流,只是想对界面上的某个元素做一点补充说明。比如一个帮助的小问号图标,或者一个“新功能”的引导提示。
这时候,ArkUI 提供的 bindPopup 属性是最优雅的选择。这意味着任何组件——一个按钮、一张图片甚至一段文字,都可以绑定一个气泡。系统会自动计算目标组件在屏幕上的位置,然后决定气泡是出现在上方、下方还是侧边,并自动生成一个小箭头指向目标。我们作为开发者,几乎不需要关心坐标计算的问题,只需要关注气泡里的内容构建即可。
@Entry
@Component
struct PopupExample {
// 控制气泡显示的开关状态
@State showPopup: boolean = false;
// 定义气泡内部的 UI 结构
@Builder
PopupContent() {
Column() {
Text('功能说明')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.margin({ bottom: 4 })
Text('这里是详细的补充文案,系统会自动根据位置计算箭头指向。')
.fontSize(12)
.fontColor('#E6E6E6')
}
.padding(12)
.backgroundColor('#4D4D4D') // 气泡背景通常与文字反色
.borderRadius(8)
}
build() {
Column() {
// 任何组件都可以绑定气泡,这里以一个问号图标为例
SymbolGlyph($r('sys.symbol.questionmark_circle'))
.fontSize(24)
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
// 1. 点击切换状态
.onClick(() => {
this.showPopup = !this.showPopup;
})
// 2. 绑定气泡属性
.bindPopup(this.showPopup, {
builder: this.PopupContent, // 指向内容构建器
placement: Placement.Bottom, // 优先显示位置(系统会自动调整)
mask: false, // false 表示非模态,不阻断用户操作其他区域
enableArrow: true, // 显示指向目标的小箭头
popupColor: '#4D4D4D', // 气泡背景色(需与 Builder 背景一致或透明)
onStateChange: (e) => {
// 3. 状态同步:当点击空白处气泡消失时,同步更新 boolean 变量
if (!e.isVisible) {
this.showPopup = false;
}
}
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
二、 定制化核心:CustomDialog 与控制器模式
当业务逻辑变得复杂,比如需要用户领取优惠券、签署隐私协议或者选择复杂的筛选条件时,系统的标准弹窗就捉襟见肘了。这时候,CustomDialog(自定义弹窗)就是我们的救星。它的设计哲学非常有趣,采用了一种 控制器(Controller) 模式。我们需要定义两个部分:一个是弹窗本身的 UI 结构,另一个是控制它打开和关闭的遥控器。
首先,我们需要定义一个被 @CustomDialog 装饰器修饰的结构体。在这个结构体里,你可以使用任何 ArkUI 组件:Column、Row、Image 甚至 List。这意味你可以把弹窗做得像普通页面一样丰富多彩。紧接着,在父组件中,我们需要实例化一个 CustomDialogController。这个控制器是连接父子组件的纽带。在实例化时,我们需要传入 builder 参数,指向我们刚才定义的弹窗组件。
@Entry
@Component
struct HomePage {
// 1. 实例化控制器:连接父组件与弹窗组件
// 必须在 @Component 中作为成员变量定义
dialogController: CustomDialogController | null = new CustomDialogController({
builder: PrivacyAgreementDialog(), // 引用外部定义的 @CustomDialog 组件
autoCancel: false, // 点击遮罩是否允许关闭(强制交互场景通常设为 false)
alignment: DialogAlignment.Center, // 弹窗在屏幕中的对齐方式
customStyle: true, // 是否完全自定义样式(去除系统默认的白色背景和圆角)
offset: { dx: 0, dy: 0 }, // 相对对齐位置的偏移量
maskColor: '#33000000', // 自定义遮罩层颜色
});
// 推荐:在组件销毁时清理控制器,防止内存泄漏
aboutToDisappear() {
this.dialogController = null;
}
build() {
Column() {
Button('打开隐私协议')
.fontSize(16)
.onClick(() => {
// 2. 通过控制器打开弹窗
if (this.dialogController != null) {
this.dialogController.open();
}
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
这里有一个初学者常犯的错误,就是试图通过 @Prop 或 @Link 来直接同步父子组件的数据。虽然 CustomDialog 支持这些装饰器,但由于弹窗并不在常规的组件渲染树中,数据的响应式更新有时会存在滞后。最佳的实践是:在打开弹窗时传入初始数据,在关闭弹窗时通过回调函数返回结果。比如做一个“领取优惠券”的弹窗,我们在构建 CustomDialog 时定义一个 confirm 回调函数。当用户点击弹窗里的“立即领取”按钮时,我们调用这个回调,把结果传回给父组件,然后关闭弹窗。这种 事件驱动 的数据流向,比复杂的双向绑定更加稳健且易于追踪。
做出来和做得好看是两码事。默认的 CustomDialog 往往带有系统默认的圆角和白色背景,有时甚至会有默认的内边距。为了实现设计师眼中那种“全屏半透明”或者“底部异形弹窗”的效果,我们一定要善用 customStyle: true 这个配置项。一旦设置为 true,系统就会移除所有默认的弹窗样式,给你一张完全空白的画布。这时候,你需要在你的 @CustomDialog 组件内部,自己定义背景色、圆角和阴影。虽然麻烦了一点,但它赋予了你像素级的控制权。
三、 综合实战:构建营销活动弹窗体系
为了将上述知识点融会贯通,我们来构建一个真实的电商营销场景。这个页面包含一个模拟的“会员中心”,右上角有一个绑定了 bindPopup 的帮助图标,点击会展示活动规则;而在页面中心,有一个“领取大礼包”的按钮,点击会唤起一个完全自定义样式的 CustomDialog 优惠券弹窗。
在这个代码中,请仔细观察 CouponDialog 的定义,它是如何通过 controller 关闭自己的,以及父组件是如何通过 CustomDialogController 配置 customStyle: true 来移除系统默认背景的。这就是构建高颜值弹窗的标准模板。
TypeScript
import { promptAction } from '@kit.ArkUI';
@CustomDialog
struct CouponDialog {
controller?: CustomDialogController;
couponAmount: number = 0;
onConfirm: () => void = () => {};
build() {
Column() {
// 顶部装饰
Stack({ alignContent: Alignment.Bottom }) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#FF4040')
.borderRadius({ topLeft: 16, topRight: 16 })
Text(`¥${this.couponAmount}`)
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.margin({ bottom: 20 })
}
.width('100%')
.height(120)
// 内容
Column({ space: 12 }) {
Text('恭喜获得新人优惠券')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333')
Text('全场通用,无门槛立减。有效期至 2026-12-31')
.fontSize(14)
.fontColor('#999')
.textAlign(TextAlign.Center)
.padding({ left: 20, right: 20 })
}
.padding({ top: 20, bottom: 20 })
// 按钮
Row() {
Button('残忍拒绝')
.backgroundColor('#F5F5F5')
.fontColor('#666')
.layoutWeight(1)
.margin({ right: 10 })
.onClick(() => {
// 【修复点 2】调用时加上 '?' (可选链),防止空指针报错
this.controller?.close();
})
Button('立即领取')
.backgroundColor('#FF4040')
.fontColor(Color.White)
.layoutWeight(1)
.onClick(() => {
this.onConfirm();
// 【修复点 3】同理,加上 '?'
this.controller?.close();
})
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 20 })
}
.width(300)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({ radius: 10, color: '#33000000', offsetY: 5 })
}
}
@Entry
@Component
struct DialogAndPopupPage {
// 状态变量:控制气泡 (Popup) 的显示与隐藏
@State isHelpPopupVisible: boolean = false;
// 【核心】定义弹窗控制器
// 必须在 build() 之外实例化
// builder 参数指向上面定义的 @CustomDialog 组件
private dialogController: CustomDialogController = new CustomDialogController({
builder: CouponDialog({
couponAmount: 100, // 向弹窗传递数据
onConfirm: () => {
// 定义弹窗确认后的逻辑
this.handleCouponReceived();
}
}),
autoCancel: true, // 允许点击遮罩关闭
customStyle: true, // 使用完全自定义样式(去除系统默认白底圆角)
alignment: DialogAlignment.Center // 居中显示
});
// 模拟业务逻辑:领取成功后的 Toast 反馈
handleCouponReceived() {
promptAction.showToast({
message: '领取成功!已存入卡包',
duration: 2000,
bottom: 100
});
}
// 定义 Popup (气泡) 的内容构建器
@Builder
PopupBuilder() {
Column() {
Text('活动规则说明')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.margin({ bottom: 8 })
Text('1. 仅限新用户领取\n2. 每日限领一张\n3. 不可与其他活动叠加')
.fontSize(12)
.fontColor(Color.White)
.lineHeight(18)
}
.padding(12)
.width(200)
}
build() {
Column() {
// --- 顶部导航栏 ---
Row() {
Text('会员中心')
.fontSize(20)
.fontWeight(FontWeight.Bold)
Blank() // 撑开中间空间
// 帮助图标 (绑定 Popup)
Text('?')
.fontSize(18)
.fontColor(Color.White)
.backgroundColor('#CCCCCC')
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.borderRadius(12)
// 【核心】绑定气泡
.bindPopup(this.isHelpPopupVisible, {
builder: this.PopupBuilder(), // 指向 Builder
placement: Placement.BottomRight, // 气泡位置
popupColor: '#4C4C4C', // 气泡深色背景
enableArrow: true, // 显示箭头
mask: false, // 非模态,不遮挡背景
onStateChange: (e) => {
// 状态同步:处理点击外部自动消失的情况
if (!e.isVisible) {
this.isHelpPopupVisible = false;
}
}
})
.onClick(() => {
// 点击切换显示状态
this.isHelpPopupVisible = !this.isHelpPopupVisible;
})
}
.width('100%')
.padding(20)
// --- 页面主体内容 ---
Column({ space: 30 }) {
// 模拟大图占位
Column()
.width(200)
.height(200)
.backgroundColor('#E0E0E0')
.borderRadius(100)
.margin({ top: 50 })
Text('超级会员大礼包')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('包含 100 元无门槛优惠券')
.fontSize(16)
.fontColor('#666')
// 【核心】触发弹窗的按钮
Button('立即领取')
.width('80%')
.height(50)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.backgroundColor('#FF4040')
.shadow({ radius: 10, color: '#4DFF4040', offsetY: 5 })
.onClick(() => {
// 打开自定义弹窗
if (this.dialogController) {
this.dialogController.open();
}
})
}
.width('100%')
.layoutWeight(1) // 占据剩余高度
}
.width('100%')
.height('100%')
.backgroundColor('#F8F8F8')
}
}

总结
弹窗和覆盖物是应用与用户沟通的第二语言。Toast 是轻声的耳语,CustomDialog 是正式的对话,而 Popup 则是贴心的便签。
在鸿蒙 HarmonyOS 6 开发中,掌握 @CustomDialog 和 bindPopup 是构建高级 UI 的必修课。我们抛弃了系统的默认样式,通过 customStyle 获得了对画布的完全掌控权,让弹窗不再只是功能的载体,更是视觉设计的延伸。切记,不要滥用弹窗,每一次遮罩的出现都是对用户注意力的强行掠夺。
好的交互应该是克制的,只在真正需要的时候才优雅地浮现。
更多推荐




所有评论(0)