前言

在一个优秀的应用设计中,界面不仅仅是平铺直叙的展示,更需要有层级感。当用户点击删除按钮时,我们需要一个确认框来防止误触;当后台数据加载完成时,我们需要一个轻量的提示告诉用户 好了 ;当用户对某个晦涩的功能图标感到困惑时,我们需要一个气泡弹窗来解释它的含义。这些浮在主界面之上的交互层,我们统称为 覆盖物(Overlays)

在早期的开发中,很多工程师习惯直接使用系统原生的 AlertDialog,那种灰底黑字的弹窗虽然功能健全,但在如今这个颜值为王的时代,它打断了用户的情绪流,也破坏了应用的整体设计语言。

在鸿蒙 HarmonyOS 6 中,ArkUI 为我们提供了极其强大的弹窗定制能力。无论是转瞬即逝的 Toast,还是完全自定义的 CustomDialog,亦或是指向性明确的 Popup 气泡,我们都可以像搭积木一样,用声明式的代码构建出既美观又灵动的交互体验。

一、 轻量级反馈与上下文气泡

在进入复杂的弹窗之前,我们先解决最基础的反馈需求。当用户复制了一段文本,或者刷新列表成功时,我们不需要让用户进行任何操作,只需要给出一个朕已阅的信号。这就是 Toast。在 API 20 中,系统将这类交互统一收敛到了 promptAction 模块下。我们不再像以前那样去寻找 Window 实例,而是直接调用 promptAction.showToast。这个 API 非常纯粹,它接受一个显示时长、一条消息文本,以及一个可选的位置参数。但在实战中,建议尽量保持 Toast 的简洁,不要试图在里面塞入过多的文字。它应该像一阵风,来过,被看到,然后消失。

如果说 Toast 是全局的广播,那么 Popup 气泡就是点对点的悄悄话。CustomDialog 是一种模态交互,它会给背景加上遮罩,强迫用户聚焦。但有时候,我们并不想打断用户的操作流,只是想对界面上的某个元素做一点补充说明。比如一个帮助的小问号图标,或者一个“新功能”的引导提示。

这时候,ArkUI 提供的 bindPopup 属性是最优雅的选择。这意味着任何组件——一个按钮、一张图片甚至一段文字,都可以绑定一个气泡。系统会自动计算目标组件在屏幕上的位置,然后决定气泡是出现在上方、下方还是侧边,并自动生成一个小箭头指向目标。我们作为开发者,几乎不需要关心坐标计算的问题,只需要关注气泡里的内容构建即可。

@Entry
@Component
struct PopupExample {
  // 控制气泡显示的开关状态
  @State showPopup: boolean = false;

  // 定义气泡内部的 UI 结构
  @Builder
  PopupContent() {
    Column() {
      Text('功能说明')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .margin({ bottom: 4 })
      
      Text('这里是详细的补充文案,系统会自动根据位置计算箭头指向。')
        .fontSize(12)
        .fontColor('#E6E6E6')
    }
    .padding(12)
    .backgroundColor('#4D4D4D') // 气泡背景通常与文字反色
    .borderRadius(8)
  }

  build() {
    Column() {
      // 任何组件都可以绑定气泡,这里以一个问号图标为例
      SymbolGlyph($r('sys.symbol.questionmark_circle'))
        .fontSize(24)
        .fontColor($r('sys.color.ohos_id_color_text_secondary'))
        // 1. 点击切换状态
        .onClick(() => {
          this.showPopup = !this.showPopup;
        })
        // 2. 绑定气泡属性
        .bindPopup(this.showPopup, {
          builder: this.PopupContent,     // 指向内容构建器
          placement: Placement.Bottom,    // 优先显示位置(系统会自动调整)
          mask: false,                    // false 表示非模态,不阻断用户操作其他区域
          enableArrow: true,              // 显示指向目标的小箭头
          popupColor: '#4D4D4D',          // 气泡背景色(需与 Builder 背景一致或透明)
          onStateChange: (e) => {
            // 3. 状态同步:当点击空白处气泡消失时,同步更新 boolean 变量
            if (!e.isVisible) {
              this.showPopup = false;
            }
          }
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

二、 定制化核心:CustomDialog 与控制器模式

当业务逻辑变得复杂,比如需要用户领取优惠券、签署隐私协议或者选择复杂的筛选条件时,系统的标准弹窗就捉襟见肘了。这时候,CustomDialog(自定义弹窗)就是我们的救星。它的设计哲学非常有趣,采用了一种 控制器(Controller) 模式。我们需要定义两个部分:一个是弹窗本身的 UI 结构,另一个是控制它打开和关闭的遥控器。

首先,我们需要定义一个被 @CustomDialog 装饰器修饰的结构体。在这个结构体里,你可以使用任何 ArkUI 组件:Column、Row、Image 甚至 List。这意味你可以把弹窗做得像普通页面一样丰富多彩。紧接着,在父组件中,我们需要实例化一个 CustomDialogController。这个控制器是连接父子组件的纽带。在实例化时,我们需要传入 builder 参数,指向我们刚才定义的弹窗组件。

@Entry
@Component
struct HomePage {
  // 1. 实例化控制器:连接父组件与弹窗组件
  // 必须在 @Component 中作为成员变量定义
  dialogController: CustomDialogController | null = new CustomDialogController({
    builder: PrivacyAgreementDialog(), // 引用外部定义的 @CustomDialog 组件
    autoCancel: false,                 // 点击遮罩是否允许关闭(强制交互场景通常设为 false)
    alignment: DialogAlignment.Center, // 弹窗在屏幕中的对齐方式
    customStyle: true,                 // 是否完全自定义样式(去除系统默认的白色背景和圆角)
    offset: { dx: 0, dy: 0 },          // 相对对齐位置的偏移量
    maskColor: '#33000000',            // 自定义遮罩层颜色
  });

  // 推荐:在组件销毁时清理控制器,防止内存泄漏
  aboutToDisappear() {
    this.dialogController = null;
  }

  build() {
    Column() {
      Button('打开隐私协议')
        .fontSize(16)
        .onClick(() => {
          // 2. 通过控制器打开弹窗
          if (this.dialogController != null) {
            this.dialogController.open();
          }
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

这里有一个初学者常犯的错误,就是试图通过 @Prop 或 @Link 来直接同步父子组件的数据。虽然 CustomDialog 支持这些装饰器,但由于弹窗并不在常规的组件渲染树中,数据的响应式更新有时会存在滞后。最佳的实践是:在打开弹窗时传入初始数据,在关闭弹窗时通过回调函数返回结果。比如做一个“领取优惠券”的弹窗,我们在构建 CustomDialog 时定义一个 confirm 回调函数。当用户点击弹窗里的“立即领取”按钮时,我们调用这个回调,把结果传回给父组件,然后关闭弹窗。这种 事件驱动 的数据流向,比复杂的双向绑定更加稳健且易于追踪。

做出来和做得好看是两码事。默认的 CustomDialog 往往带有系统默认的圆角和白色背景,有时甚至会有默认的内边距。为了实现设计师眼中那种“全屏半透明”或者“底部异形弹窗”的效果,我们一定要善用 customStyle: true 这个配置项。一旦设置为 true,系统就会移除所有默认的弹窗样式,给你一张完全空白的画布。这时候,你需要在你的 @CustomDialog 组件内部,自己定义背景色、圆角和阴影。虽然麻烦了一点,但它赋予了你像素级的控制权。

三、 综合实战:构建营销活动弹窗体系

为了将上述知识点融会贯通,我们来构建一个真实的电商营销场景。这个页面包含一个模拟的“会员中心”,右上角有一个绑定了 bindPopup 的帮助图标,点击会展示活动规则;而在页面中心,有一个“领取大礼包”的按钮,点击会唤起一个完全自定义样式的 CustomDialog 优惠券弹窗。

在这个代码中,请仔细观察 CouponDialog 的定义,它是如何通过 controller 关闭自己的,以及父组件是如何通过 CustomDialogController 配置 customStyle: true 来移除系统默认背景的。这就是构建高颜值弹窗的标准模板。

TypeScript

import { promptAction } from '@kit.ArkUI';

@CustomDialog
struct CouponDialog {
  controller?: CustomDialogController;

  couponAmount: number = 0;
  onConfirm: () => void = () => {};

  build() {
    Column() {
      // 顶部装饰
      Stack({ alignContent: Alignment.Bottom }) {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('#FF4040')
          .borderRadius({ topLeft: 16, topRight: 16 })

        Text(`¥${this.couponAmount}`)
          .fontSize(40)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .margin({ bottom: 20 })
      }
      .width('100%')
      .height(120)

      // 内容
      Column({ space: 12 }) {
        Text('恭喜获得新人优惠券')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333')

        Text('全场通用,无门槛立减。有效期至 2026-12-31')
          .fontSize(14)
          .fontColor('#999')
          .textAlign(TextAlign.Center)
          .padding({ left: 20, right: 20 })
      }
      .padding({ top: 20, bottom: 20 })

      // 按钮
      Row() {
        Button('残忍拒绝')
          .backgroundColor('#F5F5F5')
          .fontColor('#666')
          .layoutWeight(1)
          .margin({ right: 10 })
          .onClick(() => {
            // 【修复点 2】调用时加上 '?' (可选链),防止空指针报错
            this.controller?.close();
          })

        Button('立即领取')
          .backgroundColor('#FF4040')
          .fontColor(Color.White)
          .layoutWeight(1)
          .onClick(() => {
            this.onConfirm();
            // 【修复点 3】同理,加上 '?'
            this.controller?.close();
          })
      }
      .width('100%')
      .padding({ left: 20, right: 20, bottom: 20 })
    }
    .width(300)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({ radius: 10, color: '#33000000', offsetY: 5 })
  }
}


@Entry
@Component
struct DialogAndPopupPage {
  // 状态变量:控制气泡 (Popup) 的显示与隐藏
  @State isHelpPopupVisible: boolean = false;

  // 【核心】定义弹窗控制器
  // 必须在 build() 之外实例化
  // builder 参数指向上面定义的 @CustomDialog 组件
  private dialogController: CustomDialogController = new CustomDialogController({
    builder: CouponDialog({
      couponAmount: 100, // 向弹窗传递数据
      onConfirm: () => {
        // 定义弹窗确认后的逻辑
        this.handleCouponReceived();
      }
    }),
    autoCancel: true,                 // 允许点击遮罩关闭
    customStyle: true,                // 使用完全自定义样式(去除系统默认白底圆角)
    alignment: DialogAlignment.Center // 居中显示
  });

  // 模拟业务逻辑:领取成功后的 Toast 反馈
  handleCouponReceived() {
    promptAction.showToast({
      message: '领取成功!已存入卡包',
      duration: 2000,
      bottom: 100
    });
  }

  // 定义 Popup (气泡) 的内容构建器
  @Builder
  PopupBuilder() {
    Column() {
      Text('活动规则说明')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .margin({ bottom: 8 })

      Text('1. 仅限新用户领取\n2. 每日限领一张\n3. 不可与其他活动叠加')
        .fontSize(12)
        .fontColor(Color.White)
        .lineHeight(18)
    }
    .padding(12)
    .width(200)
  }

  build() {
    Column() {
      // --- 顶部导航栏 ---
      Row() {
        Text('会员中心')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)

        Blank() // 撑开中间空间

        // 帮助图标 (绑定 Popup)
        Text('?')
          .fontSize(18)
          .fontColor(Color.White)
          .backgroundColor('#CCCCCC')
          .width(24)
          .height(24)
          .textAlign(TextAlign.Center)
          .borderRadius(12)
          // 【核心】绑定气泡
          .bindPopup(this.isHelpPopupVisible, {
            builder: this.PopupBuilder(), // 指向 Builder
            placement: Placement.BottomRight, // 气泡位置
            popupColor: '#4C4C4C',            // 气泡深色背景
            enableArrow: true,                // 显示箭头
            mask: false,                      // 非模态,不遮挡背景
            onStateChange: (e) => {
              // 状态同步:处理点击外部自动消失的情况
              if (!e.isVisible) {
                this.isHelpPopupVisible = false;
              }
            }
          })
          .onClick(() => {
            // 点击切换显示状态
            this.isHelpPopupVisible = !this.isHelpPopupVisible;
          })
      }
      .width('100%')
      .padding(20)

      // --- 页面主体内容 ---
      Column({ space: 30 }) {
        // 模拟大图占位
        Column()
          .width(200)
          .height(200)
          .backgroundColor('#E0E0E0')
          .borderRadius(100)
          .margin({ top: 50 })

        Text('超级会员大礼包')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)

        Text('包含 100 元无门槛优惠券')
          .fontSize(16)
          .fontColor('#666')

        // 【核心】触发弹窗的按钮
        Button('立即领取')
          .width('80%')
          .height(50)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .backgroundColor('#FF4040')
          .shadow({ radius: 10, color: '#4DFF4040', offsetY: 5 })
          .onClick(() => {
            // 打开自定义弹窗
            if (this.dialogController) {
              this.dialogController.open();
            }
          })
      }
      .width('100%')
      .layoutWeight(1) // 占据剩余高度
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8F8F8')
  }
}

总结

弹窗和覆盖物是应用与用户沟通的第二语言。Toast 是轻声的耳语,CustomDialog 是正式的对话,而 Popup 则是贴心的便签。

在鸿蒙 HarmonyOS 6 开发中,掌握 @CustomDialogbindPopup 是构建高级 UI 的必修课。我们抛弃了系统的默认样式,通过 customStyle 获得了对画布的完全掌控权,让弹窗不再只是功能的载体,更是视觉设计的延伸。切记,不要滥用弹窗,每一次遮罩的出现都是对用户注意力的强行掠夺。

好的交互应该是克制的,只在真正需要的时候才优雅地浮现。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐