HarmonyOS Next——鸿蒙自定义 Dialog 的 6 种方式

之前已经有多篇文章介绍过我封装的开源组件库 XTHUD,该组件库的 V3 版本中,针对在特殊场景中调用显示 HUD 的诸多问题,做了一次较大的更新,即之前的 HUD 是通过构造 CustomDialog 的方式实现的,但是在 V3 版本中新增的 XTPromptHUD 工具类,其 HUD 是通过 ComponentContent 实现的,ComponentContent 是 API12 新增的特性,可以支持在非 UI 组件环境中构造 UI,十分适合弹窗类的 UI 解耦开发。

在完善 HUD 这个组件库的开发过程中,针对鸿蒙开发中自定义 Dialog 的多种方式有了一些接触和尝试,下面的内容就是相关知识点的简单总结。

1. 自定义弹窗 (CustomDialog)

1.1. 简单的使用示例

自定义弹窗的最常规方式就是通过 CustomDialogController 类去控制显示自定义的弹窗或者浮层。

针对该方式的用法,可以参考官方的 API 文档:

在我的 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 无法显示。

  1. 在组件内部,可以直接通过 this.getUIContext()获取 UI 上下文:
ts 复制代码
this.getUIContext()
  1. 在 EntryAbility 中,可以在 onWindowStageCreate中执行 loadContent后,通过获取主 window 的方式,利用 window 去获取 UIContext:
ts 复制代码
onWindowStageCreate(windowStage: window.WindowStage): void {
  windowStage.loadContent('pages/Index', (err) => {
    let uiContext = windowStage.getMainWindowSync().getUIContext()
  });
}
  1. 在未知环境中,还可以 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,可以针对某些特殊的开发场景,具体文档如下:

相关推荐
让开,我要吃人了2 小时前
HarmonyOS开发实战( Beta5.0)橡皮擦案例实践详解
开发语言·前端·华为·移动开发·harmonyos·鸿蒙·鸿蒙系统
ImomoTo2 小时前
HarmonyOS学习(十一)——安全管理
学习·安全·harmonyos·arkts·arkui
爱桥代码的程序媛11 小时前
HarmonyOS开发5.0【应用程序包】
分布式·harmonyos·鸿蒙·鸿蒙系统·openharmony·鸿蒙开发·程序包
爱桥代码的程序媛11 小时前
HarmonyOS开发5.0【rcp网络请求】
网络·移动开发·harmonyos·鸿蒙·鸿蒙系统·openharmony·rcp
让开,我要吃人了11 小时前
HarmonyOS应用开发( Beta5.0)一杯冰美式的时间“拿捏Grid组件”
服务器·前端·华为·移动开发·harmonyos·鸿蒙·openharmony
Android技术栈12 小时前
鸿蒙开发(API 12 Beta6版)【P2P模式】 网络WLAN服务开发
网络·harmonyos·鸿蒙·鸿蒙系统·p2p·openharmony·wlan
Android技术栈13 小时前
鸿蒙(API 12 Beta6版)图形加速【OpenGL ES平台内插模式】超帧功能开发
elasticsearch·harmonyos·鸿蒙·鸿蒙系统·openharmony·图形·超帧
OH五星上将15 小时前
鸿蒙轻内核M核源码分析系列十七(2) 异常钩子函数的注册操作
移动开发·harmonyos·openharmony·鸿蒙开发·鸿蒙内核·鸿蒙源码·liteos_m
让开,我要吃人了17 小时前
OpenHarmony鸿蒙( Beta5.0)智能门铃开发实践
驱动开发·华为·移动开发·硬件工程·harmonyos·鸿蒙·openharmony
青瓷看世界17 小时前
HarmonyOS 是如何实现一次开发多端部署 -- HarmonyOS自学1
华为·harmonyos