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,可以针对某些特殊的开发场景,具体文档如下:

相关推荐
周胡杰6 小时前
组件导航 (HMRouter)+flutter项目搭建-混合开发+分栏效果
前端·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
bestadc6 小时前
鸿蒙 Core File Kit(文件基础服务)之简单使用文件
harmonyos
SuperHeroWu78 小时前
【HarmonyOS 5】鸿蒙星闪NearLink详解
华为·蓝牙·harmonyos·nearlink·鸿蒙星闪·绿牙
积跬步DEV10 小时前
RN 鸿蒙混合开发实践(踩坑)
react native·华为·harmonyos
繁依Fanyi14 小时前
我的 PDF 工具箱:CodeBuddy 打造 PDFMagician 的全过程记录
java·pdf·uni-app·生活·harmonyos·codebuddy首席试玩官
Huang兄14 小时前
#跟着若城学鸿蒙# web篇-获取定位
harmonyos
zkmall16 小时前
Java + 鸿蒙双引擎:ZKmall开源商城如何定义下一代B2C商城技术标准?
java·开源·harmonyos
lqj_本人17 小时前
鸿蒙OS&UniApp实现视频播放与流畅加载:打造完美的移动端视频体验#三方框架 #Uniapp
uni-app·音视频·harmonyos
交叉编译之王 hahaha17 小时前
RK3568平台OpenHarmony系统移植可行性评估
华为·harmonyos
lqj_本人19 小时前
鸿蒙OS&UniApp 实现一个精致的日历组件#三方框架 #Uniapp
uni-app·harmonyos