很多 ArkUI 页面一开始都会这样写弹窗:页面里直接 new 一个 CustomDialogController,底部操作面板再临时写一套 bindSheet,当页面一多,样式、按钮文案、关闭逻辑、埋点时机就会越写越散。
这篇文章不讲“怎么把弹窗弹出来”,而是讲怎么把 Dialog 和 Sheet 收敛成一套可复用方案。目标很明确:
- 页面侧只关心“弹什么”
- 组件侧负责“长什么样”和“怎么关”
- 后续新增确认弹窗、删除提醒、底部操作面板时,尽量不再复制布局代码
一、为什么不要在每个页面里散落写弹窗
我自己做 ArkUI 页面时,最容易失控的不是列表,也不是表单,而是弹窗。
原因很简单,弹窗天然横跨业务层和交互层:
- 业务层关心什么时候弹、点确认后做什么
- UI 层关心标题、内容、按钮样式、圆角、蒙层、动画
- 交互层还要关心点击空白是否关闭、二次确认、危险操作高亮
如果这些逻辑都直接堆在页面里,通常会出现三个问题:
- 一个页面一个写法,视觉风格越来越不统一
- 关闭逻辑分散在各个按钮回调里,后期很难维护
Dialog和Sheet明明表达的是同一组业务动作,却被拆成两套完全不同的调用方式
所以这篇文章的核心不是“封装一个弹窗组件”,而是“统一一套弹层表达方式”。
二、这次封装的目标
我希望最终得到的是下面这种调用方式:
this.openDeleteDialog()
this.openMoreActionSheet()
页面侧只负责传配置,不再重复写按钮布局。
这套方案至少要满足四件事:
- 支持基础
Dialog显示和关闭 - 支持标题、内容、按钮的参数化
- 支持把同一组动作扩展成
Sheet - 支持后续继续抽主题、样式和埋点
三、推荐的目录组织
如果你是中小项目,不要一上来做得太重。先把弹层能力收敛在一个 modal 目录里就够用了。
entry/src/main/ets/
├── components/
│ └── modal/
│ ├── ModalConfig.ets
│ ├── AppDialog.ets
│ └── AppSheet.ets
└── pages/
└── DialogDemoPage.ets
我这里把“配置”和“视图”分开,后面你想把颜色、尺寸、危险按钮样式继续抽出去,也不会影响页面调用。
四、先定义统一的数据结构
先别急着写 UI,先把数据结构定下来。因为真正可复用的关键,不是布局,而是“调用方传什么”。
export type ModalActionRole = 'normal' | 'primary' | 'danger'
export interface ModalAction {
text: string
role?: ModalActionRole
onClick?: () => void
}
export interface ModalConfig {
title: string
message?: string
actions: ModalAction[]
}
这里我建议保留三个最小能力:
text:按钮文案role:按钮语义,后面统一决定颜色onClick:业务回调
这样做的好处是,Dialog 和 Sheet 都可以复用同一套动作配置,不需要分别维护两份按钮模型。
五、Dialog 组件实现
下面先写一个基础的确认弹窗组件。它只做一件事:接收配置并渲染,不直接关心你的业务。
import { ModalAction, ModalConfig } from './ModalConfig'
@CustomDialog
export struct AppDialog {
controller?: CustomDialogController
config: ModalConfig = {
title: '',
message: '',
actions: []
}
private resolveFontColor(role?: string): ResourceColor {
if (role === 'danger') {
return '#E84026'
}
if (role === 'primary') {
return '#0A59F7'
}
return '#1F2329'
}
build() {
Column({ space: 16 }) {
Text(this.config.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1F2329')
.width('100%')
.textAlign(TextAlign.Start)
if (this.config.message) {
Text(this.config.message)
.fontSize(15)
.lineHeight(24)
.fontColor('#4E5969')
.width('100%')
}
Column({ space: 12 }) {
ForEach(this.config.actions, (action: ModalAction, index: number) => {
Button(action.text)
.width('100%')
.height(44)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(this.resolveFontColor(action.role))
.backgroundColor('#F2F3F5')
.borderRadius(12)
.onClick(() => {
this.controller?.close()
action.onClick?.()
})
})
}
.width('100%')
}
.width('86%')
.padding(24)
.backgroundColor(Color.White)
.borderRadius(24)
}
}
这里有两个我比较在意的点:
1. 先关弹窗,再执行业务回调
很多同学会先调业务逻辑,再关弹窗。看起来没问题,但如果确认按钮里要更新状态、跳转页面、发请求,弹窗可能会出现闪一下或者状态错乱。
更稳的做法是:
- 先
close() - 再执行
action.onClick?.()
2. 组件只处理展示,不直接写业务判断
比如“删除后是否 toast”“确认后是否跳转”,这些都应该留给页面层。组件只负责统一视觉和交互壳子。
六、页面侧怎么调用 Dialog
页面侧建议只暴露一个 openDialog(config) 方法。真正需要弹什么,由业务调用方传配置决定。
import { AppDialog } from '../components/modal/AppDialog'
import { ModalConfig } from '../components/modal/ModalConfig'
@Entry
@Component
struct DialogDemoPage {
private dialogController?: CustomDialogController
private openDialog(config: ModalConfig) {
this.dialogController = new CustomDialogController({
builder: AppDialog({
controller: this.dialogController,
config
}),
customStyle: true,
alignment: DialogAlignment.Center
})
this.dialogController.open()
}
private openDeleteDialog() {
this.openDialog({
title: '确认删除这条记录吗?',
message: '删除后不可恢复,建议先确认是否已经完成同步。',
actions: [
{
text: '取消',
role: 'normal'
},
{
text: '确认删除',
role: 'danger',
onClick: () => {
console.info('execute delete action')
}
}
]
})
}
build() {
Column({ space: 16 }) {
Button('打开删除确认弹窗')
.width('100%')
.onClick(() => this.openDeleteDialog())
}
.padding(24)
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
如果你后面要扩展“埋点”“统一关闭回调”“弹窗队列”,都可以继续往 openDialog 这一层加,而不用改每个页面的布局代码。
七、再把同一组动作扩展成 Sheet
很多业务里,Dialog 和 Sheet 的区别并不是数据结构不同,而是展示方式不同。
比如:
- 删除确认,更适合
Dialog - 更多操作、选择动作,更适合
Sheet
那我们就没必要再设计第二套动作模型,直接复用同一个 ModalAction 即可。
import { ModalAction } from './ModalConfig'
@Builder
export function AppSheet(title: string, actions: ModalAction[]) {
Column({ space: 12 }) {
Text(title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1F2329')
.width('100%')
.textAlign(TextAlign.Start)
ForEach(actions, (action: ModalAction) => {
Button(action.text)
.width('100%')
.height(48)
.fontSize(16)
.backgroundColor('#F2F3F5')
.borderRadius(12)
.onClick(() => {
action.onClick?.()
})
})
}
.width('100%')
.padding(20)
}
页面侧使用 bindSheet 时,也还是传同样的数据:
@Entry
@Component
struct DialogDemoPage {
@State private showMoreSheet: boolean = false
private moreActions: ModalAction[] = [
{
text: '编辑',
role: 'primary',
onClick: () => {
this.showMoreSheet = false
console.info('edit action')
}
},
{
text: '删除',
role: 'danger',
onClick: () => {
this.showMoreSheet = false
this.openDeleteDialog()
}
}
]
build() {
Column({ space: 16 }) {
Button('打开更多操作')
.width('100%')
.onClick(() => {
this.showMoreSheet = true
})
}
.padding(24)
.width('100%')
.height('100%')
.bindSheet($$this.showMoreSheet, AppSheet('更多操作', this.moreActions), {
showDragBar: true,
backgroundColor: Color.White,
borderRadius: 24
})
}
}
这样一来,页面侧真正需要记住的只有两件事:
Dialog用openDialog(config)Sheet用统一动作数组 +showMoreSheet
视觉和动作语义都已经收口了。
八、这套封装最值钱的地方,不是少写代码
很多人封装弹窗时,第一反应是“少写几行代码”。
但我实际用下来,真正值钱的是下面三点:
1. 风格统一
标题字号、按钮高度、危险按钮颜色、圆角、间距都能统一管理。后面设计改版时,只需要改组件,不需要全项目搜页面。
2. 调用方式统一
不管是确认弹窗还是底部操作面板,调用方传的都是同一类数据。这样业务层的心智成本会低很多。
3. 后续扩展更顺手
你后面想加这些能力时,都会舒服很多:
- 埋点
- 国际化
- 暗色模式
- 多端适配
- 弹层优先级控制
九、几个很容易踩的坑
最后说几个我觉得最值得提前规避的问题。
1. 不要把 controller 生命周期写得太散
如果每个按钮、每个分支都自己去处理 controller,后面非常容易出现“这个弹窗能关,那个弹窗关不掉”的问题。
建议统一在组件内部处理关闭,在页面层只负责触发。
2. 不要把业务逻辑塞进 Builder 里
Builder 最好只负责视图拼装。请求、跳转、复杂状态修改尽量放回页面方法里,不然 Builder 会越来越重。
3. 不要一开始就做全局弹窗中心
很多项目一上来就想做全局 ModalManager。如果你的项目还不大,我建议先把“页面内复用”做好,等你真的遇到跨页面队列、统一路由拦截再升级。
十、总结
如果你正在做 HarmonyOS NEXT 或 ArkUI 项目,我很建议你尽早把弹窗从“页面细节”升级成“基础能力”。
一套好的弹层封装,不只是为了少写代码,更是为了让:
- 页面只表达业务意图
- 组件统一交互和视觉
Dialog与Sheet共享同一套动作模型
后面你无论是接确认删除、退出登录、底部动作面板,还是做危险操作提醒,整体维护成本都会低很多。
如果你也在做 ArkUI 组件封装,可以继续往下扩展:主题抽离、危险态按钮规范、全局弹层队列,甚至做成项目内的 ModalPresenter。
这类基础能力,越早收口,后面越轻松。