ArkUI 弹窗组件实战:封装一个可复用的 Dialog/Sheet 方案

4 阅读7分钟

很多 ArkUI 页面一开始都会这样写弹窗:页面里直接 new 一个 CustomDialogController,底部操作面板再临时写一套 bindSheet,当页面一多,样式、按钮文案、关闭逻辑、埋点时机就会越写越散。

这篇文章不讲“怎么把弹窗弹出来”,而是讲怎么把 DialogSheet 收敛成一套可复用方案。目标很明确:

  • 页面侧只关心“弹什么”
  • 组件侧负责“长什么样”和“怎么关”
  • 后续新增确认弹窗、删除提醒、底部操作面板时,尽量不再复制布局代码

一、为什么不要在每个页面里散落写弹窗

我自己做 ArkUI 页面时,最容易失控的不是列表,也不是表单,而是弹窗。

原因很简单,弹窗天然横跨业务层和交互层:

  • 业务层关心什么时候弹、点确认后做什么
  • UI 层关心标题、内容、按钮样式、圆角、蒙层、动画
  • 交互层还要关心点击空白是否关闭、二次确认、危险操作高亮

如果这些逻辑都直接堆在页面里,通常会出现三个问题:

  1. 一个页面一个写法,视觉风格越来越不统一
  2. 关闭逻辑分散在各个按钮回调里,后期很难维护
  3. DialogSheet 明明表达的是同一组业务动作,却被拆成两套完全不同的调用方式

所以这篇文章的核心不是“封装一个弹窗组件”,而是“统一一套弹层表达方式”。

二、这次封装的目标

我希望最终得到的是下面这种调用方式:

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:业务回调

这样做的好处是,DialogSheet 都可以复用同一套动作配置,不需要分别维护两份按钮模型。

五、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

很多业务里,DialogSheet 的区别并不是数据结构不同,而是展示方式不同。

比如:

  • 删除确认,更适合 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
    })
  }
}

这样一来,页面侧真正需要记住的只有两件事:

  • DialogopenDialog(config)
  • Sheet 用统一动作数组 + showMoreSheet

视觉和动作语义都已经收口了。

八、这套封装最值钱的地方,不是少写代码

很多人封装弹窗时,第一反应是“少写几行代码”。

但我实际用下来,真正值钱的是下面三点:

1. 风格统一

标题字号、按钮高度、危险按钮颜色、圆角、间距都能统一管理。后面设计改版时,只需要改组件,不需要全项目搜页面。

2. 调用方式统一

不管是确认弹窗还是底部操作面板,调用方传的都是同一类数据。这样业务层的心智成本会低很多。

3. 后续扩展更顺手

你后面想加这些能力时,都会舒服很多:

  • 埋点
  • 国际化
  • 暗色模式
  • 多端适配
  • 弹层优先级控制

九、几个很容易踩的坑

最后说几个我觉得最值得提前规避的问题。

1. 不要把 controller 生命周期写得太散

如果每个按钮、每个分支都自己去处理 controller,后面非常容易出现“这个弹窗能关,那个弹窗关不掉”的问题。

建议统一在组件内部处理关闭,在页面层只负责触发。

2. 不要把业务逻辑塞进 Builder 里

Builder 最好只负责视图拼装。请求、跳转、复杂状态修改尽量放回页面方法里,不然 Builder 会越来越重。

3. 不要一开始就做全局弹窗中心

很多项目一上来就想做全局 ModalManager。如果你的项目还不大,我建议先把“页面内复用”做好,等你真的遇到跨页面队列、统一路由拦截再升级。

十、总结

如果你正在做 HarmonyOS NEXT 或 ArkUI 项目,我很建议你尽早把弹窗从“页面细节”升级成“基础能力”。

一套好的弹层封装,不只是为了少写代码,更是为了让:

  • 页面只表达业务意图
  • 组件统一交互和视觉
  • DialogSheet 共享同一套动作模型

后面你无论是接确认删除、退出登录、底部动作面板,还是做危险操作提醒,整体维护成本都会低很多。

如果你也在做 ArkUI 组件封装,可以继续往下扩展:主题抽离、危险态按钮规范、全局弹层队列,甚至做成项目内的 ModalPresenter

这类基础能力,越早收口,后面越轻松。