在这里插入图片描述

适用版本:HarmonyOS 5.0.0 / HarmonyOS NEXT / HarmonyOS 7
难度:中级
预计阅读时间:35 分钟


写在前面

弹窗是 UI 开发中最常见的需求之一,但也是最容易被低估复杂度的模块。

很多开发者觉得"弹窗不就是一个浮在页面上的框吗",结果在实际项目中踩了一堆坑:页面跳转后弹窗还赖在屏幕上不走、多个弹窗叠加时层级混乱、PC 上弹窗被限制在主窗口内、自定义弹窗拦截不到返回键……

这些问题的根源在于:ArkUI 的弹窗不是简单的"盖一层遮罩",而是一套完整的层级管理体系。 弹窗挂在 Root 节点下,和 Page 是平级关系;它有应用级、页面级、子窗级三种显示模式;不同类型的弹窗在路由切换、层级叠加、生命周期管理上的行为完全不同。

这篇文章我会把 ArkUI 弹窗的完整技术体系拆开讲透。读完之后,你应该能回答这些问题:

  • 我的弹窗应该用 Dialog 还是 bindSheet?
  • 为什么页面跳转后弹窗还在?怎么让它跟着页面走?
  • PC 上怎么让弹窗超出主窗口显示?
  • 多个弹窗叠加时,怎么控制谁在上谁在下?

一、先理解弹窗的"家"在哪里:ArkUI 组件树层级

在讲具体弹窗之前,必须先搞清楚 ArkUI 的组件树结构。不然你永远不会理解为什么弹窗会有那些"奇怪"的行为。

1.1 Root 节点下的挂载关系

ArkUI 的组件树最顶层是 Root 节点。一个正在运行的应用,它的树结构大致长这样:

Root(根节点)
├── Page(当前显示的页面)
│   ├── NavBar / NavDestination
│   └── 你的业务组件(Column、Row、List 等)
│
├── Overlay(浮层,挂载在 Root 下)
│   └── 一些全局浮层内容
│
├── Dialog(弹窗,挂载在 Root 下)
├── Popup(气泡,挂载在 Root 下)
├── Menu(菜单,挂载在 Root 下)
├── Toast(提示,挂载在 Root 下)
├── bindSheet(模态页,挂载在 Root 下)
├── bindContentCover(内容覆盖层,挂载在 Root 下)
└── 带 Order 的 Overlay(有序浮层,挂载在 Root 下)

关键洞察:

  1. 弹窗和 Page 是兄弟关系,不是父子关系。 弹窗不是盖在 Page “上面”,而是挂在 Root 下、和 Page 平级。这意味着弹窗的层级天然高于 Page 内的任何组件。

  2. 弹窗之间按层级数字排序。 后弹出的弹窗层级数字更大,显示在先弹出弹窗的上方。

  3. Overlay 浮层是最底层,弹窗/模态/有序浮层都在它上面。 也就是说,如果你有一个全局的半透明遮罩 Overlay,弹窗弹出来的时候会自动显示在它之上,你不需要手动调整。

1.2 多页面场景下的树结构

当一个应用有多个 Page,通过 Router 或 Navigation 跳转时:

Root
├── Page A(可能被压栈,不在前台)
├── Page B(当前前台页面)
│   └── 页面内的组件树
├── Navigation(导航栈)
│   ├── NavDestination 1
│   └── NavDestination 2(当前显示)
│
└── 弹窗们(挂在 Root 下,跨页面共享)
    ├── Dialog(层级 100)
    ├── Popup(层级 101)
    └── bindSheet(层级 102)

这个结构解释了一个最常见的困惑:为什么我从 Page A 跳转到 Page B,Page A 上的弹窗还在?

答案是:因为弹窗挂在 Root 下,不属于任何 Page。Router 切换的是 Page,但 Root 下的弹窗不受 Page 切换的影响。


二、弹窗的三大显示模式

理解了组件树,现在来看弹窗的三种显示模式。选择正确的模式,是解决 80% 弹窗问题的关键。

2.1 应用级弹窗(默认行为)

特点: 弹窗显示在当前应用窗口的最上层,层级高于应用主窗内所有页面。

行为: 页面切换前后,弹窗始终显示在页面上方。新的路由/导航页面不会覆盖在弹窗之上。

适用场景: 全局性的通知、必须用户处理的操作(如登录弹窗、隐私协议、网络断线提示)。

// AlertDialog 默认就是应用级弹窗
AlertDialog.show({
  title: '网络异常',
  message: '当前网络连接已断开,请检查网络设置',
  primaryButton: {
    value: '去设置',
    action: () => {
      // 打开系统网络设置
    }
  },
  secondaryButton: {
    value: '知道了',
    action: () => {}
  }
});

// 此时用户如果按返回键或跳转到其他页面,这个弹窗仍然显示在最上层

注意一个坑: 如果 Popup 或 Menu 这类绑定到具体组件的弹窗,在页面跳转后,因为绑定的组件已经不在新页面里了,系统会自动关闭它们。但如果开发者把 show 参数硬编码为 true,弹窗可能会继续显示在新页面上——这通常不是想要的效果。

2.2 页面级弹窗(Dialog + bindSheet)

特点: 弹窗只属于当前页面,页面跳转时被新页面覆盖,回到原页面时弹窗仍然显示。

支持组件: 目前支持页面级能力的有 DialogbindSheet

适用场景: 页面内的操作面板、表单填写、当前页面相关的确认弹窗。

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

@Entry
@Component
struct PageLevelDialogPage {
  @State isDialogVisible: boolean = false;
  private dialogController: CustomDialogController | null = null;

  aboutToAppear() {
    // 创建页面级弹窗控制器
    this.dialogController = new CustomDialogController({
      builder: MyCustomDialog(),
      // 关键:设置为页面级弹窗
      alignment: DialogAlignment.Center,
      offset: { dx: 0, dy: -20 },
      // 页面级弹窗的参数
      maskColor: 'rgba(0,0,0,0.5)',
      // 点击蒙层关闭
      autoCancel: true
    });
  }

  build() {
    Column({ space: 20 }) {
      Text('页面级弹窗演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Button('打开页面级 Dialog')
        .onClick(() => {
          this.dialogController?.open();
        })

      Button('跳转到下一页')
        .onClick(() => {
          router.pushUrl({ url: 'pages/NextPage' });
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

// 自定义弹窗组件
@CustomDialog
struct MyCustomDialog {
  controller: CustomDialogController;

  build() {
    Column({ space: 16 }) {
      Text('这是页面级弹窗')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)

      Text('跳转到其他页面时,我会被新页面覆盖')
        .fontSize(14)
        .fontColor('#666')

      Button('关闭')
        .onClick(() => {
          this.controller.close();
        })
    }
    .width(280)
    .padding(24)
    .backgroundColor(Color.White)
    .borderRadius(16)
  }
}

页面级 bindSheet 的实现:

@Entry
@Component
struct BindSheetPage {
  @State isSheetVisible: boolean = false;

  @Builder
  SheetContent() {
    Column({ space: 16 }) {
      Text('底部操作面板')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)

      Button('选项一')
        .width('100%')
        .onClick(() => {
          // 处理选项一
          this.isSheetVisible = false;
        });

      Button('选项二')
        .width('100%')
        .onClick(() => {
          this.isSheetVisible = false;
        });

      Button('取消')
        .width('100%')
        .backgroundColor('#f5f5f5')
        .fontColor('#333')
        .onClick(() => {
          this.isSheetVisible = false;
        });
    }
    .width('100%')
    .padding(24)
  }

  build() {
    Column() {
      Button('打开页面级 Sheet')
        .bindSheet(
          this.isSheetVisible,
          this.SheetContent(),
          {
            // 页面级配置
            height: SheetSize.MEDIUM,
            showClose: false,
            dragBar: true,
            // 点击蒙层关闭
            maskColor: 'rgba(0,0,0,0.4)',
            // 页面级行为:随页面切换被覆盖
            onWillDismiss: (dismissSheetAction: DismissSheetAction) => {
              if (dismissSheetAction === DismissSheetAction.PRESS_BACK) {
                this.isSheetVisible = false;
              }
            }
          }
        )
        .onClick(() => {
          this.isSheetVisible = true;
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

2.3 子窗弹窗(PC / 2in1 设备)

特点: 弹窗显示在独立的窗口内,窗口层级高于应用所在窗口。

适用场景: PC 或 2in1 设备上,需要弹窗超出主窗口边界显示的场景。比如一个音乐播放器应用,点击"歌词"后弹出的歌词窗口可以浮在主窗口旁边。

@Entry
@Component
struct SubWindowDialogPage {
  private dialogController: CustomDialogController | null = null;

  aboutToAppear() {
    this.dialogController = new CustomDialogController({
      builder: SubWindowDialog(),
      alignment: DialogAlignment.TopEnd,
      // 关键:在子窗口中显示
      showInSubWindow: true,
      // 子窗的偏移位置
      offset: { dx: -20, dy: 60 }
    });
  }

  build() {
    Column() {
      Button('在子窗口打开弹窗')
        .onClick(() => {
          this.dialogController?.open();
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

@CustomDialog
struct SubWindowDialog {
  controller: CustomDialogController;

  build() {
    Column({ space: 12 }) {
      Text('子窗口弹窗')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)

      Text('我可以浮在主窗口外面')
        .fontSize(12)
        .fontColor('#666')

      Button('关闭')
        .onClick(() => {
          this.controller.close();
        })
    }
    .width(200)
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({ radius: 12, color: 'rgba(0,0,0,0.15)', offsetY: 6 })
  }
}

子窗弹窗的重要限制:

  • 在移动设备上,子窗模式的弹窗当前无法超出主窗口边界
  • 在 2in1 设备上,子窗可以超出主窗口显示
  • 子窗弹窗的层级受窗口管理器控制,高于应用窗口但低于系统窗口(如系统输入法、系统弹窗)

三、弹窗类型全解析与代码实战

ArkUI 提供了 6 大类弹窗能力,每一类都有明确的适用场景。选错类型是弹窗开发中最常见的错误。

3.1 Dialog:需要用户必须关注的信息

Dialog 是最通用的弹窗类型,用于展示用户当前需要或必须关注的信息。它自带蒙层,会阻断用户与下层界面的交互。

AlertDialog(系统样式,快速使用):

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

// 基础确认弹窗
function showConfirmDialog(): void {
  AlertDialog.show({
    title: '确认删除',
    message: '删除后无法恢复,是否继续?',
    autoCancel: true,  // 点击蒙层自动关闭
    alignment: DialogAlignment.Center,
    offset: { dx: 0, dy: -20 },
    primaryButton: {
      value: '删除',
      fontColor: '#ff4444',
      action: () => {
        performDelete();
      }
    },
    secondaryButton: {
      value: '取消',
      action: () => {}
    }
  });
}

// 单按钮提示弹窗
function showInfoDialog(): void {
  AlertDialog.show({
    title: '保存成功',
    message: '您的笔记已同步到云端',
    confirm: {
      value: '知道了',
      action: () => {}
    }
  });
}

// 非模态弹窗(不阻断操作)
function showNonModalDialog(): void {
  AlertDialog.show({
    title: '提示',
    message: '您可以继续使用应用',
    isModal: false,  // 设置为非模态
    confirm: {
      value: '好的',
      action: () => {}
    }
  });
}

CustomDialog(完全自定义内容):

@CustomDialog
struct PrivacyPolicyDialog {
  controller: CustomDialogController;
  @Prop policyContent: string;
  onAgree: () => void;
  onReject: () => void;

  build() {
    Column({ space: 16 }) {
      Text('隐私政策')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Scroll() {
        Text(this.policyContent)
          .fontSize(14)
          .fontColor('#666')
          .lineHeight(22)
      }
      .width('100%')
      .height(200)
      .padding(12)
      .backgroundColor('#f9f9f9')
      .borderRadius(8)

      Row({ space: 12 }) {
        Button('不同意')
          .layoutWeight(1)
          .backgroundColor('#f5f5f5')
          .fontColor('#666')
          .onClick(() => {
            this.onReject();
            this.controller.close();
          })

        Button('同意')
          .layoutWeight(1)
          .backgroundColor('#2196F3')
          .onClick(() => {
            this.onAgree();
            this.controller.close();
          })
      }
      .width('100%')
    }
    .width('85%')
    .padding(24)
    .backgroundColor(Color.White)
    .borderRadius(20)
  }
}

// 使用自定义弹窗
@Entry
@Component
struct CustomDialogDemo {
  private privacyController: CustomDialogController | null = null;

  aboutToAppear() {
    this.privacyController = new CustomDialogController({
      builder: PrivacyPolicyDialog({
        policyContent: '我们非常重视您的隐私保护...(完整隐私政策内容)',
        onAgree: () => {
          AppStorage.setOrCreate('privacy_agreed', true);
        },
        onReject: () => {
          // 用户不同意:可以退出应用或限制功能
          getContext().terminateSelf();
        }
      }),
      autoCancel: false,  // 禁止点击蒙层关闭,必须做出选择
      alignment: DialogAlignment.Center
    });
  }

  build() {
    Column() {
      Button('显示隐私政策')
        .onClick(() => {
          this.privacyController?.open();
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

3.2 Menu:提供可执行的操作选项

Menu 用于给用户提供一组可执行的操作,通常通过长按或点击触发。

@Entry
@Component
struct MenuDemo {
  @State items: string[] = ['项目 A', '项目 B', '项目 C'];

  @Builder
  MenuBuilder(item: string) {
    Menu() {
      MenuItem({ startIcon: $r('app.media.ic_edit'), content: '编辑' })
        .onClick(() => {
          this.editItem(item);
        })

      MenuItem({ startIcon: $r('app.media.ic_share'), content: '分享' })
        .onClick(() => {
          this.shareItem(item);
        })

      MenuItem({ startIcon: $r('app.media.ic_delete'), content: '删除' })
        .fontColor('#ff4444')
        .onClick(() => {
          this.deleteItem(item);
        })
    }
  }

  build() {
    Column() {
      List({ space: 12 }) {
        ForEach(this.items, (item: string) => {
          ListItem() {
            Row() {
              Text(item)
                .fontSize(16)
                .layoutWeight(1)

              Image($r('app.media.ic_more'))
                .width(24)
                .height(24)
            }
            .width('100%')
            .height(56)
            .padding({ left: 16, right: 16 })
            .backgroundColor('#f5f5f5')
            .borderRadius(12)
          }
          // 绑定菜单
          .bindMenu(this.MenuBuilder(item))
        })
      }
      .padding(16)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
  }

  private editItem(item: string): void {
    console.info(`编辑:${item}`);
  }

  private shareItem(item: string): void {
    console.info(`分享:${item}`);
  }

  private deleteItem(item: string): void {
    const index = this.items.indexOf(item);
    if (index > -1) {
      this.items.splice(index, 1);
      this.items = [...this.items];
    }
  }
}

3.3 Popup:轻量提示气泡

Popup 用于给用户提供轻量的提示信息,比如点击问号图标弹出帮助说明。

@Entry
@Component
struct PopupDemo {
  @State showHelpPopup: boolean = false;

  @Builder
  HelpPopupBuilder() {
    Column({ space: 8 }) {
      Text('帮助说明')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)

      Text('这里可以查看详细的使用说明和操作指南')
        .fontSize(13)
        .fontColor('rgba(255,255,255,0.9)')
        .maxLines(3)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      Button('查看详情')
        .fontSize(12)
        .height(32)
        .backgroundColor('rgba(255,255,255,0.2)')
        .onClick(() => {
          router.pushUrl({ url: 'pages/HelpPage' });
          this.showHelpPopup = false;
        })
    }
    .width(220)
    .padding(16)
    .backgroundColor('#333')
    .borderRadius(12)
  }

  build() {
    Column({ space: 40 }) {
      Text('Popup 气泡提示演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Row({ space: 8 }) {
        Text('用户名')
          .fontSize(16)

        Image($r('app.media.ic_help'))
          .width(20)
          .height(20)
          .bindPopup(
            this.showHelpPopup,
            this.HelpPopupBuilder(),
            {
              placement: Placement.Bottom,  // 在组件下方显示
              enableArrow: true,            // 显示箭头
              arrowOffset: 0,
              onStateChange: (isVisible: boolean) => {
                this.showHelpPopup = isVisible;
              }
            }
          )
          .onClick(() => {
            this.showHelpPopup = !this.showHelpPopup;
          })
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

3.4 bindSheet / bindContentCover:模态页面覆盖

bindSheet 和 bindContentCover 是 ArkUI 中用于"新界面覆盖旧界面,但旧界面不消失"场景的弹窗方案。它们特别适合图片预览、底部操作面板、半屏表单等场景。

bindSheet(从底部弹出的半屏面板):

@Entry
@Component
struct SheetDemo {
  @State isSheetOpen: boolean = false;
  @State selectedPayment: string = '';

  @Builder
  PaymentSheetBuilder() {
    Column({ space: 20 }) {
      // 拖拽指示条
      Row()
        .width(40)
        .height(4)
        .backgroundColor('#ddd')
        .borderRadius(2)

      Text('选择支付方式')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)

      Column({ space: 12 }) {
        this.PaymentOption('微信支付', 'wechat', $r('app.media.ic_wechat'));
        this.PaymentOption('支付宝', 'alipay', $r('app.media.ic_alipay'));
        this.PaymentOption('华为支付', 'huawei', $r('app.media.ic_huawei'));
      }
      .width('100%')

      Button('取消')
        .width('100%')
        .height(48)
        .backgroundColor('#f5f5f5')
        .fontColor('#333')
        .onClick(() => {
          this.isSheetOpen = false;
        })
    }
    .width('100%')
    .padding(24)
  }

  @Builder
  PaymentOption(name: string, value: string, icon: Resource) {
    Row({ space: 12 }) {
      Image(icon)
        .width(32)
        .height(32)

      Text(name)
        .fontSize(16)
        .layoutWeight(1)

      if (this.selectedPayment === value) {
        Image($r('app.media.ic_check'))
          .width(24)
          .height(24)
          .fillColor('#2196F3')
      }
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .backgroundColor(this.selectedPayment === value ? '#e3f2fd' : '#f9f9f9')
    .borderRadius(12)
    .onClick(() => {
      this.selectedPayment = value;
      // 可以在这里延迟关闭
      setTimeout(() => {
        this.isSheetOpen = false;
      }, 300);
    })
  }

  build() {
    Column({ space: 40 }) {
      Text(`当前选择:${this.selectedPayment || '未选择'}`)
        .fontSize(16)

      Button('选择支付方式')
        .bindSheet(
          this.isSheetOpen,
          this.PaymentSheetBuilder(),
          {
            height: SheetSize.MEDIUM,     // 半屏高度
            showClose: false,             // 不显示关闭按钮
            dragBar: true,                // 显示拖拽条
            maskColor: 'rgba(0,0,0,0.4)',
            // 点击蒙层关闭
            onWillDismiss: (action: DismissSheetAction) => {
              if (action === DismissSheetAction.PRESS_BACK ||
                  action === DismissSheetAction.TAP_MASK) {
                this.isSheetOpen = false;
              }
            }
          }
        )
        .onClick(() => {
          this.isSheetOpen = true;
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

bindContentCover(内容覆盖层,适合大图预览):

@Entry
@Component
struct ContentCoverDemo {
  @State isPreviewOpen: boolean = false;
  @State previewImage: Resource = $r('app.media.photo1');

  @Builder
  ImagePreviewBuilder() {
    Stack() {
      // 全屏黑色背景
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(Color.Black)

      // 可缩放的大图
      Image(this.previewImage)
        .width('100%')
        .height('100%')
        .objectFit(ImageFit.Contain)
        .gesture(
          PinchGesture()
            .onActionUpdate((event: GestureEvent) => {
              // 处理缩放
            })
        )

      // 顶部操作栏
      Row() {
        Button('×')
          .fontSize(24)
          .fontColor(Color.White)
          .backgroundColor('transparent')
          .onClick(() => {
            this.isPreviewOpen = false;
          })

        Blank()

        Button('分享')
          .fontColor(Color.White)
          .backgroundColor('transparent')
          .onClick(() => {
            // 分享图片
          })
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
    }
    .width('100%')
    .height('100%')
  }

  build() {
    Column({ space: 20 }) {
      Text('图片预览演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Grid() {
        GridItem() {
          Image($r('app.media.photo1'))
            .width('100%')
            .height(120)
            .objectFit(ImageFit.Cover)
            .borderRadius(8)
        }
        .bindContentCover(
          this.isPreviewOpen,
          this.ImagePreviewBuilder(),
          {
            modalTransition: ModalTransition.DEFAULT,
            onDisappear: () => {
              this.isPreviewOpen = false;
            }
          }
        )
        .onClick(() => {
          this.previewImage = $r('app.media.photo1');
          this.isPreviewOpen = true;
        })

        // 更多图片...
      }
      .columnsTemplate('1fr 1fr 1fr')
      .columnsGap(8)
      .rowsGap(8)
      .padding(16)
    }
    .width('100%')
    .height('100%')
  }
}

3.5 Toast:即时反馈

Toast 用于提供用户当前操作的简单反馈,通常自动消失。

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

// 基础 Toast
function showToast(message: string): void {
  promptAction.showToast({
    message: message,
    duration: 2000  // 显示 2 秒
  });
}

// 带图标的 Toast(HarmonyOS 5.0+)
function showSuccessToast(): void {
  promptAction.showToast({
    message: '保存成功',
    duration: 2000,
    bottom: '80vp'  // 距离底部距离
  });
}

// 使用示例
Button('保存')
  .onClick(async () => {
    await saveData();
    showSuccessToast();
  })

Toast 的注意事项:

  • Toast 是非模态的,不会阻断用户操作
  • 不要在一个操作里连续弹出多个 Toast,会把前面的覆盖掉
  • Toast 文字不要超过 15 个字,否则用户读不完
  • 重要操作结果用 Toast,非常严重的错误用 Dialog

3.6 OverlayManager:完全自定义的浮层

当内置弹窗类型都无法满足需求时,OverlayManager 提供了最高自由度的浮层能力。你可以完全控制内容、样式、位置和行为。

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

@Entry
@Component
struct OverlayDemo {
  private overlayId: number = -1;

  @Builder
  FloatingMusicPlayer() {
    Row({ space: 12 }) {
      Image($r('app.media.album_cover'))
        .width(48)
        .height(48)
        .borderRadius(24)

      Column({ space: 4 }) {
        Text('正在播放')
          .fontSize(12)
          .fontColor('rgba(255,255,255,0.7)')
        Text('夜曲 - 周杰伦')
          .fontSize(14)
          .fontColor(Color.White)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Button('▶')
        .width(36)
        .height(36)
        .fontSize(14)
        .backgroundColor('rgba(255,255,255,0.2)')
        .fontColor(Color.White)
    }
    .width(280)
    .height(64)
    .padding(8)
    .backgroundColor('rgba(0,0,0,0.8)')
    .borderRadius(32)
    .gesture(
      PanGesture()
        .onActionUpdate((event: GestureEvent) => {
          // 可以拖拽移动位置
        })
    )
  }

  showOverlay(): void {
    const uiContext = this.getUIContext();
    if (!uiContext) return;

    this.overlayId = uiContext.getOverlayManager().addOverlay(
      this.FloatingMusicPlayer(),
      {
        // 浮层显示在屏幕右下角
        alignment: Alignment.BottomEnd,
        offset: { x: -16, y: -80 }
      }
    );
  }

  hideOverlay(): void {
    if (this.overlayId !== -1) {
      const uiContext = this.getUIContext();
      uiContext?.getOverlayManager().removeOverlay(this.overlayId);
      this.overlayId = -1;
    }
  }

  build() {
    Column({ space: 20 }) {
      Text('OverlayManager 浮层演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Button('显示音乐浮层')
        .onClick(() => {
          this.showOverlay();
        })

      Button('隐藏音乐浮层')
        .onClick(() => {
          this.hideOverlay();
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

OverlayManager 的适用场景:

  • 全局悬浮按钮/胶囊(如音乐播放球、语音助手)
  • 需要拖拽改变位置的浮层
  • 完全自定义形状和行为的特殊弹窗
  • 需要常驻显示、不随页面切换消失的控件

四、模态 vs 非模态:选对交互强度

弹窗的核心设计决策之一是选择模态还是非模态。这个选择决定了用户被"打断"的程度。

特性 模态弹窗 非模态弹窗
是否阻断用户操作 是,必须响应才能继续 否,不影响当前操作
是否有蒙层 通常有 通常没有
关闭方式 必须用户主动关闭 可自动消失或用户关闭
适用场景 重要确认、警告、必须用户决策 提示、反馈、可延后处理的信息
用户体验 强打扰 弱打扰

实战中的选择建议:

// 模态场景:删除确认(必须用户决策)
function showDeleteConfirm(onConfirm: () => void): void {
  AlertDialog.show({
    title: '确认删除',
    message: '此操作不可撤销',
    isModal: true,  // 模态
    primaryButton: {
      value: '删除',
      fontColor: '#ff4444',
      action: onConfirm
    },
    secondaryButton: {
      value: '取消',
      action: () => {}
    }
  });
}

// 非模态场景:保存成功提示(用户可以继续操作)
function showSaveNotification(): void {
  promptAction.showToast({
    message: '保存成功',
    duration: 2000
  });
  // 用户不需要做任何回应,Toast 2 秒后自动消失
}

// 可切换场景:根据内容重要性决定
function showUpdatePrompt(isForceUpdate: boolean): void {
  AlertDialog.show({
    title: '发现新版本',
    message: '建议更新到最新版本以获得更好体验',
    isModal: isForceUpdate,  // 强制更新时模态,非强制时非模态
    primaryButton: {
      value: '立即更新',
      action: () => startUpdate()
    },
    secondaryButton: isForceUpdate ? undefined : {
      value: '稍后',
      action: () => {}
    }
  });
}

五、实战案例:常见场景的弹窗选型

5.1 场景一:应用启动时的隐私政策弹窗

需求: 用户首次打开应用时,必须同意隐私政策才能使用。

选型: CustomDialog(应用级,模态,禁止蒙层关闭)

@Entry
@Component
struct LaunchPage {
  private privacyController: CustomDialogController | null = null;

  aboutToAppear() {
    const hasAgreed = AppStorage.get('privacy_agreed') || false;
    if (!hasAgreed) {
      this.privacyController = new CustomDialogController({
        builder: PrivacyPolicyDialog({
          onAgree: () => {
            AppStorage.setOrCreate('privacy_agreed', true);
            this.goToMainPage();
          },
          onReject: () => {
            getContext().terminateSelf();
          }
        }),
        autoCancel: false,      // 禁止点击蒙层关闭
        maskColor: 'rgba(0,0,0,0.7)'
      });
      // 延迟一点点弹出,避免启动瞬间弹窗
      setTimeout(() => {
        this.privacyController?.open();
      }, 500);
    } else {
      this.goToMainPage();
    }
  }

  private goToMainPage(): void {
    router.replaceUrl({ url: 'pages/MainPage' });
  }

  build() {
    Column() {
      Text('加载中...')
        .fontSize(16)
        .fontColor('#999')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

5.2 场景二:长按列表项弹出操作菜单

需求: 用户长按列表中的某一项,弹出"编辑/分享/删除"菜单。

选型: bindMenu(轻量,跟随组件,自动定位)

代码参考 3.2 节的 Menu 示例。

5.3 场景三:图片点击大图预览

需求: 用户点击缩略图后,全屏查看大图,支持手势缩放。

选型: bindContentCover(全屏覆盖,支持复杂手势)

代码参考 3.4 节的 bindContentCover 示例。

5.4 场景四:底部选择面板

需求: 用户点击"选择支付方式",从底部弹出半屏面板。

选型: bindSheet(半屏高度,拖拽关闭,体验自然)

代码参考 3.4 节的 bindSheet 示例。

5.5 场景五:全局音乐播放悬浮球

需求: 用户退出音乐播放页面后,有一个悬浮球显示当前播放状态,可点击展开。

选型: OverlayManager(完全自定义位置,跨页面常驻)

代码参考 3.6 节的 OverlayManager 示例。


六、避坑指南:弹窗开发的 8 个常见陷阱

陷阱 1:弹窗在页面跳转后还赖着不走

现象: 用户在 Page A 打开了一个弹窗,然后 Router.push 到 Page B,弹窗还在屏幕上。

原因: 默认弹窗是应用级的,挂在 Root 下,不受 Page 切换影响。

解决: 如果希望弹窗跟着页面走,使用页面级弹窗(Dialog + bindSheet)。或者在页面 aboutToDisappear 里手动关闭弹窗。

aboutToDisappear() {
  // 页面销毁前关闭所有弹窗
  this.dialogController?.close();
  this.isSheetOpen = false;
}

陷阱 2:Popup/Menu 绑定组件消失后弹窗残留

现象: Popup 绑定在列表的某一项上,用户滑动列表后该项被复用了,Popup 还显示在错误位置。

原因: Popup 绑定的是组件引用,组件被销毁或复用后,Popup 位置会错乱。

解决: 在列表滚动或数据变化时,主动关闭绑定的 Popup。或者改用 Dialog/Sheet 这类不绑定具体组件的弹窗。

陷阱 3:多个弹窗叠加时层级混乱

现象: 弹窗 A 打开后,弹窗 B 打开了,但 B 显示在 A 的下面。

原因: 默认情况下,后打开的弹窗层级更高。但如果手动设置了 zIndexorder,可能打乱了系统层级。

解决: 不要手动干预弹窗的层级,让系统按"后打开在上"的规则管理。如果必须控制层级,使用 overlayManageraddOverlay 时传入 order 参数。

陷阱 4:PC 上弹窗被限制在主窗口内

现象: 在鸿蒙 PC 上,弹窗无法超出应用主窗口边界。

原因: 默认弹窗模式在移动端和 PC 端都限制在主窗口内。

解决: 在 PC/2in1 设备上,设置 showInSubWindow: true,弹窗会显示在独立子窗口中,可以超出主窗口。

陷阱 5:弹窗打开时背景还可以滚动

现象: 弹窗打开了,但后面的列表还能滚动。

原因: 某些自定义弹窗没有正确阻断手势穿透。

解决: 确保弹窗容器使用 StackColumn 包裹,并且蒙层组件覆盖全屏。系统弹窗(AlertDialog、bindSheet)默认会阻断底层交互。

陷阱 6:返回键没有关闭弹窗而是退出了应用

现象: 用户按返回键,弹窗没关闭,整个应用退出了。

原因: 没有处理返回键事件,系统默认行为是退出应用。

解决: 在弹窗显示时拦截返回键事件。

.onKeyEvent((event: KeyEvent) => {
  if (event.keyCode === KeyCode.KEYCODE_BACK && event.type === KeyType.Down) {
    if (this.isDialogVisible) {
      this.isDialogVisible = false;
      return true; // 拦截返回键
    }
  }
  return false;
})

陷阱 7:频繁打开关闭弹窗导致内存泄漏

现象: 应用运行一段时间后变慢,检查发现内存持续增长。

原因: 每次打开弹窗都创建新的 Controller 实例,但没有释放。

解决: 复用 Controller 实例,不要在每次打开时都 new CustomDialogController()

// ❌ 错误:每次点击都创建新实例
.onClick(() => {
  const controller = new CustomDialogController({ builder: MyDialog() });
  controller.open();
})

// ✅ 正确:复用已有实例
aboutToAppear() {
  this.dialogController = new CustomDialogController({ builder: MyDialog() });
}

.onClick(() => {
  this.dialogController?.open();
})

陷阱 8:在非前台状态下调用弹窗显示

现象: 应用在后台时调用了弹窗显示,用户切回前台后发现弹窗没有显示,或者显示异常。

原因: 系统出于安全和体验考虑,不允许后台应用弹出非系统弹窗。

解决: 在调用弹窗前检查应用是否在前台。

import { application } from '@kit.AbilityKit';

function safeShowDialog(dialogController: CustomDialogController): void {
  const appState = application.getApplicationContext().getApplicationState();
  if (appState === 'foreground') {
    dialogController.open();
  } else {
    // 应用不在前台,延迟到前台再显示,或者放弃显示
    console.warn('应用不在前台,跳过弹窗显示');
  }
}

七、总结与选型速查表

7.1 弹窗选型决策树

你需要什么类型的弹窗?
├── 必须用户做出选择/确认?
│   └── 用 Dialog(AlertDialog 快速使用 / CustomDialog 完全自定义)
│       ├── 应用全局级别 → 默认应用级 Dialog
│       └── 只属于当前页面 → 页面级 Dialog
│
├── 给用户提供一组操作选项?
│   └── 用 Menu(bindMenu)
│
├── 轻量提示/帮助说明?
│   └── 用 Popup(bindPopup)
│
├── 底部操作面板/半屏表单?
│   └── 用 bindSheet
│
├── 全屏内容覆盖(大图预览/视频播放)?
│   └── 用 bindContentCover
│
├── 简单操作反馈?
│   └── 用 Toast(promptAction.showToast)
│
└── 完全自定义、常驻、可拖拽?
    └── 用 OverlayManager

7.2 弹窗类型速查表

弹窗类型 模态支持 页面级支持 子窗支持 自定义内容 典型时长 主要用途
AlertDialog 有限 持续到用户关闭 快速确认/提示
CustomDialog 完全 持续到用户关闭 复杂自定义弹窗
Menu 有限 持续到用户选择 操作选项列表
Popup 通常自动关闭 轻量提示/帮助
bindSheet 完全 用户关闭/下滑 底部操作面板
bindContentCover 完全 用户关闭 全屏内容覆盖
Toast 2-3.5 秒自动消失 操作反馈
OverlayManager 可选 完全 开发者控制 悬浮控件

7.3 弹窗规格约束速记

  • 多个弹窗先后弹出时,后弹出的层级高于先弹出
  • 退出时按照层级从高到低的顺序逐次退出
  • 系统弹窗出现时,禁止显示非系统弹窗
  • 不建议在非前台状态下调用弹窗显示接口
  • 在移动设备中,子窗模式的弹窗无法超出主窗口
  • 在 2in1 设备上,设置 showInSubWindow: true 可让弹窗超出主窗口

弹窗是用户与应用对话的窗口。一个好的弹窗应该像一位有礼貌的侍者——在恰当的时机出现,传达清晰的信息,不打扰用户的正事,然后安静地离开。希望这篇文章能帮助你在鸿蒙应用中设计出恰到好处的弹窗体验。

Logo

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

更多推荐