之前已经有多篇文章介绍过我封装的开源组件库 XTHUD,该组件库的 V3 版本中,针对在特殊场景中调用显示 HUD 的诸多问题,做了一次较大的更新,即之前的 HUD 是通过构造 CustomDialog 的方式实现的,但是在 V3 版本中新增的 XTPromptHUD 工具类,其 HUD 是通过 ComponentContent 实现的,ComponentContent 是 API12 新增的特性,可以支持在非 UI 组件环境中构造 UI,十分适合弹窗类的 UI 解耦开发。
在完善 HUD 这个组件库的开发过程中,针对鸿蒙开发中自定义 Dialog 的多种方式有了一些接触和尝试,下面的内容就是相关知识点的简单总结。
1. 自定义弹窗 (CustomDialog)
1.1. 简单的使用示例
自定义弹窗的最常规方式就是通过 CustomDialogController 类去控制显示自定义的弹窗或者浮层。
针对该方式的用法,可以参考官方的 API 文档:
- HarmonyOS Next API12 - 自定义弹窗 (CustomDialog)
- OpenHarmony API11 - 自定义弹窗 (CustomDialog)
- OpenHarmony API11 - 自定义弹窗 (CustomDialog) 开发范式
在我的 XTHUD 组件库中,核心实现如下,为便于展示自定义 Dialog 的使用方式,源码做了删减:
ts
// @CustomDialog装饰器用于装饰自定义弹框,此装饰器内进行自定义内容(也就是弹框内容)
@CustomDialog
struct XTToastCustomDialogView {
// 这里可以不用初始化,但是必须声明
// @CustomDialog component should have a property of the CustomDialogController type.
controller: CustomDialogController
// 显示文本,可动态响应更新
@Prop text: string = ''
// 动态响应配置
@Prop options: XTHUDToastOptions = defaultToastOptions
build() {
Column () {
if (this.options?.iconSrc) {
Image(this.options?.iconSrc)
.objectFit(ImageFit.Contain)
.size(this.options?.iconSize ?? defaultToastOptions.iconSize)
.fillColor(this.options?.tintColor ?? defaultToastOptions.tintColor)
.margin(this.options?.iconMargin ?? defaultToastOptions.iconMargin)
}
Text(this.text)
.fontSize(this.options?.fontSize ?? defaultToastOptions.fontSize)
.fontColor(this.options?.textColor ?? defaultToastOptions.textColor)
.padding({
top: this.options?.iconSrc ? 0 : this.options?.textPadding?.top ?? defaultToastOptions.textPadding.top,
left: this.options?.textPadding?.left ?? defaultToastOptions.textPadding.left,
right: this.options?.textPadding?.right ?? defaultToastOptions.textPadding.right,
bottom: this.options?.textPadding?.bottom ?? defaultToastOptions.textPadding.bottom
})
.textAlign(TextAlign.Center)
}
.backgroundColor(this.options?.backgroundColor ?? defaultToastOptions.backgroundColor)
.borderRadius(this.options?.borderRadius ?? defaultToastOptions.borderRadius)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.constraintSize({
minWidth: this.options?.minWidth ?? defaultToastOptions.minWidth,
maxWidth: this.options?.maxWidth ?? defaultToastOptions.maxWidth
})
}
}
@Component
export struct XTHUDToast {
/// 弹窗控制器
private _dialogController: CustomDialogController | null = null
/// toast视图构造器
@Builder private toastCustomDialogView() {
XTToastCustomDialogView({
text: this._currentText,
options: this._currentOptions,
})
}
showToastDialog(): void {
// 打开
this._dialogController?.open()
}
hide(): void {
this._dialogController?.close()
}
// 组件挂载
aboutToAppear() {
// 后初始化,避免options更新无效
this._dialogController = new CustomDialogController({
builder: () => {
this.toastCustomDialogView()
},
alignment: this._currentOptions?.alignment ?? defaultToastOptions.alignment,
// 是否可以点击背景关闭
autoCancel: this._currentOptions?.closeOnClickOutside ?? defaultToastOptions.closeOnClickOutside,
// 偏移量
offset: this._currentOptions?.offset ?? defaultToastOptions.offset,
// 自定义样式,默认就是白色圆角弹窗背景
customStyle: true,
maskColor: this._currentOptions?.maskColor ?? defaultToastOptions.maskColor,
openAnimation: this._currentOptions?.openAnimation ?? defaultToastOptions.openAnimation,
closeAnimation: this._currentOptions?.closeAnimation ?? defaultToastOptions.closeAnimation,
// 默认true,是否全屏展示,false只有弹窗区域UI
isModal: this._currentOptions?.isModal ?? defaultToastOptions.isModal,
})
}
build() {
}
}
1.2. 自定义 Dialog 不显示的问题探究
在 鸿蒙ArkUI自定义组件的构造原理探索 一文中,我使用了一种非常规的组件挂载方式,即只接 new XTHUDToast()
去操作 HUD 的展示,通过 XTEasyHUD 管理类去控制,优势就是灵活,可以在任意地方无需初始化一句代码执行 HUD 的显示和隐藏操作。
但是问题也是有很多的,在我的 Demo 仓库 issues 中可以看到对应的的使用反馈:
核心问题就是,在非 UI 组件环境中使用,HUD 可能会无法显示,本质原因应该是上下文环境丢失或者上下文获取异常。
其实这个问题在官方文档中也有说明:
在Stage模型中,WindowStage/Window可以通过loadContent接口加载页面并创建UI的实例,并将页面内容渲染到关联的窗口中,所以UI实例和窗口是一一关联的。一些全局的UI接口是和具体UI实例的执行上下文相关的,在当前接口调用时,通过追溯调用链跟踪到UI的上下文,来确定具体的UI实例。若在非UI页面中或者一些异步回调中调用这类接口,可能无法跟踪到当前UI的上下文,导致接口执行失败。
再次翻阅 arkui/ace_engine 源码,可以看到 CustomDialog 的承载控制器 CustomDialogController 的源码实现部分:
其基类 NativeCustomDialogController 是 C++ 实现的:
在 Dialog 的打开操作中,可以看到上下文溯源相关的操作 auto pipelineContext = PipelineContext::GetCurrentContext()
:
具体更深入的源码部分我就没再深读了,有兴趣的可以研究下:
2. promptAction.openCustomDialog
系统的 promptAction 似乎并不受上下文环境影响,实测可在 EntryAbility 中直接执行显示:
ts
promptAction.showToast({
message: 'Hello World'
})
promptAction 核心源码也是 C++ 实现的:
2.1. openCustomDialog 简单示例
API11 之后,新增了 promptAction.openCustomDialog 方法,可以利用 promptAction 调用显示自定义的 Dialog:
ts
@Entry
@Component
struct Index {
private customDialogComponentId: number = 0
@Builder customDialogComponent() {
Column() {
Text('弹窗').fontSize(30)
Row({ space: 50 }) {
Button("确认").onClick(() => {
promptAction.closeCustomDialog(this.customDialogComponentId)
})
Button("取消").onClick(() => {
promptAction.closeCustomDialog(this.customDialogComponentId)
})
}
}.height(200).padding(5).justifyContent(FlexAlign.SpaceBetween)
}
build() {
Row() {
Column({ space: 20 }) {
Text('组件内弹窗')
.fontSize(30)
.onClick(() => {
promptAction.openCustomDialog({
builder: () => {
this.customDialogComponent()
},
onWillDismiss: (dismissDialogAction: DismissDialogAction) => {
console.info("reason" + JSON.stringify(dismissDialogAction.reason))
console.log("dialog onWillDismiss")
if (dismissDialogAction.reason == DismissReason.PRESS_BACK) {
dismissDialogAction.dismiss()
}
if (dismissDialogAction.reason == DismissReason.TOUCH_OUTSIDE) {
dismissDialogAction.dismiss()
}
}
}).then((dialogId: number) => {
this.customDialogComponentId = dialogId
})
})
}
.width('100%')
}
.height('100%')
}
}
2.2. 脱离 UI 组件环境的问题
如果我们想进一步脱离 UI 组件环境,可以将 builder 替换为全局 @Builder 函数,但是很可惜,直接这么使用,会报错崩溃:
查阅 API 文档,可以看到这句说明:
如果是全局builder需要在组件内部创建一个builder,在内部builder中调用全局builder。
其实官方给出的示例也是这么写的:
ts
@Builder
customDialogComponent() {
customDialogBuilder()
}
也就是说,这种方式,我们同样无法完全脱离 UI 组件构建环境,本质上和 CustomDialog 一样。
其完整实现为:
ts
import promptAction from '@ohos.promptAction'
let customDialogId: number = 0
@Builder
function customDialogBuilder() {
Column() {
Text('Custom dialog Message').fontSize(10)
Row() {
Button("确认").onClick(() => {
promptAction.closeCustomDialog(customDialogId)
})
Blank().width(50)
Button("取消").onClick(() => {
promptAction.closeCustomDialog(customDialogId)
})
}
}
}
@Entry
@Component
struct Index {
@State message: string = 'Hello World'
@Builder
customDialogComponent() {
customDialogBuilder()
}
build() {
Row() {
Column() {
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(() => {
promptAction.openCustomDialog({
builder: () => {
this.customDialogComponent()
},
showInSubWindow: false,
offset: { dx: 5, dy: 5 },
backgroundColor: 0xd9ffffff,
cornerRadius: 20,
width: '80%',
height: 200,
borderWidth: 1,
borderStyle: BorderStyle.Dashed, //使用borderStyle属性,需要和borderWidth属性一起使用
borderColor: Color.Blue, //使用borderColor属性,需要和borderWidth属性一起使用
shadow: ({
radius: 20,
color: Color.Grey,
offsetX: 50,
offsetY: 0
}),
}).then((dialogId: number) => {
customDialogId = dialogId
})
})
}
.width('100%')
}
.height('100%')
}
}
3. UIContext.getPromptAction().openCustomDialog 和 ComponentContent
在 UIContext 的文档中,可以看到 API12 新增了 openCustomDialog 这个方法,其入参之一是一个 ComponentContent 对象:
ComponentContent表示组件内容的实体封装,ComponentContent对象支持在非UI组件中创建与传递,便于开发者对弹窗类组件进行解耦封装。
看描述,这十分满足我们的需求,实际上,API12 中新增了很多自定义组件相关的底层能力,可以使得自定义组件的自由度大幅提升:
3.1. 简单示例
通过 wrapBuilder 函数,可以封装一个全局 @Builder 函数:
ts
import { BusinessError } from '@ohos.base';
import { ComponentContent } from "@ohos.arkui.node";
class Params {
text: string = ""
constructor(text: string) {
this.text = text;
}
}
@Builder
function buildText(params: Params) {
Column() {
Text(params.text)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.margin({bottom: 36})
}.backgroundColor('#FFF0F0F0')
}
@Entry
@Component
struct Index {
@State message: string = "hello"
build() {
Row() {
Column() {
Button("click me")
.onClick(() => {
let uiContext = this.getUIContext();
let promptAction = uiContext.getPromptAction();
let contentNode = new ComponentContent(uiContext, wrapBuilder(buildText), new Params(this.message));
try {
promptAction.openCustomDialog(contentNode);
} catch (error) {
let message = (error as BusinessError).message;
let code = (error as BusinessError).code;
console.error(`OpenCustomDialog args error code is ${code}, message is ${message}`);
};
})
}
.width('100%')
.height('100%')
}
.height('100%')
}
}
3.2. UIContext 的获取方式
ComponentContent 实例化核心参数之一就是 UIContext。
注意使用不同的方式获取的 UIContext 可能会导致 Dialog 显示在不同的层级,如果获取到错误的上下文信息,可能会导致 Dialog 无法显示。
- 在组件内部,可以直接通过
this.getUIContext()
获取 UI 上下文:
ts
this.getUIContext()
- 在 EntryAbility 中,可以在
onWindowStageCreate
中执行loadContent
后,通过获取主 window 的方式,利用 window 去获取 UIContext:
ts
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
let uiContext = windowStage.getMainWindowSync().getUIContext()
});
}
- 在未知环境中,还可以
getContext
函数去间接获取 UIContext,getContext()
入参是组件对象,但是可选,如果不传,就会默认根据当前环境去自动获取,但是这种方式并不安全,实测在某些环境下,getContext()
返回值为undefined
,会导致后续获取执行失败:
ts
let windowClass = await window.getLastWindow(getContext())
let uiContext = windowClass.getUIContext()
3.3. 更新显示的问题
可以直接通过 updateCustomDialog 函数去更新 Dialog 的显示,但是这种操作其实是整个 Dialog 维度的,可能会重绘自定义 Dialog 的整体组件元素,如果是 ProgressHUD 这种每次更新的只是一个数值进度,如果都会重绘 HUD 整体,可能会有性能问题。
其实,还有一个更新方式,ComponentContent 有一个 update 方法,它更新的只是 ComponentContent 构造时的 WrappedBuilder 的参数部分。
在我的 XTHUD 组件库中,核心实现如下,为便于展示,源码做了删减修改:
ts
// 显示 dialog
function _showDialogNode<T extends Object>(
nodeBuilder: WrappedBuilder<[T]>,
nodeArgs: T,
defaultOptions: XTHUDCustomDefaultOptions,
currentOptions?: XTHUDReactiveBaseOptions
): void {
// hud计数器
this._HUDCount ++
// 避免重复创建
if (this._HUDCount > 1) {
// 更新 node 参数
this._dialogNode?.update(nodeArgs)
return
}
// 单次显示时,执行上下文相关配置信息,不重复更新,避免出问题
this._promptAction = this._uiContext.getPromptAction()
this._dialogNode = new ComponentContent(
this._uiContext,
nodeBuilder,
nodeArgs
)
// node构造参数
const dialogOptions: promptAction.BaseDialogOptions = {
alignment: currentOptions?.alignment ?? defaultOptions.alignment,
// 偏移量
offset: currentOptions?.offset ?? defaultOptions.offset,
// 默认true,是否全屏展示,false只有弹窗区域UI
isModal: currentOptions?.isModal ?? defaultOptions.isModal,
// 是否可以点击背景关闭
autoCancel: currentOptions?.closeOnClickOutside ?? defaultOptions.closeOnClickOutside,
maskColor: currentOptions?.maskColor ?? defaultOptions.maskColor
}
// 打开
this._promptAction?.openCustomDialog(this._dialogNode, dialogOptions)
}
function _updateDialogNode<T extends Object>(
nodeArgs: T
): void {
// 更新 node 参数
this._dialogNode?.update(nodeArgs)
}
function _hide(
onCompletion?: XTHUDCallback
): void {
this._promptAction?.closeCustomDialog(this._dialogNode)
}
4. @ohos.arkui.advanced.Dialog (弹出框)
在 ArkUI 的高级组件中,也有 Dialog 相关的部分:@ohos.arkui.advanced.Dialog (弹出框)
但其本质还是 CustomDialogController,只是提供了一些 @CustomDialog 的组件封装实现。
4.1. LoadingDialog
LoadingDialog 是官方实现的 LoadingHUD,其样式相对固定,符合官方 UX 范式。
ts
import { LoadingDialog } from '@ohos.arkui.advanced.Dialog'
@Entry
@Component
struct Index {
dialogControllerProgress: CustomDialogController = new CustomDialogController({
builder: LoadingDialog({
content: '文本文本文本文本文本...',
}),
})
build() {
Row() {
Stack() {
Column() {
Button("进度条弹出框")
.width(96)
.height(40)
.onClick(() => {
this.dialogControllerProgress.open()
})
}.margin({ bottom: 300 })
}.align(Alignment.Bottom)
.width('100%').height('100%')
}
.backgroundImageSize({ width: '100%', height: '100%' })
.height('100%')
}
}
4.2. CustomContentDialog
CustomContentDialog 是对应的自定义 Dialog,其 API 设计更符合弹窗范式。
ts
import { CustomContentDialog } from '@ohos.arkui.advanced.Dialog'
@Entry
@Component
struct Index {
dialogController: CustomDialogController = new CustomDialogController({
builder: CustomContentDialog({
primaryTitle: '标题',
secondaryTitle: '辅助文本',
contentBuilder: () => {
this.buildContent();
},
buttons: [{ value: '按钮1', buttonStyle: ButtonStyleMode.TEXTUAL, action: () => {
console.info('Callback when the button is clicked')
} }, { value: '按钮2', buttonStyle: ButtonStyleMode.TEXTUAL, role: ButtonRole.ERROR }],
}),
});
build() {
Column() {
Button("支持自定义内容弹出框")
.onClick(() => {
this.dialogController.open()
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
@Builder
buildContent(): void {
Column() {
Text('内容区')
}
}
}
5. 通过自定义 window 构建 Dialog
系统的 promptAction 的底层实现,就可以看到 Subwindow 类似的字样,大体可以猜测其实现机制之一就是自定义 window:
在 iOS 开发中,部分自定义弹窗或浮层,就是通过自定义 window 实现的,这种自定义的形式显示层级更高,但是带来的问题也更多,很多时候会导致横竖屏显示方向错误,或者和其他系统级的 window 显示冲突,所以使用时必须特别谨慎。
鸿蒙系统中,自定义 window 有两种方式,一个是 createWindow ,一个是 createSubWindow,
5.1. createWindow
可以设置一个 Dailog 类型的 window,如果设置 parentId,那就等效于 createSubWindow。
注意 window 的尺寸是 px 单位值,需要使用vp2px()
做转换。
ts
let config: window.Configuration = {
name: "test",
windowType: window.WindowType.TYPE_DIALOG,
// parentId: windowStage.getMainWindowSync().getWindowProperties().id,
ctx: this.context
};
window.createWindow(config, (err, data) => {
let windowClass = data;
// window 尺寸
windowClass.resize(vp2px(300), vp2px(300))
// window 内容
windowClass.setUIContent('pages/Index')
// windowClass.loadContent('pages/Index', null).then(() => {
// console.log('Succeeded in loading the')
// })
// 显示 window
windowClass.showWindow(() => {
console.info('Succeeded show')
})
});
/// 3 秒后销毁 window
setTimeout(() => {
window.findWindow('test').destroyWindow()
}, 3000)
5.2. createSubWindow
如果是基于 window 自定义 Dialog,相对更合适的方式是创建子 window:
ts
windowStage.createSubWindow('test', (err, data) => {
// window 尺寸
data.resize(vp2px(300), vp2px(300)).then(() => {
})
// window 位置
data.moveWindowTo(0, 0).then(() => {
})
// window 内容
data.setUIContent('pages/Index', () => {
})
// 显示 window
data.showWindow()
})
/// 3 秒后销毁 window
setTimeout(() => {
// window.getLastWindow(this.context, (err, windowClass) => {
// windowClass.destroyWindow()
// })
window.findWindow('test').destroyWindow()
}, 3000)
5.3. 实际使用
中心仓中有很多 Dialog 组件库都是基于自定义 window 的方式实现的,例如 @abner/dialog:
但就像前面说到的,利用自定义 window 去做 Dialog,其自由度很高,可以做到很多,但是使用时还是要注意处理,避免未知的冲突问题,相对来说,个人还是更倾向于使用 ComponentContent 或者 FrameNode 的方式去构建自定义 Dialog,毕竟 API 相对更加简单,也更加可控。
6. Native 方式
这是一套 C API,不仅限于自定义 Dialog,而是整个 ArkUI 框架都提供了对应的 C API,可以针对某些特殊的开发场景,具体文档如下: