鸿蒙NEXT开发【应用自定义弹窗开发实践】应用框架开发

概述

弹窗是应用开发需要实现的基础功能,通常用来展示用户当前需要或用户必须关注的信息或操作,可用于广告、中奖、警告、软件更新等与用户交互响应的操作。在应用开发中,经常需要实现自定义UI和功能要求,系统弹窗往往无法满足需求,此时就需要使用到自定义弹窗了。

自定义弹窗选型

合理选择不同的系统能力实现弹窗,有利于提升应用开发效率,实现更好的功能需求,因此了解自定义弹窗的选型和差异非常重要。在应用开发中,为了选择出合适的弹窗选型,从使用场景上,需要重点关注以下两点:

  • 弹窗与界面代码解耦

    在开发业务逻辑时,例如遇到一些网络请求失败的场景,需要触发相应的弹窗提醒用户进行操作,由于在任何页面都有可能触发对应的弹窗,此时弹窗不是与某个页面相关联,这个情况下,就需要弹窗与界面的解耦。

  • 弹窗在界面跳转后保留

    在一些权限配置页,用户首次进入应用时会弹出权限配置弹窗,让用户进行操作,此时若点击跳转到隐私详情页面,返回后弹窗需要保留在页面上。

从能力角度,系统提供了四种不同的方式来实现自定义弹窗,分别是CustomDialog、promptAction、UIContext.getPromptAction、Navigation.Dialog,在开发业务时,需要结合每种弹窗的特点来选择弹窗。

  • [CustomDialog]弹窗,必须在@Component struct内部定义,即在UI层创建控制器,当一个页面需要弹出多个自定义弹窗时,就需要创建对应个数的CustomDialogController,这会造成UI层代码冗余,无法做到弹窗与界面的解耦。
  • [promptAction]弹窗,为了解决CustomDialog弹窗的问题,支持了UI元素复用机制@Builder,但依赖UI组件。
  • [UIContext.getPromptAction]弹窗,基于promptAction弹窗演进而来,支持全局自定义弹窗,不依赖UI组件,依赖UIContext,支持在非页面文件中使用,弹窗内容支持动态修改,支持自定义弹窗圆角半径、大小和位置,适合在[与页面解耦的全局弹窗]、[自定义弹窗显示和退出动画]等场景下使用。
  • [Navigation.Dialog]弹窗,基于Navigation路由形式,以Component组件页面存在于路由栈中,以进出栈的方式打开或关闭弹窗,可以实现弹窗与UI界面解耦,默认透明显示,适合在[切换页面弹窗不消失]场景下使用。

上述四种弹窗的使用区别,可以从是否支持弹窗与界面代码解耦、弹窗在界面跳转后是否保留这两个方面去进行对比,总结如下:

CustomDialogpromptActionUIContext.getPromptActionNavigation.Dialog
弹窗与界面代码解耦不支持不支持支持支持
弹窗在界面跳转后保留

在对解耦要求不高的情况下,可以使用上面CustomDialog、promptAction两个弹窗,本文围绕开发时主要常见的场景和问题,介绍UIContext.getPromptAction、Navigation.Dialog两个弹窗的使用。

与页面解耦的全局弹窗

在应用开发的过程中,开发者要实现在不关联页面的情况下进行弹窗,此时需要对弹窗进行封装。

典型场景

  • 登录提示弹窗
  • 全局的广告弹窗
  • 网络请求与其他操作行为的提示弹窗
  • 请求或操作异常时的警告弹窗

实现思路

可以使用[UIContext.getPromptAction.openCustomDialog]的方式,创建并弹出对应的自定义弹窗,支持弹窗和页面解耦。使用Navigation.Dialog弹窗也可以实现全局自定义弹窗,但需要自己实现弹窗模态效果。

示例代码

  1. 封装弹窗内容和样式,创建Params类方便开发者进行传参,开发者可以在@Builder里自定义组件的内容。
// DialogComponent.ets
class Params {
  text: string = '';

  constructor(text: string) {
    this.text = text;
  }
}

@Builder
export function buildText(params: Params) {
  Column() {
    Text(params.text)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
  }
  .width(328)
  .padding(24)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor(Color.White)
  .borderRadius(24)
}
  1. 在需要使用弹窗的页面中引入弹窗组件。
import { buildText } from '../uitls/DialogComponent'
import { BusinessError } from '@kit.BasicServicesKit';
import { ComponentContent, promptAction } from '@kit.ArkUI';

class Params {
  text: string = '';

  constructor(text: string) {
    this.text = text;
  }
}

@Entry
@Component
struct GlobalDialogDecoupledFromThePage {
  @State message: string = 'hello';

  build() {
    Row() {
      Column() {
        Button('click me')
          .fontSize(16)
          .width(328)
          .backgroundColor('#0A59F7')
          .onClick(() => {
            let uiContext = this.getUIContext();
            let promptAction = uiContext.getPromptAction();
            let contentNode = new ComponentContent(uiContext, wrapBuilder(buildText), new Params(this.message));
            let options: promptAction.BaseDialogOptions = {
              alignment: DialogAlignment.Center
            };
            try {
              promptAction.openCustomDialog(contentNode, options);
            } 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%')
      .margin({ bottom: 40 })
      .justifyContent(FlexAlign.End)
    }
    .height('100%')
  }
}

效果演示

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

拦截物理返回按钮、手势滑动关闭弹窗

用户只能通过按钮关闭弹窗,不允许使用物理返回按钮、手势滑动关闭弹窗。

典型场景

  • 密码输入框,输入密码之前不允许关闭弹窗
  • 展示隐私协议弹窗时,用户必须点击同意才能继续使用应用

实现思路

方式一 基于UIContext.getPromptAction弹窗,使用[弹窗的选项] 对象中的onWillDismiss交互式关闭回调函数,支持物理拦截返回。当用户执行点击遮障层关闭、左滑/右滑、三键back、键盘ESC关闭交互操作时,如果注册该回调函数,则不会立刻关闭弹窗。在回调函数中可以通过[DismissReason] 得到关闭弹窗的操作类型,从而根据原因选择是否能关闭弹窗。

示例代码

import { BusinessError } from '@kit.BasicServicesKit';
import { ComponentContent } from "@kit.ArkUI";

class Params {
  text: string = '';

  constructor(text: string) {
    this.text = text;
  }
}

@Builder
function buildText(params: Params) {
  Row() {
    Column() {
      Text(params.text)
        .fontSize(16)
        .margin({ top: 24 })
      Row() {
        Button('Confirm')
          .fontSize(16)
          .backgroundColor('#0A59F7')
          .onClick(() => {
            let link1: SubscribedAbstractProperty<number> = storage.link('PropA');
            let num = link1.get();
            console.info(':::num', link1.get());
            link1.set(++num);
          })
          .width('100%')
          .margin({
            top: 8,
            bottom: 16
          })
      }
      .padding({
        left: 16,
        right: 16
      })
    }
    .justifyContent(FlexAlign.Center)
    .backgroundColor(Color.White)
    .borderRadius(24)
    .width(328)
  }
  .height('100%')
}

//自定义组件的内容
let para: Record<string, number> = { 'PropA': 1 };
let storage: LocalStorage = new LocalStorage(para); // 创建新实例并使用给定对象初始化

@Entry(storage)
@Component
struct InterceptReturn01 {
  @State message: string = 'Dialog Confirm';
  @LocalStorageLink('uiContext') context: UIContext = this.getUIContext();
  @LocalStorageLink('PropAA') contentNode: ComponentContent<Params> | null = null;
  @LocalStorageLink('PropA') @Watch('onchange') nums: number = 0;

  onchange() {
    try {
      this.context.getPromptAction().closeCustomDialog(this.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}`);
    }
  }

  build() {
    Row() {
      Column() {
        Button('click me')
          .fontSize(16)
          .width(328)
          .backgroundColor('#0A59F7')
          .onClick(() => {
            let uiContext = this.getUIContext();
            let promptAction = uiContext.getPromptAction();
            let contentNode =
              new ComponentContent(uiContext, wrapBuilder(buildText), new Params(this.message)); // 上下文、自定义节点、传参
            this.contentNode = contentNode;
            try {
              promptAction.openCustomDialog(contentNode, {
                onWillDismiss: (dismissDialogAction: DismissDialogAction) => {
                  console.info('reason' + JSON.stringify(dismissDialogAction.reason));
                }
              });
            } 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%')
      .margin({ bottom: 40 })
      .justifyContent(FlexAlign.End)
    }
    .height('100%')
  }
}

效果演示

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

方式二 可以基于Navigation自定义弹窗实现,使用NavDestination的回调函数[onBackPressed] ,当与Navigation绑定的页面栈中存在内容时,此回调生效。当点击物理返回按钮或使用手势滑动时,触发该回调。返回值为true时,表示重写返回键逻辑,即可实现拦截。

示例代码

@Entry
@Component
struct InterceptReturn02 {
  @Provide('NavPathStack') pageStack: NavPathStack = new NavPathStack();

  @Builder
  PagesMap(name: string) {
    if (name === 'DialogPage') {
      DialogPage()
    }
  }

  build() {
    Navigation(this.pageStack) {
      Button('Push DialogPage')
        .fontSize(16)
        .width(328)
        .backgroundColor('#0A59F7')
        .onClick(() => {
          this.pageStack.pushPathByName('DialogPage', '');
        })
    }
    .mode(NavigationMode.Stack)
    .title('Main')
    .navDestination(this.PagesMap)
  }
}

@Component
export struct DialogPage {
  @Consume('NavPathStack') pageStack: NavPathStack;

  build() {
    NavDestination() {
      Stack({ alignContent: Alignment.Center }) {
        Column() {
          Text('Dialog NavDestination')
            .fontSize(20)
            .margin({ top: 24 })
          Row() {
            Button('Close')
              .fontSize(16)
              .backgroundColor('#0A59F7')
              .onClick(() => {
                this.pageStack.pop();
              })
              .width('100%')
              .margin({
                top: 8,
                bottom: 16
              })
          }
          .padding({
            left: 16,
            right: 16
          })
        }
        .justifyContent(FlexAlign.Center)
        .backgroundColor(Color.White)
        .borderRadius(24)
        .width(328)
      }
      .height('100%')
      .width('100%')
    }
    .backgroundColor('rgba(0,0,0,0.5)')
    .hideTitleBar(true)
    .mode(NavDestinationMode.DIALOG)
    .onBackPressed((): boolean => {
      return true;
    })
  }
}

效果演示

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

切换页面弹窗不消失

点击弹窗中的按钮或链接打开新页面,返回后自定义弹窗还在原页面上展示。

典型场景

用户首次进入应用需要进行权限配置,弹出弹窗后,点击跳转到隐私详情页面,返回后弹窗还在显示。

实现思路

[NavDestinationMode.DIALOG弹窗] 存在于路由栈中,可以实现切换页面弹窗不消失。

示例代码

@Entry
@Component
struct CustomDialogNotDisappear {
  @Provide('NavPathStack') pageStack: NavPathStack = new NavPathStack();

  @Builder
  PagesMap(name: string) {
    if (name === 'DialogPage') {
      DialogPage()
    }
    if (name === 'PageOne') {
      PageOne()
    }
  }

  build() {
    Navigation(this.pageStack) {
      Button('Push DialogPage')
        .fontSize(16)
        .width(328)
        .backgroundColor('#0A59F7')
        .onClick(() => {
          this.pageStack.pushPathByName('DialogPage', '');
        })
    }
    .mode(NavigationMode.Stack)
    .title('Main')
    .navDestination(this.PagesMap)
  }
}

@Component
export struct DialogPage {
  @Consume('NavPathStack') pageStack: NavPathStack;

  build() {
    NavDestination() {
      Stack({ alignContent: Alignment.Center }) {
        Column() {
          Text('Dialog NavDestination')
            .fontSize(16)
            .margin({ top: 24 })
          Row() {
            Button('confirm')
              .fontSize(16)
              .width(328)
              .backgroundColor('#0A59F7')
              .onClick(() => {
                this.pageStack.pushPathByName('PageOne', 'PageOne Param');
              })
              .width('100%')
              .margin({
                top: 8,
                bottom: 16
              })
          }
          .padding({
            left: 16,
            right: 16
          })
        }
        .justifyContent(FlexAlign.Center)
        .backgroundColor(Color.White)
        .borderRadius(24)
        .width(328)
      }
      .height('100%')
      .width('100%')
    }
    .backgroundColor('rgba(0,0,0,0.5)')
    .hideTitleBar(true)
    .mode(NavDestinationMode.DIALOG)
  }
}

@Component
export struct PageOne {
  @Consume('NavPathStack') pageStack: NavPathStack;

  build() {
    NavDestination() {
      Stack({ alignContent: Alignment.Center }) {
        Column() {
          Text('PageOne')
            .fontSize(20)
            .margin({ bottom: 100 })
          Button('back')
            .fontSize(16)
            .width(328)
            .backgroundColor('#0A59F7')
            .onClick(() => {
              this.pageStack.pop();
            })
        }
        .justifyContent(FlexAlign.Center)
        .backgroundColor(Color.Orange)
        .borderRadius(10)
        .height('100%')
        .width('100%')
      }
      .height('100%')
      .width('100%')
    }
    .backgroundColor('rgba(0,0,0,0.5)')
    .hideTitleBar(true)
    .mode(NavDestinationMode.STANDARD)
  }
}

效果演示

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

自定义弹窗显示和退出动画

典型场景

在应用开发中,系统弹窗的显示和退出动画往往不满足需求,若要实现自定义弹窗出入动画,可以使用以下方式,例如:1)渐隐渐显的方式弹出,2)从左往右弹出,从右往左收回,3)从下往上的抽屉式弹出、关闭时从上往下收回。我们以从下往上的抽屉式弹出、关闭时从上往下收回为例,来介绍自定义弹窗的显示和退出动画。

实现思路

可以基于[UIContext.getPromptAction] 弹窗实现,通过CustomDialogOptions自定义弹窗的内容,[BaseDialogOptions] 弹窗选项transition参数可以设置弹窗显示和退出的过渡效果。

示例代码

import { ComponentContent, PromptAction, promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

class TransitionDialogParam {
  text: string = '这是一个显示和隐藏动画不同的自定义弹窗';
}

@Builder
function customDialogBuilder(params: TransitionDialogParam) {
  Column() {
    Text(params.text)
      .fontSize(16)
  }
  .width(328)
  .padding(24)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor(Color.White)
  .borderRadius(24)
}

@Entry
@Component
struct CustomDialogDisplayAndExitAnimations {
  promptAction: PromptAction | null = null;
  contentNode: ComponentContent<TransitionDialogParam> | null = null;

  build() {
    Row() {
      Column() {
        Button('打开弹窗')
          .fontSize(16)
          .width(328)
          .backgroundColor('#0A59F7')
          .onClick(() => {
            let uiContext = this.getUIContext();
            let promptAction = uiContext.getPromptAction();
            this.promptAction = promptAction;
            let contentNode =
              new ComponentContent(uiContext, wrapBuilder(customDialogBuilder), new TransitionDialogParam());
            this.contentNode = contentNode;
            let options: promptAction.BaseDialogOptions = {
              alignment: DialogAlignment.Center,
              // 这里设置两个动画,分别对应弹窗显示和隐藏动画
              transition: TransitionEffect.asymmetric(
                TransitionEffect.OPACITY.animation({ duration: 1000 }).combine(
                  TransitionEffect.translate({ y: 1000 }).animation({ duration: 1000 }))
                ,
                TransitionEffect.OPACITY.animation({ delay: 1000, duration: 1000 }).combine(
                  TransitionEffect.translate({ y: 1000 }).animation({ duration: 1000 }))
              )
            };
            try {
              promptAction.openCustomDialog(contentNode, options);
            } 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%')
      .margin({ bottom: 40 })
      .justifyContent(FlexAlign.End)
    }
    .height('100%')
  }
}

效果演示

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

总结

本文从自定义弹窗选型对比、使用场景的角度,主要介绍了以下弹窗的使用区别:

  • UIContext.getPromptAction弹窗:适合全局自定义弹窗,不依赖UI组件的场景
  • Navigation.Dialog弹窗:适合Navigation路由形态、透明页面、切换页面弹窗不消失的场景

同时对于开发者在弹窗使用中经常遇到的问题,给出详细的解决方案,帮助开发者快速选择自定义弹窗的实现方式,提升开发效率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值