HarmonyOS应用《民族图鉴》开发第50篇:弹窗与对话框——AlertDialog/CustomDialog/ActionSheet/Toast全解析

📖 引言
弹窗是移动应用中最常见的交互模式之一。
从最简单的"确认删除"提示,到复杂的分享面板、筛选弹窗、底部操作菜单……弹窗无处不在。它就像应用的"对话框"——需要用户做决策、确认信息、选择选项时,就弹出一个浮层,用户处理完就关掉。
但弹窗用不好,也会变成"弹窗地狱"——一打开 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 |
什么时候该用弹窗?
弹窗是"打断式"的——它会打断用户当前的操作。所以不要滥用。
该用弹窗的场景:
- 需要用户确认的危险操作:删除、清空、退出
- 重要的信息告知:版本更新、权限说明
- 需要用户选择的操作:分享到哪、用什么方式打开
- 轻量的信息录入:输入手机号、简单表单
不该用弹窗的场景:
- 广告、推广:用户最烦的就是这个
- 非紧急的通知:用列表/消息中心就行
- 复杂的表单:应该新开页面
- 频繁出现的提示:用 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:点击遮罩是否关闭,默认 trueoffset:偏移量
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:显示时长(毫秒),默认 1500bottom:距离底部的距离
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 为什么需要栈管理?
如果不管控,会出现这些问题:
- 弹窗叠罗汉:一个叠一个,用户不知道关哪个
- 遮罩穿透:下面的弹窗的遮罩可能挡住上面的
- 状态混乱:关了一个,另一个的状态不对
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)的加载效果
- 🚀 用「民族列表的下拉刷新与分页加载」实战,带你掌握列表交互的精髓
🔗 相关链接
- 项目源码: GitCode 仓库
- AlertDialog: 官方文档
- CustomDialog: 官方文档
- ActionSheet: 官方文档
- bindSheet: 官方文档
- Toast / promptAction: 官方文档
💡 提示:弹窗是一个"分寸感"很强的东西——用得好,能引导用户、提升体验;用不好,就是骚扰。最好的学习方法就是多玩优秀的 App,看它们什么时候弹、弹什么、怎么弹、怎么关。看得多了,你自然就知道"这个场景该不该用弹窗"、“用哪种弹窗最合适”。技术好学,分寸难拿捏。
更多推荐



所有评论(0)