1. 项目实现效果

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


2. 工程结构

MultipleDialog-master/
├── AppScope/                          # 应用级配置与图标
│   ├── app.json5                      # bundleName、版本号
│   └── resources/base/element/string.json
├── entry/                             # 唯一 HAP 模块
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/EntryAbility.ets
│   │   │   ├── entrybackupability/EntryBackupAbility.ets
│   │   │   ├── pages/
│   │   │   │   ├── Index.ets                 # 首页 + Navigation 容器
│   │   │   │   └── PersonalInformation.ets   # 个人信息页(弹窗主战场)
│   │   │   ├── utils/CommonUtils.ets         # 日期格式化、空值判断
│   │   │   └── view/
│   │   │       ├── Dialog.ets                # 自定义弹窗封装(单例)
│   │   │       ├── TextCommonComponent.ets   # 可点击信息行
│   │   │       └── TextInputComponent.ets    # 文本输入行
│   │   ├── resources/
│   │   │   ├── base / zh_CN / en_US          # 多语言字符串与数组
│   │   │   └── base/profile/main_pages.json  # 页面路由表
│   │   └── module.json5
│   ├── build-profile.json5
│   └── oh-package.json5
├── build-profile.json5                # SDK 版本、编译产品
├── oh-package.json5
└── hvigorfile.ts

2.1 模块职责

文件职责
EntryAbility.ets应用入口:加载首页、把 UIContext 写入 AppStorage
Index.etsNavigation 根页面,跳转到个人信息页
PersonalInformation.ets表单 UI + 全部弹窗触发逻辑
Dialog.etsopenCustomDialog / closeCustomDialog / updateCustomDialog 的单例封装
TextInputComponent.ets昵称、签名输入;同步 inputIsEdit
TextCommonComponent.ets出生日期 / 性别 / 爱好展示行,点击回调打开对应弹窗
CommonUtils.ets出生日期本地化拼接、空对象/空数组判断

2.2 页面注册

main_pages.json 只注册了 pages/Index。个人信息页不是独立路由页,而是挂在 Navigation 上的 NavDestination,通过 navDestination + NavPathStack.pushPath 进入。


3. 整体架构

弹窗层

UI 组件

导航层

启动层

loadContent pages/Index

setOrCreate uiContext

Provide NavPathStack

pushPath PersonalInformation

Consume NavPathStack

inputIsEdit

isEdit / 字段

Hobbies

EntryAbility

AppStorage
uiContext / 表单字段 / 编辑标记

Index

Navigation Stack

PersonalInformation

TextInputComponent

TextCommonComponent

showAlertDialog

showDatePickerDialog

showTextPickerDialog

bindPopup

showToast

PromptActionClass
openCustomDialog

核心设计:

  1. 单页导航:首页用 Navigation + NavPathStack 管理子页,避免多 @Entry 页面。
  2. 全局状态:表单字段、编辑标记走 AppStorage + @StorageLink,跨组件同步。
  3. 弹窗入口统一走 UIContext:系统弹窗用 this.getUIContext().showXxxDialog,自定义弹窗用 getPromptAction().openCustomDialog

4. 完整实现流程

4.1 应用启动

IndexAppStorageWindowStageEntryAbility系统IndexAppStorageWindowStageEntryAbility系统onCreateonWindowStageCreateloadContent("pages/Index")加载完成回调setOrCreate("uiContext", UIContext)渲染首页onForeground

关键步骤:

  1. module.json5 指定 mainElementEntryAbility,且 exported: true,带 entity.system.home / action.system.home,作为桌面入口。
  2. onWindowStageCreate 中调用 windowStage.loadContent('pages/Index')
  3. 加载成功后,从主窗口取出 UIContext 写入 AppStorage,供 CommonUtils 读取 resourceManager 做本地化。
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
        return;
      }
      let uiContext:  UIContext | undefined = windowStage.getMainWindowSync().getUIContext();
      AppStorage.setOrCreate('uiContext', uiContext);
      hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
    });

4.2 首页到个人信息页

PersonalInformationNavPathStackIndex用户PersonalInformationNavPathStackIndex用户@Provide NavPathStackNavigation + PagesMap点击「个人信息」pushPath({ name: "PersonalInformation" })navDestination(PagesMap)实例化 PersonalInformation()@Consume NavPathStackaboutToAppear 初始化弹窗上下文与默认日期

关键步骤:

  1. Index@Provide('NavPathStack') 提供导航栈,NavigationMode.Stack 为栈式导航。
  2. @Builder PagesMap(name) 按名称映射子页,目前仅 PersonalInformation
  3. 按钮点击:this.pathStack.pushPath({ name: 'PersonalInformation' })
  4. PersonalInformation@Component(不是 @Entry),根节点是 NavDestination
  5. 子页通过 @Consume('NavPathStack') 拿到同一份栈,返回时 pathStack.pop()
@Entry
@Component
struct Index {
  @Provide('NavPathStack') pathStack: NavPathStack = new NavPathStack();

  @Builder
  PagesMap(name: string) {
    if (name === 'PersonalInformation') {
      PersonalInformation()
    }
  }

4.3 个人信息页初始化(aboutToAppear)

进入页面后立刻完成三件事:

  1. 把当前页的 UIContext 交给自定义弹窗单例 PromptActionClass
  2. 设置自定义弹窗居中:alignment: DialogAlignment.Center
  3. 用当天日期初始化 selectTime / birthDate(若尚未有出生日期)。
  aboutToAppear() {
    PromptActionClass.setContext(this.ctx);

    PromptActionClass.setOptions({ alignment: DialogAlignment.Center });
    let date = new Date();
    let year = date.getFullYear();
    let month = date.getMonth() + 1;
    let day = date.getDate();
    this.currentDate = year + '-' + month + '-' + day;
    this.selectTime = new Date(`${this.currentDate}T08:30:00`);
    if (!this.birthDate) {
      this.birthDate = CommonUtils.getBirthDateValue(year, month, day);
    }
    // ...
  }

4.4 页面交互总览

否且已编辑

个人信息页

昵称 / 签名输入

出生日期行

性别行

兴趣爱好行

右上角菜单

系统返回

inputIsEdit = true

日期选择弹窗

文本选择弹窗

自定义多选弹窗

气泡 Popup

保存 + Toast

已保存?

警告弹窗

直接 pop


5. 六类弹窗:关键步骤与代码路径

5.1 日期滑动选择器(固定样式)

触发:点击「出生日期」行(TextCommonComponent.onItemClick)。

关键步骤

  1. 调用 this.getUIContext().showDatePickerDialog
  2. 指定可选范围 start: 1925-1-1 ~ end: 2055-1-1
  3. selected 绑定当前 selectTime,打开时滚到上次选中日期。
  4. lunarSwitch: true 支持农历切换;showTime: false 不选时分。
  5. 用户点确定进入 onDateAccept
    • 更新 selectTime
    • Date 序列化后截取 YYYY-MM-DD
    • CommonUtils.getBirthDateValue 拼成本地化文案(中文为「2026年8月20日」,英文为 2026/8/20
    • AppStorage.setOrCreate('isEdit', true) 标记未保存
            onItemClick: () => {
              this.getUIContext().showDatePickerDialog({
                start: new Date('1925-1-1'),
                end: new Date('2055-1-1'),
                selected: this.selectTime,
                lunarSwitch: true,
                showTime: false,
                onDateAccept: (value: Date) => {
                  this.selectTime = value;
                  let birthDateArray = JSON.stringify(value).slice(1, 11).split('-');
                  let year = Number(birthDateArray[0]);
                  let month = Number(birthDateArray[1]);
                  let day = Number(birthDateArray[2]);
                  this.birthDate = CommonUtils.getBirthDateValue(year, month, day);
                  AppStorage.setOrCreate('isEdit', true);
                }
              })
            }

日期格式化:

  getBirthDateValue(year: number, month: number, day: number): string {
    let resourceYear = context?.resourceManager.getStringSync($r('app.string.year'));
    let resourceMonth = context?.resourceManager.getStringSync($r('app.string.month'));
    let resourceDay = context?.resourceManager.getStringSync($r('app.string.day'));
    let birthdate: string = `${year}${resourceYear}${month}` + `${resourceMonth}${day}${resourceDay}`;
    return birthdate;
  }

5.2 文本滑动选择器(固定样式)

触发:点击「性别」行。

关键步骤

  1. range 使用资源 $r('app.strarray.sex_array')(中文:男/女;英文:male/female)。
  2. selected 绑定上次选中下标 this.select
  3. canLoop: false 禁止循环滚动。
  4. onChange:滑动过程中只更新下标。
  5. onAccept:确定后写入 sexselect,并置 isEdit = true
              this.getUIContext().showTextPickerDialog({
                range: this.sexArray,
                selected: this.select,
                canLoop: false,
                onAccept: (value: TextPickerResult) => {
                  this.select = value.index as number;
                  this.sex = value.value as string;
                  AppStorage.setOrCreate('isEdit', true);
                },
                onChange: (value: TextPickerResult) => {
                  this.select = value.index as number;
                }
              })

5.3 自定义弹窗:兴趣爱好多选

这是本示例最完整的一条链路,分为「封装层」和「内容层」。

5.3.1 封装层 PromptActionClass

Dialog.ets 导出单例,持有三块状态:

成员作用
ctx: UIContext用来拿 PromptAction
contentNode: ComponentContent自定义弹窗的 UI 节点
options: BaseDialogOptions对齐方式等基础配置

对外方法:

  • setContext / setContentNode / setOptions:注入依赖
  • openDialogpromptAction.openCustomDialog(contentNode, options)
  • closeDialogpromptAction.closeCustomDialog(contentNode)
  • updateDialogpromptAction.updateCustomDialog(本示例未调用,预留刷新能力)
  openDialog() {
    if (this.contentNode !== null) {
      this.ctx?.getPromptAction().openCustomDialog(this.contentNode, this.options)
        .then(() => {
          hilog.info(0xFF00, 'PersonalInformation', '%{public}s', 'OpenCustomDialog complete');
        })
        .catch((error: BusinessError) => {
          // ...
        })
    }
  }

openCustomDialog 需要的是 已经构建好的 ComponentContent,而不是直接传入 @Builder。这是 HarmonyOS 新推荐写法,替代旧的 CustomDialogController

5.3.2 内容层:Builder + ComponentContent

弹窗 UI 定义在页面级 @Builder function buildHobbyItems(params: Params) 中,结构为:

Column(白底圆角卡片)
 ├── 标题「兴趣爱好」
 ├── List:ForEach 每一项 = 文案 + Checkbox Toggle
 └── Row:取消 | 分割线 | 确定

ParamshobbyItems 传进 Builder,Toggle 的 onChange 直接改 itemHobby.isChecked

5.3.3 打开流程
PromptActionPromptActionClassresourceManagerPersonalInformation用户PromptActionPromptActionClassresourceManagerPersonalInformation用户loop[每一项]点击兴趣爱好行hobbyItems = []getStringArrayValue(hobbies_data)["足球","羽毛球","旅游","打游戏","看书"]HobbyItem { label, isChecked:false }new ComponentContent(ctx, wrapBuilder(buildHobbyItems), Params)setContentNode(contentNode)openDialog()openCustomDialogisEdit = true

关键步骤:

  1. 清空 hobbyItems,避免重复叠加。
  2. 通过 getHostContext().resourceManager.getStringArrayValue 异步读取 $r('app.strarray.hobbies_data')
  3. 转成 { label, isChecked } 列表。
  4. wrapBuilder(buildHobbyItems) + new ComponentContent(...) 生成节点。
  5. 注入单例并 openDialog()
  6. 打开即标记 isEdit = true(即使取消也会留下编辑标记)。
            onItemClick: () => {
              this.hobbyItems = [];
              let context = this.getUIContext().getHostContext() as Context;
              // ...
              manager.getStringArrayValue($r('app.strarray.hobbies_data').id, (error, hobbyArray) => {
                // ...
                  hobbyArray.forEach((hobbyItem: string) => {
                    let tmpHobbyItem = new HobbyItem();
                    tmpHobbyItem.label = hobbyItem;
                    tmpHobbyItem.isChecked = false;
                    this.hobbyItems.push(tmpHobbyItem);
                  });
                  this.contentNode =
                    new ComponentContent(this.ctx, wrapBuilder(buildHobbyItems), new Params(this.hobbyItems));
                  PromptActionClass.setContentNode(this.contentNode);
                  PromptActionClass.openDialog();
                  AppStorage.setOrCreate('isEdit', true);
5.3.4 关闭与回写
  • 取消:只 closeDialog(),不写爱好文本。
  • 确定
    1. closeDialog()
    2. setHobbiesValue:过滤 isChecked === true 的项,用逗号拼接 label
    3. AppStorage.setOrCreate('Hobbies', text),页面 @StorageLink('Hobbies') 自动刷新展示
    4. 再次标记 isEdit = true
function setHobbiesValue(hobbyItems: HobbyItem[]) {
  if (CommonUtils.isEmptyArr(hobbyItems)) {
    hilog.info(0xFF00, 'PersonalInformation', '%{public}s', 'hobbyItems length is 0');
    return;
  }
  let hobbiesText: string = '';
  hobbiesText = hobbyItems.filter((isCheckItem: HobbyItem) => isCheckItem?.isChecked)
    .map<string>((checkedItem: HobbyItem) => {
      return checkedItem.label!;
    })
    .join(',');
  return hobbiesText;
}

按钮样式用 @Extend(Button) function dialogButtonStyle() 复用:蓝字、白底、均分宽度。

5.4 气泡 Popup + Toast(固定样式)

触发:点击导航栏右侧网格图标,.bindPopup 绑定 customPopup 布尔值。

关键步骤

  1. 点击图标:this.customPopup = !this.customPopup
  2. bindPopup 配置:
    • builder: this.PopupBuilder:气泡内容(「保存信息」)
    • placement: Placement.Bottom:在锚点下方弹出
    • onStateChange:气泡因点击外部关闭时,把 customPopup 置回 false,避免状态不同步
  3. 点击「保存信息」:
    • isSaved = true,关闭气泡
    • isEditfalse
    • showToast({ message: 保存成功, duration: 2000 })
    • 把昵称、签名、出生日期、selectTime、性别、爱好全部 setOrCreateAppStorage
      .bindPopup(this.customPopup, {
        builder: this.PopupBuilder, // Bubble content
        placement: Placement.Bottom, // The pop position of the bubble
        onStateChange: (e) => {
          if (!e.isVisible) {
            this.customPopup = false;
          }
        }
      })
    .onClick(() => {
      this.isSaved = true;
      this.customPopup = false;
      AppStorage.setOrCreate('isEdit', false);
      this.ctx.getPromptAction().showToast({
        message: $r('app.string.save_successfully'),
        duration: 2000
      })
      AppStorage.setOrCreate('nikeName', this.nikeName);
      // ... signature / birthDate / selectTime / sex / select / hobbies
      AppStorage.setOrCreate('isEdit', false);
    })

说明:这里的「保存」是进程内 AppStorage 快照,不是磁盘持久化。应用被杀后数据会丢失,符合示例定位。

5.5 警告弹窗:未保存返回拦截(固定样式)

触发NavDestination.onBackPressed(系统返回、侧滑返回)。

判断条件

!isSaved && (isEdit || inputIsEdit)
  • isSaved:是否点过气泡里的保存
  • isEdit:日期 / 性别 / 爱好是否改过
  • inputIsEdit:昵称 / 签名输入框是否改过(由 TextInputComponent 写入)

关键步骤

  1. 条件成立:调用 showAlertDialogreturn true 拦截默认返回
  2. 弹窗配置:居中、上移 20vp、gridCount: 4autoCancel: true(点遮罩可关)。
  3. 取消:只打日志,停留当前页。
  4. 确定:清空表单字段,复位 isEdit / inputIsEditpathStack.pop() 退出。
  5. 条件不成立:return false,走系统默认返回。
    .onBackPressed(() => {
      let inputIsEdit: boolean | undefined = AppStorage.get('inputIsEdit');
      if (!this.isSaved && (this.isEdit || inputIsEdit)) {
        this.getUIContext().showAlertDialog({
          message: $r('app.string.tips'),
          autoCancel: true,
          alignment: DialogAlignment.Center,
          offset: { dx: 0, dy: -20 },
          gridCount: 4,
          buttons: [
            { value: $r('app.string.cancel'), action: () => { /* 停留 */ } },
            {
              value: $r('app.string.confirm'),
              action: () => {
                // 清空字段并 pop
                this.pathStack.pop();
              }
            }
          ]
        })
        return true;
      }
      return false;
    })

6. 可复用 UI 组件

6.1 TextInputComponent(输入行)

用于昵称、个人签名。

参数说明
@Link text双向绑定父组件字符串
inputImage左侧图标
hintText占位文案

onChange 逻辑:

  • 内容相对原值变化 → inputIsEdit = true
  • 内容被清空 → inputIsEdit = false

这样「只改输入框」也能触发返回警告。

6.2 TextCommonComponent(展示行)

用于出生日期、性别、兴趣爱好。

布局:图标 | 标题 | 内容(右对齐省略) | 右箭头。整行 onClick 触发父组件传入的 onItemClick,由父组件决定打开哪类弹窗。

这是典型的「展示组件不管弹窗类型,业务页负责策略」的拆分。


7. 数据流与状态字典

7.1 AppStorage 键

绑定位置含义
uiContextEntryAbility 写入全局 UI 上下文
nikeName@StorageLink昵称(源码拼写为 nikeName)
signature@StorageLink个人签名
birthDate@StorageLink出生日期展示文案
sex@StorageLink性别
Hobbies@StorageLink爱好拼接文本
selectTime@StorageLinkDatePicker 选中 Date
select@StorageLinkTextPicker 选中下标
isEdit@StorageLink选择类控件是否编辑过
inputIsEditTextInput 写入输入框是否编辑过
hobbies保存时写入Hobbies 大小写不同,见下文注意点

页面内另有本地 @State

  • hobbyItems:自定义弹窗选项列表
  • customPopup:气泡显隐
  • isSaved:是否已点保存(不进 AppStorage,每次进入页面为 false)

7.2 编辑态状态机

进入页面

改输入框 / 选日期 / 选性别 / 打开爱好弹窗

气泡「保存信息」

按返回

点取消

点确定并 pop

按返回直接 pop

干净

已编辑

已保存

拦截返回


8. 资源与国际化

资源用途
string.json标题、按钮、提示、年月日后缀
stringarray.jsonsex_arrayhobbies_data
zh_CN / en_US / base三套语言;baseen_US 内容基本一致

差异示例:

  • 提示:中文「当前页面数据未保存,是否离开」;英文 “Data on the current page is not saved…”
  • 日期:中文用「年/月/日」;英文 year=/、month=/、day 为空,呈现 2026/8/20

爱好列表中文:足球、羽毛球、旅游、打游戏、看书。


9. 关键 HarmonyOS API 对照

API所属本项目用途
windowStage.loadContentWindow加载首页
Navigation + NavPathStackArkUI单 Ability 内页面栈
NavDestination.onBackPressedArkUI拦截返回并弹警告
UIContext.showAlertDialogArkUI未保存警告
UIContext.showDatePickerDialogArkUI出生日期
UIContext.showTextPickerDialogArkUI性别
Component.bindPopupArkUI右上角保存菜单
PromptAction.showToastArkUI保存成功提示
PromptAction.openCustomDialogArkUI爱好多选
ComponentContent + wrapBuilderArkUI把 @Builder 变成弹窗内容节点
resourceManager.getStringArrayValue资源异步读爱好数组
AppStorage / @StorageLink状态跨组件表单数据
@Provide / @Consume状态导航栈下发
@Extend(Button)语法弹窗按钮样式复用

10. 从零复现:实现清单

按本示例的做法,在新工程中落地同类能力,可按以下顺序实施。

步骤 1:工程与入口

  1. 创建 Stage 模型应用,compatibleSdkVersion ≥ 5.0.5。
  2. EntryAbility.onWindowStageCreate 加载首页,并把 UIContext 存入 AppStorage

步骤 2:Navigation 容器

  1. 首页 @Entry + Navigation(pathStack)
  2. @Provide 导航栈,@Builder 按 name 映射子页。
  3. 子页用 NavDestination + @Consume 导航栈。

步骤 3:表单页骨架

  1. 拆出输入行、展示行两个通用组件。
  2. 字段用 @StorageLink 绑定 AppStorage
  3. 输入 onChangeinputIsEdit;选择类回调写 isEdit

步骤 4:接入固定样式弹窗

  1. 日期行 → showDatePickerDialog,在 onDateAccept 格式化并回写。
  2. 性别行 → showTextPickerDialogrange 指向字符串数组资源。
  3. 导航菜单 → bindPopup + showToast 完成保存反馈。
  4. onBackPressed 中按编辑态决定是否 showAlertDialog,拦截时 return true

步骤 5:接入自定义弹窗

  1. 写单例:保存 UIContextComponentContentBaseDialogOptions
  2. @Builder 定义弹窗内容(列表 + 取消/确定)。
  3. 打开前:读资源 → 组装数据 → new ComponentContent(ctx, wrapBuilder(...), params)
  4. openCustomDialog 打开;确定时拼接结果写入 AppStoragecloseCustomDialog

步骤 6:资源

  1. 把文案、选项数组放到 string.json / stringarray.json
  2. zh_CNen_US 各准备一份,日期分隔符按语言区分。

11. 总结

这是个人信息编辑场景,把 HarmonyOS 弹窗体系拆成两条实现路径:

  • 固定样式:全部通过 UIContextshowAlertDialog / showDatePickerDialog / showTextPickerDialog,以及组件级 bindPopupshowToast。参数即外观,适合标准确认、选择、轻提示。
  • 自定义样式wrapBuilderComponentContentPromptAction.openCustomDialog。内容、多选、按钮布局完全自绘,适合系统弹窗覆盖不了的交互。

页面组织上,首页只做 Navigation 容器;业务与弹窗集中在 PersonalInformation;自定义弹窗的打开/关闭被抽到 PromptActionClass 单例;表单行抽成两个展示/输入组件。编辑态由 isEdit + inputIsEdit + isSaved 共同决定返回时是否拦截,形成一条完整的「编辑 → 弹窗改值 → 保存/放弃」闭环。
)

Logo

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

更多推荐