鸿蒙原生ArkTS布局方式之ColumnStart垂直排列

布局场景:状态驱动的自定义弹窗
核心技术栈@CustomDialog + @State + controller
适用版本:HarmonyOS NEXT(API 11+)


一、引言

在鸿蒙原生应用开发中,弹窗(Dialog)是最常见的交互组件之一。无论是确认操作、输入反馈、信息展示,还是复杂的表单填写,弹窗都承载着页面与用户之间最重要的沟通桥梁作用。HarmonyOS NEXT 提供了多种弹窗实现方式,从轻量级的 AlertDialog 到完全自定义的 @CustomDialog,开发者可以根据场景灵活选择。

本文将围绕 @CustomDialog + @State + controller 这一布局方式,从原理、实现、最佳实践到常见陷阱,进行全方位深度剖析。本文配套的完整示例代码位于项目的 entry/src/main/ets/pages/Index.ets 文件中,读者可以对照阅读。

1.1 为什么选择 @CustomDialog

相比于系统内置的 AlertDialogPromptDialog@CustomDialog 提供了完全自由的 UI 定制能力。你可以像编写普通页面组件一样,在弹窗中使用 ColumnRowTextTextInputButtonLoadingProgress 等任意 ArkUI 组件,组合出符合业务需求的弹窗界面。

@CustomDialog 的核心优势包括:

  • UI 完全自定义:不局限于系统预设的标题-内容-按钮三段式结构
  • 状态驱动:弹窗内部可以拥有独立的 @State 状态,实时响应输入变化
  • 双向数据传递:通过构造函数参数传值进入,通过回调函数传值出来
  • 控制器模式:通过 CustomDialogController 精确控制弹窗的生命周期
  • 丰富的配置项:支持遮罩层颜色、圆角、偏移量、对齐方式等精细化控制

1.2 适用场景

@CustomDialog 特别适合以下场景:

场景 说明 示例
输入反馈 需要用户输入文本后提交 意见反馈、评价填写
选择确认 带附加选项的确认操作 删除确认(含复选框)
信息展示 内容丰富的通知弹窗 版本更新说明、隐私协议
表单填写 弹窗内完成简单表单 快速登录、地址编辑
进度展示 包含操作进度的弹窗 文件上传、数据导出

二、核心技术原理

2.1 @CustomDialog 装饰器

@CustomDialog 是鸿蒙 ArkTS 提供的一个装饰器,用于将一个自定义 struct 声明为弹窗组件。被 @CustomDialog 装饰的 struct 拥有以下特性:

  • 拥有独立的 build() 方法来构建弹窗 UI
  • 必须声明 controller: CustomDialogController 属性(用于控制弹窗关闭)
  • 可以作为 CustomDialogController 构造函数的 builder 参数传入
  • 弹窗内部支持 @State@Prop 等装饰器实现状态管理
@CustomDialog
struct MyDialog {
  controller?: CustomDialogController;
  // 自定义属性...
  build() {
    // 弹窗 UI
  }
}
2.1.1 装饰器的工作机制

当 ArkTS 编译器遇到 @CustomDialog 装饰器时,会自动为 struct 注入弹窗相关的生命周期回调和方法。与 @Component 类似,@CustomDialog 也遵循组件树的构建和更新规则:

  1. 初始化阶段:弹窗 struct 被实例化,所有属性获得初始值
  2. 构建阶段build() 方法执行,创建 UI 组件树
  3. 更新阶段@State 属性变化时,触发 UI 重新渲染
  4. 销毁阶段:弹窗关闭后,组件树被回收

需要注意的是,@CustomDialog 的实例化时机不同于普通组件——它不是在父组件的 build() 中直接创建,而是通过 CustomDialogControlleropen() 方法触发。

2.2 @State 装饰器

@State 是 ArkTS 中最核心的状态管理装饰器,用于声明组件内部的可变状态。当 @State 修饰的属性值发生变化时,系统会自动重新渲染与该状态相关的 UI 部分。

在弹窗中使用 @State 有以下特点:

  • 局部作用域@State 只在当前 struct 内可见,不会影响父组件
  • 精确更新:只有依赖该状态的 UI 节点会被重新渲染
  • 异步批处理:多次状态变更会在同一帧内批量处理,避免频繁刷新
@CustomDialog
struct ConfirmDialog {
  @State inputValue: string = '';
  @State isLoading: boolean = false;

  build() {
    Column() {
      // 当 inputValue 变化时,只有这个 Text 会重新渲染
      if (this.inputValue.length > 0) {
        Text(`您输入了:${this.inputValue}`)
      }
      // 当 isLoading 变化时,只有这个 LoadingProgress 会重新渲染
      if (this.isLoading) {
        LoadingProgress()
      }
    }
  }
}
2.2.1 @State 的深层原理

@State 装饰器背后是一个基于依赖追踪的响应式系统。当 build() 方法执行时,框架会记录哪些 @State 属性被 UI 组件读取了。当这些属性被重新赋值时,框架只重新执行依赖了该属性的 UI 构建路径,而不是整个 build() 方法。

这种"细粒度更新"机制使得弹窗即使在复杂交互场景下也能保持流畅的渲染性能。例如在示例代码中,inputValueisLoading 分别控制输入预览和加载指示器,它们互不影响,各自独立刷新。

2.3 CustomDialogController

CustomDialogController 是弹窗的控制器类,负责管理弹窗的打开、关闭和配置。它的核心职责包括:

  • open():打开弹窗,触发弹窗 struct 的构建和显示
  • close():关闭弹窗,触发弹窗的销毁
  • 构造函数配置:传入弹窗的 builder 以及样式、行为配置
private dialogController?: CustomDialogController;

// 创建控制器
this.dialogController = new CustomDialogController({
  builder: MyDialog({ /* 参数 */ }),
  autoCancel: true,
  alignment: DialogAlignment.Center,
  maskColor: '#66000000'
});

// 打开弹窗
this.dialogController.open();

// 关闭弹窗(通常在弹窗内部通过 controller.close() 调用)
2.3.1 控制器配置项详解

CustomDialogController 的构造函数接受 DialogOptions 对象,完整的配置项包括:

配置项 类型 默认值 说明
builder CustomDialog 实例 必填 弹窗内容构建器
autoCancel boolean true 点击遮罩层是否关闭弹窗
alignment DialogAlignment Center 弹窗对齐方式
offset {dx: number, dy: number} {0, 0} 弹窗位置偏移量
gridCount number 自动 弹窗宽度占比(网格数)
cornerRadius number 0 弹窗圆角大小
maskColor ResourceColor 默认半透明 遮罩层颜色
openAnimation AnimateParam 默认 打开动画参数
closeAnimation AnimateParam 默认 关闭动画参数
showInSubWindow boolean false 是否在子窗口显示
isModal boolean true 是否为模态弹窗
backgroundColor ResourceColor 透明 弹窗背景色
backgroundBlurStyle BlurStyle 弹窗背景模糊效果

三、示例应用详解

3.1 应用整体架构

本示例应用包含三个核心结构:

应用结构
├── ConfirmDialog          (@CustomDialog)
│   ├── controller        — 弹窗控制器
│   ├── title/message     — 外部传入数据
│   ├── onCancel/onConfirm — 外部传入回调
│   ├── @State inputValue — 输入框状态
│   └── @State isLoading  — 加载状态
│
├── InfoDialog             (@CustomDialog)
│   ├── controller        — 弹窗控制器
│   ├── title/content     — 外部传入数据
│   └── buttonText        — 按钮文案
│
└── CustomDialogDemoPage   (@Entry @Component)
    ├── @State statusText — 状态提示文本
    ├── @State dialogCount — 弹窗打开计数
    ├── @State receivedInput — 接收的输入
    ├── confirmDialogController — 确认弹窗控制器
    └── infoDialogController — 信息弹窗控制器

3.2 ConfirmDialog 详细设计

ConfirmDialog 是示例的核心弹窗组件,展示了完整的 @CustomDialog 能力:

3.2.1 属性设计
@CustomDialog
struct ConfirmDialog {
  controller?: CustomDialogController;
  title: string = '提示';
  message: string = '';
  cancelText: string = '取消';
  confirmText: string = '确定';
  onCancel?: () => void;
  onConfirm?: (inputValue: string) => void;
  @State inputValue: string = '';
  @State isLoading: boolean = false;
}

属性分为三类:

  1. 框架注入属性controller 由框架在弹窗打开时自动注入,用于关闭弹窗
  2. 外部传入数据titlemessagecancelTextconfirmText 由宿主导页面传入,控制弹窗显示内容
  3. 外部传入回调onCancelonConfirm 由宿主页面传入,在弹窗关闭时将数据传回
  4. 内部状态inputValueisLoading 是弹窗内部状态,驱动 UI 实时更新
3.2.2 UI 布局

弹窗的 UI 布局采用纵向 Column 结构,从上到下依次为:

┌─────────────────────────────────┐
│          标题 (title)            │
│                                 │
│       消息内容 (message)         │
│                                 │
│   ┌─────────────────────────┐   │
│   │     输入框 (TextInput)    │   │
│   └─────────────────────────┘   │
│                                 │
│     输入实时预览 (条件显示)      │
│                                 │
│       加载指示器 (条件显示)      │
│                                 │
│   ┌─────────┐  ┌─────────┐     │
│   │  取消    │  │  确定    │     │
│   └─────────┘  └─────────┘     │
└─────────────────────────────────┘

布局要点:

  • 弹窗宽度占屏幕的 85%(.width('85%')
  • 圆角 16dp(.borderRadius(16)
  • 投影效果(.shadow({ radius: 24, color: '#33000000' })
  • 按钮区域使用 Row 水平排列,各占 50%(.flexGrow(1)
3.2.3 交互流程
用户点击"确定"
  → isLoading = true(显示 Loading 动画)
  → setTimeout 1.5s(模拟异步操作)
    → isLoading = false(隐藏 Loading)
    → onConfirm(inputValue)(将数据传回宿主)
    → controller.close()(关闭弹窗)

用户点击"取消"
  → onCancel()(通知宿主)
  → controller.close()(关闭弹窗)

用户点击遮罩层
  → autoCancel 机制触发
  → controller.close()(关闭弹窗)

3.3 宿主页面 CustomDialogDemoPage

宿主页面是整个应用的入口,负责管理弹窗的创建、配置和状态维护。

3.3.1 状态管理
@Entry
@Component
struct CustomDialogDemoPage {
  @State dialogTitle: string = '请输入反馈';
  @State dialogMessage: string = '请告诉我们您的想法:';
  @State receivedInput: string = '';
  @State dialogCount: number = 0;
  @State statusText: string = '等待弹窗返回数据…';
  @State count: number = 0;

  private confirmDialogController?: CustomDialogController;
  private infoDialogController?: CustomDialogController;
}

状态设计体现了宿主页面与弹窗之间的数据协作关系:

  • 传递给弹窗的dialogTitledialogMessage(每次打开可更新)
  • 从弹窗接收的receivedInputdialogCountstatusText
  • 页面独立状态count(验证宿主与弹窗 @State 各自独立刷新)
3.3.2 控制器初始化

控制器的初始化放在 aboutToAppear() 生命周期方法中,这是组件即将显示时的初始化入口:

aboutToAppear(): void {
  this.confirmDialogController = new CustomDialogController({
    builder: ConfirmDialog({
      title: this.dialogTitle,
      message: this.dialogMessage,
      onCancel: () => { this.statusText = '❌ 用户取消了弹窗'; },
      onConfirm: (val: string) => {
        this.receivedInput = val;
        this.dialogCount++;
        this.statusText = `✅ 弹窗第 ${this.dialogCount} 次返回:「${val}`;
      }
    }),
    autoCancel: true,
    alignment: DialogAlignment.Center,
    offset: ZERO_OFFSET,
    cornerRadius: 16,
    gridCount: 4,
    maskColor: MASK_COLOR
  });
}
3.3.3 动态更新控制器

为了在每次打开时更新弹窗的内容(如动态递增的标题),示例在按钮点击事件中重新创建控制器:

onClick(() => {
  this.dialogTitle = `${this.dialogCount + 1} 次反馈`;
  this.confirmDialogController = new CustomDialogController({
    builder: ConfirmDialog({
      title: this.dialogTitle,
      // ... 其他配置
    }),
    // ...
  });
  this.confirmDialogController.open();
})

这是一种灵活的模式——通过重新创建控制器来传递新的参数。不过需要注意的是,每次重新创建控制器都会产生新的实例,如果频繁打开弹窗,建议复用控制器实例,仅更新内部状态。


四、数据流分析

4.1 单向数据流

宿主 @State ──构造函数参数──→ 弹窗属性
                                        ↓
                                   弹窗渲染
                                        ↓
弹窗回调 ──────调用函数────────→ 宿主 @State
                                        ↓
                                   宿主渲染

这是 ArkUI 推荐的单向数据流模式:

  1. 数据向下流动:宿主通过 ConfirmDialog({ title: xxx }) 将数据传入弹窗
  2. 事件向上传递:弹窗通过 onConfirm(value) 回调将数据传回宿主
  3. 状态驱动渲染:宿主的 @State 变化触发宿主 UI 刷新

4.2 弹窗与宿主的状态独立性

关键设计原则:弹窗的 @State 与宿主的 @State 彼此独立

在示例中,宿主页面的计数器 count 和弹窗内部的 inputValue 互不影响:

// 宿主页面 - 点击"页面计数器 +1"按钮
onClick(() => { this.count++; })
// 只触发宿主 UI 刷新,不影响弹窗

// 弹窗内部 - 输入框内容变化
onChange((value: string) => { this.inputValue = value; })
// 只触发弹窗 UI 刷新,不影响宿主

这种独立性使得弹窗成为真正的"独立组件",可以被复用、测试和维护,而不会意外影响父组件。

4.3 数据的响应式传播路径

宿主 @State dialogTitle 变化
  → host build() 重新执行
    → new CustomDialogController 传入新值
      → 弹窗重建
        → 显示新标题

弹窗 @State inputValue 变化
  → dialog build() 局部重新执行
    → Text 组件显示新内容
    → 宿主不受影响

用户点击"确定"
  → 回调 onConfirm(inputValue)
    → 宿主 @State receivedInput 更新
      → 宿主 UI 刷新,显示最新数据

4.4 响应式系统的批处理机制

ArkTS 的响应式系统采用了批处理(Batching)机制来优化性能。当在同一个事件处理函数中连续修改多个 @State 属性时,框架不会立即触发多次 UI 重绘,而是等待当前同步代码执行完毕后,统一进行 DOM 差异计算和渲染更新。

// 示例中确定按钮的处理逻辑
onClick(() => {
  // 第一步:修改 isLoading 为 true
  this.isLoading = true;
  // 此时 UI 不会立即刷新

  // 第二步:异步操作
  setTimeout(() => {
    // 在异步回调中连续修改多个 @State
    this.isLoading = false;
    this.onConfirm?.(this.inputValue);
    this.controller?.close();
    // 以上所有 @State 变化和 controller.close() 会在同一帧内批处理
  }, 1500);

  // 当前同步代码执行完毕,框架统一处理 this.isLoading = true 的 UI 刷新
});

批处理机制带来的好处包括:

  • 避免中间态闪烁:多个状态变化不会导致 UI 反复重绘
  • 减少渲染开销:同一帧内的多次更新合并为一次渲染
  • 提升动画流畅度:框架可以更好地安排动画帧的执行时序

4.5 复杂数据传递场景

在实际项目中,弹窗与宿主之间的数据传递往往比简单的字符串传递更复杂。以下是几种常见的高级传递模式:

4.5.1 对象数据传递
// 定义数据类型
interface UserInfo {
  id: number;
  name: string;
  avatar: string;
  role: string;
}

// 弹窗定义
@CustomDialog
struct UserProfileDialog {
  controller?: CustomDialogController;
  userData?: UserInfo;            // 传入用户数据
  onUserUpdated?: (user: UserInfo) => void;  // 传出更新后的用户数据
  @State editedName: string = '';

  aboutToAppear(): void {
    // 初始化时从外部数据同步到内部状态
    this.editedName = this.userData?.name ?? '';
  }

  build() {
    Column() {
      Text(`编辑用户:${this.userData?.name}`)
      TextInput({ text: this.editedName })
        .onChange(v => { this.editedName = v; })
      Button('保存')
        .onClick(() => {
          const updatedUser: UserInfo = {
            ...this.userData!,
            name: this.editedName
          };
          this.onUserUpdated?.(updatedUser);
          this.controller?.close();
        })
    }
  }
}
4.5.2 多级弹窗交互

某些场景需要弹窗中再打开弹窗,形成弹窗链:

@CustomDialog
struct MainDialog {
  controller?: CustomDialogController;
  subDialogController?: CustomDialogController;

  openSubDialog(): void {
    this.subDialogController = new CustomDialogController({
      builder: SubDialog({
        onDone: () => {
          // 子弹窗完成后,关闭自己并更新主弹窗状态
          this.subDialogController?.close();
          // 主弹窗的 @State 自动刷新
        }
      })
    });
    this.subDialogController.open();
  }

  build() {
    Column() {
      Text('主弹窗')
      Button('打开子弹窗')
        .onClick(() => { this.openSubDialog(); })
    }
  }
}

注意:多级弹窗建议将 showInSubWindow 设为 true,以避免弹窗之间的层级覆盖问题。

4.6 弹窗内的异步数据加载

弹窗可能需要从网络或数据库加载数据。以下是一种安全的异步加载模式:

@CustomDialog
struct AsyncDataDialog {
  controller?: CustomDialogController;
  dataId?: string;
  @State dataList: string[] = [];
  @State isLoading: boolean = true;
  @State errorMessage: string = '';

  aboutToAppear(): void {
    this.loadData();
  }

  loadData(): void {
    this.isLoading = true;
    this.errorMessage = '';

    // 模拟异步数据加载
    setTimeout(() => {
      // 实际项目中这里会是网络请求或数据库查询
      if (this.dataId === 'error') {
        this.errorMessage = '数据加载失败,请稍后重试';
      } else {
        this.dataList = ['选项A', '选项B', '选项C'];
      }
      this.isLoading = false;
    }, 2000);
  }

  build() {
    Column() {
      if (this.isLoading) {
        LoadingProgress()
          .width(48).height(48)
        Text('正在加载数据...')
          .fontColor('#999999')
      } else if (this.errorMessage) {
        Text(this.errorMessage)
          .fontColor('#FF0000')
        Button('重试')
          .onClick(() => { this.loadData(); })
      } else {
        List() {
          ForEach(this.dataList, (item: string) => {
            ListItem() {
              Text(item).padding(12)
            }
          })
        }
        .height(200)
      }
    }
  }
}

这种模式提供了完整的加载状态机:加载中 → 加载成功/加载失败 → 重试 → 加载中…,确保用户在任何状态下都能获得清晰的反馈。


五、样式的艺术

5.1 颜色系统

示例采用了一套精心设计的色彩方案:

用途 色值 说明
标题文字 #333333 深灰色,保证可读性
正文文字 #666666 中灰色,层级分明
输入框背景 #F5F5F5 浅灰色底,视觉柔和
主按钮 #007DFF 品牌蓝,明确的操作指引
取消按钮 #F0F0F0 + #999999 低调辅助
页面背景 #F2F4F7 极浅灰,干净清爽
卡片背景 #F8F9FA 白底微灰,层次丰富
说明区域 #FFF7ED 暖橙色底,温和提示

5.2 尺寸比例

弹窗宽度: 85% 屏幕宽度
弹窗圆角: 16dp
输入框高度: 40dp
按钮高度: 40dp
主页面按钮高度: 48dp
主页面按钮圆角: 24dp(胶囊形)

5.3 视觉层次

页面通过背景色、圆角和投影构建清晰的视觉层次:

第一层:页面背景 (#F2F4F7)
第二层:状态面板卡片 (白底 + borderRadius: 12)
第三层:操作按钮 (彩色 + shadow)
第四层:弹窗 (白底 + borderRadius: 16 + shadow + 半透明遮罩)
第五层:遮罩层 (半透明黑色 #66000000)

六、常见问题与最佳实践

6.1 控制器生命周期管理

问题:控制器在组件销毁后仍被引用,导致内存泄漏。

最佳实践

aboutToAppear(): void {
  // 在组件显示时创建控制器
  this.dialogController = new CustomDialogController({...});
}

aboutToDisappear(): void {
  // 在组件销毁时释放控制器
  this.dialogController = undefined;
}

6.2 弹窗内状态重置

问题:每次打开弹窗时,之前的状态(如输入框内容)仍然保留。

解决方案:在打开弹窗前重置状态,或每次打开时重建控制器。

在示例中,我们采用了重新创建控制器的方式:

onClick(() => {
  // 每次打开都创建新的控制器实例,弹窗内部状态自然重置
  this.confirmDialogController = new CustomDialogController({
    builder: ConfirmDialog({ /* 参数 */ }),
    // ...
  });
  this.confirmDialogController.open();
});

6.3 异步操作处理

问题:弹窗中的异步操作(如网络请求)在弹窗关闭后仍在执行,可能导致回调到已销毁的组件。

最佳实践

onConfirm: (val: string) => {
  this.isLoading = true;
  // 使用 AbortController 或标志位控制
  fetchData(val).then(result => {
    if (this.controller) {  // 检查弹窗是否还存在
      this.isLoading = false;
      this.onConfirmCallback?.(result);
      this.controller?.close();
    }
  }).catch(err => {
    if (this.controller) {
      this.isLoading = false;
      // 显示错误
    }
  });
}

6.4 多个弹窗的栈管理

当页面需要连续打开多个弹窗时,需要注意:

// 关闭当前弹窗后再打开下一个
dialog1.open();
// ... 用户在 dialog1 中操作
dialog1.close();
// 然后
dialog2.open();

如果需要弹窗嵌套(不推荐),必须设置 showInSubWindow: true

6.5 性能优化

  1. 避免频繁重建:如果弹窗内容不经常变化,可以复用控制器实例
  2. 精简 @State:只在需要驱动 UI 的属性上使用 @State
  3. 条件渲染:使用 if/else 控制组件的创建和销毁,而非显隐控制
  4. 延迟初始化:在 aboutToAppear 中初始化控制器,而非在声明时

6.6 键盘避让

当弹窗中包含 TextInput 时,需要考虑键盘弹出时的避让:

// 弹窗配置中设置偏移量,使弹窗在键盘弹出时上移
new CustomDialogController({
  builder: ConfirmDialog({...}),
  offset: { dx: 0, dy: -100 },  // 向上偏移 100dp
  alignment: DialogAlignment.Center,
})

配合键盘监听可以实现更精确的避让:

import { window } from '@kit.ArkUI';

// 获取键盘高度并动态调整弹窗偏移
window.getLastWindow(this.context, (err, win) => {
  win.on('keyboardHeightChange', (height: number) => {
    // 根据键盘高度调整 offset
  });
});

七、与其他弹窗方案的对比

7.1 @CustomDialog vs AlertDialog

对比维度 @CustomDialog AlertDialog
UI 自由度 完全自定义 固定三段式
状态管理 支持 @State 不支持
数据传递 双向传递 仅回调确认/取消
样式配置 全面可配 仅标题/内容/按钮文案
使用复杂度 中等 简单
适用场景 复杂交互弹窗 简单确认/提示

7.2 @CustomDialog vs 页面跳转

对比维度 @CustomDialog 页面跳转(Navigation)
上下文保留 保留宿主页面 进入新页面
操作流程 轻量快捷 重量级导航
数据回传 回调方式 路由参数或全局状态
动画效果 弹窗动画 页面转场动画
使用场景 快速操作/确认 独立业务模块

7.3 @CustomDialog vs 自定义弹窗组件

有些开发者会尝试自己实现弹窗组件(通过 Stack 层叠 + 条件渲染),但这与 @CustomDialog 相比有明显劣势:

// ❌ 不推荐的手写弹窗方式
@State showDialog: boolean = false;

build() {
  Stack() {
    // 主页面内容
    Column() { /* ... */ }

    // 手动实现的弹窗
    if (this.showDialog) {
      Column() {
        // 弹窗内容
      }
      .backgroundColor(Color.White)
      .borderRadius(16)
      .shadow(...)
    }
  }
}

手写弹窗的问题:

  1. 遮罩层管理复杂:需要手动处理点击遮罩关闭、穿透等行为
  2. 动画效果缺失:没有内置的打开/关闭动画
  3. 生命周期不完整:没有 aboutToAppear/aboutToDisappear 等回调
  4. 代码耦合度高:弹窗逻辑与宿主页面混杂在一起
  5. 可复用性差:无法作为独立组件被其他页面使用

八、进阶技巧

8.1 弹窗内的表单验证

@CustomDialog
struct FormDialog {
  controller?: CustomDialogController;
  onConfirm?: (name: string, email: string) => void;

  @State name: string = '';
  @State email: string = '';
  @State nameError: string = '';
  @State emailError: string = '';

  // 验证逻辑
  validate(): boolean {
    let valid = true;
    if (this.name.length < 2) {
      this.nameError = '姓名至少2个字符';
      valid = false;
    } else {
      this.nameError = '';
    }
    if (!this.email.includes('@')) {
      this.emailError = '请输入有效的邮箱地址';
      valid = false;
    } else {
      this.emailError = '';
    }
    return valid;
  }

  build() {
    Column() {
      TextInput({ text: this.name })
        .onChange(v => { this.name = v; })
      if (this.nameError) {
        Text(this.nameError).fontColor('#FF0000').fontSize(12)
      }

      TextInput({ text: this.email })
        .onChange(v => { this.email = v; })
      if (this.emailError) {
        Text(this.emailError).fontColor('#FF0000').fontSize(12)
      }

      Button('提交')
        .onClick(() => {
          if (this.validate()) {
            this.onConfirm?.(this.name, this.email);
            this.controller?.close();
          }
        })
    }
  }
}

8.2 弹窗动画自定义

new CustomDialogController({
  builder: AnimatedDialog({}),
  openAnimation: {
    duration: 300,
    curve: Curve.FastOutSlowIn,
  },
  closeAnimation: {
    duration: 200,
    curve: Curve.Linear,
  },
})

8.3 弹窗与路由的结合

在复杂的应用场景中,弹窗可能需要获取路由参数或上下文:

// 在宿主页面中,将路由上下文传入弹窗
aboutToAppear(): void {
  const router = Router.getState();
  this.dialogController = new CustomDialogController({
    builder: MyDialog({
      pageUrl: router.url,
      pageParams: router.params,
    }),
  });
}

8.4 使用 @Prop 实现弹窗响应式传参

除了构造函数传参外,还可以使用 @Prop 装饰器实现弹窗属性的响应式更新:

@CustomDialog
struct ReactiveDialog {
  controller?: CustomDialogController;
  @Prop @Require title: string;  // 当父组件更新该值时,弹窗自动刷新
  @State content: string = '';
}

注意:@Prop 是单向同步,父组件 → 子组件。如果需要双向同步,应使用 @Link

8.5 弹窗内的长列表

当弹窗内容包含长列表时,需要谨慎处理滚动:

@CustomDialog
struct ListDialog {
  controller?: CustomDialogController;
  @State items: string[] = [];

  build() {
    Column() {
      Text('选择项目')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)

      // 使用 List 组件实现滚动列表
      List() {
        ForEach(this.items, (item: string) => {
          ListItem() {
            Text(item)
              .height(48)
              .padding({ left: 16 })
          }
          .onClick(() => {
            this.controller?.close();
          })
        })
      }
      .width('100%')
      .height(300)  // 固定高度,超出部分滚动
      .borderRadius(8)

      Button('取消')
        .onClick(() => { this.controller?.close(); })
    }
    .width('90%')
    .backgroundColor(Color.White)
    .borderRadius(16)
  }
}

九、调试与排错

9.1 常见编译错误

错误信息 原因 解决方法
controller must be declared @CustomDialog 未声明 controller 添加 controller: CustomDialogController
Object literal must correspond to type 匿名对象字面量 定义接口 + 预定义常量
Property 'scrollable' does not exist API 不支持 改用 Scroll 容器
no exported member 'CustomDialogController' 导入路径错误 该类型全局可用,无需 import

9.2 常见运行时问题

问题 现象 解决方案
弹窗不显示 open() 调用后无反应 检查 controller 是否正确初始化
弹窗无法关闭 点击按钮无响应 检查 controller.close() 的调用路径
回调未触发 宿主页面未收到数据 检查回调函数是否被正确传递
状态未刷新 UI 显示旧数据 检查 @State 是否正确赋值
内存泄漏 页面关闭后弹窗仍在 aboutToDisappear 中清理控制器

9.3 日志调试技巧

// 在弹窗的关键节点添加日志
aboutToAppear(): void {
  console.info('Dialog aboutToAppear');
}

onCancel?: () => void;
onConfirm?: (value: string) => void;

// 使用 hilog 输出调试信息
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0000;

// 在回调中打印
onConfirm: (val: string) => {
  hilog.info(DOMAIN, 'DialogDemo', `Confirm received: ${val}`);
  this.receivedInput = val;
}

十、完整代码解读

10.1 文件结构

entry/src/main/ets/pages/Index.ets
├── 接口定义 (OffsetParam)
├── 常量定义 (ZERO_OFFSET, UP_OFFSET, MASK_COLOR)
├── ConfirmDialog (@CustomDialog)
│   ├── 属性声明
│   ├── build() UI 构建
│   │   ├── 标题
│   │   ├── 消息
│   │   ├── 输入框 (@State 绑定)
│   │   ├── 输入预览 (条件渲染)
│   │   ├── 加载指示器 (条件渲染)
│   │   └── 按钮区域 (取消 + 确定)
│   └── 交互逻辑 (onClick 处理)
├── InfoDialog (@CustomDialog)
│   ├── 属性声明
│   └── build() UI 构建
│       ├── 标题
│       ├── 内容
│       └── 按钮
└── CustomDialogDemoPage (@Entry @Component)
    ├── @State 属性声明
    ├── 控制器声明
    ├── aboutToAppear() 初始化
    ├── build() 页面 UI
    │   ├── Scroll > Column
    │   │   ├── 标题区
    │   │   ├── 宿主状态面板
    │   │   ├── 操作按钮区 (3个按钮)
    │   │   └── 布局说明区
    │   └── 属性链
    └── 交互事件处理

10.2 关键代码片段精讲

弹窗定义

@CustomDialog
struct ConfirmDialog {
  controller?: CustomDialogController;
  // ...
  @State inputValue: string = '';

@CustomDialog 将 struct 标记为弹窗组件。controller? 是框架自动注入的控制器实例,使用可选属性是为了满足 ArkTS 严格模式要求——所有组件属性必须有默认值。@State inputValue 是弹窗的内部状态,驱动输入预览的实时更新。

宿主与弹窗的桥接

this.confirmDialogController = new CustomDialogController({
  builder: ConfirmDialog({
    title: this.dialogTitle,
    onCancel: () => { this.statusText = '❌ 用户取消了弹窗'; },
    onConfirm: (val) => {
      this.receivedInput = val;
      this.dialogCount++;
      this.statusText = `✅ 弹窗第 ${this.dialogCount} 次返回:「${val}`;
    }
  }),
  autoCancel: true,
  alignment: DialogAlignment.Center,
  // ...
});

builder 字段创建了 ConfirmDialog 实例并传入了属性和回调。autoCancel: true 允许用户点击遮罩层关闭弹窗。DialogAlignment.Center 使弹窗在屏幕中央显示。

Scroll 实现滚动

Scroll() {
  Column() {
    // 所有页面内容
  }
}  // 关闭 Scroll
.width('100%')
.height('100%')
.backgroundColor('#F2F4F7')
.scrollBar(BarState.Off)

使用 Scroll 组件包裹 Column,实现内容超出屏幕高度时的滚动效果。.scrollBar(BarState.Off) 隐藏滚动条,使界面更简洁。


十一、总结

@CustomDialog + @State + controller 是鸿蒙 NEXT 平台上实现自定义弹窗的推荐方式。它结合了装饰器的声明式简洁性、@State 的响应式状态管理能力和控制器模式的精确生命周期控制,为开发者提供了一种强大而灵活的弹窗解决方案。

通过本文的全面讲解,我们可以总结出以下核心要点:

  1. @CustomDialog 装饰器将自定义 struct 转变为弹窗组件,支持完全自由的 UI 布局
  2. @State 装饰器驱动弹窗内部状态变化,实现实时 UI 刷新
  3. CustomDialogController 作为控制器,精确管理弹窗的打开、关闭和配置
  4. 数据流遵循单向流动:宿主通过构造函数传参进入弹窗,弹窗通过回调函数传回数据
  5. 宿主与弹窗的状态独立:彼此的 @State 互不影响,保证了组件的封装性和复用性
  6. 严格模式适配:所有组件属性必须有默认值;对象字面量需要具名类型约束
  7. Scroll 容器用于处理内容滚动,替代了 Column 的 .scrollable() 方法

在实际项目开发中,@CustomDialog 可以广泛应用于确认操作、输入反馈、信息展示、表单填写等场景。配合 @State 实现内部状态管理,结合控制器模式的精确生命周期控制,能够构建出交互流畅、视觉统一、代码可维护的高质量弹窗体验。

11.1 架构决策对比

在决定是否使用 @CustomDialog 时,可以参考以下决策树:

需要弹窗交互?
├── 仅简单提示 → AlertDialog(系统内置,代码最少)
├── 需要用户输入?
│   ├── 简单输入 → PromptDialog(系统内置)
│   └── 复杂表单 → @CustomDialog(完全自定义)
├── 需展示丰富内容?
│   ├── 内容较少 → @CustomDialog(轻量)
│   └── 内容很多 → 页面跳转 + Navigation(更适合)
└── 需要跨页面复用弹窗?
    └── @CustomDialog + 封装为通用组件

11.2 性能考量

在使用 @CustomDialog 时,需要注意以下性能方面:

关注点 影响 优化建议
控制器创建 每次 new 都有开销 复用控制器实例,仅更新参数
@State 数量 过多状态影响 diff 性能 精简到最小必需状态集
条件渲染 if/else 创建销毁组件 优先使用条件渲染而非显隐控制
列表渲染 大量列表项卡顿 使用 LazyForEach 延迟加载
动画性能 复杂动画掉帧 使用硬件加速属性(transform/opacity)

11.3 与状态管理的深度结合

当应用规模增大时,弹窗可能需要与全局状态管理方案结合:

// 使用 AppStorage 实现跨页面状态共享
@CustomDialog
struct GlobalStateDialog {
  controller?: CustomDialogController;
  @StorageLink('userName') userName: string = '';
  @StorageLink('loginToken') loginToken: string = '';

  build() {
    Column() {
      Text(`当前用户:${this.userName}`)
      Button('清除登录状态')
        .onClick(() => {
          AppStorage.Set('userName', '');
          AppStorage.Set('loginToken', '');
          this.controller?.close();
        })
    }
  }
}

11.4 单元测试策略

弹窗作为 UI 组件,可以通过以下方式进行测试验证:

  1. 状态测试:验证弹窗的 @State 在交互后是否正确更新
  2. 渲染测试:验证不同状态下的 UI 分支是否正确渲染
  3. 回调测试:验证弹窗的回调函数是否在正确时机被调用
  4. 生命周期测试:验证弹窗的 aboutToAppear 和 aboutToDisappear 行为
// 伪代码示例 - 弹窗单元测试
describe('ConfirmDialog', () => {
  it('输入内容后确认应回调正确数据', () => {
    let receivedValue = '';
    const dialog = new ConfirmDialog({
      onConfirm: (val) => { receivedValue = val; }
    });

    // 模拟输入
    dialog.inputValue = '测试内容';
    // 模拟点击确认
    // dialog.onConfirmClick();

    expect(receivedValue).toBe('测试内容');
  });
});

下一步学习方向

  • 探索 @CustomDialog@Prop@Link 装饰器的配合使用,实现更灵活的父子组件通信
  • 了解弹窗的动画自定义和键盘避让策略,提升用户体验
  • 学习多个弹窗的栈管理机制,处理复杂的多级弹窗交互
  • 研究弹窗与全局状态管理(如 AppStorage、LocalStorage)的结合
  • 深入理解 ArkTS 响应式系统的渲染优化原理
  • 掌握弹窗在 MVVM 架构中的最佳实践位置

关于本文

本文配套的完整示例代码是一个可直接运行的 HarmonyOS NEXT 应用,包含:

  • 两个 @CustomDialog 弹窗示例(输入确认弹窗 + 信息弹窗)
  • 一个 @Entry @Component 宿主页面,完整演示了弹窗的创建、打开、数据传递和状态管理
  • 详细的 ArkTS 注释,解释每个技术点的作用和注意事项

读者可以通过运行示例代码,直观地观察弹窗的交互效果和数据流动过程,将理论与实践紧密结合。


示例代码位置entry/src/main/ets/pages/Index.ets
运行方式:在 DevEco Studio 中打开项目,连接设备或模拟器运行
最低兼容版本:HarmonyOS NEXT API 11+
作者注:本文为鸿蒙原生 ArkTS 布局方式系列文章之一,后续将持续推出更多布局方式的深度解析。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Logo

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

更多推荐