摘要

ArkUI 原生弹窗分为 AlertDialogTextPickerDialogCustomDialog,原生写法存在大量重复代码:弹窗构造器重复创建、无统一淡入淡出动画、无法全局快捷调用、弹窗层级冲突、页面销毁弹窗残留、多弹窗叠加错乱。基于 CustomDialogController 封装全局弹窗工具,封装提示弹窗、确认弹窗、输入弹窗、加载弹窗、自定义内容弹窗五大常用类型,统一动画、层级、自动销毁逻辑,支持页面内局部弹窗与全局快捷弹窗调用。API23 重构弹窗生命周期、遮罩层级、动画自动销毁机制,修复弹窗页面退出不关闭、多层弹窗遮罩穿透、动画残留、重复创建控制器内存泄漏等问题。本文提供完整弹窗工具类,搭配设置页、登录页、笔记列表实战调用,给出弹窗生命周期、层级、动画规范,是 UI 交互高频基础组件。

关键词

OpenHarmony;ArkUI;API23;CustomDialog;自定义弹窗;全局弹窗;加载弹窗;确认弹窗;弹窗动画

一、引言

1.1 弹窗开发痛点

原生弹窗直接开发存在诸多冗余与兼容性问题:

  1. 每个页面重复 new CustomDialogController,大量模板代码;
  2. 缺少统一淡入缩放动画,弹窗弹出生硬;
  3. 页面销毁时弹窗未自动关闭,后台残留遮罩阻塞交互;
  4. 多层弹窗层级管理混乱,底层弹窗可点击穿透;
  5. 加载弹窗无全局统一管理,多处重复实现 loading;
  6. 确认 / 提示弹窗按钮文字、回调重复编写。

封装统一弹窗工具实现:

  1. 内置 5 类高频弹窗:提示框、确认框、单行输入框、全局加载框、自定义布局弹窗;
  2. 统一弹出 / 关闭淡入缩放动画,全局样式统一;
  3. 自动管控控制器生命周期,页面退出自动关闭所有弹窗;
  4. 遮罩锁定底层页面,禁止穿透点击;
  5. 极简一行代码唤起弹窗,无需重复定义 Builder 控制器。

API23 弹窗核心升级变更:

  1. CustomDialogController 新增自动回收机制,页面销毁自动释放控制器;
  2. 遮罩层 mask 增强拦截,杜绝底层页面穿透点击;
  3. 弹窗动画生命周期绑定,关闭动画执行完毕再销毁弹窗实例;
  4. 优化多弹窗栈管理,顺序弹出、顺序关闭,避免布局错乱;
  5. 修复弹窗多次快速唤起导致多个控制器叠加堆积。

1.2 核心模块依赖

ets

import CustomDialogController from '@ohos.arkui.dialog.CustomDialogController'
import animateTo from '@ohos.arkui.animateTo'

二、全局弹窗工具封装 utils/dialog_util.ets

ets

import CustomDialogController from '@ohos.arkui.dialog.CustomDialogController'
import animateTo from '@ohos.arkui.animateTo'
import promptAction from '@ohos.promptAction'

// 全局存储当前打开的弹窗控制器,页面销毁批量关闭
const dialogPool: CustomDialogController[] = []

// 弹窗基础配置
interface DialogBaseOpt {
  title?: string
  content: string
  cancelText?: string
  confirmText?: string
}

// 输入弹窗配置
interface InputDialogOpt extends DialogBaseOpt {
  placeholder?: string
  defaultText?: string
}

class DialogUtil {
  private static instance: DialogUtil

  static getInstance(): DialogUtil {
    if (!DialogUtil.instance) DialogUtil.instance = new DialogUtil()
    return DialogUtil.instance
  }

  // 注册控制器到池,用于页面销毁统一关闭
  private addCtrl(ctrl: CustomDialogController) {
    dialogPool.push(ctrl)
  }

  // 页面销毁:关闭全部弹窗
  closeAllDialog() {
    dialogPool.forEach(ctrl => {
      try {
        ctrl.close()
      } catch (e) {}
    })
    dialogPool.length = 0
  }

  // 1. 纯提示弹窗(仅确认按钮)
  alert(opt: DialogBaseOpt) {
    const builder = () => {
      @CustomDialog
      struct AlertDialog {
        controller: CustomDialogController
        @State scale: number = 0.9
        @State opacity: number = 0
        title: string = opt.title ?? "提示"
        content: string = opt.content
        confirmTxt: string = opt.confirmText ?? "确定"

        aboutToAppear() {
          animateTo({ duration: 260, curve: Curve.EaseOut }, () => {
            this.scale = 1
            this.opacity = 1
          })
        }

        build() {
          Column({ space: 20 }) {
            Text(this.title).fontSize(20).fontWeight(500)
            Text(this.content).fontSize(16).fontColor("#666").textAlign(TextAlign.Center)
            Button(this.confirmTxt)
              .width("100%")
              .height(44)
              .backgroundColor("#007DFF")
              .onClick(() => {
                animateTo({ duration: 220 }, () => {
                  this.scale = 0.9
                  this.opacity = 0
                }, () => {
                  this.controller.close()
                })
              })
          }
          .width(280)
          .padding(24)
          .backgroundColor(Color.White)
          .borderRadius(16)
          .opacity(this.opacity)
          .scale({ x: this.scale, y: this.scale })
          .animation({ duration: 260, curve: Curve.EaseOut })
        }
      }
    }
    const ctrl = new CustomDialogController({
      builder: builder(),
      mask: true,
      autoCancel: false
    })
    this.addCtrl(ctrl)
    ctrl.open()
  }

  // 2. 确认弹窗(取消+确认双按钮,带回调)
  confirm(opt: DialogBaseOpt, confirmCb: () => void) {
    const builder = () => {
      @CustomDialog
      struct ConfirmDialog {
        controller: CustomDialogController
        @State scale: number = 0.9
        @State opacity: number = 0
        title: string = opt.title ?? "提示"
        content: string = opt.content
        cancelTxt: string = opt.cancelText ?? "取消"
        confirmTxt: string = opt.confirmText ?? "确认"
        okCallback: () => void = confirmCb

        aboutToAppear() {
          animateTo({ duration: 260 }, () => {
            this.scale = 1
            this.opacity = 1
          })
        }

        // 关闭淡出动画
        closeAnim(cb?: () => void) {
          animateTo({ duration: 220 }, () => {
            this.scale = 0.9
            this.opacity = 0
          }, () => {
            this.controller.close()
            if (cb) cb()
          })
        }

        build() {
          Column({ space: 20 }) {
            Text(this.title).fontSize(20).fontWeight(500)
            Text(this.content).fontSize(16).fontColor("#666").textAlign(TextAlign.Center)
            Row({ space: 12 }) {
              Button(this.cancelTxt)
                .layoutWeight(1)
                .height(44)
                .backgroundColor("#EEEEEE")
                .fontColor("#333")
                .onClick(() => this.closeAnim())
              Button(this.confirmTxt)
                .layoutWeight(1)
                .height(44)
                .backgroundColor("#007DFF")
                .onClick(() => this.closeAnim(this.okCallback))
            }
            .width("100%")
          }
          .width(300)
          .padding(24)
          .backgroundColor(Color.White)
          .borderRadius(16)
          .opacity(this.opacity)
          .scale({ x: this.scale, y: this.scale })
        }
      }
    }
    const ctrl = new CustomDialogController({ builder: builder(), mask: true, autoCancel: false })
    this.addCtrl(ctrl)
    ctrl.open()
  }

  // 3. 单行输入弹窗
  input(opt: InputDialogOpt, confirmCb: (val: string) => void) {
    const builder = () => {
      @CustomDialog
      struct InputDialog {
        controller: CustomDialogController
        @State scale: number = 0.9
        @State opacity: number = 0
        @State text: string = opt.defaultText ?? ""
        title: string = opt.title ?? "请输入"
        placeholder: string = opt.placeholder ?? ""
        confirmCb: (s: string) => void = confirmCb

        aboutToAppear() {
          animateTo({ duration: 260 }, () => {
            this.scale = 1
            this.opacity = 1
          })
        }

        closeAnim(cb?: () => void) {
          animateTo({ duration: 220 }, () => {
            this.scale = 0.9
            this.opacity = 0
          }, () => {
            this.controller.close()
            if (cb) cb()
          })
        }

        build() {
          Column({ space: 18 }) {
            Text(this.title).fontSize(20).fontWeight(500)
            TextInput({ text: this.text, placeholder: this.placeholder })
              .width("100%")
              .height(44)
              .onChange(v => this.text = v)
            Row({ space: 12 }) {
              Button("取消").layoutWeight(1).backgroundColor("#eee")
                .onClick(() => this.closeAnim())
              Button("确定").layoutWeight(1).backgroundColor("#007DFF")
                .onClick(() => this.closeAnim(() => this.confirmCb(this.text)))
            }
            .width("100%")
          }
          .width(320)
          .padding(24)
          .backgroundColor(Color.White)
          .borderRadius(16)
          .opacity(this.opacity)
          .scale({ x: this.scale, y: this.scale })
        }
      }
    }
    const ctrl = new CustomDialogController({ builder: builder(), mask: true, autoCancel: false })
    this.addCtrl(ctrl)
    ctrl.open()
  }

  // 4. 全局加载弹窗(无按钮,代码手动关闭)
  showLoading(text: string = "加载中...") {
    const builder = () => {
      @CustomDialog
      struct LoadingDialog {
        controller: CustomDialogController
        msg: string = text
        @State rotate: number = 0

        aboutToAppear() {
          // 无限旋转加载动画
          animateTo({ duration: 1200, iterations: Infinity, curve: Curve.Linear }, () => {
            this.rotate += 360
          })
        }

        build() {
          Column({ space: 12 }) {
            Text("⟳").fontSize(36).rotate({ angle: this.rotate })
            Text(this.msg).fontSize(16).fontColor(Color.White)
          }
          .width(140)
          .height(140)
          .backgroundColor("#000000AA")
          .borderRadius(12)
        }
      }
    }
    const ctrl = new CustomDialogController({ builder: builder(), mask: true, autoCancel: false })
    this.addCtrl(ctrl)
    ctrl.open()
  }
}

export default DialogUtil.getInstance()

三、页面实战调用示例

3.1 笔记列表:删除确认弹窗

ets

import DialogUtil from '../utils/dialog_util'
import RdbUtil from '../utils/rdb_util'

@Entry
@Component
struct NoteList {
  // 删除笔记弹窗确认
  async delNote(id: number) {
    DialogUtil.confirm({
      title: "删除提示",
      content: "确定要删除这条笔记吗?删除后无法恢复!"
    }, async () => {
      await RdbUtil.deleteNote(id)
      DialogUtil.alert({ content: "删除成功" })
      await this.refreshData()
    })
  }

  build() {
    List() {
      ForEach(this.noteList, item => {
        ListItem() {
          Row() {
            Text(item.title).layoutWeight(1)
            Button("删除").backgroundColor("#f56c6c")
              .onClick(() => this.delNote(item.id))
          }
        }
      })
    }
  }

  aboutToDisappear() {
    // 页面退出关闭所有弹窗,防止残留遮罩
    DialogUtil.closeAllDialog()
  }
}

3.2 新建笔记:输入弹窗

ets

Button("新建笔记")
  .onClick(() => {
    DialogUtil.input({
      title: "新建笔记",
      placeholder: "请输入笔记标题",
      defaultText: ""
    }, async (title) => {
      if (!title) {
        DialogUtil.alert({ content: "标题不能为空" })
        return
      }
      await RdbUtil.addNote(title, "");
      await this.refreshData();
    })
  })

3.3 网络请求全局加载弹窗

ets

async function loadNewsData() {
  DialogUtil.showLoading("资讯加载中...")
  const res = await HttpUtil.get("/news/list")
  DialogUtil.closeAllDialog()
  if (res) this.newsList = res.data
}

3.4 简单提示弹窗

ets

DialogUtil.alert({
  title: "操作完成",
  content: "数据保存成功"
})

四、弹窗开发规范

4.1 生命周期强制规范

  1. 页面 aboutToDisappear 必须调用 DialogUtil.closeAllDialog()
  2. 加载弹窗请求结束后统一关闭全部弹窗,避免遮罩卡住页面;
  3. 弹窗关闭必须走完淡出动画再执行回调销毁控制器,禁止直接 close。

4.2 遮罩与交互规范

  1. 所有业务弹窗开启 mask:true,底层页面不可点击穿透;
  2. autoCancel:false 禁止点击遮罩关闭弹窗,避免误触;
  3. 加载弹窗全局独占遮罩,阻断所有页面操作。

4.3 动画统一规范

  1. 弹出:scale 0.9→1、opacity 0→1,时长 260ms;
  2. 关闭:scale 1→0.9、opacity 1→0,时长 220ms;
  3. 加载旋转动画使用匀速 Linear,无限循环。

4.4 分层业务使用规范

  1. 简单成功 / 失败提示:alert 单按钮弹窗;
  2. 删除、退出、清空高危操作:confirm 双按钮确认弹窗;
  3. 新建标题、编辑备注:input 单行输入弹窗;
  4. 网络请求、文件上传、数据库批量操作:showLoading 加载弹窗。

五、API23 升级高频问题与解决方案

问题 1:打开弹窗后返回上一页,遮罩依旧挡住页面无法操作 解决:页面 aboutToDisappear 调用 closeAllDialog 批量关闭所有控制器。

问题 2:快速多次点击按钮弹出多层弹窗,堆叠错乱 解决:弹窗池统一管理,页面销毁全部回收;业务层增加 loading 状态锁拦截重复点击。

问题 3:弹窗关闭回调提前执行,动画还没消失页面就刷新 解决:动画 finish 回调内执行 controller.close 和业务回调。

问题 4:点击弹窗外部空白区域弹窗消失 解决:创建控制器配置 autoCancel: false。

问题 5:加载弹窗旋转动画页面退出后还在后台运行耗电 解决:页面销毁统一关闭控制器,动画实例同步销毁。

六、总结

DialogUtil 统一封装四类高频业务弹窗,标准化弹出 / 关闭动画、遮罩拦截、生命周期自动回收,一行代码即可唤起弹窗,省去页面重复定义 CustomDialogBuilder 与控制器的冗余模板代码。 API23 完善弹窗控制器生命周期管理、遮罩拦截、动画绑定机制,彻底解决旧版弹窗遮罩穿透、页面退出残留、多层弹窗错乱等问题。工具可搭配前文 RDB、Http、页面路由全套工具使用,是所有项目 UI 交互必备通用底层组件。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐