Bootstrap

【鸿蒙实战开发】基于ArkUI现有能力实现自定义弹窗封装方案

场景描述

自定义弹窗是应用开发需要实现的基础功能,包括但不限于HarmonyOS开发者文档中定义的模态、半模态、Toast等形式,封装一个好用且和UI组件解耦的弹窗组件是开发者的高频诉求

自定义弹窗通常的使用场景有:

场景一:在公共逻辑中触发弹窗

登录提示弹窗、全屏广告弹窗、网络请求与其他操作行为的提示、异常弹窗

场景二:侧滑手势拦截

隐私弹窗的拦截,退出登录时的确认弹窗

场景三:切换页面弹窗不消失

隐私弹窗和二级页面中的半模态弹窗

场景四:自定义弹出、关闭动画

从下往上的抽屉式弹出、关闭时从上往下收回

场景五:透明、模态、半模态背景

应用实现自定义的背景颜色

方案描述

1. 使用Navigation.Dialog

基于Navigation.Dialog的透明页面特性,可以用于实现弹窗效果

而且Navigation.Dialog存在于路由栈中,天然可以实现切换页面弹窗不消失

当前限制:

弹窗组件中的动效建议开发者自行实现

Navigation.Dialog自身无颜色,需要开发者自行实现模态遮罩,以及手势事件。

演示效果:

对于少量弹窗的实现,可以直接使用Navigation来进行路由跳转,参考 Navigation常见场景及解决方案

其他Navigation的使用也可参考上述文章

步骤一:封装路由工具类,并注册自定义弹窗组件

定义路由工具类AppRouter,并创建路由栈NavPathStack

export class AppRouter {

  private static instance = new AppRouter();

  private pathStack: NavPathStack = new NavPathStack();  // 初始化路由栈

  public static getInstance(): AppRouter {

    return AppRouter.instance;

  }

  public getPathStack(): NavPathStack {

    return this.pathStack;

  }

  ...

}

在根页面中注册NavPathStack

@Entry

@Component

struct Index {

  build() {

    Navigation(AppRouter.getInstance().getPathStack()) {

      ...

    }

  }

}

在.navDestination注册封装的自定义弹窗组件DefaultDialog

@Builder

PageMap(name: string) {

  if (name === CommonConstants.DEFAULT_DIALOG) {

    DefaultDialog()

  }

  ...

}

Navigation(AppRouter.getInstance().getPathStack()) {

  ...

}.navDestination(this.PageMap)

进阶用法:可以参考动态路由案例实现动态路由, HarmonyOS NEXT应用开发案例集 - Gitee.com

步骤二:封装弹窗UI 组件

定义弹窗选项类AppDialogOption

export class AppDialogOption {

  view?: WrappedBuilder<Object[]> // 自定义弹窗内容组件

  buildParams?: Object  // 自定义弹窗内容参数

  params?: Object  // 打开时传递参数

  autoClose?: number  // 自动关闭时间

  onPop?: (data: PopInfo) => void  // 接收上一个弹窗关闭时的参数回调

  onBackPressed?: () => boolean  // 侧滑返回拦截

  styles?: AppDialogStyle = new AppDialogStyle()  // 弹窗样式

  animation?: TransitionEffect  // 弹窗动画

  instance?: AppDialog  // 弹窗操作对象

}

定义弹窗样式类AppDialogStyle

export class AppDialogStyle {

  transparent: boolean = false

  background: string = 'rgba(0,0,0,0.5)'

  radius: Length = 5

  align: Alignment = Alignment.Center

}

创建自定义弹窗组件DefaultDialog

通过Stack布局及2个Column容器实现模态遮罩和自定义弹窗内容,通过NavDestinationMode定义页面类型

@Component

export struct DefaultDialog {

  private dialogOptions?: AppDialogOption;

  build() {

    NavDestination() {

      Stack() {

        Column() {

          // 模态遮罩

        }

        Column() {

          // 弹窗内容

        }

      }

      .width("100%")

      .height("100%")

    }

    .mode(NavDestinationMode.DIALOG)  // 页面类型为dialog

  }

}

通过.backgroundColor设置模态遮罩的背景颜色

...

Stack() {

  Column() {

    // 模态遮罩

  }

  .backgroundColor(this.dialogOptions?.styles?.transparent ? Color.Transparent : this.dialogOptions?.styles?.background) // 背景颜色

  Column() {

    // 弹窗内容

  }

}

通过Stack.alignContent设置弹窗定位

Stack({

  alignContent: this.dialogOptions?.styles?.align

}) {

  Column() {

    // 模态遮罩

  }

  Column() {

    // 弹窗内容

  }

}

步骤三:封装弹窗控制器,与UI 组件解耦

提供链式调用的Api

export class AppDialog {

  static indexArr: number[] = [];

  private stackIndex: number = 0;

  private options?: AppDialogOption;

  public static buildWithOptions(options?: AppDialogOption): AppDialog {

    let instance: AppDialog = new AppDialog();

    // 获取并保存弹窗的路由栈序号

    let index: number = AppRouter.getInstance().getPathStack().size() - 1;

    AppDialog.indexArr.push(index);

    instance.stackIndex = index;

    instance.options = options;

    options!.instance = instance;

    return instance;

  }

  public static build(builder: WrappedBuilder<Object[]>): AppDialog {

    let options: AppDialogOption = new AppDialogOption();

    options.view = builder;

    return AppDialog.buildWithOptions(options);

  }

  public static toast(msg: string): AppDialog {

    let options: AppDialogOption = new AppDialogOption();

    options.view = AppDialog.toastBuilder;

    options.buildParams = msg;

    return AppDialog.buildWithOptions(options);

  }

  public static closeAll(): void {

    AppRouter.getInstance().getPathStack().removeByName(CommonConstants.DEFAULT_DIALOG);

  }

  public static closeLast(params?: Object): void {

    let lastIndex = AppDialog.indexArr.pop()

    if (!lastIndex) {

      AppDialog.closeAll();

    } else if (lastIndex && AppRouter.getInstance().getPathStack().size() > lastIndex) {

      AppRouter.getInstance().getPathStack().popToIndex(lastIndex, params);

    }

  }

  public open(): AppDialog {

    AppRouter.getInstance()

      .getPathStack()

      .pushPathByName(CommonConstants.DEFAULT_DIALOG, this.options, this.options!.onPop!, true);

    return this;

  }

  public close(params?: Object): void {

    if (AppRouter.getInstance().getPathStack().size() > this.stackIndex) {

      AppRouter.getInstance().getPathStack().popToIndex(this.stackIndex, params);

    }

  }

  public buildParams(buildParams: Object): AppDialog {

    this.options!.buildParams = buildParams;

    return this;

  }

  public params(params: Object): AppDialog {

    this.options!.params = params;

    return this;

  }

  public onBackPressed(callback: () => boolean): AppDialog {...}

  public onPop(callback: (data: PopInfo) => void): AppDialog {...}

  public animation(animation: TransitionEffect): AppDialog {...}

  public autoClose(time: number): AppDialog {...}

  public align(align: Alignment): AppDialog {...}

  public transparent(transparent: boolean): AppDialog {...}

}

步骤四:页面与弹窗,弹窗与弹窗之间传递参数

通过路由跳转NavPathStack.pushPathByName传递参数

在弹窗组件的.onReady事件中获取路由跳转参数。

@Component

export struct DefaultDialog {

  private dialogOptions?: AppDialogOption;

  build() {

    NavDestination() {

      ...

    }

    .onReady((ctx: NavDestinationContext) => {

      console.log("onReady")

      this.dialogOptions = ctx.pathInfo.param as AppDialogOption;

    })

  }

}

使用NavPathStack中的onPop回调来接收上一个弹窗返回的参数。

onPop = (data: PopInfo) => {

  console.log("onPop")

  // 更新状态变量

  this.params[index] = JSON.stringify(data.result)

}

navPathStack.pushPathByName(CommonConstants.DEFAULT_DIALOG, this.options, this.options!.onPop!, true)

上一个弹窗在关闭时传入参数

navPathStack.popToIndex(this.stackIndex, params);

步骤五:实现弹窗自定义动画

通过.transition属性分别实现背景和内容的转场动画

...

Stack() {

  Column() {

    // 模态遮罩

  }

  .transition(  // 转场动画

    TransitionEffect.OPACITY.animation({

      duration: 300,

      curve: Curve.Friction

    })

  )

  Column() {

    // 弹窗内容

  }

  .transition(  // 转场动画

    this.dialogOptions?.animation ?

      this.dialogOptions?.animation :

    TransitionEffect.scale({ x: 0, y: 0 }).animation({

      duration: 300,

      curve: Curve.Friction

    })

  )

}

通过监听模态遮罩的点击事件实现关闭动画

...

Stack() {

  Column() {

    // 模态遮罩

  }

  .opacity(this.opacityNum)

  .onClick(() => {

    animateTo({

      duration: 200,

      curve: Curve.Friction,

      onFinish: () => {

        this.dialogOptions?.instance?.close();

      }

    }, () => {

      this.opacityNum = 0  // 修改模态遮罩的透明度

      if (this.dialogOptions?.styles?.align === Alignment.Bottom) {

        this.translateY = "100%"

      }

    })

  })

  Column() {

    // 弹窗内容

  }

  .translate({ x: 0, y: this.translateY })

}

步骤五:实现自定义弹窗内容

在弹窗内容的Column容器中传入WrappedBuilder来实现动态的自定义弹窗内容。

Stack() {

  Column() {

    // 模态遮罩

  }

  Column() {

    // 弹窗内容

    this.dialogOptions?.view?.builder(this.dialogOptions);

  }

}

定义弹窗内容组件

@Builder

export function DialogViewBuilder(dialogOptions: AppDialogOption) {

  DialogView({ options: dialogOptions })

}

@Component

struct DialogView {

  private options?: dialogOptions ;

  build() {

    Column() {

    }

    ...

  }

}

步骤六:侧滑手势拦截

在弹窗组件的.onBackPressed事件中进行拦截

@Component

export struct DefaultDialog {

  private dialogOptions?: AppDialogOption;

  build() {

    NavDestination() {

      ...

    }

    .onBackPressed((): boolean => {

      // true为拦截

      if (this.dialogOptions?.onBackPressed) {

        return this.dialogOptions?.onBackPressed()

      } else {

        return false;

      }

    })

  }

}

使用效果:

使用弹窗控制器即可在非UI业务逻辑中打开弹窗

export class AppService {

  buzz(): void {

    setTimeout(() => {

      AppDialog

        .toast("登录成功")

        .onBackPressed(() => true)

        .autoClose(2000)

        .transparent(true)

        .open();

    }, 1000)  // 模拟业务接口调用耗时

  }

}

AppDialog.toastBuilder = wrapBuilder(ToastViewBuilder)

@Builder

export function ToastViewBuilder(dialogOptions: AppDialogOption) {

  ToastView({ msg: dialogOptions.buildParams as string })

}

@Component

struct ToastView {

  private msg?: string;

  build() {

    Column() {

      Text(this.msg)

        .fontSize(14)

        .fontColor(Color.White)

        .padding(10)

    }

    .backgroundColor("rgba(0,0,0,0.8)")

    .justifyContent(FlexAlign.Center)

    .borderRadius(12)

    .width(100)

  }

}

关闭弹窗

// 全局使用

AppDialog.closeLast();

AppDialog.closeAll();

// 弹窗页面中使用

this.dialogOptions?.instance?.close();

鸿蒙全栈开发全新学习指南

有很多小伙伴不知道学习哪些鸿蒙开发技术?不知道需要重点掌握哪些鸿蒙应用开发知识点?而且学习时频繁踩坑,最终浪费大量时间。所以要有一份实用的鸿蒙(HarmonyOS NEXT)学习路线与学习文档用来跟着学习是非常有必要的。

针对一些列因素,整理了一套纯血版鸿蒙(HarmonyOS Next)全栈开发技术的学习路线,包含了鸿蒙开发必掌握的核心知识要点,内容有(ArkTS、ArkUI开发组件、Stage模型、多端部署、分布式应用开发、WebGL、元服务、OpenHarmony多媒体技术、Napi组件、OpenHarmony内核、OpenHarmony驱动开发、系统定制移植等等)鸿蒙(HarmonyOS NEXT)技术知识点。

本路线共分为四个阶段

第一阶段:鸿蒙初中级开发必备技能

在这里插入图片描述

第二阶段:鸿蒙南北双向高工技能基础:gitee.com/MNxiaona/733GH

在这里插入图片描述

第三阶段:应用开发中高级就业技术

第四阶段:全网首发-工业级南向设备开发就业技术:gitee.com/MNxiaona/733GH

在这里插入图片描述

《鸿蒙 (Harmony OS)开发学习手册》(共计892页)

如何快速入门?

1.基本概念
2.构建第一个ArkTS应用
3.……

开发基础知识:gitee.com/MNxiaona/733GH

1.应用基础知识
2.配置文件
3.应用数据管理
4.应用安全管理
5.应用隐私保护
6.三方应用调用管控机制
7.资源分类与访问
8.学习ArkTS语言
9.……

在这里插入图片描述

基于ArkTS 开发

1.Ability开发
2.UI开发
3.公共事件与通知
4.窗口管理
5.媒体
6.安全
7.网络与链接
8.电话服务
9.数据管理
10.后台任务(Background Task)管理
11.设备管理
12.设备使用信息统计
13.DFX
14.国际化开发
15.折叠屏系列
16.……

在这里插入图片描述

鸿蒙开发面试真题(含参考答案):gitee.com/MNxiaona/733GH

在这里插入图片描述

鸿蒙入门教学视频:

美团APP实战开发教学:gitee.com/MNxiaona/733GH

写在最后

  • 如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
  • 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
  • 关注小编,同时可以期待后续文章ing🚀,不定期分享原创知识。
  • 想要获取更多完整鸿蒙最新学习资源,请移步前往小编:gitee.com/MNxiaona/733GH

;