这个月在做商城详情页时,产品提了个需求:长按商品卡要弹出一个规格浮层,左边放主图、右边放可滚动规格项,底下还要跟一个备注输入框。我习惯性地想去翻系统弹窗,试了一圈发现内置AlertDialog只能承载标题、文本和按钮,做完的感觉就是“能用,但很委屈”。后来我把目光转向了鸿蒙自定义弹窗——基于CustomDialogController的完整弹窗方案,不仅长相全由自己控制,布局、动效、数据回传也能按业务来设计。这篇文章就是这段时间项目的实操总结。
我会从系统弹窗的边界、弹窗背后的UI机制、三种高频弹窗形态的实现,到项目里反复踩到的状态同步和重复打开崩溃问题,完整讲清楚。读者对象是正在学习HarmonyOS NEXT或ArkUI的开发者,尤其适合那些已经开始写业务页面、对“如何优雅地做弹窗”有真实需求的同学。
1. 先看清边界:系统弹窗搞不定的场景,才是自定义弹窗的战场
1.1 系统内置弹窗究竟能做什么
先说结论,系统自带的那几个弹窗接口不是没用,而是适用面非常固定。它们适合的是“打断式确认”和“简单提示”。
- 退出登录时的二次确认
- 删除数据前的风险提醒
- 展示接口返回的错误文案
- 升级提示、公告通知这类纯文本场景
这种弹窗有一个共同特点:内容静态、结构简单、不需要承载太多用户操作。你只需要告诉用户“发生了什么,你要选哪个”,系统弹窗就能胜任。它们可以提供标题、正文、一到三个按钮,开发者能做的基本只是改文案和按钮点击回调。
如果业务刚好在这些场景里,我不建议你强行上自定义弹窗。原因很简单:系统弹窗实现成本几乎为零,代码也短,而且它内部处理了遮罩、点击外部关闭、返回键逻辑和基础转场动画。自己写一套反而要操心用户体验的一致性,并不划算。
1.2 哪三类需求会让系统弹窗“漏底”
当业务需求开始进入下面三类,系统弹窗就不够用了。
第一类是内容复杂度超过“文本+按钮”的弹窗。比如商品规格选择,弹窗内需要图片、价格、库存标签、Radio选项、上下滚动区域,还可能有多级联动。你可以试着用系统弹窗的customBuilder去塞自定义内容,但你会发现样式约束、尺寸适配都会很别扭,内容一多,屏幕适配和滚动点击问题就全冒出来了。
第二类是需要在弹窗内完成完整交互流程的需求。例如订单备注填写弹窗,用户要输入文字、选择快捷短语、勾选隐私授权,再点提交。系统弹窗的输入体验很弱,回调语义也不够丰富。你要拿它做表单,开发者需要绕很多路。
第三类是用弹窗承载“页面级功能”的需求。最典型的是付款前安全验证、地址选择器、时间范围筛选这类。它们虽然形态上是个浮层,但实际上是一次独立操作流程,有完整的UI布局、数据加载、状态校验和结果回传。这种弹窗用系统接口实现,会把你逼进死角。
1.3 业务侧的一个典型失败案例
我最初接到的规格浮层需求,第一版就用AlertDialog.show的思路做的。具体来说,是把一个自绘的Column强行塞进系统弹窗的content扩展区。
第一轮跑通看起来没什么大碍,但到了真机测试问题就来了:弹窗内规格超过8个以后内容会顶出屏幕边界,而且自己加的Scroll在弹窗环境里的滚动判定很怪。更要命的是弹窗内部状态一变,整个浮层会跟着父页面一起重绘,导致滚动位置丢、选中态闪烁。这些问题合在一起,倒逼我回到自定义弹窗方案。
所以我的真实体会是:如果预判弹窗内容会超过“100个字加两个按钮”,不如一开始就考虑自定义弹窗,别到联调阶段再推倒返工。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自定义弹窗不是“另一个页面”,而是一棵独立UI子树
2.1 CustomDialogController背后的执行逻辑
很多首次接触鸿蒙自定义弹窗的开发者,容易把它想象成“打开了一个新窗口”或“往页面上盖了一层自己做的透明遮罩”。这两种理解都不完全准确。
如果弹窗相当于新开了一个窗口,那弹窗打开后不应该还能看到原页面自然过渡的动画,而且系统开销也会很大。如果只是页面内的透明遮罩,那它应该严格受页面组件生命周期约束,父页面一刷新,遮罩内容也应该更新。但实际表现是:自定义弹窗的UI状态和主页面是分离的,主页面业务状态变化不会直接导致弹窗内部临时状态重置。
更接近真相的描述是:使用CustomDialogController.open()时,鸿蒙的ArkUI框架会创建一个与此弹窗控制器绑定的UI子树节点,并把“将弹窗组件渲染到界面上层”这件事交给框架处理。这个独立的节点有自己的状态管理上下文。它虽然在视觉上覆盖在当前页面之上,但在组件树中并不依附于页面内部的某个具体容器。
正因如此,弹窗内部才能做到自己的滚动列表、输入框焦点、选中状态都不被外部页面打扰。这让我在写规格选择浮层时,不用反复去考虑页面侧状态刷新会不会波及弹窗。
2.2 为什么不推荐用Stack加Visibility做伪弹窗
我知道有些开发者会自己做一个“伪弹窗”:在页面底部放一层半透明遮罩,中间放一个需要时显示、不需要时隐藏的Column,用状态控制Visibility来切换。
这个方案原理上说得通,也确实是很多前端同学从Web开发迁移过来后最容易想到的做法。但它有一些长期维护时的隐患。
第一,原页面组件复用了同一个build方法,每当你更新页面上任何一个状态,页面组件会重新走渲染流程。伪弹窗虽然视觉上没变化,但内部子组件可能被重建,输入框焦点、列表滚动位置这类“非状态信息”很容易丢。
第二,伪弹窗需要开发者自己处理事件穿透。遮罩层如果包在Stack里,点击遮罩却没有关闭弹窗,你会发现在遮罩后面的页面按钮照样能接收到点击。你只能额外加一个透明层拦截事件,层级关系会越写越绕。
第三,弹窗的进出场动画,以及点击返回键关闭,这类系统能力在伪弹窗里都要手动实现。
这些痛点刚好就是CustomDialogController帮你保证的基线能力。它本身自带遮罩和点击外部关闭策略,弹窗内容并不是页面build方法里的同一个分支,所以不会遇到上面说的重建问题。
2.3 一个帮助理解的设计对比
你可以把页面想象成一个主舞台,页面里的普通组件是舞台上的固定布景。调用系统弹窗时,相当于舞台右侧临时推上来一个统一规格的小箱子,箱子里的内容有限,但操作简单。
而自定义弹窗,相当于舞台工作人员临时在布景前方搭建了一块独立的可移动台子。这块台子是提前制作好的“预制模块”(对应@CustomDialog装饰的组件),台子上有什么、怎么互动、什么时候撤下去,全部由你说了算,并且它和背后的固定布景互不干扰。
一旦建立起这个模型,后面看代码、踩坑的思路都会顺很多。
3. 从零封装一个确认浮层:原理、代码与生命周期
3.1 声明一个最小可用的@CustomDialog组件
在ArkUI里写自定义弹窗,入口是一个特殊装饰器:@CustomDialog。它作用于struct,我们不能直接把这个struct当成普通页面组件放进某个Column里。它的使用方式非常固定:先声明组件结构,再创建控制器实例,最后通过控制器的open()和close()方法控制显隐。
下面这个组件就是一个基础的确认弹窗,代码适用于HarmonyOS NEXT的ArkTS开发环境。
arkts复制@CustomDialog
struct ConfirmDialog {
// 框架要求控制器对象必须存在,关闭弹窗时靠它
controller: CustomDialogController;
title: string = '提示';
content: string = '';
cancelText: string = '取消';
confirmText: string = '确定';
// 对外开放的回调,由页面侧决定按钮点击后的行为
cancelAction: () => void = () => {};
confirmAction: () => void = () => {};
build() {
Column() {
Text(this.title)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.margin({ top: 24 })
Text(this.content)
.fontSize(14)
.fontColor('#666666')
.textAlign(TextAlign.Center)
.margin({ top: 16, left: 20, right: 20 })
Row() {
Button(this.cancelText)
.layoutWeight(1)
.height(40)
.fontColor('#999999')
.backgroundColor('#F1F3F5')
.onClick(() => {
this.cancelAction();
this.controller.close();
})
Button(this.confirmText)
.layoutWeight(1)
.height(40)
.fontColor(Color.White)
.backgroundColor('#007DFF')
.margin({ left: 16 })
.onClick(() => {
this.confirmAction();
this.controller.close();
})
}
.padding({ left: 20, right: 20, top: 20, bottom: 20 })
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(16)
}
}
看到上面的代码,你可能会问:这个组件内的controller为什么不用@State修饰?因为它由框架在创建弹窗时自动注入,是控制器和弹窗组件通信的桥梁。你不应该试图给controller赋值,更不应该在页面里直接改这个引用。它更像遥控器上的信号接收器,弹窗内容只知道“收到指令要关闭”,但不关心遥控器具体是什么品牌。
另一个值得注意的点:弹窗组件的宽度,默认会由系统内容来决定。设置了.width('100%')之后,它实际会按弹窗默认的最大可用宽度来展示,而不是等于页面物理宽度。具体的表现与是否开启自定义样式有关,后面我会讲到。
3.2 在页面中创建控制器并打开弹窗
有了弹窗组件,下一步是在宿主页面中创建一个与之对应的控制器。控制器的创建方式不是写在某个事件回调里,而是在页面组件内部作为成员属性声明。
arkts复制@Entry
@Component
struct Index {
dialogController: CustomDialogController = new CustomDialogController({
builder: ConfirmDialog({
title: '删除确认',
content: '删除后无法恢复,确定要删除这条记录吗?',
cancelText: '再想想',
confirmText: '删除',
cancelAction: () => {
console.info('用户点击取消');
},
confirmAction: () => {
console.info('用户点击确认');
}
}),
alignment: DialogAlignment.Center,
autoCancel: true,
customStyle: false
});
build() {
Column() {
Button('打开确认弹窗')
.onClick(() => {
this.dialogController.open();
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
看到这里,很多同学第一反应是:为什么控制器不能写在onClick里,每次点击时新建一个?其实写在事件回调里也能运行,但除非你有特别理由,建议不要那样做。因为控制器的生命周期应该和页面组件保持一致,你把它挂到页面成员属性上,就不需要担心页面还没创建完就去打开弹窗,或者页面已经销毁但控制器还保留着引用这类低级错误。
DialogAlignment.Center是弹窗在屏幕上的对齐方式,取值还有Top、Bottom、Center等。如果要做底部抽屉,你需要用DialogAlignment.Bottom。
3.3 理解open和close背后的生命周期
调用open()之后,弹窗组件不会立刻把完整UI渲染出来。框架会经过创建、布局、动画、显示这样一个流程。在这些关键节点,弹窗组件内部可以覆写生命周期回调,常用的有aboutToAppear()、onDidAppear()、onDidDisappear()。
aboutToAppear()适合做弹窗打开前的数据初始化。比如弹窗需要根据id拉取详情,在请求接口前设置loading状态,放在这里比较合适。
onDidAppear()是弹窗已经完全可见、进场动画已经结束后触发。需要针对弹窗展示打点、或者对输入框做自动聚焦的地方,可以放在这里。
onDidDisappear()是弹窗关闭并离场后的最终回调。我通常会在这里做父页面数据刷新、收集关闭原因等操作。需要注意,这只是弹窗组件侧收到了关闭事件,它不一定代表用户点了确认按钮,也可能用户点了遮罩、按了返回键。具体触发源由业务区分。
3.4 确认按钮关闭弹窗的顺序问题
很多初学者容易犯一个错误:先做业务逻辑,再调controller.close(),顺序反了。
正确的顺序是:先调用业务回调,再关闭弹窗。因为如果先关闭弹窗,弹窗组件会立即进入消失流程,部分状态可能被重置,此时再去触发confirmAction()内部的逻辑,可能拿到已经失效的数据。而且某些confirmAction()的写法依赖弹窗内输入框的内容,先关闭后取值,输入框的值可能已经清空,就会拿到一个空值。
我自己的习惯是让所有事件回调都放在controller.close()之前,真正把“关闭”当作最后一步UI操作。
4. 底部抽屉、表单浮层、全屏页:三种高频形态的实现对比
4.1 底部抽屉弹窗的制作与适配细节
规格选择、地址选择这类业务通常在屏幕下半部分弹出一个类似抽屉的浮层。实现时可以沿用基础弹窗组件,但有几个参数必须调整。
arkts复制dialogController: CustomDialogController = new CustomDialogController({
builder: SpecSelectDialog({}),
alignment: DialogAlignment.Bottom,
customStyle: true
});
在弹窗组件里,如果使用customStyle: true,系统不再为弹窗容器提供默认白底圆角卡片样式,你需要自己控制根节点外观。我会把最外层Column设计成:
arkts复制Column() {
// 顶部拖拽条
Row()
.width(36)
.height(4)
.backgroundColor('#DDDDDD')
.borderRadius(2)
.margin({ top: 8, bottom: 12 })
// 内容区域
Scroll() {
Column() { /* 业务内容 */ }
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
}
.width('100%')
.height('60%')
.backgroundColor(Color.White)
.borderRadius({ topLeft: 20, topRight: 20 })
这里有两个细节很容易被忽略。
第一,如果弹窗内容是固定且不会超过一屏,你可以给高度一个具体值。但如果内容可能变化,不要让高度写死。比较好的做法是外层Column不设置高度,让内容自然撑开,或者设置一个合适的百分比最大值,内部用Scroll承接滚动。
第二,borderRadius只加顶部两个角。底部抽屉贴在屏幕底边时,如果四个角都有圆角,视觉上会出现浮空感。从屏幕底部弹出的组件只需要圆滑顶部转角。
4.2 带输入框的表单弹窗:焦点与键盘避让
在鸿蒙开发中,弹窗里放TextInput很容易出现一个现象:键盘弹出后,输入框被键盘遮住。因为弹窗初始位置默认居中,键盘从底部升起后自然会覆盖底部按钮和下方内容。
在实际项目里,我通常采取组合策略。如果弹窗只承载单个输入框,首选把弹窗整体对齐位置调偏上一些,比如alignment: DialogAlignment.Center本身没问题,但要确保输入框不在屏幕下半区。为了达到这个目的,弹窗根节点最外层不要纵向填满,而是让内容整体保持紧凑,这样键盘弹出时输入框仍能露在键盘上方。
如果弹窗内容是完整的表单,有多个输入项和按钮,就不能只靠调位置了,而需要监听键盘弹出带来的布局变化。一个比较务实的做法是给表单底部按钮区域设置一个足够大的底部安全间距,确保键盘弹出时按钮能跟随上移。不同API版本在键盘弹窗的处理上细节有差异,我会建议你在真机上反复验证“输入框→键盘→按钮”三者之间的位置关系。这一点模拟器很难完全还原手感。
另外还有焦点问题:弹窗打开时,如果希望TextInput自动弹出光标,可以在组件的生命周期中主动请求焦点,但要注意键盘弹出动画和弹窗进场动画重叠时可能出现闪烁。稳妥的办法是用onDidAppear来等弹窗完全展示后,再控制焦点。
4.3 全屏风格弹窗:视觉上覆盖整页的另一种用法
自定义弹窗并不只有小卡片形态。某些业务希望弹窗表现出“一个从底部升起的全屏页面”,例如筛选页、多步骤配置页,这种场景同样可以用弹窗实现。
关键依然是把customStyle设为true,然后让弹窗根节点占据整个屏幕。代码大概长这样:
arkts复制@CustomDialog
struct FullScreenFilterDialog {
controller: CustomDialogController;
build() {
Column() {
// 顶部导航区
Row() {
Text('取消').onClick(() => {
this.controller.close();
})
Blank()
Text('筛选').fontSize(16).fontWeight(FontWeight.Bold)
Blank()
Text('重置').onClick(() => {
console.info('重置筛选条件');
})
}
.height(50)
.padding({ left: 16, right: 16 })
// 中间可滚动内容
Scroll() {
Column() { /* 筛选条件组件 */ }
}
.layoutWeight(1)
// 底部确认按钮
Button('确认')
.width('100%')
.height(44)
.margin({ bottom: 20, left: 16, right: 16 })
}
.width('100%')
.height('100%')
.backgroundColor(Color.White)
}
}
当全屏弹窗根节点设置成height('100%')后,系统默认的遮罩依然存在但已经完全被内容盖住,所以视觉上就是一个完整的页面。在这种模式下,你应该把页面安全区预留考虑进去,尤其是刘海屏底部和顶部。给顶部导航栏加上合适的顶部边距,给底部按钮加上底部安全区距离,不然会看起来顶天立地很压抑。
4.4 三种形态的参数速查
| 形态 | alignment | customStyle | 根节点背景 | 关键点 |
|---|---|---|---|---|
| 居中确认弹窗 | Center | false | 由框架提供默认样式 | 简单、稳定,覆盖绝大多数确认场景 |
| 底部抽屉 | Bottom | true | 自己设置白色背景与顶部圆角 | 内容可滚动,高度需控制 |
| 全屏浮层 | Center | true | 自己设置不透明背景铺满 | 独立交互逻辑,注意安全区和转场 |
表格里的组合只是参考,并非强制。例如你完全可以在customStyle: false的时候让系统提供基础卡片样式,然后内部再放精确的布局。我选择的原则是:只要弹窗和系统默认卡片样式长得不一样,就统一开customStyle: true,把所有视觉细节握在自己手里。
5. 状态初始化链路里最容易翻车的细节:传值、刷新与关闭回调
5.1 不要在open之后去修改弹窗子组件的普通属性
这是我在第一个弹窗项目里实际踩进去的坑,问题表现非常隐蔽。
当时我设计了一个弹窗组件RemarkDialog,里面定义了一个普通属性typeValue: string。页面侧需要根据用户点击的商品类别动态改变弹窗文案,于是我在open()之后写了这样一段逻辑:
arkts复制this.remarkDialog.dialogController.open();
this.remarkDialog.dialogController.builder.typeValue = 'A类';
从逻辑上看,这似乎应该在弹窗打开时就把typeValue传进去。但实际运行后,我调试发现弹窗内部展示的文字始终是初始值,怎么改都无效。这是因为当deep value作为普通属性传入时,它的变更不会触发UI刷新。你改了值,UI层根本感知不到。
正确的做法是把要传递的数据以构造参数形式传入builder,或者在弹窗内部用@State声明这些数据,再由页面通过合适方式驱动内部状态。
5.2 给弹窗传对象时,注意深拷贝与引用副作用
弹窗组件接收一个对象参数,可能是最常见的传值方式。比如规格选择浮层要接收商品信息,这时候页面侧通常会直接把一个对象引用传进去。
第一次这么做,我遇到了一个很难发现的bug:父页面和弹窗内共用同一个对象引用,弹窗里修改对象的部分字段时,父页面的对象被连带修改了。这在某些场景下确实是我们期望的,但在另一些场景下会成为隐患——用户没点确认,弹窗内做的临时勾选却已经污染了页面的状态。
我的建议是:凡是进入弹窗时涉及“临时编辑、确认后生效”的数据,尽量在弹窗组件内部做好拷贝。你可以把对象里需要展示的字段拆出来赋给@State属性,或者干脆在弹窗的后台构造方法中做一次简单的字段复制。确认时再把最终结果整体回传。
5.3 用回调而不是“直接读取弹窗属性”回传结果
弹窗关闭时要把用户选择带到页面侧,正确姿势是在构造弹窗时传入一个回调函数。
我在第3章的确认弹窗里已经演示过confirmAction、cancelAction的写法。带业务参数的可以这样设计:
arkts复制@CustomDialog
struct SpecDialog {
controller: CustomDialogController;
currentSpec: string = '';
specConfirmed: (spec: string) => void = () => {};
build() {
// 若干Radio点击后,将选中值赋给this.currentSpec
Button('确定').onClick(() => {
this.specConfirmed(this.currentSpec);
this.controller.close();
})
}
}
这样页面侧只需要拿到回调结果,不需要关心弹窗内部用的是哪几个按钮、哪个中间状态。这个设计模式能最大程度降低页面和弹窗之间的耦合。
5.4 弹窗内部状态声明规范
在弹窗组件中,所有会因用户交互而变化的展示数据都应该加@State。例如用户选中的规格、输入的文字、加载状态。不要把这些值写到普通属性里再指望UI自动更新。
一句话记忆:“普通属性是入口参数,状态属性是弹窗内部变量。”入口参数在打开前定好,内部变量只在弹窗生命周期里随用户动作变化。
6. 弹窗用久了会踩到的崩溃坑:重复打开、异步关闭与遮罩处理
6.1 快速双击打开导致的崩溃
有段时间,我的弹窗总在用户快速点击按钮时闪退,报错信息指向CustomDialogController的重复open调用。
按钮点击事件里直接调了open(),用户手速够快时,第一次弹窗还没完全展示,第二次open()就进来了。在一些API版本上,同一个控制器实例不能同时打开两次,此时会抛异常甚至导致页面退出。
这个问题最简单的解决方案是加防重复点击开关。我写了一个通用开关标记,核心逻辑如下:
arkts复制private dialogOpeningFlag: boolean = false;
onButtonClick() {
if (this.dialogOpeningFlag) {
return;
}
this.dialogOpeningFlag = true;
this.dialogController.open();
}
// 在onDidDisappear时重置
this.dialogOpeningFlag = false;
在弹窗组件内部收到onDidDisappear回调后,再放开标记。这样既能防止连续双击打开同一个弹窗,也不影响弹窗关闭后再次打开新弹窗。
6.2 异步回调里重复关闭弹窗
另一个高频崩溃场景是异步操作后close()调用时机不可控。例如用户点确认后,前端先请求接口,接口成功后再关闭弹窗。如果接口很快,用户又点了一次返回键或遮罩,框架已经自动关闭了一次;等请求回调回来又执行一次close(),就可能因为控制器状态不一致而崩溃。
从框架行为看,对同一个控制器连续执行关闭操作,并不是每一次都会有兜底判断。因此要养成习惯,在弹窗组件内部用一个布尔变量记录关闭状态,每次尝试关闭前判断:
arkts复制private closedFlag: boolean = false;
closeDialog() {
if (this.closedFlag) {
return;
}
this.closedFlag = true;
if (this.controller) {
this.controller.close();
}
}
这个标志在aboutToAppear时重置,在关闭方法里置为true。之后的异步代码,哪怕晚到,也能避免重复关闭。
6.3 遮罩半透明看起来像蒙了一层“灰布”
使用customStyle: true后,有些人会发现遮罩层的效果不如系统默认那么自然。如果你没有显式配置遮罩透明度,有时会得到一层偏灰的遮罩,页面主内容就暗暗的,不太好看。
自定义弹窗的遮罩样式并不像React Native那样可以直接传个transparent属性。我的处理办法是:在弹窗根节点上自己做一层抬升效果,或者想办法降低遮罩的视觉存在感。
实际项目里,我会评估这个弹窗是否需要强烈阻断。对于普通信息展示,遮罩颜色浅一点更舒服;对于需要用户强制确认的重要操作,深色遮罩能提醒用户注意。核心思路是:不要直接依赖系统默认值,而是主动适配业务氛围。
6.4 弹窗内滚动区域不听话
有一部分崩溃和样式问题来自于弹窗内的Scroll。使用LazyForEach或Scroll时,如果外层高度结构不明确,弹窗内容可能会溢出屏幕,又或者滚动不生效。
最稳的滚动结构是:弹窗根节点Column不要直接包滚动列表,而是设定一个明确高度,把Scroll放在中间,再用layoutWeight(1)固定滚动区域的高度占比。这样框架能算出可视区域和内容区域,滚动行为就正常了。
如果你需要的是一个不限制高度的内容弹窗,那不要加Scroll,直接让内容自然排列,并确保总高不超过屏幕安全区。
7. 沉淀一套通用弹窗工具:我现在的封装思路
7.1 从面向单页面到面向团队的组件抽象
当项目里弹窗种类越来越多,我意识到每个弹窗都写一份“页面成员属性+控制器+组件”的样板代码,会变得臃肿。于是我把弹窗启动逻辑抽成了一个通用入口,让团队内部可以像调用函数一样打开弹窗。
arkts复制export class DialogHelper {
static showConfirm(params: {
title: string;
content: string;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
}): CustomDialogController {
const controller = new CustomDialogController({
builder: ConfirmDialog({
title: params.title,
content: params.content,
confirmText: params.confirmText ?? '确定',
cancelText: params.cancelText ?? '取消',
confirmAction: () => {
params.onConfirm?.();
},
cancelAction: () => {
params.onCancel?.();
}
}),
autoCancel: true,
alignment: DialogAlignment.Center
});
controller.open();
return controller;
}
}
使用方调用时会变得很舒服:
arkts复制DialogHelper.showConfirm({
title: '退出登录',
content: '退出后需要重新验证身份',
onConfirm: () => {
// 真正的退出逻辑
}
});
像规格选择、地址选择这类携带复杂结果的业务弹窗,也可以在DialogHelper中按业务域单独封装方法。这样弹窗内部代码变更时,页面调用方几乎不需要改动。
7.2 弹窗控制器的创建与回收策略
通过辅助函数封装后,控制器对象的生命周期变得简单了:每个弹窗在showConfirm内创建并打开,调用方不需要长期持有引用。如果后续需要强制关闭一个被动展示的弹窗,可以把它返回的CustomDialogController存到页面层,在页面销毁时做一次性的关闭操作。
这种短生命周期的方式比把所有控制器都集中挂在页面成员属性上更不容易发生状态泄漏。尤其在页面被路由销毁时,如果弹窗还开着,系统会做回收处理;但我们自己最好在页面销毁前主动关闭所有活动中的弹窗,避免不可预期的问题。
7.3 关闭动画和自定义进出场设计
不同业务弹窗适合不同转场动画,例如底部抽屉应该垂直滑动升起,中心确认弹窗适合淡入缩放。
自定义转场需要拿到CustomDialogController创建参数,比较直观的做法是使用transition配合animateTo来实现。鸿蒙框架允许在弹窗组件根节点上绑定进出场动画效果:
arkts复制transition(TransitionEffect.asymmetric(
TransitionEffect.translate({ y: 300 }).animation({ duration: 250, curve: Curve.EaseOut }),
TransitionEffect.translate({ y: 300 }).animation({ duration: 200, curve: Curve.EaseIn })
))
这样弹窗打开时从底部y=300的位置滑入,关闭时向底部滑出。不同的alignment可搭配不同的位移方向,中间确认弹窗搭配opacity效果比较自然。
第一次调转场动画时,我没有理解TransitionEffect.asymmetric的作用,导致进场动画正常、离场没有任何效果。原因就在于关闭动画和打开动画本质上是两条不同方向的过渡曲线,需要分别描述。
7.4 关于自定义弹窗和系统弹窗的选择,我的最终意见
写到这里,再回答最开始的问题:系统弹窗要不要被替换成自定义弹窗?我的答案是不要一刀切。
如果是“提示性确认类弹窗”,系统弹窗体验统一、实现高效,应该优先使用。如果弹窗内涉及复杂排版、交互流程、选择结果回传,或者需要和页面深度联动,尽早切换到自定义弹窗。
从团队协作的角度看,自定义弹窗的封装也要保持克制。不要把任何一个带@CustomDialog的组件堆成几百行的庞然大物,而是按业务域拆分组件。弹窗虽然视觉上是临时浮层,但它的内部设计依然要遵循组件化原则,该抽子组件就抽子组件,该统一样式就统一样式。
我在项目里的常规做法是:公共确认和轻提示走系统接口,所有复杂业务浮层走自定义弹窗辅助函数,并且为自定义弹窗建立统一的遮罩透明度、圆角规范、进出场动画参数表。这样两套方案各司其职,既不为了炫技而多写代码,也不因为在复杂场景生搬硬套系统弹窗而折磨用户。
如果你正准备在鸿蒙项目里落地自定义弹窗,建议直接拿本文的第三段代码搭出最小验证Demo,再加上第六段里的关闭防抖标志,基本可以在开发阶段规避掉大部分崩溃坑。踩过一次双击打开崩溃和重复关闭崩溃之后,你会深刻理解:自定义弹窗虽然灵活,但打开和关闭的时机管理比写UI本身更重要。
