【鸿蒙实战开发】基于ArkUI现有能力实现自定义弹窗封装方案
场景描述
自定义弹窗是应用开发需要实现的基础功能,包括但不限于HarmonyOS开发者文档中定义的模态、半模态、Toast等形式,封装一个好用且和UI组件解耦的弹窗组件是开发者的高频诉求
自定义弹窗通常的使用场景有:
场景一:在公共逻辑中触发弹窗
登录提示弹窗、全屏广告弹窗、网络请求与其他操作行为的提示、异常弹窗
场景二:侧滑手势拦截
隐私弹窗的拦截,退出登录时的确认弹窗
场景三:切换页面弹窗不消失
隐私弹窗和二级页面中的半模态弹窗
场景四:自定义弹出、关闭动画
从下往上的抽屉式弹出、关闭时从上往下收回
场景五:透明、模态、半模态背景
应用实现自定义的背景颜色
方案描述
1. 使用Navigation.Dialog
基于Navigation.Dialog的透明页面特性,可以用于实现弹窗效果
而且Navigation.Dialog存在于路由栈中,天然可以实现切换页面弹窗不消失
当前限制:
弹窗组件中的动效建议开发者自行实现
Navigation.Dialog自身无颜色,需要开发者自行实现模态遮罩,以及手势事件。
演示效果:
对于少量弹窗的实现,可以直接使用Navigation来进行路由跳转,参考 Navigation常见场景及解决方案
其他Navigation的使用也可参考上述文章
步骤一:封装路由工具类,并注册自定义弹窗组件
定义路由工具类AppRouter,并创建路由栈NavPathStack
▼text复制代码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
▼text复制代码@Entry @Component struct Index { build() { Navigation(AppRouter.getInstance().getPathStack()) { ... } } }
在.navDestination注册封装的自定义弹窗组件DefaultDialog
▼text复制代码@Builder PageMap(name: string) { if (name === CommonConstants.DEFAULT_DIALOG) { DefaultDialog() } ... } Navigation(AppRouter.getInstance().getPathStack()) { ... }.navDestination(this.PageMap)
进阶用法:可以参考动态路由案例实现动态路由, HarmonyOS NEXT应用开发案例集 - Gitee.com
步骤二:封装弹窗UI 组件
定义弹窗选项类AppDialogOption
▼text复制代码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定义页面类型
▼text复制代码@Component export struct DefaultDialog { private dialogOptions?: AppDialogOption; build() { NavDestination() { Stack() { Column() { // 模态遮罩 } Column() { // 弹窗内容 } } .width("100%") .height("100%") } .mode(NavDestinationMode.DIALOG) // 页面类型为dialog } }
通过.backgroundColor设置模态遮罩的背景颜色
▼text复制代码... Stack() { Column() { // 模态遮罩 } .backgroundColor(this.dialogOptions?.styles?.transparent ? Color.Transparent : this.dialogOptions?.styles?.background) // 背景颜色 Column() { // 弹窗内容 } }
通过Stack.alignContent设置弹窗定位
▼text复制代码Stack({ alignContent: this.dialogOptions?.styles?.align }) { Column() { // 模态遮罩 } Column() { // 弹窗内容 } }
步骤三:封装弹窗控制器,与UI 组件解耦
提供链式调用的Api
▼text复制代码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事件中获取路由跳转参数。
▼text复制代码@Component export struct DefaultDialog { private dialogOptions?: AppDialogOption; build() { NavDestination() { ... } .onReady((ctx: NavDestinationContext) => { console.log("onReady") this.dialogOptions = ctx.pathInfo.param as AppDialogOption; }) } }
使用NavPathStack中的onPop回调来接收上一个弹窗返回的参数。
▼text复制代码onPop = (data: PopInfo) => { console.log("onPop") // 更新状态变量 this.params[index] = JSON.stringify(data.result) } navPathStack.pushPathByName(CommonConstants.DEFAULT_DIALOG, this.options, this.options!.onPop!, true)
上一个弹窗在关闭时传入参数
▼text复制代码navPathStack.popToIndex(this.stackIndex, params);
步骤五:实现弹窗自定义动画
通过.transition属性分别实现背景和内容的转场动画
▼text复制代码... 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 }) ) }
通过监听模态遮罩的点击事件实现关闭动画
▼text复制代码... 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来实现动态的自定义弹窗内容。
▼text复制代码Stack() { Column() { // 模态遮罩 } Column() { // 弹窗内容 this.dialogOptions?.view?.builder(this.dialogOptions); } }
定义弹窗内容组件
▼text复制代码@Builder export function DialogViewBuilder(dialogOptions: AppDialogOption) { DialogView({ options: dialogOptions }) } @Component struct DialogView { private options?: dialogOptions ; build() { Column() { } ... } }
步骤六:侧滑手势拦截
在弹窗组件的.onBackPressed事件中进行拦截
▼text复制代码@Component export struct DefaultDialog { private dialogOptions?: AppDialogOption; build() { NavDestination() { ... } .onBackPressed((): boolean => { // true为拦截 if (this.dialogOptions?.onBackPressed) { return this.dialogOptions?.onBackPressed() } else { return false; } }) } }
使用效果:
使用弹窗控制器即可在非UI业务逻辑中打开弹窗
▼text复制代码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) } }
关闭弹窗
▼text复制代码// 全局使用 AppDialog.closeLast(); AppDialog.closeAll(); // 弹窗页面中使用 this.dialogOptions?.instance?.close();
写在最后
如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
- 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力;
- 关注小编,同时可以期待后续文章ing🚀,不定期分享原创知识;
- 想要获取更多完整鸿蒙最新学习知识点,可关注B站:码牛课堂鸿蒙开发;
