跨端: P5 悬浮玻璃导航取代系统 TabBar —— 自绘浮动条 + 命中区 ≥44vp + 内容让位

(subject 原为「跨端(P5): …」—— 被自己的 commit-hygiene 判据判红:约定是 subject 里带
 `跨端:`,而 `跨端(P5):` 让字面 grep 找不到。判据是对的,改提交不改成判据。)
This commit is contained in:
2026-09-14 15:25:02 +08:00
parent f412751b37
commit 25ac19b343
7 changed files with 373 additions and 29 deletions

View File

@ -301,7 +301,7 @@ test('B旧机制不得回来手写玻璃 alpha、替系统猜深色、与
*/
const GLASS_REGISTRY = [
// 文件(相对 ets 根) + 组件/Builder 名:为什么这里可以有一层系统材质
{ file: 'pages/MainPage.ets', provider: 'TabBarBuilder', why: '底部导航栏要浮在内容之上P5 的悬浮导航)' }
{ file: 'pages/MainPage.ets', provider: 'NavBar', why: 'P5 悬浮玻璃条:浮在**会滚动的内容**之上pi 给的放行条件②),壁纸层整个不吃材质' }
];
/**

View File

@ -386,8 +386,18 @@ test('★ 页面真的把背景画出来了这一条是补漏P4 第一版
* 这一条与下面那条"遮盖色方向"是同一件事的两个面:这里钉"层在不在、浓度接没接上"。
*/
assert.match(main, /\.backgroundColor\(Theme\.wallpaperScrim\)[\s\S]{0,80}?\.opacity\(this\.bgPlan\.scrim\)/, '遮盖层要用页面底色系令牌与算出来的浓度');
// 背景在主界面这一层Tabs 之上不该再有不透明的底色把背景盖死
assert.match(main, /Stack\(\) \{[\s\S]{0,120}?this\.WallpaperLayer\(\)/, '背景要铺在内容之下Stack 的底层)');
/*
* 背景在主界面这一层:内容之前不该再有不透明的底色把背景盖死。
* ⚠️ P5 起根 `Stack` 带了构造参数(`{ alignContent: Alignment.Bottom }`,浮动条贴底用),
* 所以这里不能写死 `Stack() {` —— 判据钉的是"壁纸是**第一个**子节点",不是 Stack 的写法。
*/
const rootFrom = main.indexOf('this.WallpaperLayer()');
assert.ok(rootFrom > 0, '根里要渲染壁纸层');
const stackOpen = [...main.matchAll(/Stack\([^)]*\)\s*\{/g)].map(m => m.index).filter(i => i < rootFrom).pop();
assert.ok(stackOpen !== undefined, '壁纸层要挂在某个 Stack 里(浮在最底)');
const between = main.slice(stackOpen, rootFrom);
assert.ok(!/[A-Za-z]+\(/.test(between.replace(/Stack\([^)]*\)/, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')),
`壁纸层必须是 Stack 的**第一个**子节点(中间不该有别的组件):${between.replace(/\s+/g, ' ').slice(0, 120)}`);
});
test('★ 模糊归属:壁纸层**不许**再模糊导航条必须有系统材质互斥形式pi 修正后的口径)', () => {
@ -403,7 +413,7 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统
/*
* 取"壁纸层 builder 的正文"要**按行**截:用 `indexOf('build() {')` 两头夹,
* 要么撞上文件里更早的那个 build()(切片成空串),要么一路跨到后面的
* TabBarBuilder那里**正当地**有 `.backgroundBlurStyle`)——于是判据会误报。
* 底栏 builderP5 起叫 `NavBar`那里**正当地**有 `.backgroundBlurStyle`)——于是判据会误报。
* 这是我自己的切片毛病,和 pi 指出 §三 那条是同一类。
*/
const mainLines = main.split('\n');
@ -414,7 +424,7 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统
if (/^ (@Builder|build\()/.test(mainLines[i])) { stop = i; break; }
}
const wallpaperBuilder = mainLines.slice(start, stop).join('\n');
assert.ok(!/TabBarBuilder/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去');
assert.ok(!/NavBar/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去');
assert.ok(wallpaperBuilder.length > 200, '要能取到壁纸层的 builder 正文');
assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder), '壁纸层不许再用材质(同一张底糊两遍 = 更脏更掉帧)');
assert.ok(!/blur\(/i.test(wallpaperBuilder), '壁纸层不许出现任何模糊调用');

View File

@ -228,8 +228,16 @@ test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是
// 第一项 2026-09-14 从「收件箱」变成「通信」:收件箱/发件箱/授权现在是**通信页内部**的三栏
// (用户:「收件发件授权改为一个导航项,通过内部导航区分」),标签必须跟着改 ——
// 否则底部写着"收件箱"、点进去却有发件箱和授权,比没有更让人困惑。
const tabLabels = [...pageCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['通信', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`);
/*
* P5 起底栏换成自绘的浮动玻璃条(`NavBar` + `NavItems.ts`),不再是系统 `Tabs` 的
* `TabBarBuilder('标签')` 调用 —— 所以标签从**清单**里读,判的仍是同一件事:
* 平级项只剩两个、顺序不变。
*/
const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length;
assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动');
const navSource = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8');
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`);
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions否则是没人看的请求');
});
@ -500,7 +508,9 @@ test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐
test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => {
// 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变)
const tabLabels = [...sendCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]);
// P5底栏标签现在来自 NAV_ITEMS自绘浮动条不是 TabBarBuilder 的参数
const navSource2 = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8');
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字

View File

@ -0,0 +1,202 @@
// P5 悬浮玻璃导航:判据钉"点得到、点对了、没盖住内容",不钉观感。
//
// 这一期换掉的是**条**(系统 `Tabs` 的 bar → 自绘悬浮玻璃条),信息架构没动:
// 内容仍按 `currentIndex` 挂载,两个平级项仍是 通信 / 联系人。
// 所以判据分四类:
// ① 点击:每个导航项点下去落到**它自己**那个 index配对不是"存在 onClick"
// ② 挂载index 决定挂哪个页面(点第 2 项必须挂联系人,不是挂了但没变);
// ③ 命中区≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜;
// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质),
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts');
const read = p => readFileSync(join(HARMONY_ETS, p), 'utf8');
/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */
const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
/** 取某个 `@Builder` 的正文(按行切到下一个成员) */
function builderBody(src, signature) {
const lines = src.split('\n');
const start = lines.findIndex(l => l.includes(signature));
assert.ok(start > 0, `要能找到 ${signature}`);
let stop = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^ (@Builder|build\()/.test(lines[i])) { stop = i; break; }
}
return lines.slice(start, stop).join('\n');
}
/**
* 取 `from` 处第一个 `{` 到**配对**的 `}` 之间的正文。
*
* ⚠️ 我第一版用的是非贪婪正则 `\{([\s\S]*?)\}`:它在
* `CommPage({ bgActive: this.bgActive })` 的**对象字面量** `}` 上就收尾了,
* 于是"else 分支里有没有 ContactsTab"被判成没有 —— 又是我自己那条
* "判结构要配对/解析,不要窗口"(判据也被它咬)。这里按花括号配对取。
*/
function braceBody(src, from) {
const at = src.indexOf('{', src.indexOf(from));
assert.ok(at > 0, `要能找到 ${from} 后面的 {`);
let depth = 0;
for (let i = at; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(at + 1, i); }
}
assert.fail(`${from} 的花括号没有闭合`);
}
const N = await import(NAV_TS);
const main = read('pages/MainPage.ets');
test('① 点击:每个导航项点下去落到**它自己**那个 index配对不是"有 onClick"', () => {
/*
* 这条刻意不写成"存在 onClick" —— 那是最容易被满足、也最容易骗人的形状:
* 点哪个都赋 0 也能过。这里要求**每一项的点击目标与它的项下标配对**
* 点击处理器里赋的值,必须来自这一项自己的 indexForEach 的第二个参数),
* 并且过 `normalizeNavIndex` 归一化(与内部页签同一套纪律:不直接赋值)。
*/
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
assert.ok(item.length > 100, '要能取到导航项的正文');
/*
* 点击处理器是 `() => { this.currentIndex = normalizeNavIndex(index); }`。
* 取赋值语句并断言它**同时**用到 `index` 与归一化函数 ——
* 只看"出现过 normalizeNavIndex"不够(可以写 `normalizeNavIndex(0)` 骗过去),
* 所以要求括号里的实参就是 `index`。
*/
const assigns = [...item.matchAll(/this\.currentIndex\s*=\s*([^;]+);/g)].map(m => m[1].trim());
assert.equal(assigns.length, 1, `导航项里应当恰好有一次点击赋值(实际 ${assigns.length} 次)`);
assert.match(assigns[0], /normalizeNavIndex\(\s*index\s*\)/,
`点击要落到本项自己的 index 并过归一化,现在赋的是:${assigns[0]}`);
// 处理器必须挂在**这一项**上(不是别的成员上)
assert.match(item, /\.onClick\(\(\)\s*=>\s*\{[\s\S]{0,120}?this\.currentIndex/, '点击处理器要挂在本项上');
// ForEach 要真的把 index 传进来(否则上面那句 index 无从谈起)
const bar = builderBody(main, 'NavBar() {');
assert.match(bar, /ForEach\(NAV_ITEMS,\s*\(item: NavItem,\s*index: number\)/, 'ForEach 要带 index 参数');
assert.match(bar, /this\.NavItem\(item,\s*index\)/, '要把每一项(连同 index交给导航项 builder');
});
test('② 挂载index 决定挂哪个页面(点第 2 项必须挂联系人)', () => {
/*
* 换了条之后最容易出的错是"点了没反应"——点击改了状态,但内容仍写死挂第一个页面。
* 所以把 index → 页面的映射钉死0 → CommPage否则 → ContactsTab
* 且两边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。
*/
const stack = main.slice(main.indexOf('Stack({ alignContent: Alignment.Bottom })'));
// 只在**根 build()** 里数分派(页面内部也有 if/else扫全文会数错
const root = stack.slice(0, stack.indexOf('this.NavBar()') + 100);
const dispatch = [...root.matchAll(/if \(this\.currentIndex === 0\)/g)];
assert.equal(dispatch.length, 1, `根里应当恰好一处"按 index 分派内容"(实际 ${dispatch.length} 处)`);
const first = braceBody(root, 'if (this.currentIndex === 0)');
const second = braceBody(root.slice(root.indexOf('if (this.currentIndex === 0)')), 'else');
assert.match(first, /CommPage\(\{ bgActive: this\.bgActive \}\)/, 'index 0 要挂通信页');
assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive \}\)/, 'index 1 要挂联系人页');
/*
* 分派必须只有这两支,且必须**穷尽** NAV_ITEMS —— 现在只有两项,用 if/else 就够了;
* 一旦 NAV_ITEMS 变长,这条会红并提醒改成按清单挂载(否则第 3 项点了没内容)。
*/
assert.equal(N.NAV_ITEMS.length, 2,
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,而内容分派只有 if/else 两支 —— 加了项必须同时加分派`);
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
const navAt = stack.indexOf('this.NavBar()');
const contentAt = stack.indexOf('CommPage({ bgActive: this.bgActive })');
assert.ok(navAt > contentAt, '浮动条要在内容之后渲染(浮在上层)');
});
test('③ 命中区 ≥44vp判的是数值本身从 NavItems.ts 导入,不猜源码)', () => {
assert.ok(N.NAV_ITEM_MIN_HIT >= 44, `命中区下限必须 ≥44vp现在 ${N.NAV_ITEM_MIN_HIT}`);
assert.ok(N.NAV_BAR_HEIGHT >= N.NAV_ITEM_MIN_HIT,
`条高(${N.NAV_BAR_HEIGHT})不得小于命中区下限(${N.NAV_ITEM_MIN_HIT}`);
// 数值写了还要**用上**:项必须有下限约束,且高度取条高
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
assert.match(item, /\.constraintSize\(\{\s*minHeight:\s*NAV_ITEM_MIN_HIT,\s*minWidth:\s*NAV_ITEM_MIN_HIT\s*\}\)/,
'导航项要显式声明命中区下限minHeight 与 minWidth 都要)');
assert.match(item, /\.height\(NAV_BAR_HEIGHT\)/, '导航项高度取条高');
assert.match(item, /\.layoutWeight\(1\)/, '两项要等分条宽(否则命中区宽度靠内容撑,会小于下限)');
// 条自身也要够高
const bar = builderBody(main, 'NavBar() {');
assert.match(bar, /\.height\(NAV_BAR_HEIGHT\)/, '条高取同一常量');
});
test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),内容底部让出的高度 ≥ 条高 + 离底留白', () => {
const bar = builderBody(main, 'NavBar() {');
/*
* "悬浮"是可判的形状:四周留白 + 圆角 + 浮在内容之上。
* 这三样缺一样就不再是 WebUI 那条玻璃条了(贴边的全宽条 = 又变回系统 bar 的样子)。
*/
assert.match(bar, /\.margin\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/,
'四周要留白(左右 + 离底),贴边就不是悬浮');
assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)');
assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '材质用系统档次,不手写 alpha');
/*
* 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值"
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
* 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。
*/
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(code(bar)),
`条上不许出现手写色值(深浅两套由系统给),实际:${(code(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`);
// 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到)
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
`内容让位(${N.NAV_CONTENT_RESERVE})必须 ≥ 条高 + 离底留白(${N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM}`);
assert.match(main, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, '内容底部要让出这段高度');
// 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)
const contentPad = main.slice(main.indexOf('Column() {\n if (this.currentIndex === 0)'));
assert.match(contentPad.slice(0, 400), /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
'让位要加在挂载内容的那层上');
});
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项仍是两项', () => {
/*
* 反面:如果只是"加了自绘条但仍留着系统 bar",界面上会出现两条导航(且系统那条
* 依然贴边、依然占位),这正是这一期要消除的东西。所以断言根导航里**没有**
* `Tabs(` / `TabContent` / `tabBar(`。
* 注意:**通信页内部**的三栏仍然用系统 `Tabs`(那是页面内部页签,不是根导航)——
* 所以这条只在"根组件"的范围内断言,别扫全文。
*/
const root = main.slice(main.indexOf('build() {\n /*\n * 底部两项'));
assert.ok(root.length > 300, '要能取到根 build() 的正文');
const navCode = code(root);
assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs');
assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent内容改为按 index 挂载)');
assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()');
// 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效)
assert.ok(!/TabBarBuilder/.test(code(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
// 平级项仍是两项且顺序不变(信息架构这一期不动)
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '联系人'], '平级项与顺序');
assert.equal(new Set(N.NAV_ITEMS.map(i => i.key)).size, N.NAV_ITEMS.length, '键要唯一ForEach 的 key');
assert.deepEqual(N.NAV_ITEMS.map(i => N.navKeyAt(N.NAV_ITEMS.indexOf(i))), N.NAV_ITEMS.map(i => i.key),
'navKeyAt 与清单一致');
// 归一化:越界/负数都退回第一项(点击与外部传值都过它)
assert.equal(N.normalizeNavIndex(-1), 0);
assert.equal(N.normalizeNavIndex(99), 0);
assert.equal(N.normalizeNavIndex(1), 1);
assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
});
test('★ 玻璃只在两处、且这一处是"背后有可变内容"GLASS 登记的放行条件)', () => {
/*
* pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次;
* ②模糊该出现在"背后是可变内容"的层。悬浮条背后是**会滚动的内容** ✓,
* 而壁纸层背后什么都没有 → 壁纸层不许有材质/模糊。
* 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`GLASS_REGISTRY
*/
const wallpaper = builderBody(main, 'WallpaperLayer() {');
assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)');
assert.ok(!/blur\(/i.test(code(wallpaper)), '壁纸层不许有任何模糊调用');
const bar = builderBody(main, 'NavBar() {');
assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)');
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
const mainRaw = read('pages/MainPage.ets');
const navDoc = mainRaw.slice(mainRaw.indexOf('底部导航:**自绘的悬浮玻璃条**'), mainRaw.indexOf('@Builder\n NavBar() {'));
assert.match(navDoc, /会滚动的内容|滚动内容/, '要写清"为什么这一处可以有材质"(背后是可变内容)');
assert.match(navDoc, /GLASS_REGISTRY/, '要指名登记册,后人查得到放行流程');
});

View File

@ -67,9 +67,11 @@ const SUITE = [
['test/harmony-system-api.test.mjs', [], 5],
// P4 外观同步:跑 model/Appearance.ts纯逻辑所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
['test/appearance-defaults.test.mjs', [], 3],
['test/build-stamp.test.mjs', [], 5],
['test/build-stamp.test.mjs', [], 6],
['test/packaging.test.mjs', [], 3],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2]
];

View File

@ -0,0 +1,72 @@
/*
* 底部导航P5悬浮玻璃条的**清单与尺寸**。
*
* 为什么数据放在 `.ts` 而不是写在 `MainPage.ets` 里:
* - 判据可以直接 `import` 它(`node --experimental-strip-types` 能跑纯逻辑 `.ts`
* 于是"命中区 ≥44vp"这类要求判的是**数值本身**,而不是拿正则去源码里猜;
* - 「通信 → 联系人」的顺序与文案是信息架构,和 CommTabs 一样属于跨端要对齐的东西
* WebUI 侧同样是两项,撤掉会话 tab 的时机见计划文档 §7.15)。
*/
/** 一个底部导航项。字段与 WebUI `Sidebar.tsx` 的项一一对应 */
export interface NavItem {
/** 稳定键ForEach 的 key也是判据认的名字 */
key: string;
/** 显示文案(与 WebUI 逐字一致) */
label: string;
/** 图标字形(鸿蒙侧用文本字形,不用猜 sys.media 名字) */
icon: string;
}
/**
* 平级导航项。**只有两项**:通信(内部三栏:收件箱/发件箱/授权)与联系人。
*
* 「会话」原先是个平级 tab已撤 —— 它不是第三个地方,而是"同一批数据的另一种看法"
* (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由与时机见 `MainPage.build()` 的注释。
* 「日历」等 P6 的内容做完再上入口 —— 不留点进去空着的页签§7.15)。
*/
export const NAV_ITEMS: NavItem[] = [
{ key: 'comm', label: '通信', icon: '✉️' },
{ key: 'contacts', label: '联系人', icon: '👤' }
];
/** 命中区下限vp。**44 是可点区域的下限**,不是"看起来够大"的估计 */
export const NAV_ITEM_MIN_HIT: number = 44;
/** 浮动条自身高度vp。≥ NAV_ITEM_MIN_HIT否则命中区内边距会互相挤压 */
export const NAV_BAR_HEIGHT: number = 56;
/** 悬浮左右留白vp—— 贴边就不是"悬浮"了WebUI 的底栏同样浮在内容之上 */
export const NAV_BAR_SIDE: number = 16;
/** 悬浮离屏幕底部的留白vp */
export const NAV_BAR_BOTTOM: number = 12;
/** 圆角vp。用"胶囊"半径(= 高度一半),与 WebUI 的 `rounded-2xl` 观感一致 */
export const NAV_BAR_RADIUS: number = 28;
/**
* 内容底部要让出的高度vp条高 + 离底留白 + 一点余量。
*
* **这是"点得到"的问题,不是美观问题**:条是浮在内容之上的,不让出这段高度,
* 列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
*/
export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + 8;
/** 把任意下标归一化到合法范围(点击/外部传值都过这里,别直接赋值) */
export function normalizeNavIndex(index: number): number {
if (index >= 0 && index < NAV_ITEMS.length) {
return index;
}
return 0;
}
/** 第 index 项的键(内容按它挂载,判据也认它) */
export function navKeyAt(index: number): string {
return NAV_ITEMS[normalizeNavIndex(index)].key;
}
/** 第 index 项的标签(给判据与无障碍文案用) */
export function navLabelAt(index: number): string {
return NAV_ITEMS[normalizeNavIndex(index)].label;
}

View File

@ -48,6 +48,17 @@ import {
emptyHint
} from '../model/CommTabs';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import {
NAV_BAR_BOTTOM,
NAV_BAR_HEIGHT,
NAV_BAR_RADIUS,
NAV_BAR_SIDE,
NAV_CONTENT_RESERVE,
NAV_ITEM_MIN_HIT,
NAV_ITEMS,
NavItem,
normalizeNavIndex
} from '../model/NavItems';
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
const INBOX_PAGE_SIZE: number = 50;
@ -1674,15 +1685,52 @@ struct MainPage {
}
@Builder
TabBarBuilder(title: string, icon: string, index: number) {
NavItem(item: NavItem, index: number) {
Column() {
Text(icon).fontSize(20)
Text(title)
Text(item.icon).fontSize(20)
Text(item.label)
.fontSize(Theme.fontTiny)
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
}
.width('100%')
/*
* 命中区:**显式给下限**44vp不靠"看起来够大"。
* 每个项 `layoutWeight(1)` 分到条宽的一半,高度就是条高 —— 两处都远大于 44
* 但仍把下限写出来:以后有人把条调矮时,这里会挡住(判据也钉这个常量)。
*/
.layoutWeight(1)
.height(NAV_BAR_HEIGHT)
.constraintSize({ minHeight: NAV_ITEM_MIN_HIT, minWidth: NAV_ITEM_MIN_HIT })
.justifyContent(FlexAlign.Center)
// 点击:过 normalizeNavIndex 归一化(与内部页签同一套纪律:不直接赋值)
.onClick(() => {
this.currentIndex = normalizeNavIndex(index);
})
}
/**
* 底部导航:**自绘的悬浮玻璃条**P5取代系统 `Tabs` 的 bar。
*
* 为什么换成自绘的:
* - 系统 `Tabs` 的 bar 只能在 `barPosition` 上下两侧、贴边、按系统规矩排布,
* 摆不出 WebUI 那种"浮在内容之上、四周留白、圆角胶囊"的形状;
* - 内容仍按 `currentIndex` 挂载(状态机是同一个),所以这次换的是**条**,不是信息架构。
*
* 玻璃**只在这一层**,而且这次是"**第二处**允许的模糊"
* 壁纸层整个不吃材质(同一张底只许糊一次),而这条背后是**会滚动的内容** ——
* 满足 pi 给的放行条件§7.16),所以它在 `GLASS_REGISTRY` 里登记了并写了理由。
*/
@Builder
NavBar() {
Row() {
ForEach(NAV_ITEMS, (item: NavItem, index: number) => {
this.NavItem(item, index)
}, (item: NavItem) => item.key)
}
.width('100%')
.height(NAV_BAR_HEIGHT)
// 悬浮:四周留白(贴边就不是悬浮了)+ 胶囊圆角
.margin({ left: NAV_BAR_SIDE, right: NAV_BAR_SIDE, bottom: NAV_BAR_BOTTOM })
.borderRadius(NAV_BAR_RADIUS)
/*
* 导航底:**系统材质**,不是手写 alpha。
*
@ -1690,8 +1738,6 @@ struct MainPage {
* 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突,
* 而且还要我们自己维护两套。现在只声明**档次**`Theme.navMaterial`
* 深浅两套颜色与模糊半径都由系统按主题给。
*
* 玻璃**只出现在这一层**(内容面不透)—— 嵌套各加一层模糊是 pi 点名要避免的。
*/
.backgroundBlurStyle(Theme.navMaterial)
}
@ -1717,25 +1763,27 @@ struct MainPage {
* status 与 from_agent 参考实现也不显示,见 `WorkCard.tsx` 的字段集,
* 判据里有一条"卡片字段两边一致"钉住这件事,避免以后以为漏了。)
*/
Stack() {
Stack({ alignContent: Alignment.Bottom }) {
// 背景在最底下:内容面(系统色)盖在它上面
this.WallpaperLayer()
Tabs({ barPosition: BarPosition.End }) {
TabContent() {
CommPage({ bgActive: this.bgActive })
/*
* 内容按 `currentIndex` 挂载(原来这里是系统 `Tabs` 的 TabContent)。
* 底部**必须让出** NAV_CONTENT_RESERVE 的高度:条是浮在内容之上的,
* 不让出这一段,列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
*/
Column() {
if (this.currentIndex === 0) {
CommPage({ bgActive: this.bgActive })
} else {
ContactsTab({ bgActive: this.bgActive })
}
}
.tabBar(this.TabBarBuilder('通信', '✉️', 0))
TabContent() {
ContactsTab({ bgActive: this.bgActive })
}
.tabBar(this.TabBarBuilder('联系人', '👤', 1))
}
.onChange((index: number) => {
this.currentIndex = index;
})
.width('100%')
.height('100%')
.padding({ bottom: NAV_CONTENT_RESERVE })
// 浮动玻璃条浮在最上层(自绘,见 NavBar 的说明)
this.NavBar()
}
.width('100%')
.height('100%')