自定义弹窗:使用CustomDialogController实现复杂交互(27)

在 HarmonyOS 中,使用 CustomDialogController 实现复杂交互的核心在于:通过 @CustomDialog 装饰器定义弹窗 UI,利用 CustomDialogController 控制其显示与隐藏,并通过属性传递和事件回调实现父子组件间的数据与交互解耦。

以下是实现复杂交互弹窗的完整方案与实战代码:

一、 定义自定义弹窗组件 (@CustomDialog)

在弹窗组件内部,定义好需要接收的数据(Props)和回调函数(Events),并在按钮点击时通过 controller.close() 关闭弹窗并触发回调。

TypeScript 复制代码
// 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,推荐封装一个静态工具类来统一管理和调用弹窗。

TypeScript 复制代码
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;
    }
  }
}

三、 页面使用示例

在业务页面中,只需一行代码即可唤起带有复杂交互的弹窗,并通过回调处理业务逻辑。

TypeScript 复制代码
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() 方法。

TypeScript 复制代码
@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 可以实现高度定制。

  1. 自定义外观样式
    通过设置 customStyle: true 开启完全自定义样式,配合 backgroundColorcornerRadiusborderWidthborderColor 甚至 shadow(阴影)属性,打造精致的弹窗 UI。
  2. 出入场动画定制
    使用 openAnimationcloseAnimation 属性,可以自定义弹窗弹出的动画时长(duration)、速度曲线(curve)以及延迟(delay),实现丝滑的过渡效果。

1、 自定义外观样式代码

通过设置 customStyle: true,您可以完全接管弹窗的样式渲染,并配合 backgroundColorcornerRadiusborderWidthborderColor 以及 shadow 等属性打造精致的 UI。

TypeScript 复制代码
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 属性,您可以控制弹窗出现动画的持续时间、速度曲线、延迟等参数,实现丝滑的过渡效果。

TypeScript 复制代码
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,弹窗会依然高高盖在所有新页面之上,遮挡新页面的内容。

根因揭秘

CustomDialogpromptAction.showDialog() 默认挂载在应用的 Root(根节点),显示层级高于所有的 Page 页面。

解决方案

如果希望弹窗随所属页面走(页面跳转后弹窗自动跟随或关闭,不遮挡新页),必须使用页面级弹出框

  • 方案 1 :使用 bindSheet 组件,并将 mode 设置为 SheetMode.EMBEDDED。此时弹层挂载在当前 Page/NavDestination 节点,随页面入栈/出栈。
  • 方案 2 :在 API 15 及以上版本,配置 CustomDialogControllerlevelModeLevelMode.EMBEDDED,使其成为页面级弹窗。

这是目前处理页面级弹窗最优雅的方式。通过将 NavDestination 设置为弹窗模式,弹窗会作为路由栈中的一个页面存在。每次 push 一个 Dialog 页面,它就会出现在当前弹窗之上,其层级顺序完全由路由栈控制,天然实现了页面级绑定。

TypeScript 复制代码
// 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 上下文。如果希望弹窗不被子窗口遮挡,或者希望弹窗在特定的窗口中打开,可以通过转移焦点来控制弹窗的层级。

TypeScript 复制代码
// 获取主窗口和子窗口 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 解决方案。

核心优势

  1. 页面级生命周期绑定:通过 UIObserver 实时监听应用内的路由变化。当路由发生变化(页面切换或导航)时,DialogHub 会自动检查并隐藏旧页面的弹窗,页面销毁时自动清理资源。
  2. UI 解耦:支持在全局监听里创建弹窗,通过链式调用的方式绑定目标组件并弹出,无需在组件内部声明 Controller。
TypeScript 复制代码
// 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 开始,自定义弹窗提供了完整的生命周期回调函数,方便开发者在弹窗状态变化时执行特定逻辑(如埋点上报、暂停视频播放等)。触发时序依次为:

  1. onWillAppear:弹出框显示动效前触发。
  2. onDidAppear:弹出框完全弹出后触发。
  3. onWillDisappear:弹出框退出动效前触发。
  4. onDidDisappear:弹出框完全消失后触发。
1. 基础自定义弹窗(CustomDialog)

CustomDialogController 中直接配置生命周期回调:

TypeScript 复制代码
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+):

TypeScript 复制代码
let promptAction = this.getUIContext().getPromptAction();
promptAction.showDialog({
  title: '提示',
  message: '确定要删除吗?',
  buttons: [{ text: '确定' }, { text: '取消' }]
}, {
  onWillAppear: () => { /* 动效前 */ },
  onDidAppear: () => { /* 弹出后 */ },
  onWillDisappear: () => { /* 退出前 */ },
  onDidDisappear: () => { /* 消失后 */ }
});
3. 半模态/全模态页面(bindSheet / bindContentCover)

直接在组件的属性链上配置生命周期回调:

TypeScript 复制代码
.bindSheet($$this.isShowSheet, this.sheetBuilder(), {
  onWillAppear: () => { /* 半模态显示动效前 */ },
  onAppear: () => { /* 半模态显示动效后 */ },
  onWillDisappear: () => { /* 半模态回退动效前 */ },
  onDisappear: () => { /* 半模态回退动效后 */ }
})

八、 模态与非模态交互

  • 模态弹窗(默认)isModal: true。弹窗带有蒙层,不可与蒙层下方的控件进行交互(不支持点击和手势向下透传)。
  • 非模态弹窗isModal: false。弹窗周围的蒙层区可以透传事件。适用于需要用户在查看弹窗信息的同时,依然能与底层页面进行交互的场景。
1. 基础自定义弹窗(CustomDialog)

CustomDialogController 中通过 isModal 属性进行配置:

TypeScript 复制代码
dialogController: CustomDialogController = new CustomDialogController({
  builder: CustomDialogExample(),
  isModal: false, // 设置为非模态弹窗,允许手势向下透传
  autoCancel: true,
  alignment: DialogAlignment.Center
});
2. 气泡提示弹窗(Popup)

通过 bindPopupmask 属性来控制模态与非模态状态:

  • masktrue 或颜色值时,气泡为模态窗口。
  • maskfalse 时,气泡为非模态窗口。
TypeScript 复制代码
Button('显示非模态气泡')
  .bindPopup(this.showPopup, {
    message: '这是一个非模态提示',
    mask: false // 设置为非模态窗口
  })
3. 全局自定义弹窗(openCustomDialog)

在使用 UIContextPromptAction 打开自定义弹窗时,同样可以通过 isModal 进行配置:

TypeScript 复制代码
let promptAction = this.getUIContext().getPromptAction();
promptAction.openCustomDialog(contentNode, {
  isModal: false, // 设置为非模态弹窗
  alignment: DialogAlignment.Center
});
相关推荐
Swift社区1 小时前
当 AI 接管游戏世界:鸿蒙游戏 Workspace Runtime 架构揭秘
人工智能·游戏·harmonyos
世人万千丶2 小时前
家庭记账本小应用 - HarmonyOS ArkUI 开发实战-Tabs与List组件-PC版本
华为·list·harmonyos·鸿蒙
至乐活着2 小时前
HarmonyOS开发深度解析:网络请求与数据持久化实战全攻略
网络请求·harmonyos·arkts·数据持久化·鸿蒙开发
星释2 小时前
鸿蒙智能体开发实战:3.创建工作流
华为·harmonyos·智能体
hahjee2 小时前
【鸿蒙 PC三方库构建系统】解决 OpenHarmony SHA 库编译问题:从动态链接错误到静态链接优化
华为·harmonyos
伶俜662 小时前
鸿蒙原生应用实战(二十)ArkUI 课程表 App:Grid 网格 + SQLite 存储 + 周次切换 + 上课提醒
华为·sqlite·harmonyos
Davina_yu2 小时前
画布Canvas:2D绘图上下文(Context2D)绘制复杂图表(33)
harmonyos·鸿蒙·鸿蒙系统
风华圆舞3 小时前
鸿蒙 Flutter 页面怎么感知防窥状态并调整 UI 可见性
flutter·ui·harmonyos
小雨下雨的雨3 小时前
HarmonyOS ArkUI训练营入门-组件掌握系列-Grid 网格布局深度解析-PC版本
学习·华为·harmonyos·鸿蒙·鸿蒙系统