HarmonyOS openCustomDialog 实战:从入门到理解原理

22 阅读6分钟

HarmonyOS openCustomDialog 实战:从入门到理解原理

openCustomDialog 是 ArkUI 中以代码方式创建弹窗的核心 API,支持完全自定义 UI、跨页面持久、动画控制等 @State 绑定式弹窗无法做到的场景。本文从「怎么用」出发,再深入「为什么这么用」,最后剖析「底层是怎么实现的」。


一、两种弹窗:什么时候该用它

ArkUI 中有两套弹窗体系,适用于不同场景:

对比项@State 绑定式(AlertDialog / bindSheetopenCustomDialog
声明方式模板中以变量控制显隐纯代码调用,无需模板变量
UI 能力框架内置 DSL,能力受限任意 @Builder,完全自定义
生命周期绑定在组件树上,页面卸载即消失独立于组件树,可跨页面持久
典型场景简单确认弹窗、下拉菜单自定义动画、复杂交互弹窗

**一句话:**简单弹窗用内置 DSL,复杂或需要精细控制的弹窗用 openCustomDialog


二、十分钟上手:最小化示例

openCustomDialog 的调用链路只有三步:获取 UIContext → 创建 ComponentContent → 调用 open

① window.getLastWindow(context)              // 获取当前窗口(异步)
② new ComponentContent(uiContext, Builder, param)   // 创建内容载体
③ promptAction.openCustomDialog(content, options)   // 弹出

2.1 弹窗入口函数

// ImageDialogExt.ets
import { ComponentContent, PromptAction, window } from "@kit.ArkUI"
import { ImageAlertDialog } from "./ImageAlertDialog"
import { UIContextHelper } from '@zebra/foundation/IndexArk'

export let imageDialogAction: PromptAction
export let imageDialogComponentContent: ComponentContent<object> | null = null

export function showImageAlertDialog(param?: ImageAlertDialogParams) {
  window.getLastWindow(UIContextHelper.getHostContext()!).then((windowClass) => {
    const uiContext = windowClass.getUIContext()

    // ① 包装 Builder  →  ② 创建 ComponentContent  →  ③ 打开弹窗
    imageDialogComponentContent = new ComponentContent<ImageAlertDialogParams>(
      uiContext,
      wrapBuilder<[ImageAlertDialogParams]>(ImageAlertDialogBuilder),
      {
        imageUrl: param?.imageUrl,
        closeImgUrl: param?.closeImgUrl,
        modalType: param?.modalType,
        onImageClickCallback: param?.onImageClickCallback,
        onDismissClickCallback: param?.onDismissClickCallback,
      }
    )

    imageDialogAction = uiContext.getPromptAction()
    imageDialogAction.openCustomDialog(imageDialogComponentContent, {
      alignment: DialogAlignment.Center,
      isModal: true,
      autoCancel: true,
      onWillDismiss: (dismissDialogAction: DismissDialogAction) => {
        if (param?.canDismiss?.(dismissDialogAction.reason) ?? false) {
          dismissDialogAction.dismiss()
        }
      },
      onDidAppear: () => {
        if (param?.onShownCallback) {
          param.onShownCallback()
        }
      },
      onDidDisappear: () => {
        if (param?.onCloseCallback) {
          param.onCloseCallback()
        }
      }
    })
  })
}

@Builder
export function ImageAlertDialogBuilder(params: ImageAlertDialogParams) {
  ImageAlertDialog({
    imageUrl: params.imageUrl,
    closeImgUrl: params.closeImgUrl,
    modalType: params.modalType,
    onImageClickCallback: params.onImageClickCallback,
    onDismissClickCallback: params.onDismissClickCallback,
  })
}

export interface ImageAlertDialogParams {
  imageUrl?: string | ResourceStr
  closeImgUrl?: ResourceStr
  modalType?: number
  onImageClickCallback?: VoidCallback
  onDismissClickCallback?: VoidCallback
  canDismiss?: (dismissReason: DismissReason) => boolean
  onShownCallback?: VoidCallback
  onCloseCallback?: VoidCallback
}

2.2 弹窗 UI 组件

@Component
export struct ImageAlertDialog {
  @Require imageUrl: string = ''
  closeImgUrl: ResourceStr = $r('app.media.icon_dialog_cancel')
  onImageClickCallback: () => void = () => {}
  onDismissClickCallback: () => void = () => {}

  build() {
    Column({ space: 24 }) {
      Image(this.imageUrl)
        .width(280)
        .objectFit(ImageFit.Contain)
        .onClick(() => {
          imageDialogAction?.closeCustomDialog(imageDialogComponentContent)
          this.onImageClickCallback()
        })

      Image(this.closeImgUrl)
        .width(32)
        .onClick(() => {
          imageDialogAction?.closeCustomDialog(imageDialogComponentContent)
          this.onDismissClickCallback()
        })
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#B3000000')
    .layoutWeight(1)
  }
}

2.3 调用方式

import { showImageAlertDialog } from '@zebra/zebraui/IndexArk'

showImageAlertDialog({
  imageUrl: 'https://example.com/image.png',
  canDismiss: (reason) => {
    // 仅允许"点击遮罩"关闭,三键 Back 等不允许
    return reason === DismissReason.TOUCH_OUTSIDE
  },
  onImageClickCallback: () => {
    // 点击图片:跳转链接等
  },
  onDismissClickCallback: () => {
    // 点击关闭按钮
  },
  onShownCallback: () => {
    console.info('弹窗已显示')
  },
  onCloseCallback: () => {
    console.info('弹窗已关闭')
  }
})

三、DialogOptions 配置详解

openCustomDialog(content, {
  alignment,           // 弹窗对齐:Center / Top / Bottom / Default
  offset,              // 相对于 alignment 的偏移(dx, dy)
  isModal,             // true=模态(遮罩);false=非模态(可穿透)
  autoCancel,          // 点击遮罩是否自动关闭
  showInSubWindow,     // true=在子窗口中显示,适合全局弹窗
  onWillDismiss,       // 关闭前回调,拦截关闭行为
  onDidAppear,         // 显示动画完成后
  onDidDisappear,      // 关闭动画完成后
})

模态 vs 非模态的区别:

isModal: true(模态)
  ├── 黑色半透明遮罩覆盖全屏
  ├── 底层页面禁止交互
  └── autoCancel: true 时,点击遮罩自动关闭

isModal: false(非模态)
  ├── 无遮罩,内容直接叠加在页面上
  ├── 底层页面仍可交互
  └── 通常配合 showInSubWindow: true 做"全局弹窗"

四、关闭弹窗

两种方式:弹窗内部主动关闭,或外部任意位置强制关闭。

// 弹窗内部(ImageAlertDialog 中)
imageDialogAction?.closeCustomDialog(imageDialogComponentContent)

// 外部任意位置
imageDialogAction?.closeCustomDialog(imageDialogComponentContent)

?. 安全调用是必要的——弹窗尚未创建时,imageDialogActionimageDialogComponentContent 均为 null


五、原理剖析:为什么这么用

5.1 三个核心 API 是什么

API来自作用
UIContextwindow.getLastWindow(...).getUIContext()ArkUI 的渲染上下文,所有 UI 操作的根节点
ComponentContentnew ComponentContent(uiContext, wrappedBuilder, params)弹窗内容的运行时"载体",持有 Builder 图纸和参数
PromptActionuiContext.getPromptAction()弹窗控制器,负责 open / close 等操作
uiContext.getPromptAction()
       ├── openCustomDialog(ComponentContent, options)   // 打开
       ├── closeCustomDialog(ComponentContent)             // 关闭
       ├── showToast(...)
       ├── showActionMenu(...)
       └── ...

5.2 ComponentContent 是什么

ComponentContent<T> 是一个持有渲染上下文 + Builder 图纸 + 参数的运行时对象

┌─────────────────────────────────────┐
│       ComponentContent<T>           │
│                                     │
│  uiContext      →  渲染上下文        │
│  wrappedBuilder →  UI 布局图纸       │
│  params         →  传给图纸的数据    │
└─────────────────────────────────────┘
        │
        │ 交给 PromptAction 管理
        ▼
┌──────────────────┐     ┌────────────┐
│   openCustomDialog│ ──▶ │  弹窗显示   │
│  closeCustomDialog│ ──▶ │  弹窗关闭   │
└──────────────────┘     └────────────┘

它不属于组件树,而是作为"内容包"交给 PromptAction 显示/隐藏。

5.3 wrapBuilder<T>(builder):ArkTS 到框架的桥接

new ComponentContent(uiContext, wrapBuilder<[T]>(BuilderFn), param)

wrapBuilder@Builder 函数适配为 ComponentContent 内部渲染管线所需的稳定签名。

为什么不能直接传 @Builder

ArkTS 的 @Builder 是语法糖,编译后类型信息擦除。ComponentContent 内部需要泛型 T 的完整类型信息才能正确解析 params

@Builder function MyBuilder(param: T) { ... }
       │
       │  直接传入
       ▼
ComponentContent(..., MyBuilder, ...)   ❌ 类型擦除,params 结构无法推断

       │
       │  wrapBuilder<T>(...) 包装
       ▼
ComponentContent(..., wrappedBuilder, ...)   ✅ 泛型保留,params 正确解析

使用约束:

约束说明
泛型 T 须与 Builder 参数类型一致wrapBuilder<[T]>(...)T 必须匹配 Builder 入参
只接受 @Builder 声明的函数普通函数、@Styles/@Extend 不行
Builder 参数只能是单个对象框架限制,多参数请打包成对象
// ✅ 推荐:params 打包成对象,扩展字段不影响签名
@Builder
export function ImageAlertDialogBuilder(params: ImageAlertDialogParams) {
  ImageAlertDialog({ ...params })
}

// ❌ 不推荐:多个独立参数,扩展时需改 Builder 签名
@Builder
function BadBuilder(imageUrl: string, modalType: number) { ... }

5.4 生命周期时序

openCustomDialog()
        │
        ▼
  [显示动画] ──── onDidAppear()  ← 动画完成后才触发
        │
        ▼
  ┌─── 弹窗可见 ───┐
  │                │
  │  onWillDismiss() ←── 用户触发关闭(遮罩点击 / 三键Back / 手势 / ESC)
  │       │            返回 true 才放行关闭
  │       ▼
  │   action.dismiss()
  │       │
  │       ▼
  └───────────────┘
        │
        ▼
  [关闭动画] ──── onDidDisappear()  ← 动画完成后才触发
        │
        ▼
  弹窗已关闭,ComponentContent 可重新打开

5.5 为什么需要模块级变量

ArkUI 组件靠 @State/@Link 挂在组件树上,生命周期由父组件管理。弹窗有两类跨越组件边界的操作:

  1. 打开弹窗:业务层触发,弹窗 UI 由 Builder 描述,两者不在同一组件树
  2. 关闭弹窗:弹窗内部按钮或外部任意位置主动关闭

PromptActionComponentContent 都是运行时对象,无法通过模板 props 传递。模块级 export let 变量是当前绕过组件树共享引用最直接的方案。

模块顶层
  │
  ├── imageDialogAction: PromptAction          →  控制 open / close
  └── imageDialogComponentContent: ComponentContent →  持有弹窗内容
        │
        ├── Builder(ImageAlertDialogBuilder) →  UI 布局图纸
        └── params                            →  传给图纸的数据

六、避坑指南

6.1 ComponentContent 必须在 openCustomDialog 前创建

// ✅ 正确
content = new ComponentContent(uiContext, wrappedBuilder, params)
action.openCustomDialog(content, options)

// ❌ 错误:openCustomDialog 第一个参数不能直接传 builder
action.openCustomDialog(wrappedBuilder, options)  // 编译报错

6.2 closeCustomDialog 必须传入同一个实例

// ✅ 正确:传入创建时的同一个对象
action.closeCustomDialog(imageDialogComponentContent)

// ❌ 错误:每次创建新实例,关闭的不是目标弹窗
action.closeCustomDialog(new ComponentContent(...))

6.3 getLastWindow 是异步的

// ❌ 错误:同步调用,uiContext 尚未就绪
export function showDialog() {
  const action = uiContext.getPromptAction()  // uiContext 为 undefined
  action.openCustomDialog(...)
}

// ✅ 正确:在 Promise 回调中调用
window.getLastWindow(context).then(windowClass => {
  const action = windowClass.getUIContext().getPromptAction()
  action.openCustomDialog(...)
})

6.4 layoutWeight(1) 让遮罩覆盖全屏

ImageAlertDialog 的根 Column 设置了 .layoutWeight(1),撑满 DialogContent 区域,使半透明遮罩 #B3000000 能覆盖整个屏幕,而非仅覆盖图片区域。


七、总结

openCustomDialog 核心三步:
① 获取 UIContext(从 window)
② 创建 ComponentContent(uiContext + wrapBuilder + params)
③ 调用 openCustomDialog(content + DialogOptions)

关闭时:closeCustomDialog(ComponentContent)
设计原则:逻辑层(生命周期/PromptAction)与视图层(@Builder)分离

两文件分工:

文件职责
ImageDialogExt.ets弹窗生命周期——创建 ComponentContent、调用 open/close、定义参数接口
ImageAlertDialog.ets弹窗 UI 组件——声明式描述布局和交互,不持有弹窗控制器
HarmonyOS openCustomDialog 实战:从入门到理解原理