在这里插入图片描述

📖 引言

弹窗是移动应用中最常见的交互模式之一。

从最简单的"确认删除"提示,到复杂的分享面板、筛选弹窗、底部操作菜单……弹窗无处不在。它就像应用的"对话框"——需要用户做决策、确认信息、选择选项时,就弹出一个浮层,用户处理完就关掉。

但弹窗用不好,也会变成"弹窗地狱"——一打开 App 就弹一堆,关都关不完,用户烦都烦死了。

ArkUI 提供了丰富的弹窗组件:AlertDialog、CustomDialog、ActionSheet、Toast、bindSheet 等等。但很多开发者只会用最简单的 AlertDialog.show,复杂一点的弹窗就不知道怎么搞了。

结果就是:

  • 弹窗样式丑,和设计稿差太远
  • 弹窗穿透,点弹窗外面居然能点到下面的按钮
  • 多个弹窗叠在一起,管理混乱
  • 键盘弹起来挡住弹窗内容
  • 弹窗关闭后状态不对,数据丢了

本文我们就从弹窗的分类讲起,深入到 ArkUI 的各种弹窗方案,结合「民族图鉴」项目的实际场景(分享弹窗、筛选弹窗、确认对话框、操作菜单等),带你系统性地掌握鸿蒙弹窗开发。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解弹窗的分类与各自的使用场景
  • ✅ 掌握 AlertDialog:系统提示弹窗的使用
  • ✅ 掌握 CustomDialog:自定义弹窗的深度定制
  • ✅ 掌握 ActionSheet:底部操作面板的实现
  • ✅ 掌握 Toast / 轻提示的使用技巧
  • ✅ 理解弹窗的栈管理与多个弹窗的协调
  • ✅ 学会弹窗的动画与转场效果
  • ✅ 避开弹窗开发的常见坑:穿透、遮挡、状态丢失

💡 需求分析

弹窗的分类

不是所有弹窗都叫"Dialog"。不同的场景,应该用不同类型的弹窗。

类型 特点 典型场景 ArkUI 方案
Alert 警告框 居中、标题+内容+按钮、必须操作 确认删除、重要提示 AlertDialog
ActionSheet 操作面板 从底部弹出、多个操作选项 分享、更多操作 ActionSheet
BottomSheet 底部抽屉 从底部弹出、可滑动、内容多 筛选、评论、详情 bindSheet / 自定义
CustomDialog 自定义弹窗 完全自定义、任意位置任意样式 分享弹窗、点赞弹窗 CustomDialog
Toast 轻提示 自动消失、不阻断操作 操作成功、网络错误 promptAction.showToast
Popup 气泡弹窗 依附于某个元素、小提示 长按提示、新手引导 bindPopup

什么时候该用弹窗?

弹窗是"打断式"的——它会打断用户当前的操作。所以不要滥用。

该用弹窗的场景

  1. 需要用户确认的危险操作:删除、清空、退出
  2. 重要的信息告知:版本更新、权限说明
  3. 需要用户选择的操作:分享到哪、用什么方式打开
  4. 轻量的信息录入:输入手机号、简单表单

不该用弹窗的场景

  1. 广告、推广:用户最烦的就是这个
  2. 非紧急的通知:用列表/消息中心就行
  3. 复杂的表单:应该新开页面
  4. 频繁出现的提示:用 Toast 或者直接内嵌在页面里

💡 弹窗黄金法则
用户打开你的 App 是来完成任务的,不是来关弹窗的。
一个页面最多不要超过 1 个弹窗,超过了就是设计有问题。

「民族图鉴」的弹窗场景

「民族图鉴」项目中有很多典型的弹窗场景:

场景 弹窗类型 技术方案
删除收藏确认 确认对话框 AlertDialog
民族分享 自定义弹窗 CustomDialog
筛选条件 底部抽屉 bindSheet / 自定义
更多操作(分享/收藏/举报) 底部操作面板 ActionSheet
收藏成功/删除成功 轻提示 Toast
长按菜单提示 气泡弹窗 bindPopup
语言切换确认 确认对话框 AlertDialog

🛠️ 核心实现

步骤1:AlertDialog——系统提示弹窗

AlertDialog 是最简单、最常用的弹窗。用于展示重要信息、确认操作。

1.1 基本用法
Button('显示弹窗')
  .onClick(() => {
    AlertDialog.show({
      title: '提示',
      message: '确定要删除这条收藏吗?',
      primaryButton: {
        value: '取消',
        action: () => {
          console.info('点击了取消');
        }
      },
      secondaryButton: {
        value: '删除',
        action: () => {
          console.info('点击了删除');
          this.deleteItem();
        }
      }
    });
  })

参数说明

  • title:标题
  • message:内容
  • primaryButton:主按钮(通常是"确认")
  • secondaryButton:次按钮(通常是"取消")
  • autoCancel:点击遮罩是否关闭,默认 true
1.2 多按钮弹窗

如果有两个以上的按钮,可以用 buttons 数组:

AlertDialog.show({
  title: '选择操作',
  message: '请选择你要进行的操作',
  buttons: [
    {
      value: '分享',
      action: () => { this.share(); }
    },
    {
      value: '收藏',
      action: () => { this.favorite(); }
    },
    {
      value: '取消',
      action: () => {}
    }
  ]
});
1.3 实战:删除收藏确认

「民族图鉴」收藏页,点击删除时弹出确认框:

// CollectionPage.ets
private confirmDelete(ethnicId: string): void {
  AlertDialog.show({
    title: this.isChinese() ? '确认删除' : 'Confirm Delete',
    message: this.isChinese() ? '确定要删除这个收藏吗?' : 'Are you sure to remove this favorite?',
    primaryButton: {
      value: this.isChinese() ? '取消' : 'Cancel',
      action: () => {
        console.info('取消删除');
      }
    },
    secondaryButton: {
      value: this.isChinese() ? '删除' : 'Delete',
      action: () => {
        this.deleteFavorite(ethnicId);
      }
    }
  });
}

private async deleteFavorite(ethnicId: string): Promise<void> {
  try {
    const storage = StorageService.getInstance();
    await storage.removeFavoriteEthnic(ethnicId);
    this.favoriteEthnics = this.favoriteEthnics.filter(id => id !== ethnicId);
    // 显示成功提示
    promptAction.showToast({
      message: this.isChinese() ? '已删除' : 'Removed',
      duration: 1500
    });
  } catch (e) {
    console.error('[CollectionPage] delete failed:', JSON.stringify(e));
  }
}
1.4 AlertDialog 的优缺点

优点

  • ✅ 用法简单,一行代码搞定
  • ✅ 系统样式,统一规范
  • ✅ 自动处理遮罩、动画

缺点

  • ❌ 样式固定,自定义程度低
  • ❌ 内容只能是文字,不能放图片、输入框等
  • ❌ 位置固定在中间,不能改

适用场景:简单的确认、提示、选择。


步骤2:CustomDialog——自定义弹窗

当 AlertDialog 满足不了需求时(比如要放图片、输入框、列表等复杂内容),就需要 CustomDialog。

2.1 基本用法

CustomDialog 需要用 @CustomDialog 装饰器定义一个组件,然后用 controller 控制显示隐藏。

// 定义自定义弹窗
@CustomDialog
struct MyCustomDialog {
  controller: CustomDialogController;
  @Prop title: string = '';

  build() {
    Column({ space: 16 }) {
      Text(this.title)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)

      Text('这是自定义弹窗的内容')
        .fontSize(14)

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

// 使用
@Entry
@Component
struct DemoPage {
  dialogController: CustomDialogController = new CustomDialogController({
    builder: MyCustomDialog({ title: '标题' }),
    alignment: DialogAlignment.Center,
    customStyle: true
  });

  build() {
    Button('显示弹窗')
      .onClick(() => {
        this.dialogController.open();
      })
  }
}

关键参数

  • builder:弹窗内容组件
  • alignment:弹窗对齐方式(Center / Top / Bottom 等)
  • customStyle:是否使用自定义样式(true 才会应用你写的背景、圆角等)
  • autoCancel:点击遮罩是否关闭,默认 true
  • offset:偏移量
2.2 弹窗与父组件通信

弹窗经常需要和父组件通信——比如弹窗里输入了内容,要传给父组件;或者父组件要刷新弹窗里的数据。

方式1:通过 Props 传值 + 回调

@CustomDialog
struct ShareDialog {
  controller: CustomDialogController;
  @Prop ethnic: EthnicGroup | null = null;
  @Prop onShare?: (platform: string, ethnic: EthnicGroup) => void;

  build() {
    Column({ space: 16 }) {
      Text('分享到')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)

      Row({ space: 24 }) {
        this.buildShareItem('\u{1F4AC}', '微信', 'wechat')
        this.buildShareItem('\u{1F4F7}', '朋友圈', 'moments')
        this.buildShareItem('\u{1F4E7}', '微博', 'weibo')
        this.buildShareItem('\u{1F4CB}', '复制链接', 'copy')
      }

      Button('取消')
        .width('100%')
        .type(ButtonType.Normal)
        .backgroundColor('#F5F5F5')
        .fontColor('#666666')
        .onClick(() => {
          this.controller.close();
        })
    }
    .width(300)
    .padding(20)
    .backgroundColor($r('app.color.card_background'))
    .borderRadius(16)
  }

  @Builder
  buildShareItem(icon: string, name: string, platform: string) {
    Column({ space: 8 }) {
      Text(icon)
        .fontSize(32)
        .width(56)
        .height(56)
        .textAlign(TextAlign.Center)
        .backgroundColor('#F5F5F5')
        .borderRadius(28)

      Text(name)
        .fontSize(12)
        .fontColor($r('app.color.text_secondary'))
    }
    .onClick(() => {
      if (this.onShare && this.ethnic) {
        this.onShare(platform, this.ethnic);
      }
      this.controller.close();
    })
  }
}

使用:

// 父组件
shareDialogController: CustomDialogController = new CustomDialogController({
  builder: ShareDialog({
    ethnic: null,
    onShare: (platform: string, ethnic: EthnicGroup) => {
      this.handleShare(platform, ethnic);
    }
  }),
  alignment: DialogAlignment.Center,
  customStyle: true
});

// 打开时传值
private openShareDialog(ethnic: EthnicGroup): void {
  // 注意:CustomDialog 的 props 要在 open 前更新
  this.shareDialogController.close();
  this.shareDialogController = new CustomDialogController({
    builder: ShareDialog({
      ethnic: ethnic,
      onShare: (platform: string, e: EthnicGroup) => {
        this.handleShare(platform, e);
      }
    }),
    alignment: DialogAlignment.Center,
    customStyle: true,
    autoCancel: true
  });
  this.shareDialogController.open();
}
2.3 实战:民族分享弹窗

「民族图鉴」详情页的分享弹窗,支持分享到多个平台:

@CustomDialog
export struct EthnicShareDialog {
  controller: CustomDialogController;
  @Prop ethnic: EthnicGroup | null = null;
  @Prop onShare?: (platform: string) => void;

  private platforms: Array<SharePlatform> = [
    { icon: '\u{1F4AC}', name: '微信', key: 'wechat', color: '#07C160' },
    { icon: '\u{1F4F7}', name: '朋友圈', key: 'moments', color: '#07C160' },
    { icon: '\u{1F4E7}', name: '微博', key: 'weibo', color: '#E6162D' },
    { icon: '\u{1F4CB}', name: '复制', key: 'copy', color: '#666666' }
  ];

  build() {
    Column({ space: 20 }) {
      // 标题
      Text('分享民族')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.text_primary'))

      // 民族信息
      if (this.ethnic) {
        Row({ space: 12 }) {
          Text(this.ethnic.name.charAt(0))
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .width(48)
            .height(48)
            .borderRadius(24)
            .backgroundColor(this.ethnic.emblemColor)
            .textAlign(TextAlign.Center)

          Column({ space: 4 }) {
            Text(this.ethnic.name)
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor($r('app.color.text_primary'))

            Text(this.ethnic.region + ' · ' + this.ethnic.population)
              .fontSize(13)
              .fontColor($r('app.color.text_secondary'))
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .padding(12)
        .backgroundColor('#F8F8F8')
        .borderRadius(12)
      }

      // 分享平台
      Row() {
        ForEach(this.platforms, (platform: SharePlatform) => {
          Column({ space: 8 }) {
            Text(platform.icon)
              .fontSize(28)
              .width(52)
              .height(52)
              .textAlign(TextAlign.Center)
              .backgroundColor('#F5F5F5')
              .borderRadius(26)

            Text(platform.name)
              .fontSize(12)
              .fontColor($r('app.color.text_secondary'))
          }
          .layoutWeight(1)
          .onClick(() => {
            if (this.onShare) {
              this.onShare(platform.key);
            }
            this.controller.close();
          })
        }, (p: SharePlatform) => p.key)
      }
      .width('100%')

      // 取消按钮
      Button('取消')
        .width('100%')
        .height(44)
        .type(ButtonType.Normal)
        .backgroundColor('#F5F5F5')
        .fontColor($r('app.color.text_secondary'))
        .borderRadius(22)
        .onClick(() => {
          this.controller.close();
        })
    }
    .width(320)
    .padding(20)
    .backgroundColor($r('app.color.card_background'))
    .borderRadius(20)
    .transition({ type: TransitionType.All, opacity: 0, scale: { x: 0.9, y: 0.9 } })
  }
}

interface SharePlatform {
  icon: string;
  name: string;
  key: string;
  color: string;
}
2.4 CustomDialog 的动画

CustomDialog 默认有淡入淡出的动画。如果想要自定义动画,可以用 transition:

@CustomDialog
struct AnimatedDialog {
  controller: CustomDialogController;

  build() {
    Column() {
      // 内容...
    }
    .width(280)
    .padding(20)
    .backgroundColor(Color.White)
    .borderRadius(12)
    // 自定义转场动画
    .transition({
      type: TransitionType.All,
      opacity: 0,
      translate: { y: 50 },
      scale: { x: 0.9, y: 0.9 }
    })
  }
}

但注意:CustomDialog 的动画支持有限,特别复杂的动画可能实现不了。如果需要完全自定义的动画,可以考虑用 Stack + 状态变量自己实现弹窗。


步骤3:ActionSheet——底部操作面板

ActionSheet 是从底部弹出的操作面板,通常用于展示多个操作选项。

3.1 基本用法
Button('显示操作面板')
  .onClick(() => {
    ActionSheet.show({
      title: '选择操作',
      message: '请选择你要进行的操作',
      confirm: {
        value: '取消',
        action: () => {
          console.info('点击了取消');
        }
      },
      sheets: [
        {
          title: '分享',
          action: () => { console.info('分享'); }
        },
        {
          title: '收藏',
          action: () => { console.info('收藏'); }
        },
        {
          title: '举报',
          action: () => { console.info('举报'); }
        }
      ]
    });
  })

参数说明

  • title:标题
  • message:副标题/说明
  • confirm:取消按钮
  • sheets:操作项数组
3.2 实战:民族详情页更多操作

「民族图鉴」详情页右上角的"更多"按钮,点击弹出操作面板:

// EthnicDetailPage.ets
private showMoreActions(): void {
  const sheets: Array<SheetInfo> = [
    {
      title: this.isChinese() ? '分享民族' : 'Share',
      action: () => {
        this.openShareDialog();
      }
    },
    {
      title: this.isFavorite
        ? (this.isChinese() ? '取消收藏' : 'Unfavorite')
        : (this.isChinese() ? '添加收藏' : 'Add to favorites'),
      action: () => {
        this.toggleFavorite();
      }
    },
    {
      title: this.isChinese() ? '复制名称' : 'Copy name',
      action: () => {
        this.copyEthnicName();
      }
    },
    {
      title: this.isChinese() ? '反馈问题' : 'Feedback',
      action: () => {
        router.pushUrl({ url: 'pages/FeedbackPage' });
      }
    }
  ];

  ActionSheet.show({
    title: this.isChinese() ? '更多操作' : 'More Actions',
    confirm: {
      value: this.isChinese() ? '取消' : 'Cancel',
      action: () => {}
    },
    sheets: sheets
  });
}

private copyEthnicName(): void {
  if (!this.ethnic) return;
  
  pasteboard.getSystemPasteboard().setData(
    pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.ethnic.name)
  );
  
  promptAction.showToast({
    message: this.isChinese() ? '已复制到剪贴板' : 'Copied to clipboard',
    duration: 1500
  });
}
3.3 ActionSheet 的优缺点

优点

  • ✅ 用法简单
  • ✅ 系统样式,符合用户习惯
  • ✅ 从底部弹出,符合移动端交互习惯

缺点

  • ❌ 样式固定,只能改文字
  • ❌ 不能放图片、图标等复杂内容
  • ❌ 操作项不能太多,太多了会滚动

适用场景:简单的操作列表、分享选项、更多操作。


步骤4:Toast——轻提示

Toast 是最轻量的提示方式——出现一小段时间,自动消失,不打断用户操作。

4.1 基本用法
import { promptAction } from '@kit.ArkUI';

// 简单用法
promptAction.showToast({
  message: '操作成功',
  duration: 1500
});

参数说明

  • message:提示文字
  • duration:显示时长(毫秒),默认 1500
  • bottom:距离底部的距离
4.2 使用场景

Toast 适合用在这些场景:

场景 示例
操作成功 “收藏成功”、“已删除”、“已复制”
操作失败 “网络错误,请重试”、“收藏失败”
轻量提示 “已加入浏览历史”
状态反馈 “正在加载…”

不适合用 Toast 的场景:

  • ❌ 重要的错误信息(用户可能没看到)
  • ❌ 需要用户操作的提示(用户不能点)
  • ❌ 很长的文字(显示不完)
4.3 最佳实践

1. 时长要合适

// 短提示:1500ms
promptAction.showToast({ message: '成功', duration: 1500 });

// 长一点的提示:2500ms
promptAction.showToast({ message: '已复制到剪贴板', duration: 2500 });

不要太长(超过 3 秒用户会烦),也不要太短(小于 1 秒用户看不到)。

2. 文字要简洁

Toast 空间有限,文字要短。

// ✅ 好:简洁
'收藏成功'
'网络错误'
'已复制'

// ❌ 不好:太长
'你已经成功地将这个民族添加到了你的收藏列表中'

3. 不要频繁弹

短时间内弹多个 Toast,后面的会把前面的顶掉,用户一个都看不全。

// ❌ 不好:连续弹两个
promptAction.showToast({ message: '第一个' });
promptAction.showToast({ message: '第二个' }); // 第一个还没显示完就被顶掉了

如果有多个提示,合并成一个,或者用其他方式展示。


步骤5:自定义底部弹窗(BottomSheet)

ActionSheet 太简单,放不下复杂内容(比如筛选条件、评论列表)。这时候需要自定义底部弹窗。

可以用 Stack + 状态变量自己实现,也可以用 bindSheet。

5.1 用 bindSheet 实现

ArkUI 提供了 bindSheet 方法,可以绑定一个底部抽屉:

@State showBottomSheet: boolean = false;
@State sheetHeight: number = 400;

build() {
  Column() {
    Button('打开筛选')
      .onClick(() => {
        this.showBottomSheet = true;
      })
  }
  .width('100%')
  .height('100%')
  .bindSheet($$this.showBottomSheet, this.buildSheetContent(), {
    height: this.sheetHeight,
    dragBar: true,
    showClose: false,
    type: SheetType.CENTER,
    onDisappear: () => {
      console.info('弹窗关闭了');
    }
  })
}

@Builder
buildSheetContent() {
  Column({ space: 16 }) {
    Text('筛选条件')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .width('100%')
      .textAlign(TextAlign.Center)

    // 筛选内容...
    Text('这里是筛选内容')
      .fontSize(14)

    Button('确定')
      .width('100%')
      .onClick(() => {
        this.showBottomSheet = false;
      })
  }
  .width('100%')
  .padding(20)
}

bindSheet 参数

  • height:抽屉高度
  • dragBar:是否显示顶部拖拽条
  • showClose:是否显示关闭按钮
  • type:抽屉类型(CENTER / BOTTOM)
  • preferType:优先类型
  • detents:停靠点(比如半屏、全屏)
  • onDisappear:消失回调
5.2 实战:民族筛选弹窗

「民族图鉴」列表页的筛选弹窗,支持按地区、人口等条件筛选:

@Component
struct FilterBottomSheet {
  @Link isShow: boolean;
  @State selectedRegion: string = 'all';
  @State sortBy: string = 'default';
  @Prop onConfirm?: (filters: FilterOptions) => void;

  private regionOptions: Array<OptionItem> = [
    { key: 'all', label: '全部地区' },
    { key: 'southwest', label: '西南' },
    { key: 'north', label: '北方' },
    { key: 'northeast', label: '东北' },
    { key: 'southeast', label: '东南' },
    { key: 'northwest', label: '西北' }
  ];

  private sortOptions: Array<OptionItem> = [
    { key: 'default', label: '默认排序' },
    { key: 'population_desc', label: '人口从多到少' },
    { key: 'population_asc', label: '人口从少到多' },
    { key: 'name', label: '按名称拼音' }
  ];

  build() {
    Column({ space: 20 }) {
      // 标题栏
      Row() {
        Text('取消')
          .fontSize(16)
          .fontColor($r('app.color.text_secondary'))
          .onClick(() => {
            this.isShow = false;
          })

        Text('筛选')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        Text('确定')
          .fontSize(16)
          .fontColor($r('app.color.primary_color'))
          .fontWeight(FontWeight.Medium)
          .onClick(() => {
            this.confirm();
          })
      }
      .width('100%')

      // 地区筛选
      Column({ space: 12 }) {
        Text('地区')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .width('100%')

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(this.regionOptions, (option: OptionItem) => {
            Text(option.label)
              .fontSize(14)
              .fontColor(this.selectedRegion === option.key
                ? $r('app.color.primary_color')
                : $r('app.color.text_secondary'))
              .padding({ left: 16, right: 16, top: 8, bottom: 8 })
              .margin(4)
              .borderRadius(16)
              .backgroundColor(this.selectedRegion === option.key
                ? '#E8F3FF'
                : '#F5F5F5')
              .onClick(() => {
                this.selectedRegion = option.key;
              })
          }, (o: OptionItem) => o.key)
        }
      }
      .width('100%')

      // 排序方式
      Column({ space: 12 }) {
        Text('排序')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .width('100%')

        Column({ space: 0 }) {
          ForEach(this.sortOptions, (option: OptionItem, index: number) => {
            Row() {
              Text(option.label)
                .fontSize(15)
                .fontColor($r('app.color.text_primary'))
                .layoutWeight(1)

              if (this.sortBy === option.key) {
                Text('\u2713')
                  .fontSize(16)
                  .fontColor($r('app.color.primary_color'))
              }
            }
            .width('100%')
            .height(48)
            .onClick(() => {
              this.sortBy = option.key;
            })

            if (index < this.sortOptions.length - 1) {
              Divider()
            }
          }, (o: OptionItem) => o.key)
        }
        .width('100%')
        .backgroundColor('#F8F8F8')
        .borderRadius(12)
      }
      .width('100%')

      Blank()

      // 重置按钮
      Row({ space: 12 }) {
        Button('重置')
          .layoutWeight(1)
          .height(44)
          .type(ButtonType.Normal)
          .backgroundColor('#F5F5F5')
          .fontColor($r('app.color.text_secondary'))
          .borderRadius(22)
          .onClick(() => {
            this.reset();
          })

        Button('确定')
          .layoutWeight(2)
          .height(44)
          .type(ButtonType.Normal)
          .backgroundColor($r('app.color.primary_color'))
          .fontColor('#FFFFFF')
          .borderRadius(22)
          .onClick(() => {
            this.confirm();
          })
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }

  private reset(): void {
    this.selectedRegion = 'all';
    this.sortBy = 'default';
  }

  private confirm(): void {
    if (this.onConfirm) {
      this.onConfirm({
        region: this.selectedRegion,
        sortBy: this.sortBy
      });
    }
    this.isShow = false;
  }
}

interface OptionItem {
  key: string;
  label: string;
}

interface FilterOptions {
  region: string;
  sortBy: string;
}

步骤6:弹窗的栈管理

当页面上有多个弹窗时,怎么管理?比如点击分享弹窗里的某个按钮,又弹出一个确认框——这时候两个弹窗叠在一起了。

6.1 为什么需要栈管理?

如果不管控,会出现这些问题:

  1. 弹窗叠罗汉:一个叠一个,用户不知道关哪个
  2. 遮罩穿透:下面的弹窗的遮罩可能挡住上面的
  3. 状态混乱:关了一个,另一个的状态不对
6.2 简单的栈管理思路

可以自己实现一个简单的弹窗栈:

// utils/DialogStack.ets
class DialogStack {
  private stack: Array<DialogInfo> = [];

  push(dialog: DialogInfo): void {
    this.stack.push(dialog);
  }

  pop(): DialogInfo | undefined {
    return this.stack.pop();
  }

  get top(): DialogInfo | undefined {
    return this.stack[this.stack.length - 1];
  }

  get size(): number {
    return this.stack.length;
  }

  clear(): void {
    while (this.stack.length > 0) {
      const dialog = this.stack.pop();
      dialog?.close();
    }
  }
}

interface DialogInfo {
  id: string;
  close: () => void;
}

export const dialogStack = new DialogStack();

使用:

// 打开弹窗前入栈
openDialog() {
  const controller = new CustomDialogController({
    builder: MyDialog({
      onClose: () => {
        dialogStack.pop();
      }
    }),
    alignment: DialogAlignment.Center,
    customStyle: true
  });

  dialogStack.push({
    id: 'my_dialog',
    close: () => controller.close()
  });

  controller.open();
}
6.3 最佳实践

1. 同一时间只显示一个弹窗

尽量不要让弹窗叠弹窗。如果第二个弹窗是从第一个弹窗里触发的,先关掉第一个,再显示第二个。

// ✅ 好:先关第一个,再开第二个
shareDialogController.close();
setTimeout(() => {
  confirmDialogController.open();
}, 200);

2. 重要的弹窗放上面

如果一定要叠,重要的(比如确认框)放上面,不重要的(比如分享弹窗)放下面。

3. 页面离开时清空所有弹窗

aboutToDisappear(): void {
  // 页面销毁时,关掉所有弹窗
  dialogStack.clear();
}

不然用户跳走了,弹窗还在,回来就尴尬了。


步骤7:弹窗的动画与转场

好的弹窗动画,能让弹窗体验提升一个档次。

7.1 常见的弹窗动画
动画类型 效果 适用场景
淡入淡出 透明度从 0 到 1 Alert、Toast
缩放 从 0.9 缩放到 1 居中弹窗
从底部滑入 从下方滑上来 ActionSheet、BottomSheet
从顶部滑入 从上方滑下来 顶部通知、下拉菜单
从左/右滑入 从侧边滑进来 侧边栏、抽屉
7.2 自定义弹窗的转场

用 CustomDialog + transition 可以实现自定义转场:

@CustomDialog
struct SlideUpDialog {
  controller: CustomDialogController;

  build() {
    Column() {
      Text('从底部滑上来的弹窗')
        .fontSize(16)
    }
    .width('100%')
    .height(300)
    .padding(20)
    .backgroundColor($r('app.color.card_background'))
    .borderRadius({ topLeft: 20, topRight: 20 })
    .transition({
      type: TransitionType.All,
      translate: { y: 300 },  // 从下方 300vp 处滑上来
      opacity: 0
    })
  }
}
7.3 用 Stack 实现完全自定义的弹窗

如果 CustomDialog 的动画满足不了需求,可以用 Stack + 状态变量自己实现:

@Component
struct FullyCustomDialog {
  @State isShow: boolean = false;
  @State dialogOpacity: number = 0;
  @State dialogOffsetY: number = 100;

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      // 页面内容
      Column() {
        Button('显示弹窗')
          .onClick(() => {
            this.show();
          })
      }
      .width('100%')
      .height('100%')

      // 遮罩
      if (this.isShow) {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('#000000')
          .opacity(this.dialogOpacity)
          .onClick(() => {
            this.hide();
          })
      }

      // 弹窗内容
      if (this.isShow) {
        Column() {
          Text('自定义弹窗')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
        }
        .width('100%')
        .height(400)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius({ topLeft: 20, topRight: 20 })
        .translate({ y: this.dialogOffsetY })
      }
    }
    .width('100%')
    .height('100%')
  }

  show(): void {
    this.isShow = true;
    this.dialogOffsetY = 400;
    this.dialogOpacity = 0;

    animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
      this.dialogOffsetY = 0;
      this.dialogOpacity = 0.5;
    });
  }

  hide(): void {
    animateTo({
      duration: 250,
      curve: Curve.EaseIn,
      onFinish: () => {
        this.isShow = false;
      }
    }, () => {
      this.dialogOffsetY = 400;
      this.dialogOpacity = 0;
    });
  }
}

这种方式虽然代码多一点,但完全可控——动画、交互、样式,想怎么改就怎么改。


⚠️ 常见问题与解决方案

问题1:弹窗穿透,点遮罩能点到下面的按钮

现象
弹窗弹出来了,但点击弹窗外面的区域,居然能触发下面页面的按钮点击。

原因
弹窗的遮罩层没有拦截点击事件,或者遮罩层不是全屏的。

解决方案

方案1:确保遮罩是全屏的

如果是自己用 Stack 实现的弹窗,确保遮罩层宽高都是 100%:

// 遮罩层
Column()
  .width('100%')
  .height('100%')  // 一定要 100%
  .backgroundColor('#000000')
  .opacity(0.5)
  .onClick(() => {
    // 点击遮罩关闭弹窗
    this.hide();
  })

方案2:CustomDialog 用 autoCancel

CustomDialog 默认就有遮罩,会拦截点击。确保 autoCancel 的行为符合预期。

方案3:用 bindSheet

bindSheet 是系统级的,不会有穿透问题。


问题2:键盘弹起来挡住弹窗内容

现象
弹窗里有输入框,键盘弹起来后,输入框被挡住了,用户看不到自己输入的内容。

解决方案

方案1:让弹窗上移

监听键盘高度,键盘弹起时,把弹窗往上移。

@State keyboardHeight: number = 0;

// 监听键盘高度变化
// 可以用 @ohos.inputMethod 或者系统 API

build() {
  Column() {
    // 弹窗内容
  }
  .translate({ y: -this.keyboardHeight / 2 })  // 键盘弹起时上移
}

方案2:弹窗内容可滚动

把弹窗内容放在 Scroll 里,键盘弹起时用户可以滚动查看。

Column() {
  Scroll() {
    Column({ space: 12 }) {
      // 表单内容...
    }
  }
  .layoutWeight(1)

  // 底部按钮
  Button('确定')
    .width('100%')
}
.height(400)

方案3:弹窗改成全屏

如果表单内容比较多,干脆做成全屏的页面,而不是弹窗。


问题3:弹窗关闭后状态丢失

现象
弹窗里填了一半内容,不小心点了遮罩关掉了,再打开内容没了。

解决方案

方案1:状态提到父组件

把弹窗的状态放在父组件里,而不是弹窗内部。这样弹窗关了,状态还在。

// 父组件
@State formData: FormData = { name: '', region: '' };

// 弹窗里用 @Link 或者 @Prop 引用父组件的状态
@CustomDialog
struct FormDialog {
  controller: CustomDialogController;
  @Link formData: FormData;  // 用 @Link 双向绑定
  // ...
}

方案2:关闭前确认

如果用户有未保存的内容,关闭弹窗时提示确认。

// 点击遮罩时不自动关闭,而是询问
autoCancel: false

// 关闭按钮点击时判断
close() {
  if (this.hasUnsavedChanges()) {
    AlertDialog.show({
      title: '提示',
      message: '有未保存的内容,确定关闭吗?',
      confirm: { value: '确定', action: () => this.controller.close() },
      cancel: { value: '取消', action: () => {} }
    });
  } else {
    this.controller.close();
  }
}

问题4:多个弹窗管理混乱

现象

  • 页面上弹了好几个弹窗,不知道哪个在前哪个在后
  • 关了一个,另一个的状态不对
  • 页面跳走了,弹窗还在

解决方案

1. 建立弹窗栈

前面讲的 DialogStack,统一管理所有弹窗。

2. 同一时间只显示一个

尽量避免弹窗叠弹窗。第二个弹窗弹出来之前,先把第一个关掉。

3. 页面销毁时清空

aboutToDisappear(): void {
  // 关掉所有弹窗
  this.shareDialogController.close();
  this.filterDialogController.close();
  // ...
}

4. 用路由守卫

如果用了路由,在路由跳转前检查有没有弹窗,有关掉再跳。


问题5:弹窗动画不流畅

现象
弹窗出现/消失时卡顿,或者动画很生硬。

常见原因及优化

原因1:弹窗内容太复杂

弹窗一打开就要渲染一堆东西,当然卡。

优化

  • 弹窗内容尽量简单
  • 图片懒加载,弹窗打开后再加载
  • 复杂的内容延后渲染

原因2:动画属性选得不好

改 width、height 这些影响布局的属性,性能差。

优化

  • 用 translate 代替 position/top/left
  • 用 scale 代替 width/height
  • 用 opacity 做淡入淡出

原因3:同时动画的元素太多

弹窗里每个元素都有入场动画,同时开始,一帧要算很多东西。

优化

  • 减少同时动画的元素
  • 用错峰动画(stagger),一个接一个来
  • 不重要的元素直接显示,不用动画

📝 本章小结

核心知识点

本文从弹窗的分类讲起,系统介绍了 ArkUI 中各种弹窗的使用:

1. 弹窗的分类

  • AlertDialog:简单确认提示
  • CustomDialog:完全自定义
  • ActionSheet:底部操作面板
  • Toast:轻量自动提示
  • BottomSheet / bindSheet:底部抽屉
  • Popup / bindPopup:气泡提示

2. AlertDialog

  • 简单的确认/提示弹窗
  • 标题 + 内容 + 按钮
  • 样式固定,适合简单场景

3. CustomDialog

  • 用 @CustomDialog 定义
  • controller 控制 open/close
  • 可以传 props、回调
  • 支持自定义样式和动画

4. ActionSheet

  • 从底部弹出的操作列表
  • 适合"更多操作"、"分享"等场景
  • 样式固定,内容只能是文字

5. Toast

  • 最轻量的提示
  • 自动消失,不打断操作
  • 适合成功、失败等轻量反馈

6. 底部弹窗(BottomSheet)

  • 用 bindSheet 或自定义实现
  • 适合筛选、评论等内容较多的场景
  • 可以拖拽、调整高度

7. 弹窗管理

  • 弹窗栈:统一管理多个弹窗
  • 页面销毁时清空
  • 尽量不要弹窗叠弹窗

最佳实践总结

选对弹窗类型

简单确认 → AlertDialog
操作列表 → ActionSheet
轻量提示 → Toast
复杂内容 → BottomSheet / bindSheet
完全自定义 → CustomDialog / 自己实现

弹窗要克制,不要滥用

用户打开 App 是来用的,不是来关弹窗的。
一个页面最多一个弹窗,多了就是设计问题。
广告、推广不要用弹窗,会被用户骂死。

状态放父组件,避免丢失

弹窗里的表单数据,放在父组件里。
弹窗关了,数据还在。
不然用户填了一半不小心关掉,会很崩溃。

动画要自然,不要花哨

居中弹窗:淡入 + 缩放
底部弹窗:从下往上滑
顶部弹窗:从上往下滑
时长 200-300ms,EaseOut 曲线
不要搞花里胡哨的旋转、弹跳。

键盘弹出要处理

弹窗里有输入框?
要么内容可滚动,要么弹窗上移。
不然键盘挡住输入框,用户都看不到自己在打什么。

页面走时清干净

页面跳转、销毁前,把所有弹窗都关掉。
不然用户回来看到一个飘着的弹窗,会很懵。

下一步预告

在下一篇文章中,我们将:

  • 🔄 学习列表交互的完整形态:下拉刷新 + 上拉加载 + 空状态 + 错误状态
  • 📥 掌握 Refresh 组件的使用与自定义
  • 📤 理解上拉加载更多的实现原理
  • 📊 学会列表状态管理(加载中、加载失败、没有更多、全部加载完)
  • 💀 实现骨架屏(Skeleton)的加载效果
  • 🚀 用「民族列表的下拉刷新与分页加载」实战,带你掌握列表交互的精髓

🔗 相关链接


💡 提示:弹窗是一个"分寸感"很强的东西——用得好,能引导用户、提升体验;用不好,就是骚扰。最好的学习方法就是多玩优秀的 App,看它们什么时候弹、弹什么、怎么弹、怎么关。看得多了,你自然就知道"这个场景该不该用弹窗"、“用哪种弹窗最合适”。技术好学,分寸难拿捏。

Logo

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

更多推荐