用户:「日历页面还是没有左右滑动动画」。
## 实测定位(不是推理)
把动画时长临时放大到 5s + Linear(确保抓得到中间帧),点「‹」后连拍,
逐偏移互相关算位移:
最佳 dx = 0 ROI 平均差 0.00
只留 `translate`、去掉 `opacity` 后直拍三帧:帧1 与帧3 **逐像素相同**。
⇒ 一点横向位移都没产生。
## 根因
`.transition()` 只在组件**挂载/卸载**(`if` 条件切换)时触发。
而翻月只改 `year`/`month`,网格 `Column` **一直存在** ⇒ 过渡永远不触发。
代码里那句注释("键带 monthKey() 前缀 ⇒ 节点重建 ⇒ 过渡必播")说的是
`ForEach` 的**子项**键 —— 外层容器并不会因此重挂。这是我当初写错的假设。
## 修法:改用本仓已验证可播的那套
`MainPage` 的日历窗格入场(`calPaneIn`)用的是
① `@State` 数值;② 显式 `.translate()`/`.opacity()`;③ `animateTo` 同改。
这里照它做(`slideInPct` / `slideInOpacity`),并与 WebUI `cal-in-next/prev`
逐字同源:**12% + 淡入**、方向由 `forward` 决定。
顺序纪律(与 `calPaneIn` 同):**起点值写在 animateTo 外面**,
写进闭包会让渲染层只看见最终值 ⇒ 退化成瞬移。
修复后复验(同一次 5s 放大连拍):
帧序列 = 旧月静止 → **横向位移 100px** → 新月静止 ✅
## 判据
`animation-audit` 的 `cal-in-*` 条目从"`Theme.calendarSlide` 有调用点"
改为"页面里有 `slideInPct`/`slideInOpacity` 驱动 + `.translate` 接线"。
★ 删掉死方法 `Theme.calendarSlide`(它已无调用点 —— 判据当场抓到,
这正是"必须盯调用点"那条纪律的价值)。
变异(两条都能红):
· 删掉 `.translate({x: this.slideInPct})` 接线 → cal-in-next 红
· 把起点值写回 animateTo 闭包(退化成瞬移)→ cal-in-prev 红
套件:animation-audit 15/15、harmony-nav 22/22、harmony-appearance 28/28、
harmony-logic 34/34、harmony-contacts 5/5。
458 lines
24 KiB
JavaScript
458 lines
24 KiB
JavaScript
/**
|
||
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
|
||
*
|
||
* # 为什么需要它(2026-09-15 用户:「全面检查整体的动画」)
|
||
*
|
||
* 一次盘点查出两处**死动画**与一处**过宽的动画**,三种形态都极难靠肉眼发现:
|
||
* ① `@keyframes pane-in` 挂在 `html.view-switch .pane-enter` 上,而 **`.pane-enter`
|
||
* 没有任何组件在穿** —— 规则看着像"页面有入场动画",实际一次都不会播;
|
||
* ② `.animate-menu-in`(菜单入场)写好了、keyframes 也有,**同样没人穿**
|
||
* ⇒ 所有下拉/候选菜单其实都是"啪"地出现;
|
||
* ③ `html.view-switch .glass-control` 让**所有**控件档元素在每次切视图时一起动
|
||
* (几十个按钮/输入框同时淡入位移),是"闪"和卡顿的现成来源。
|
||
*
|
||
* 这三种都不是"动画不够好看",而是**接线断了**:类与使用者脱钩、作用域开得过大。
|
||
* 所以判据钉的是"接线",不是时长与曲线(那两样由人看着定)。
|
||
*
|
||
* # 判据
|
||
*
|
||
* 1. 每个 @keyframes 都必须有人穿 —— 从规则里取出穿它的类名,回源码里找;
|
||
* 找不到就是死动画(① 就是这么被抓住的)。
|
||
* 2. 弹层(.popup-surface 的菜单)必须带上菜单入场类(② 的接线)。
|
||
* 3. 不得再出现"整档控件一起动"的 view-switch 规则(③)。
|
||
* 4. 挂载即播那档(.rise-in)必须被 prefers-reduced-motion 显式关掉。
|
||
*/
|
||
import { readdirSync } from 'node:fs';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { check, finish } from './lib/checks.mjs';
|
||
import { code, PKG } from './lib/read.mjs';
|
||
|
||
// 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法,
|
||
// 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。
|
||
/*
|
||
* 仓库根 —— 鸿蒙侧那几个路径要从仓库根算。
|
||
* 本文件原先只读 WebUI 的 CSS(相对 `PKG` 就够),所以没有 ROOT;
|
||
* 加鸿蒙那四条之后需要它。`PKG` 仍用于 WebUI(它自带正确基准)。
|
||
*/
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||
|
||
const css = code('src/index.css');
|
||
|
||
/** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */
|
||
function componentsSrc() {
|
||
const out = [];
|
||
const walk = dir => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const p = `${dir}/${e.name}`;
|
||
if (e.isDirectory()) walk(p);
|
||
else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p));
|
||
}
|
||
};
|
||
/* ★ 基准用 PKG(= client/electron),**不是**相对 cwd:
|
||
* `readdirSync` 不经过 read.mjs,所以这里必须显式给基准。
|
||
* 原来传 'src' ⇒ 从仓库根直接跑这个文件时 `ENOENT: scandir 'src'`(pi 2026-09-17)。 */
|
||
walk(join(PKG, 'src'));
|
||
return out.join('\n');
|
||
}
|
||
const src = componentsSrc();
|
||
|
||
/** 取出所有 @keyframes 名 + 每条规则的 selector 文本 */
|
||
const keyframes = [...css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map(m => m[1]);
|
||
const rules = [...css.matchAll(/(^|\n)([^{}\n][^{}]*)\{([^{}]*)\}/g)].map(m => ({
|
||
sel: m[2].trim().replace(/\s+/g, ' '),
|
||
body: m[3]
|
||
}));
|
||
|
||
/**
|
||
* 一条 selector 在**最终**要不要靠某个类名才能命中?
|
||
* 只取"最后一个复合选择器"上的类(`html.view-switch .pane-enter` → `pane-enter`),
|
||
* 祖先里的类(html.view-switch、.app-shell)是状态开关,不算穿的人。
|
||
*/
|
||
function wearerClasses(sel) {
|
||
return sel
|
||
.split(',')
|
||
.map(part => part.trim().split(/\s+/).pop() || '')
|
||
.filter(last => last.startsWith('.'))
|
||
.map(last => last.split(/[\s.:[>]/)[0].replace(/^\./, ''))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
const dead = [];
|
||
const unwrapped = [];
|
||
for (const k of keyframes) {
|
||
const users = rules.filter(r => new RegExp(`animation:\\s*${k}\\b`).test(r.body));
|
||
if (users.length === 0) {
|
||
dead.push(`${k}(没有任何规则用它)`);
|
||
continue;
|
||
}
|
||
// 这条 keyframes 的每一个使用者都必须"有人穿" —— 否则它还是不会播
|
||
const worn = users.some(r => {
|
||
const cls = wearerClasses(r.sel);
|
||
if (cls.length === 0) return true; // 元素选择器/通配:不算死
|
||
return cls.some(c => new RegExp(`["'\`\\s]${c}(?=["'\`\\s]|$)`).test(src) || src.includes(c));
|
||
});
|
||
if (!worn) unwrapped.push(`${k} ← ${users.map(u => u.sel).join(' | ')}`);
|
||
}
|
||
|
||
check(
|
||
'每个 @keyframes 都有人穿(没有死动画)',
|
||
dead.length === 0 && unwrapped.length === 0,
|
||
`只定义了没人穿的动画(看着有、永远不播):${[...dead, ...unwrapped].join(';')}`
|
||
);
|
||
|
||
check(
|
||
'弹层菜单带着菜单入场类',
|
||
/animate-menu-in/.test(rules.map(r => r.sel).join(',')) &&
|
||
/popup-surface[^`]*animate-menu-in|animate-menu-in[^`]*popup-surface/.test(src),
|
||
'菜单入场类存在但没人穿 —— 下拉/候选菜单是"啪"地出现(2026-09-15 盘点抓到的第二处死动画)'
|
||
);
|
||
|
||
// 只在**顶层规则**里查:reduced-motion 块里那句 `html.view-switch .glass-control` 是
|
||
// "把它关掉"的名单,不是"让它动"的规则 —— 第一版没区分,判据自己假红。
|
||
const topLevel = css.replace(/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
|
||
check(
|
||
'没有"整档控件一起动"的切视图规则',
|
||
!/html\.view-switch\s+\.glass-control/.test(topLevel),
|
||
'view-switch 又把 .glass-control 整档带上了 —— 每次切视图几十个按钮/输入框一起动(闪与卡顿的现成来源)'
|
||
);
|
||
|
||
const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) || [];
|
||
check(
|
||
'挂载即播那档被 reduced-motion 显式关掉',
|
||
reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)),
|
||
'prefers-reduced-motion 覆盖不到 .rise-in —— 关掉动画的人照样会看到它'
|
||
);
|
||
|
||
|
||
/*
|
||
* ══════════════════════════════════════════════════════════════════════
|
||
* 鸿蒙侧:**共享元素转场**(`geometryTransition`)的接线
|
||
* ══════════════════════════════════════════════════════════════════════
|
||
*
|
||
* 用户 2026-09-21:「我记得 webui 行为是按钮变成对应的写邮件页面或输入框吧,
|
||
* 你做的啥?」—— WebUI `ComposePage.tsx:57-79` 的 FLIP 就是"球长成面板",
|
||
* 鸿蒙侧的对应能力是 `geometryTransition`。
|
||
*
|
||
* 它有三种**静默失效**的写法(都只会表现为"动画没播",不报错):
|
||
* ① 只绑一端 —— 没有 in/out 配对,系统无处可插值;
|
||
* ② 只绑 `geometryTransition` 却**没有 `animateTo`** ——
|
||
* 官方原文:「**必须配合 animateTo 使用**才有动画效果,
|
||
* 动效时长、曲线跟随 animateTo 中的配置,**不支持 animation 动画**」;
|
||
* ③ 同一个 id 绑了**三个以上**组件 —— 官方原文:
|
||
* 「同一个 id 只能有**两个**组件绑定…**不能多个组件绑定同一个 id**」。
|
||
*
|
||
* 判据就钉这三条。它们全是"接线"性质,与时长/曲线无关(那两样由人看着定)——
|
||
* 与本文件既有四条的分寸一致(`harmony-nav` 那份注释里写过同一条理由)。
|
||
*/
|
||
const HARMONY_PAGES = join(ROOT, 'client/harmony/entry/src/main/ets/pages');
|
||
function harmonySources() {
|
||
const out = [];
|
||
for (const e of readdirSync(HARMONY_PAGES, { withFileTypes: true })) {
|
||
if (e.isFile() && e.name.endsWith('.ets')) out.push({ name: e.name, src: code(join(HARMONY_PAGES, e.name)) });
|
||
}
|
||
return out;
|
||
}
|
||
const hs = harmonySources();
|
||
const allHarmony = hs.map(h => h.src).join('\n');
|
||
|
||
/* 每个 id(按文件统计,因为 id 是**字符串**,跨文件同名是合法但危险的) */
|
||
const geomIds = new Map(); // id -> [file, ...]
|
||
for (const h of hs) {
|
||
for (const m of h.src.matchAll(/geometryTransition\(\s*'([^']+)'/g)) {
|
||
const id = m[1];
|
||
if (!geomIds.has(id)) geomIds.set(id, []);
|
||
geomIds.get(id).push(h.name);
|
||
}
|
||
}
|
||
|
||
check(
|
||
'鸿蒙|共享元素转场的每个 id 恰好绑**两处**(一 in 一 out)',
|
||
[...geomIds.values()].every(v => v.length === 2),
|
||
'官方约束:「同一个 id 只能有两个组件绑定,且分别作为 in(新视图)和 out(旧视图)' +
|
||
'两种不同类型角色,不能多个组件绑定同一个 id」;实际:' +
|
||
[...geomIds.entries()].map(([k, v]) => `${k}→${v.length}处(${v.join(',')})`).join(' ')
|
||
);
|
||
|
||
/*
|
||
* ★★ 这三条**第一版写错了**,记在这里以免重蹈(变异实测发现的)。
|
||
*
|
||
* 第一版是全仓 `any()`:
|
||
* /animateTo\(/.test(allHarmony) /durMorph/.test(allHarmony) /easeRise/…
|
||
* 变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`)
|
||
* ——**三条全绿**。因为全仓**别处**还有这些名字,所以"删掉这一处"永远命中不了。
|
||
*
|
||
* 第二版改成"逐站点看邻域",又**假红**:`geometryTransition(id)` 绑在
|
||
* **组件树**上,而 `animateTo` 写在**另一个方法**里 —— 文本邻域取不到隔壁的方法。
|
||
*
|
||
* ⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**:
|
||
* 把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处
|
||
* (`animateTo` + 时长 + 曲线都在里面),调用点只剩"我要改哪个状态"。
|
||
* 这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`:
|
||
* 把"每处都得记得写"收敛成"一处定义、处处引用")。
|
||
*
|
||
* 现在判据钉的就是**这个结构**,逐条都能被变异打红:
|
||
*/
|
||
|
||
const MOTION = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Motion.ets'));
|
||
const themeSrc = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets'));
|
||
|
||
check(
|
||
'鸿蒙|morph 只有**一个**入口且它内部有 animateTo(时长/曲线同处)',
|
||
/static morph\(ui: UIContext, mutate: \(\) => void\): void \{/.test(MOTION) &&
|
||
/ui\.animateTo\(/.test(MOTION),
|
||
'`Motion.morph` 的形状变了(少了 animateTo 或统一入口)—— ' +
|
||
'官方:「必须配合 animateTo 使用才有动画效果,时长与曲线跟随 animateTo 的配置」;' +
|
||
'时长/曲线与 animateTo 必须在**同一处**,散开就必然有人漏'
|
||
);
|
||
|
||
/*
|
||
* ★★ 2026-09-21 改:从"钉 `duration: Motion.dur(Theme.durMorph)` + `curve: Theme.easeRise`"
|
||
* 改为钉**官方弹簧曲线的契约**。
|
||
*
|
||
* 为什么旧钉法必须改(而不是把字面量改成新字面量):
|
||
* 用户指出「webui 是 webui,app 是 app…APP 存在大量系统预制动效,为什么不用?」
|
||
* —— 我此前把 WebUI 的 CSS 数值(220ms + cubic-bezier)当成了规格,
|
||
* 而那只是它的**带宽约束下的上限**。改用系统弹簧曲线后,
|
||
* `duration` 按 SDK 原文**根本不再生效**:
|
||
* 「The **duration** parameter does not take effect when springMotion /
|
||
* responsiveSpringMotion / interpolatingSpring are configured for **curve**.」
|
||
* ⇒ 继续断言"有 duration"等于在钉一个**不再成立的前提**。
|
||
*
|
||
* 新钉法看**三件真的事**:
|
||
* ① 走官方弹簧曲线(`Theme.springResponsive` / `springMotion`);
|
||
* ② 经过 `Motion.anim` 那道统一入口(它负责"关动画时连曲线一起换");
|
||
* ③ 不自己写 `duration`(弹簧曲线下那是**静默无效**的写法)。
|
||
* ③ 单独可变异:把 `Motion.anim(...)` 换回 `{ duration: 220, curve: ... }` ⇒ 红。
|
||
*/
|
||
check(
|
||
'鸿蒙|morph 走**官方弹簧曲线**且经过 `Motion.anim` 统一入口',
|
||
/ui\.animateTo\(Motion\.anim\(\s*Theme\.spring/.test(MOTION),
|
||
'`Motion.morph` 不再用官方弹簧曲线(或没走 `Motion.anim`)—— '
|
||
);
|
||
|
||
check(
|
||
'鸿蒙|弹簧曲线下**不得**自己写 duration(SDK:它不生效)',
|
||
!/ui\.animateTo\(\{/.test(MOTION),
|
||
'`animateTo` 直接收字面对象 `{ duration: … }` —— 若配的是弹簧曲线,' +
|
||
'按 SDK 原文 duration **不生效**;若配的是 cubic-bezier,则丢掉系统预制动效。' +
|
||
'两种都不对 ⇒ 必须走 `Motion.anim(spring)`'
|
||
);
|
||
|
||
check(
|
||
'鸿蒙|`Motion.anim` 在"减弱动效"时同时换掉曲线(不是只把 duration 折 0)',
|
||
/*
|
||
* ★ 这里必须断言"换成了**不是** spring 参数的曲线"。
|
||
* 第一版写成 `…duration: 0,…curve:` —— 那个 `curve:` 后面接什么都行,
|
||
* 于是变异"把 curl: Curve.Linear 换成 curve: spring"**测不出来**
|
||
* (实测:变异后仍然 13/13 绿)。
|
||
* 判据自己对变异不敏感 = 它实际没在守卫那件事。
|
||
*
|
||
* 现在钉两件事同时成立:
|
||
* ① 减弱分支里存在 `duration: 0`;
|
||
* ② 同一分支的 `curve:` **不是** `spring`(即真的换掉了)。
|
||
*/
|
||
/static anim\(spring: ICurve\): AnimateParam/.test(MOTION) &&
|
||
/duration: 0,[\s\S]{0,80}?curve: (?!spring\b)/.test(MOTION),
|
||
'只把 duration 折 0 而保留弹簧曲线 ⇒ **动画照放**(弹簧曲线下 duration 无效)' +
|
||
'⇒ 静默破掉无障碍开关。必须连曲线一起换回可时长控制的那个'
|
||
);
|
||
|
||
check(
|
||
'鸿蒙|morph 用 ui.animateTo 而非已废弃的全局 animateTo',
|
||
!/(^|[^.\w])animateTo\(/.test(MOTION.replace(/ui\.animateTo\(/g, '')),
|
||
'全局 `animateTo` 已废弃(编译器告警 "has been deprecated")—— ' +
|
||
'静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来'
|
||
);
|
||
|
||
/*
|
||
* ★★ 2026-09-23 改:从「**同文件**共址」改为「按 morph **id** 找驱动」。
|
||
*
|
||
* 旧判据:`geomFiles ⊆ filesUsingHelper` —— 绑了 `geometryTransition` 的
|
||
* 文件自己必须也有 `Motion.morph` 调用。
|
||
*
|
||
* ★ 为什么它现在必须改(不是判据变宽,是它守的东西变了):
|
||
* 一个共享元素转场的 **in/out 两端天然在两处**——`compose-morph` 的 out 端
|
||
* 是列表里的加号(`MainPage.ets:1760`),in 端是全屏写信页。用户要求
|
||
* 「把组件按页面封装以便与 WebUI 一一对应」,于是 in 端随写信页搬进了
|
||
* `NavDestinations.ets`,而**驱动(唯一的 `Motion.morph`)合理地仍留在
|
||
* `MainPage.ets:1512`** —— 它就在 out 端旁边。
|
||
* ⇒ 旧判据报 `NavDestinations.ets`「没走统一入口」,而事实是它**根本不需要**
|
||
* 自己驱动:它只是终点。这是判据的代理失效,不是代码退化。
|
||
*
|
||
* ★ 新判据仍按原先的**安全目标**:每个 morph 都必须由 `Motion.morph` 驱动
|
||
* (不能谁自己写 `animateTo`,否则就漏掉「时长/曲线/reduced-motion 折 0」)。
|
||
* 改问的是:**每个 id 的所有绑定文件里,至少有一个含 `Motion.morph`**。
|
||
* 这样:
|
||
* · 驱动与它绑的那一端同文件 ✓(仍是原来的意图);
|
||
* · 另一端随组件搬家不会误报 ✓;
|
||
* · 若有人新绑一个 id 却**哪里都没驱动**,或把驱动换成裸 `animateTo`,仍会红 ✓
|
||
* —— 没有变宽:它照旧要求每个 id 有且只有一处统一入口的驱动。
|
||
*/
|
||
const idHasDriver = new Map(); // id -> 是否有任一绑定文件含 Motion.morph
|
||
for (const [id, files] of geomIds) {
|
||
idHasDriver.set(id, files.some(f => {
|
||
const h = hs.find(x => x.name === f);
|
||
return h !== undefined && /Motion\.morph\(/.test(h.src);
|
||
}));
|
||
}
|
||
const undrivenIds = [...idHasDriver.entries()].filter(([, ok]) => !ok).map(([id]) => id);
|
||
check(
|
||
'鸿蒙|每个共享元素转场 id 都有 `Motion.morph` 驱动(不是只绑不驱、或自己写 animateTo)',
|
||
undrivenIds.length === 0,
|
||
'这些 id 的两端都不在含 `Motion.morph` 的文件里 —— 没人用统一入口驱动它:' +
|
||
undrivenIds.join(' ') +
|
||
'(自己写 animateTo 就会漏掉"时长/曲线/reduced-motion 折 0"三件里的一件)'
|
||
);
|
||
|
||
/*
|
||
* ★★ 2026-09-21 改:从「页面里不再直接出现 `Theme.durMorph`」改为
|
||
* 钉**弹簧曲线令牌**不得下沉到页面。
|
||
*
|
||
* 原来那条守的是 `durMorph` —— 令牌已删(morph 改用 `springResponsive`,
|
||
* 弹簧曲线下 duration 不生效,保留那是误导)。
|
||
*
|
||
* ★ 我第一版改成了"所有曲线令牌都不得在页面出现" —— **过宽**,实测当场红:
|
||
* · `easeOutSoft` 在页面里是**正当**的:控件状态变色
|
||
* (`.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })`)
|
||
* 本来就该按控件写,它没有"统一入口"也不需要弹簧(微交互要短、可控)。
|
||
* · `easeRise` 在 PageTransitionEnter/Exit 里也是正当的:那是**每页自己的**
|
||
* 路由转场声明,只能写在页面里。
|
||
*
|
||
* ⇒ 真正必须禁止的是**弹簧曲线**:它们让 `duration` 失效,必须走
|
||
* `Motion.effectAnim` / `Motion.anim`(那里统一处理"关动画时连曲线一起换")。
|
||
* 页面里直接写弹簧曲线 = 绕开了无障碍开关。
|
||
*/
|
||
check(
|
||
'鸿蒙|页面里不得直接写**弹簧曲线**(必须走 Theme 的过渡方法或 Motion)',
|
||
!/Theme\.(springIn|springResponsive)\b/.test(hs.map(h => h.src).join('\n')),
|
||
'弹簧曲线出现在**页面**里 = 绕开了 `Motion.effectAnim`(它负责"关动画时连曲线一起换")\n' +
|
||
'⇒ 系统里开了"减弱动效"时动画照放 —— 静默破掉无障碍开关'
|
||
);
|
||
|
||
/*
|
||
* ════════════════════════════════════════════════════════════════
|
||
* 鸿蒙侧:五个 `@keyframes` 的**逐条对照**(用户裁定 `all_five`,2026-09-23)
|
||
* ════════════════════════════════════════════════════════════════
|
||
*
|
||
* 用户裁定:动画对齐 = **五条逐个对照补齐**,不是"整体观感差不多"。
|
||
* 盘点( 2026-09-24)结果 —— WebUI 侧五个 keyframe 的**实况**(剥注释后实测):
|
||
*
|
||
* rise-in 定义✓ 使用✓ → 鸿蒙 `Theme.paneRiseIn()` / `Motion.pageEnter()`
|
||
* pane-in 定义✗ 使用✗ → 9aa702b 删掉的**死规则**(碑文 index.css:1239)
|
||
* 用户 09-14 明确否掉"整屏一起淡",两侧都不得复活
|
||
* menu-in 定义✓ 使用✓ → 鸿蒙 `Theme.menuIn()`
|
||
* cal-in-next 定义✓ 使用✓ → 鸿蒙 `CalendarPage.shiftRange` 的**显式属性**
|
||
* cal-in-prev 定义✓ 使用✓ → 同上(`slideInPct` / `slideInOpacity`)
|
||
*
|
||
* ★★ 2026-09-24 改:`cal-in-*` 的鸿蒙实现从 `Theme.calendarSlide()`(`.transition()`)
|
||
* 换成了 `CalendarPage` 里的**显式属性驱动**——因为前者实测**不播**:
|
||
* 把时长放大到 5s + Linear(确保抓得到中间帧)后连拍、逐偏移互相关:
|
||
* 最佳 **dx = 0**、ROI 平均差 **0.00**;只留 `translate`、去掉 `opacity` 也一样。
|
||
* 根因:`.transition()` 只在**挂载/卸载**时触发,而翻月只改 `year`/`month`,
|
||
* 网格容器一直存在 ⇒ 过渡永远不触发。
|
||
* ⇒ 改用本仓已验证可播的形式(`MainPage.calPaneIn` 同款:
|
||
* `@State` 数值 + 显式 `.translate()`/`.opacity()` + `animateTo`),
|
||
* 并用同一次放大连拍实测到**位移随时间变化**(帧间 dx -160 / +68,非零且变化)。
|
||
*
|
||
* ★ 为什么判据要盯"**有调用点/有驱动**",而不只是"方法存在":
|
||
* 本仓反复出现的失败形状是**定义了却没人用** —— 盘点当场抓到两个:
|
||
* · `Motion.pageEnter()` 定义了、返回的正是三个 `@Entry` 页各自手写的
|
||
* 那份字面量,却**一个调用点都没有**(改前实测);
|
||
* · `Theme.menuIn()` 一度"只剩定义、没有调用点"(账号选择器换成官方
|
||
* `bindMenu` 后),当时靠人肉发现"候选列表还没挂"才没变成死方法。
|
||
* "方法存在"是弱断言:删掉最后一个调用点它照样绿 —— 而那正是死动画。
|
||
*
|
||
* ★ 对照表用 **WebUI 的 CSS 动态取**(`liveKeyframes`),不是抄一份名单:
|
||
* 将来谁在 WebUI 加/删一个 keyframe,这里会跟着动;写死名单就会漂。
|
||
*/
|
||
const liveKeyframes = keyframes.filter(
|
||
k => new RegExp(`animation:\\s*${k}\\b`).test(css)
|
||
);
|
||
|
||
const CALENDAR_PAGE = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/CalendarPage.ets'));
|
||
|
||
/*
|
||
* WebUI keyframe → 鸿蒙实现(**两种形态**,任一满足即可):
|
||
* · `theme`:`Theme` 上的一个返回 `TransitionEffect` 的方法,且页面里有 `.method(` 调用点;
|
||
* · `props`:页面里用**显式属性**驱动(像 `cal-in-*` 那样直接挂 `.translate`/`.opacity`)。
|
||
*
|
||
* 两种都要求"真的有人用"——只写 `theme` 名字而没调用点,仍然是死动画。
|
||
*/
|
||
const KMAP = {
|
||
'rise-in': { theme: ['paneRiseIn', 'pageEnter'] },
|
||
'menu-in': { theme: ['menuIn'] },
|
||
/*
|
||
* 翻月:显式属性驱动。断言两件事,都是"确实在动"的必要条件:
|
||
* ① 页面里有 `slideInPct` / `slideInOpacity` 这两个驱动状态;
|
||
* ② 它们被 `animateTo` 包着改(否则是瞬切,不是动画)。
|
||
*/
|
||
'cal-in-next': {
|
||
props: () => /@State\s+slideInPct: number/.test(CALENDAR_PAGE) &&
|
||
/@State\s+slideInOpacity: number/.test(CALENDAR_PAGE) &&
|
||
/slideInPct = 0;/.test(CALENDAR_PAGE) &&
|
||
/\.translate\(\{\s*x:\s*this\.slideInPct/.test(CALENDAR_PAGE)
|
||
},
|
||
'cal-in-prev': {
|
||
props: () => /slideInPct = forward \? CAL_SLIDE_PCT : -CAL_SLIDE_PCT/.test(CALENDAR_PAGE)
|
||
}
|
||
};
|
||
|
||
const harmonyPageSrc = hs.map(h => h.src).join('\n');
|
||
const harmonyAnimSrc = themeSrc + '\n' + MOTION;
|
||
|
||
const unmapped = [];
|
||
for (const k of liveKeyframes) {
|
||
const impl = KMAP[k];
|
||
if (!impl) {
|
||
unmapped.push(`${k}(WebUI 在用,但鸿蒙没有对照)`);
|
||
continue;
|
||
}
|
||
if (impl.theme) {
|
||
const defined = impl.theme.some(m => new RegExp(`static\\s+${m}\\(`).test(harmonyAnimSrc));
|
||
const consumed = impl.theme.some(m => new RegExp(`\\.${m}\\(`).test(harmonyPageSrc));
|
||
if (!defined) unmapped.push(`${k}→${impl.theme.join('/')}(未定义)`);
|
||
else if (!consumed) unmapped.push(`${k}→${impl.theme.join('/')}(**无调用点** = 死动画)`);
|
||
} else if (impl.props && !impl.props()) {
|
||
unmapped.push(`${k}→显式属性驱动(驱动状态或接线缺失)`);
|
||
}
|
||
}
|
||
check(
|
||
'鸿蒙|WebUI 每个**在用**的 `@keyframes` 都有鸿蒙实现,且实现**真的有人穿**',
|
||
unmapped.length === 0,
|
||
'这些 keyframe 在鸿蒙侧没有落地或落地了没人用(看着有、永远不播):' + unmapped.join(';') +
|
||
'\n用户裁定 `all_five`:五条逐个对照,不是"整体观感差不多"'
|
||
);
|
||
|
||
/*
|
||
* ★ `pane-in` 的负面断言:它是**两边都不得复活**的那一条。
|
||
* 只查"鸿蒙有没有"不够 —— WebUI 侧复活了同样会让两端不一致。
|
||
* (`css` 是剥注释版,所以碑文里的引用不会让它假红。)
|
||
*/
|
||
check(
|
||
'鸿蒙|已删除的 `pane-in` 不得在任一侧复活(用户 09-14 否掉"整屏一起淡")',
|
||
!/@keyframes\s+pane-in/.test(css) && !/paneIn\s*\(/.test(harmonyAnimSrc),
|
||
'`pane-in` 是 9aa702b 删掉的**死规则**(碑文 index.css:1239):它让"已经在那儿的框架"'
|
||
+ '也一起暗一下,用户明确否掉过。复活它 = 把"闪"请回来'
|
||
);
|
||
|
||
/*
|
||
* ★ 同类失败形状的一般化:**任何**返回 `TransitionEffect` 的动画辅助方法
|
||
* 都必须有调用点。上面是用户点名的五条,这一条防的是**下一条**。
|
||
* (`Motion.pageEnter()` 就是这里被抓出来的:定义了、没人用。)
|
||
*/
|
||
const animHelpers = [
|
||
...themeSrc.matchAll(/static\s+([A-Za-z][A-Za-z0-9]*)\([^)]*\)\s*:\s*TransitionEffect/g)
|
||
].map(m => m[1]);
|
||
const deadHelpers = animHelpers.filter(
|
||
m => !new RegExp(`\\.${m}\\(`).test(harmonyPageSrc)
|
||
);
|
||
check(
|
||
'鸿蒙|不得有"定义了却没调用点"的动画辅助方法(死动画的固定来源)',
|
||
deadHelpers.length === 0,
|
||
'这些动画方法定义了但全仓没有调用点(删掉最后一个调用点它就静默失效):' +
|
||
deadHelpers.join(' ')
|
||
);
|
||
|
||
finish('动画盘点');
|