自定义弹窗:使用CustomDialogController实现复杂交互(27)
在 HarmonyOS 中,使用CustomDialogController实现复杂交互的核心在于:通过@CustomDialog装饰器定义弹窗 UI,利用CustomDialogController控制其显示与隐藏,并通过属性传递和事件回调实现父子组件间的数据与交互解耦。
以下是实现复杂交互弹窗的完整方案与实战代码:
一、 定义自定义弹窗组件 (@CustomDialog)
在弹窗组件内部,定义好需要接收的数据(Props)和回调函数(Events),并在按钮点击时通过controller.close()关闭弹窗并触发回调。
// 1. 定义弹窗参数接口 export interface CommonDialogOptions { title?: string; content?: string; cancelText?: string; confirmText?: string; onCancel?: () => void; onConfirm?: () => void; } // 2. 使用 @CustomDialog 装饰器定义弹窗 UI @CustomDialog export struct CommonDialog { controller: CustomDialogController; options: CommonDialogOptions; build() { Column() { if (this.options.title) { Text(this.options.title) .fontSize(18) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 10 }) } if (this.options.content) { Text(this.options.content) .fontSize(14) .fontColor('#666666') .margin({ bottom: 20 }) } // 按钮组交互 Row() { Button(this.options.cancelText || '取消') .flex(1) .height(44) .backgroundColor('#F5F5F5') .onClick(() => { this.controller.close(); // 关闭弹窗 this.options.onCancel?.(); // 触发取消回调 }) Button(this.options.confirmText || '确认') .flex(1) .height(44) .backgroundColor('#007DFF') .margin({ left: 12 }) .onClick(() => { this.controller.close(); // 关闭弹窗 this.options.onConfirm?.(); // 触发确认回调 }) } .width('100%') .margin({ bottom: 20 }) } .width('100%') .padding({ left: 20, right: 20 }) .backgroundColor('#FFFFFF') .borderRadius(12) } }二、 封装弹窗控制器 (DialogController)
为了避免在每个页面都重复创建CustomDialogController,推荐封装一个静态工具类来统一管理和调用弹窗。
import { CommonDialog, CommonDialogOptions } from './CommonDialog'; export class DialogController { private static dialogController: CustomDialogController | null = null; static showCommonDialog(options: CommonDialogOptions) { // 先关闭之前的弹窗,避免重复弹出 this.dismiss(); this.dialogController = new CustomDialogController({ builder: CommonDialog({ options: options }), alignment: DialogAlignment.Center, customStyle: true, cornerRadius: 12, maskColor: 0x33000000, cancelable: true, // 点击蒙层可关闭 onDismiss: () => { this.dialogController = null; // 弹窗关闭后释放引用,避免内存泄漏 } }); this.dialogController.open(); } static dismiss() { if (this.dialogController) { this.dialogController.close(); this.dialogController = null; } } }三、 页面使用示例
在业务页面中,只需一行代码即可唤起带有复杂交互的弹窗,并通过回调处理业务逻辑。
import { DialogController } from '../common/DialogController'; @Entry @Component struct Index { build() { Column() { Button('显示自定义交互弹窗') .margin(10) .onClick(() => { DialogController.showCommonDialog({ title: '提示', content: '确定要执行此操作吗?', onConfirm: () => { console.log('用户点击了确认'); }, onCancel: () => { console.log('用户点击了取消'); } }); }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }四、 嵌套弹窗:在自定义弹窗中再次弹窗
在复杂的业务流中,经常需要在第一个弹窗内触发第二个弹窗(例如:点击“确认”后弹出“操作成功”提示)。
实现原理:
在第一个@CustomDialog组件内部,定义第二个弹窗的CustomDialogController。当点击按钮时,调用第二个控制器的open()方法。
@CustomDialog struct FirstDialog { controller?: CustomDialogController; // 定义第二个弹窗的控制器 dialogControllerTwo: CustomDialogController | null = new CustomDialogController({ builder: SecondDialog(), alignment: DialogAlignment.Bottom, offset: { dx: 0, dy: -25 } }); build() { Column() { Text('这是第一个弹窗') Button('点我打开第二个弹窗') .margin(20) .onClick(() => { if (this.dialogControllerTwo != null) { this.dialogControllerTwo.open(); } }) } } }注:若需要传入多个其他弹窗的 Controller,当前自定义弹窗的 controller 定义需放在其他传入的 controller 后面。
五、 样式与动画深度定制
系统默认的弹窗样式往往难以满足品牌化需求,通过CustomDialogControllerOptions可以实现高度定制。
- 自定义外观样式:
通过设置customStyle: true开启完全自定义样式,配合backgroundColor、cornerRadius、borderWidth、borderColor甚至shadow(阴影)属性,打造精致的弹窗 UI。 - 出入场动画定制:
使用openAnimation和closeAnimation属性,可以自定义弹窗弹出的动画时长(duration)、速度曲线(curve)以及延迟(delay),实现丝滑的过渡效果。
1、 自定义外观样式代码
通过设置customStyle: true,您可以完全接管弹窗的样式渲染,并配合backgroundColor、cornerRadius、borderWidth、borderColor以及shadow等属性打造精致的 UI。
dialogController: CustomDialogController | null = new CustomDialogController({ builder: CustomDialogExample(), autoCancel: true, alignment: DialogAlignment.Center, offset: { dx: 0, dy: -20 }, customStyle: false, backgroundColor: 0xd9ffffff, cornerRadius: 20, width: '80%', height: '100px', borderWidth: 1, borderStyle: BorderStyle.Dashed, // 使用borderStyle属性,需要和borderWidth属性一起使用 borderColor: Color.Blue, // 使用borderColor属性,需要和borderWidth属性一起使用 shadow: ({ radius: 20, color: Color.Grey, offsetX: 50, offsetY: 0 }), });2、 出入场动画定制代码
通过openAnimation属性,您可以控制弹窗出现动画的持续时间、速度曲线、延迟等参数,实现丝滑的过渡效果。
dialogController: CustomDialogController | null = new CustomDialogController({ builder: CustomDialogExample(), openAnimation: { duration: 1200, curve: Curve.Friction, delay: 500, playMode: PlayMode.Alternate, onFinish: () => { hilog.info(DOMAIN, 'testTag', 'play end'); } }, autoCancel: true, alignment: DialogAlignment.Bottom, offset: { dx: 0, dy: -20 }, gridCount: 4, customStyle: false, backgroundColor: 0xd9ffffff, cornerRadius: 10, });六、 页面级弹窗(防遮挡新页面)
在电商或资讯类应用中,常遇到一个痛点:在页面 A 打开自定义弹窗,此时如果跳转到页面 B,弹窗会依然高高盖在所有新页面之上,遮挡新页面的内容。
根因揭秘:CustomDialog和promptAction.showDialog()默认挂载在应用的 Root(根节点),显示层级高于所有的 Page 页面。
解决方案:
如果希望弹窗随所属页面走(页面跳转后弹窗自动跟随或关闭,不遮挡新页),必须使用页面级弹出框。
- 方案 1:使用
bindSheet组件,并将mode设置为SheetMode.EMBEDDED。此时弹层挂载在当前 Page/NavDestination 节点,随页面入栈/出栈。 - 方案 2:在 API 15 及以上版本,配置
CustomDialogController的levelMode为LevelMode.EMBEDDED,使其成为页面级弹窗。
方案一:使用 NavDestinationMode.DIALOG(推荐)
这是目前处理页面级弹窗最优雅的方式。通过将NavDestination设置为弹窗模式,弹窗会作为路由栈中的一个页面存在。每次 push 一个 Dialog 页面,它就会出现在当前弹窗之上,其层级顺序完全由路由栈控制,天然实现了页面级绑定。
// 1. 定义弹窗页面 export struct DialogPage1 { @Provide('NavPathStack') pageStack: NavPathStack = new NavPathStack(); build() { NavDestination() { Stack({ alignContent: Alignment.Center }) { Column() { Text('这是一个页面级弹窗') .fontSize(20) .margin({ bottom: 100 }) Button('关闭') .width('30%') .onClick(() => { this.pageStack.pop(); // 出栈关闭弹窗 }) } .backgroundColor(Color.White) .borderRadius(10) .height('30%') .width('80%') } .height('100%') .width('100%') } .backgroundColor('rgba(0,0,0,0.5)') // 半透明蒙层 .hideTitleBar(true) .mode(NavDestinationMode.DIALOG) // 核心:设置为弹窗模式 } } // 2. 在主页面中通过路由栈唤起 Button('打开页面级弹窗') .onClick(() => { this.pageStack.pushPath({ name: 'DialogPage1' }); })方案二:使用 openCustomDialog 全局弹窗 + 焦点转移
对于使用openCustomDialog实现的全局自定义弹窗,其所在的窗口取决于自身锚点的 UI 上下文。如果希望弹窗不被子窗口遮挡,或者希望弹窗在特定的窗口中打开,可以通过转移焦点来控制弹窗的层级。
// 获取主窗口和子窗口 ID let subWindowID: number = window.findWindow('ResizeSubWindow').getWindowProperties().id; let windowStage = AppStorage.get('windowStage') as window.WindowStage; let mainWindowID: number = windowStage.getMainWindowSync().getWindowProperties().id; // 将焦点从主窗口转移到子窗口,使弹窗在子窗口中打开从而不被遮挡 let promise = window.shiftAppWindowFocus(mainWindowID, subWindowID); promise.then(() => { // 焦点转移成功后,再打开全局弹窗 OMDialog.instance.open(); });方案三:使用 DialogHub 三方库(企业级推荐)
在实际开发中,为了彻底解决弹窗与 UI 的耦合问题,并实现弹窗随页面生命周期自动管理,推荐使用华为官方推出的DialogHub解决方案。
核心优势:
- 页面级生命周期绑定:通过 UIObserver 实时监听应用内的路由变化。当路由发生变化(页面切换或导航)时,DialogHub 会自动检查并隐藏旧页面的弹窗,页面销毁时自动清理资源。
- UI 解耦:支持在全局监听里创建弹窗,通过链式调用的方式绑定目标组件并弹出,无需在组件内部声明 Controller。
// 1. 初始化 DialogHub.init(this.getUIContext()); // 2. 链式调用创建并显示弹窗(自动跟随页面生命周期) DialogHub.getToast() .setContent(wrapBuilder(TextToastBuilder), new TextToastParams('提示内容')) .setAnimation({ dialogAnimation: AnimationType.UP_DOWN }) .setConfig({ dialogBehavior: { isModal: true } }) .build() .show();七、 弹窗生命周期管理
从 API 19 开始,自定义弹窗提供了完整的生命周期回调函数,方便开发者在弹窗状态变化时执行特定逻辑(如埋点上报、暂停视频播放等)。触发时序依次为:
onWillAppear:弹出框显示动效前触发。onDidAppear:弹出框完全弹出后触发。onWillDisappear:弹出框退出动效前触发。onDidDisappear:弹出框完全消失后触发。
1. 基础自定义弹窗(CustomDialog)
在CustomDialogController中直接配置生命周期回调:
dialogController: CustomDialogController = new CustomDialogController({ builder: CustomDialogExample(), // 生命周期回调 onWillAppear: () => { console.info('弹窗即将显示,准备暂停视频...'); }, onDidAppear: () => { console.info('弹窗已完全显示,上报曝光埋点'); }, onWillDisappear: () => { console.info('弹窗即将关闭,保存表单状态...'); }, onDidDisappear: () => { console.info('弹窗已完全消失,恢复视频播放,释放资源'); } });2. 固定样式弹窗(如 AlertDialog / ActionSheet)
通过UIContext获取PromptAction对象后,在调用接口时传入生命周期参数(API 19+):
let promptAction = this.getUIContext().getPromptAction(); promptAction.showDialog({ title: '提示', message: '确定要删除吗?', buttons: [{ text: '确定' }, { text: '取消' }] }, { onWillAppear: () => { /* 动效前 */ }, onDidAppear: () => { /* 弹出后 */ }, onWillDisappear: () => { /* 退出前 */ }, onDidDisappear: () => { /* 消失后 */ } });3. 半模态/全模态页面(bindSheet / bindContentCover)
直接在组件的属性链上配置生命周期回调:
.bindSheet($$this.isShowSheet, this.sheetBuilder(), { onWillAppear: () => { /* 半模态显示动效前 */ }, onAppear: () => { /* 半模态显示动效后 */ }, onWillDisappear: () => { /* 半模态回退动效前 */ }, onDisappear: () => { /* 半模态回退动效后 */ } })八、 模态与非模态交互
- 模态弹窗(默认):
isModal: true。弹窗带有蒙层,不可与蒙层下方的控件进行交互(不支持点击和手势向下透传)。 - 非模态弹窗:
isModal: false。弹窗周围的蒙层区可以透传事件。适用于需要用户在查看弹窗信息的同时,依然能与底层页面进行交互的场景。
1. 基础自定义弹窗(CustomDialog)
在CustomDialogController中通过isModal属性进行配置:
dialogController: CustomDialogController = new CustomDialogController({ builder: CustomDialogExample(), isModal: false, // 设置为非模态弹窗,允许手势向下透传 autoCancel: true, alignment: DialogAlignment.Center });2. 气泡提示弹窗(Popup)
通过bindPopup的mask属性来控制模态与非模态状态:
- 当
mask为true或颜色值时,气泡为模态窗口。 - 当
mask为false时,气泡为非模态窗口。
Button('显示非模态气泡') .bindPopup(this.showPopup, { message: '这是一个非模态提示', mask: false // 设置为非模态窗口 })3. 全局自定义弹窗(openCustomDialog)
在使用UIContext的PromptAction打开自定义弹窗时,同样可以通过isModal进行配置:
let promptAction = this.getUIContext().getPromptAction(); promptAction.openCustomDialog(contentNode, { isModal: false, // 设置为非模态弹窗 alignment: DialogAlignment.Center });