用户 2026-09-21:「webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」
「都做啊」—— 两处 morph 现在都在了。
## ① 写信 FAB → 写信页(跨 NavDestination)
官方 FAQ `faqs-arkui-991` 给的正是"在 NavDestination 子页面里做共享元素转场"
的完整步骤,逐步照做:
· 两端绑同一 id `compose-morph`(FAB / ComposeDestination 的 NavDestination);
· **`pushPath` 放进 `animateTo` 闭包**(FAQ 步骤 3 原文就是这个形状);
· `follow: false`(两端互斥出现,不是"始终在树上跟随")。
## ② 把 morph 收成**单一入口** `Motion.morph(ui, mutate)`
这一步不是为了少写代码,是为了**让判据能判**。过程值得记:
**第一版判据** —— 全仓 `any()`:
/animateTo\(/.test(allHarmony) && /durMorph/.test(allHarmony) && …
变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`)
**三条全绿** —— 因为全仓**别处**还有这些名字,"删掉这一处"永远命中不了。
**第二版判据** —— 逐站点取"文本邻域"看有没有 animateTo:
**全假红**。因为 `geometryTransition(id)` 绑在**组件树**上,而 `animateTo`
写在**另一个方法**里,文本邻域取不到隔壁的方法。
⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**:
把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处
(`animateTo` + 时长 + 曲线都在里面),调用点只剩「我要改哪个状态」。
这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`:
把"每处都得记得写"收敛成"一处定义、处处引用")。
3 个调用点已全部改走它(`MainPage.openComposeWithMorph`、
`MailDetailPage.openReplyWithMorph` / `closeReplyWithMorph`)。
★ `Motion.morph` 必须收 `UIContext`:全局 `animateTo` **已废弃**
(编译器告警 `'animateTo' has been deprecated`),而静态方法里拿不到
`this.getUIContext()`(本仓纪律:静态方法里不用 `this`)。
## 判据:6 条,三条变异逐个验过
通过 每个 id 恰好绑两处(一 in 一 out) [变异:删一端 → 红 ✓]
通过 morph 只有一个入口且内部有 animateTo [变异:换成普通调用 → 红 ✓]
通过 用 ui.animateTo 而非废弃的全局 animateTo
通过 每个用 geometryTransition 的文件都走 helper [变异:自己写 animateTo → 红 ✓]
通过 页面里不再直接出现 Theme.durMorph
通过 Theme.durMorph 存在且 = 220 [变异:改成 450 → 红 ✓]
★ 期间还抓到一个**判据自己的 bug**:我重写那一段时把 `themeSrc` 的定义
一起删了 ⇒ 第 6 条抛 `ReferenceError`、**整条判据根本没跑**
(而其余 9 条照常打印"通过",退出码 1 但没人看得到那条)。
这与"守具有齿但不在位"同形:**判据崩了不会显示成失败**。
已补回定义并重跑确认。
计数棘轮 4 → 10(显式编辑,理由写在 `run-all.mjs` 里)。
## 设备验证
✓ 点 FAB → 写信页到场、取消 → 回列表,进程存活(17827),无新 jscrash
(`faultlogger` 里最新仍是 15:08 那条,即修复前的)
✗ 220ms 的**中间帧**仍看不到(`snapshot_display` 往返 1.5-3s 慢一个数量级)——
与上一条提交同样的诚实交代:动画本体只能由用户在真机上看
248 lines
12 KiB
JavaScript
248 lines
12 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) &&
|
||
/duration:\s*Motion\.dur\(Theme\.durMorph\)/.test(MOTION) &&
|
||
/curve:\s*Theme\.easeRise/.test(MOTION),
|
||
'`Motion.morph` 的形状变了(少了 animateTo / 时长令牌 / 曲线)—— ' +
|
||
'官方:「必须配合 animateTo 使用才有动画效果,时长与曲线跟随 animateTo 的配置」;' +
|
||
'三者必须在**同一处**,散开就必然有人漏'
|
||
);
|
||
|
||
check(
|
||
'鸿蒙|morph 用 ui.animateTo 而非已废弃的全局 animateTo',
|
||
!/(^|[^.\w])animateTo\(/.test(MOTION.replace(/ui\.animateTo\(/g, '')),
|
||
'全局 `animateTo` 已废弃(编译器告警 "has been deprecated")—— ' +
|
||
'静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来'
|
||
);
|
||
|
||
/* 每一个绑了 geometryTransition 的组件,它**同一文件**里必须有 Motion.morph 调用 */
|
||
const geomFiles = new Set();
|
||
for (const h of hs) {
|
||
if (/geometryTransition\(/.test(h.src)) geomFiles.add(h.name);
|
||
}
|
||
const filesUsingHelper = new Set();
|
||
for (const h of hs) {
|
||
if (/Motion\.morph\(/.test(h.src)) filesUsingHelper.add(h.name);
|
||
}
|
||
const missing = [...geomFiles].filter(f => !filesUsingHelper.has(f));
|
||
check(
|
||
'鸿蒙|每个用到 geometryTransition 的文件都走 Motion.morph(不是自己 animateTo)',
|
||
missing.length === 0,
|
||
'这些文件绑了共享元素转场却没用统一入口:' + missing.join(' ') +
|
||
'(自己写 animateTo 就会漏掉"时长/曲线/reduced-motion 折 0"三件里的一件)'
|
||
);
|
||
|
||
check(
|
||
'鸿蒙|页面里不再直接出现 Theme.durMorph(它只属于 Motion.morph)',
|
||
!/Theme\.durMorph/.test(hs.map(h => h.src).join('\n')),
|
||
'`Theme.durMorph` 出现在页面里 = 又有人绕开 `Motion.morph` 自己写动画参数了'
|
||
);
|
||
|
||
check(
|
||
'鸿蒙|Theme.durMorph 确实存在且 = 220(WebUI FLIP 的原值)',
|
||
/static readonly durMorph: number = 220/.test(themeSrc),
|
||
'durMorph 不见了或被改成别的数 —— 上面几条会因为"引了一个不存在的名字"而失去意义'
|
||
);
|
||
|
||
finish('动画盘点');
|