HarmonyOS | ArkUI弹窗
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.ets | Navigation 根页面,跳转到个人信息页 |
PersonalInformation.ets | 表单 UI + 全部弹窗触发逻辑 |
Dialog.ets | 对 openCustomDialog / closeCustomDialog / updateCustomDialog 的单例封装 |
TextInputComponent.ets | 昵称、签名输入;同步 inputIsEdit |
TextCommonComponent.ets | 出生日期 / 性别 / 爱好展示行,点击回调打开对应弹窗 |
CommonUtils.ets | 出生日期本地化拼接、空对象/空数组判断 |
2.2 页面注册
main_pages.json 只注册了 pages/Index。个人信息页不是独立路由页,而是挂在 Navigation 上的 NavDestination,通过 navDestination + NavPathStack.pushPath 进入。
3. 整体架构
核心设计:
- 单页导航:首页用
Navigation+NavPathStack管理子页,避免多@Entry页面。 - 全局状态:表单字段、编辑标记走
AppStorage+@StorageLink,跨组件同步。 - 弹窗入口统一走
UIContext:系统弹窗用this.getUIContext().showXxxDialog,自定义弹窗用getPromptAction().openCustomDialog。
4. 完整实现流程
4.1 应用启动
关键步骤:
module.json5指定mainElement为EntryAbility,且exported: true,带entity.system.home/action.system.home,作为桌面入口。onWindowStageCreate中调用windowStage.loadContent('pages/Index')。- 加载成功后,从主窗口取出
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 首页到个人信息页
关键步骤:
Index用@Provide('NavPathStack')提供导航栈,NavigationMode.Stack为栈式导航。@Builder PagesMap(name)按名称映射子页,目前仅PersonalInformation。- 按钮点击:
this.pathStack.pushPath({ name: 'PersonalInformation' })。 PersonalInformation是@Component(不是@Entry),根节点是NavDestination。- 子页通过
@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)
进入页面后立刻完成三件事:
- 把当前页的
UIContext交给自定义弹窗单例PromptActionClass。 - 设置自定义弹窗居中:
alignment: DialogAlignment.Center。 - 用当天日期初始化
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 页面交互总览
5. 六类弹窗:关键步骤与代码路径
5.1 日期滑动选择器(固定样式)
触发:点击「出生日期」行(TextCommonComponent.onItemClick)。
关键步骤:
- 调用
this.getUIContext().showDatePickerDialog。 - 指定可选范围
start: 1925-1-1~end: 2055-1-1。 selected绑定当前selectTime,打开时滚到上次选中日期。lunarSwitch: true支持农历切换;showTime: false不选时分。- 用户点确定进入
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 文本滑动选择器(固定样式)
触发:点击「性别」行。
关键步骤:
range使用资源$r('app.strarray.sex_array')(中文:男/女;英文:male/female)。selected绑定上次选中下标this.select。canLoop: false禁止循环滚动。onChange:滑动过程中只更新下标。onAccept:确定后写入sex、select,并置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:注入依赖openDialog→promptAction.openCustomDialog(contentNode, options)closeDialog→promptAction.closeCustomDialog(contentNode)updateDialog→promptAction.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:取消 | 分割线 | 确定
Params 把 hobbyItems 传进 Builder,Toggle 的 onChange 直接改 itemHobby.isChecked。
5.3.3 打开流程
关键步骤:
- 清空
hobbyItems,避免重复叠加。 - 通过
getHostContext().resourceManager.getStringArrayValue异步读取$r('app.strarray.hobbies_data')。 - 转成
{ label, isChecked }列表。 wrapBuilder(buildHobbyItems)+new ComponentContent(...)生成节点。- 注入单例并
openDialog()。 - 打开即标记
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(),不写爱好文本。 - 确定:
closeDialog()setHobbiesValue:过滤isChecked === true的项,用逗号拼接 labelAppStorage.setOrCreate('Hobbies', text),页面@StorageLink('Hobbies')自动刷新展示- 再次标记
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 布尔值。
关键步骤:
- 点击图标:
this.customPopup = !this.customPopup。 bindPopup配置:builder: this.PopupBuilder:气泡内容(「保存信息」)placement: Placement.Bottom:在锚点下方弹出onStateChange:气泡因点击外部关闭时,把customPopup置回false,避免状态不同步
- 点击「保存信息」:
isSaved = true,关闭气泡- 把
isEdit置false showToast({ message: 保存成功, duration: 2000 })- 把昵称、签名、出生日期、selectTime、性别、爱好全部
setOrCreate回AppStorage
.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写入)
关键步骤:
- 条件成立:调用
showAlertDialog,return true拦截默认返回。 - 弹窗配置:居中、上移 20vp、
gridCount: 4、autoCancel: true(点遮罩可关)。 - 取消:只打日志,停留当前页。
- 确定:清空表单字段,复位
isEdit/inputIsEdit,pathStack.pop()退出。 - 条件不成立:
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 键
| 键 | 绑定位置 | 含义 |
|---|---|---|
uiContext | EntryAbility 写入 | 全局 UI 上下文 |
nikeName | @StorageLink | 昵称(源码拼写为 nikeName) |
signature | @StorageLink | 个人签名 |
birthDate | @StorageLink | 出生日期展示文案 |
sex | @StorageLink | 性别 |
Hobbies | @StorageLink | 爱好拼接文本 |
selectTime | @StorageLink | DatePicker 选中 Date |
select | @StorageLink | TextPicker 选中下标 |
isEdit | @StorageLink | 选择类控件是否编辑过 |
inputIsEdit | TextInput 写入 | 输入框是否编辑过 |
hobbies | 保存时写入 | 与 Hobbies 大小写不同,见下文注意点 |
页面内另有本地 @State:
hobbyItems:自定义弹窗选项列表customPopup:气泡显隐isSaved:是否已点保存(不进 AppStorage,每次进入页面为 false)
7.2 编辑态状态机
8. 资源与国际化
| 资源 | 用途 |
|---|---|
string.json | 标题、按钮、提示、年月日后缀 |
stringarray.json | sex_array、hobbies_data |
zh_CN / en_US / base | 三套语言;base 与 en_US 内容基本一致 |
差异示例:
- 提示:中文「当前页面数据未保存,是否离开」;英文 “Data on the current page is not saved…”
- 日期:中文用「年/月/日」;英文 year=
/、month=/、day 为空,呈现2026/8/20
爱好列表中文:足球、羽毛球、旅游、打游戏、看书。
9. 关键 HarmonyOS API 对照
| API | 所属 | 本项目用途 |
|---|---|---|
windowStage.loadContent | Window | 加载首页 |
Navigation + NavPathStack | ArkUI | 单 Ability 内页面栈 |
NavDestination.onBackPressed | ArkUI | 拦截返回并弹警告 |
UIContext.showAlertDialog | ArkUI | 未保存警告 |
UIContext.showDatePickerDialog | ArkUI | 出生日期 |
UIContext.showTextPickerDialog | ArkUI | 性别 |
Component.bindPopup | ArkUI | 右上角保存菜单 |
PromptAction.showToast | ArkUI | 保存成功提示 |
PromptAction.openCustomDialog | ArkUI | 爱好多选 |
ComponentContent + wrapBuilder | ArkUI | 把 @Builder 变成弹窗内容节点 |
resourceManager.getStringArrayValue | 资源 | 异步读爱好数组 |
AppStorage / @StorageLink | 状态 | 跨组件表单数据 |
@Provide / @Consume | 状态 | 导航栈下发 |
@Extend(Button) | 语法 | 弹窗按钮样式复用 |
10. 从零复现:实现清单
按本示例的做法,在新工程中落地同类能力,可按以下顺序实施。
步骤 1:工程与入口
- 创建 Stage 模型应用,
compatibleSdkVersion≥ 5.0.5。 EntryAbility.onWindowStageCreate加载首页,并把UIContext存入AppStorage。
步骤 2:Navigation 容器
- 首页
@Entry+Navigation(pathStack)。 @Provide导航栈,@Builder按 name 映射子页。- 子页用
NavDestination+@Consume导航栈。
步骤 3:表单页骨架
- 拆出输入行、展示行两个通用组件。
- 字段用
@StorageLink绑定AppStorage。 - 输入
onChange写inputIsEdit;选择类回调写isEdit。
步骤 4:接入固定样式弹窗
- 日期行 →
showDatePickerDialog,在onDateAccept格式化并回写。 - 性别行 →
showTextPickerDialog,range指向字符串数组资源。 - 导航菜单 →
bindPopup+showToast完成保存反馈。 onBackPressed中按编辑态决定是否showAlertDialog,拦截时return true。
步骤 5:接入自定义弹窗
- 写单例:保存
UIContext、ComponentContent、BaseDialogOptions。 - 用
@Builder定义弹窗内容(列表 + 取消/确定)。 - 打开前:读资源 → 组装数据 →
new ComponentContent(ctx, wrapBuilder(...), params)。 openCustomDialog打开;确定时拼接结果写入AppStorage再closeCustomDialog。
步骤 6:资源
- 把文案、选项数组放到
string.json/stringarray.json。 - 为
zh_CN、en_US各准备一份,日期分隔符按语言区分。
11. 总结
这是个人信息编辑场景,把 HarmonyOS 弹窗体系拆成两条实现路径:
- 固定样式:全部通过
UIContext的showAlertDialog/showDatePickerDialog/showTextPickerDialog,以及组件级bindPopup、showToast。参数即外观,适合标准确认、选择、轻提示。 - 自定义样式:
wrapBuilder→ComponentContent→PromptAction.openCustomDialog。内容、多选、按钮布局完全自绘,适合系统弹窗覆盖不了的交互。
页面组织上,首页只做 Navigation 容器;业务与弹窗集中在 PersonalInformation;自定义弹窗的打开/关闭被抽到 PromptActionClass 单例;表单行抽成两个展示/输入组件。编辑态由 isEdit + inputIsEdit + isSaved 共同决定返回时是否拦截,形成一条完整的「编辑 → 弹窗改值 → 保存/放弃」闭环。
)
更多推荐
所有评论(0)