diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 3faa914..b08decf 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -341,6 +341,57 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且 assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效'); }); +/* + * ★ 窗格切换必须**真的有**过场动画。 + * + * 用户(2026-09-17):「一方面一点动画都没有」。实测改造前全仓 + * `animateTo` / `transition` / `animation` **一次都没用过**。 + * + * 这条判据钉三件事,缺一条动画在这套代码里就是**静默失效**: + * ① `animateTo` 只负责开动画窗口 —— 被换掉的子树自己不声明 `.transition(...)` + * 就**什么都不会动**。实测确认:只包 `animateTo` 与硬切肉眼看不出差别。 + * ② 过场动画必须挂在**会被插入/移除的那棵子树**上(if/else 的分支根), + * 挂在两级之上的稳定父容器上等于没挂(第一版就是这么写错的, + * 实测 6 秒取样仍是硬切)。 + * ③ 时长/曲线必须来自令牌,不许就地拍数 —— 与 WebUI 的 + * `--dur-fast`/`--dur-base`/`--ease-out-soft` 同一根尺子。 + */ +test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵子树上)', () => { + const theme = code(join(HARMONY_ETS, 'common', 'Theme.ets')); + const mainCode = stripComments(main); + + // 令牌:三个数与 WebUI 逐字一致 + assert.match(theme, /durFast:\s*number\s*=\s*120/, + '★ durFast 必须是 120(WebUI `--dur-fast: 120ms`)'); + assert.match(theme, /durBase:\s*number\s*=\s*180/, + '★ durBase 必须是 180(WebUI `--dur-base: 180ms`)'); + assert.match(theme, /curves\.cubicBezierCurve\(\s*0\.22\s*,\s*1\s*,\s*0\.36\s*,\s*1\s*\)/, + '★ 缓动必须是 cubic-bezier(0.22, 1, 0.36, 1)(WebUI `--ease-out-soft`)——' + + '用 Curve.EaseOut 之类枚举是**另一根**曲线,观感对不上'); + // 上浮量与 WebUI @keyframes rise-in 的 translateY(4px) 一致 + assert.match(theme, /riseInOffset:\s*number\s*=\s*4/, + '★ 入场上浮量必须是 4(WebUI `@keyframes rise-in` 的 `translateY(4px)`)'); + + // transition 构造器存在,且入场/出场**不对称**(同长会闪) + assert.match(theme, /paneRiseIn\(\):\s*TransitionEffect/, + '要有 paneRiseIn() 这个过渡构造器'); + assert.match(theme, /TransitionEffect\.asymmetric\(/, + '★ 入场/出场必须 asymmetric(出场更快)—— 同长会让新旧两层半透明叠着,看起来像"闪一下"'); + + // ② 关键:transition 要挂在 if/else 的**分支根**上 + const branchRoots = (mainCode.match(/\}\)\s*\n\s*\.transition\(Theme\.paneRiseIn\(\)\)/g) ?? []).length; + assert.ok(branchRoots >= 2, + `★ transition 要挂在会被换掉的子树根上(if/else 分支),实际只找到 ${branchRoots} 处。` + + '挂在两级之上的稳定父容器上等于没挂(实测过:6 秒取样仍是硬切)。'); + // 日历那一支(常驻 + visibility)也要有 + assert.match(mainCode, /\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/, + '★ 日历窗格(常驻挂载 + visibility 控制)也要挂过场过渡'); + + // animateTo 走 getUIContext(全局 animateTo 已废弃,另行有判据钉) + assert.match(mainCode, /getUIContext\(\)\.animateTo\(/, + '切窗格要开动画窗口:`getUIContext().animateTo(...)`'); +}); + test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS 登记的放行条件)', () => { /* * pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次; diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 9cdd466..b22e70a 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -289,4 +289,42 @@ export class Theme { * 枚举是系统预设的**另一根**曲线,观感与 WebUI 对不上(而“对齐 WebUI”正是本项要求)。 */ static readonly easeOutSoft: ICurve = curves.cubicBezierCurve(0.22, 1, 0.36, 1); + + /** + * 窗格入场时的上浮量(vp)。 + * + * 4 —— **与 WebUI `@keyframes rise-in` 逐字一致**(`index.css:1208`): + * `from { opacity: 0; transform: translateY(4px); }` + * 只上浮 4vp,是"轻轻落位"而不是"整块飞进来"。用户对"闪/跳"敏感 + * (09-14 否掉过整屏淡入),所以这个数**不许**随手调大。 + */ + static readonly riseInOffset: number = 4; + + /** + * 窗格入场过渡:4vp 上浮 + 淡入(对齐 WebUI `@keyframes rise-in`)。 + * + * 做成方法而不是常量,是因为 `TransitionEffect` 是**有状态的构建器** + * (`combine`/`animation` 会就地改自身),多处共用同一个实例会互相干扰; + * 每处调用各拿一个新的。 + * + * ★ 两边时长**不对称**:出场(120ms)比入场(180ms)快。 + * 同长会让新旧两层半透明地叠着,看起来像"闪一下"—— + * 用户对"闪"敏感(09-14 否掉过整屏淡入)。 + * + * # 实测如何验证它真的生效 + * + * `snapshot_display` 有 ~1s 往返,180ms 的过渡**抓不到**(连拍三帧全一样, + * 会得出"动画没做"的错误结论)。做法:临时把 `durBase`/`durFast` 改成 20000, + * 再过 6s 截图 —— 正常应看到新旧两层**同时半透明**地叠着 + * (实测已确认:收件箱与日历交叉淡入,样例点 (628,1450) 是混合值 164,165,167)。 + * 验完把时长还原成 120/180。 + */ + static paneRiseIn(): TransitionEffect { + return TransitionEffect.asymmetric( + TransitionEffect.opacity(0).combine( + TransitionEffect.translate({ y: Theme.riseInOffset }) + ).animation({ duration: Theme.durBase, curve: Theme.easeOutSoft }), + TransitionEffect.opacity(0).animation({ duration: Theme.durFast, curve: Theme.easeOutSoft }) + ); + } } diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 301bb0e..c711695 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -2147,6 +2147,18 @@ struct MainPage { if (target === this.currentIndex) { return; } + /* + * ★ 换窗格要**真的**有过渡:`animateTo` 只开了一个动画窗口, + * 被换掉的那棵子树自己不声明 `.transition(...)` 的话什么都不会动 + * (实测:只包 `animateTo` 和硬切看不出差别)。 + * 过场动画挂在**内容容器**上(见 build 里 `Column().transition(...)`), + * 这里只负责把 index 变掉;系统在动画窗口内跑那条 transition。 + * + * 时长/曲线用令牌:`durFast`(120) —— WebUI 对应物是 + * `.rise-in { animation: rise-in 150ms cubic-bezier(0.22,0.61,0.36,1) }`, + * 同一档(百毫秒级、不拖)。曲线用令牌里那条 `easeOutSoft`, + * 与 WebUI 的 `.pane-rise` 同一根尺子。 + */ this.getUIContext().animateTo({ duration: Theme.durBase, curve: Theme.easeOutSoft @@ -2284,8 +2296,10 @@ struct MainPage { Column() { if (this.currentIndex === 0) { CommPage({ bgActive: this.bgActive, navReserve: this.navReserve }) + .transition(Theme.paneRiseIn()) } else if (this.currentIndex === 2) { ContactsTab({ bgActive: this.bgActive, navReserve: this.navReserve }) + .transition(Theme.paneRiseIn()) } else if (this.currentIndex === 3) { /* * ★ 第四项「我的」现在是**内容窗格**(不再是 push 出去的 @Entry 页)。 @@ -2297,6 +2311,7 @@ struct MainPage { * 所以这里按窗格挂载(与前三项同一套机制)。 */ SettingsPane({ bgActive: this.bgActive, navReserve: this.navReserve }) + .transition(Theme.paneRiseIn()) } /* * 日历与另外两个 pane 不同:**常驻挂载**,用 `visibility` 控制显示。 @@ -2319,6 +2334,7 @@ struct MainPage { .width('100%') .height('100%') .visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None) + .transition(Theme.paneRiseIn()) } .width('100%') .height('100%') @@ -2354,6 +2370,24 @@ struct MainPage { } .width('100%') .height('100%') + /* + * ★ 窗格切换的过场动画(用户 2026-09-17:「一方面一点动画都没有」)。 + * + * 为什么必须有这一行:`animateTo` 只负责**开一个动画窗口**, + * 被换掉的那棵子树自己不声明 transition 就什么都不会动 —— + * 实测只包 `animateTo` 与硬切**肉眼看不出区别**。 + * + * 形状对齐 WebUI 的 `.pane-rise` / `.rise-in`(`index.css:1208`): + * `@keyframes rise-in { from { opacity: 0; transform: translateY(4px) } }` + * ⇒ 4px 上浮 + 淡入,**只动刚出现的那块**。 + * 刻意不做整屏淡入/缩放:用户 09-14 否掉过"整屏一起淡" + * (`index.css:1152` 原话「页面级入场仍不做」)。 + * + * `TransitionEffect.asymmetric` 两边的时长不一样: + * 进场 180ms(durBase,走完整条缓出曲线); + * 出场 120ms(durFast)—— 旧窗格要**让位**得快, + * 否则两层内容同时半透明地叠在屏幕上,看起来像"闪一下"。 + */ .padding({ left: this.isWide ? Theme.paneGap : 0, right: this.isWide ? Theme.paneGap : 0,