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

13 篇文章 3 订阅
12 篇文章 0 订阅

场景描述

自定义弹窗是应用开发需要实现的基础功能,包括但不限于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();

写在最后

●如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我两个小忙:
●点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
●关注小编,同时可以期待后续文章ing ,不定期分享原创知识。
在这里插入图片描述

  • 20
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值