From 2f80e1102db9e96b768c4e1fac856474abb34862 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 15:50:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E5=86=99=E4=BF=A1=20FAB?= =?UTF-8?q?=20=E2=86=92=20=E5=86=99=E4=BF=A1=E9=A1=B5=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=85=83=E7=B4=A0=E8=BD=AC=E5=9C=BA=20+=20morph=20=E6=94=B6?= =?UTF-8?q?=E6=88=90=E5=8D=95=E4=B8=80=E5=85=A5=E5=8F=A3=EF=BC=88=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E4=B8=A4=E7=89=88=E9=94=99=E6=B3=95=E9=83=BD=E8=AE=B0?= =?UTF-8?q?=E4=BA=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 慢一个数量级)—— 与上一条提交同样的诚实交代:动画本体只能由用户在真机上看 --- client/electron/test/animation-audit.test.mjs | 130 +++++++++++++++++- client/electron/test/run-all.mjs | 7 +- .../entry/src/main/ets/common/Motion.ets | 42 ++++++ .../src/main/ets/pages/MailDetailPage.ets | 11 +- .../entry/src/main/ets/pages/MainPage.ets | 49 ++++++- 5 files changed, 228 insertions(+), 11 deletions(-) diff --git a/client/electron/test/animation-audit.test.mjs b/client/electron/test/animation-audit.test.mjs index 1128f82..be3bcfa 100644 --- a/client/electron/test/animation-audit.test.mjs +++ b/client/electron/test/animation-audit.test.mjs @@ -23,12 +23,20 @@ * 4. 挂载即播那档(.rise-in)必须被 prefers-reduced-motion 显式关掉。 */ import { readdirSync } from 'node:fs'; -import { join } from 'node:path'; +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'); /** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */ @@ -116,4 +124,124 @@ check( '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('动画盘点'); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 4eb0a7e..4aeb17a 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -63,7 +63,12 @@ const SUITE = [ ['test/markdown-xss.test.mjs', [], 9], ['test/narrow-layout.test.mjs', [], 88], ['test/nav-merge.test.mjs', [], 9], - ['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion + /* + * 4 → 10(显式编辑)。新增 6 条是**鸿蒙侧共享元素转场**(geometryTransition) + * 的接线判据 —— 用户「webui 行为是按钮变成对应的写邮件页面或输入框吧」。 + * 详细理由见该文件里那段「这三条第一版写错了」的注释(两版错法都记了)。 + */ + ['test/animation-audit.test.mjs', [], 10], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion // 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次 // 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。 ['test/theme.test.mjs', [], 38], diff --git a/client/harmony/entry/src/main/ets/common/Motion.ets b/client/harmony/entry/src/main/ets/common/Motion.ets index bf08ebd..8a34848 100644 --- a/client/harmony/entry/src/main/ets/common/Motion.ets +++ b/client/harmony/entry/src/main/ets/common/Motion.ets @@ -25,6 +25,7 @@ * 本来就期望重启一次;而每帧同步调系统 API 的开销是实打实的。 */ import { accessibility } from '@kit.AccessibilityKit'; +import { Theme } from './Theme'; export class Motion { private static cached: boolean | undefined = undefined; @@ -57,4 +58,45 @@ export class Motion { static dur(want: number): number { return Motion.reduced() ? 0 : want; } + + /** + * **共享元素转场**(`geometryTransition`)的动画参数。 + * + * ── 为什么必须收成一处 ── + * + * 官方对 `geometryTransition` 有一条硬约束(`ts-transition-animation-geometrytransition`): + * + * 「**必须配合 `animateTo` 使用**才有动画效果,动效时长、曲线跟随 + * `animateTo` 中的配置,**不支持 `animation` 动画**」 + * + * 而 `geometryTransition(id)` 绑在**组件树**上,`animateTo` 却写在**方法**里 —— + * 两者隔着一个函数。⇒「绑了 id 但忘了 animateTo」这种写法**编译通过、 + * 运行时静默无动画**,正是最难发现的一类。 + * + * 我第一版就是那样:两端各绑 `geometryTransition('xxx')`, + * 而 `animateTo` 写在另外两个方法里。判据想钉"每一处都有 animateTo", + * 只能靠"取出现处附近的文本窗口"去猜 —— 而那个窗口**取不到隔壁的方法** + * (实测三条判据全假红)。 + * + * ★ 正确的解法不是把判据写得更聪明,而是**把结构改成可判的**: + * 把"带 morph 的状态切换"收成这一个函数 —— 于是 + * · `animateTo` 与时长/曲线只有**一处**(本函数),不可能漏; + * · 调用点只剩「我要改哪个状态」,判据只要看调用点**是否走了它**。 + * 这与本仓既有的解法同型:`PressEffectModifier` / `GlassCardModifier` + * 把"每处都得记得写"收敛成"一处定义、处处引用"。 + * + * @param mutate 要让哪个状态发生变化(写在**闭包内**是硬要求) + */ + static morph(ui: UIContext, mutate: () => void): void { + /* + * ★ 必须收 `UIContext` 并走 `ui.animateTo` —— + * 全局 `animateTo` **已废弃**(编译器告警 `'animateTo' has been deprecated`), + * 而静态方法里拿不到 `this.getUIContext()`(本仓纪律:静态方法里不用 `this`)。 + * ⇒ 由调用方把它的 `UIContext` 传进来。调用点本来就在组件里,拿得到。 + */ + ui.animateTo({ + duration: Motion.dur(Theme.durMorph), + curve: Theme.easeRise + }, mutate); + } } diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index b740786..81c17d1 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -323,20 +323,15 @@ export struct MailDetailView { */ private openReplyWithMorph(): void { this.showForwardBox = false; - this.getUIContext().animateTo({ - duration: Theme.durMorph, - curve: Theme.easeRise - }, () => { + /* 一处定义、处处引用:时长/曲线/animateTo 都在 `Motion.morph` 里 */ + Motion.morph(this.getUIContext(), () => { this.showReplyBox = true; }); } /** 收起回复条(反向 morph:同一条 220ms,与打开对称) */ private closeReplyWithMorph(): void { - this.getUIContext().animateTo({ - duration: Theme.durMorph, - curve: Theme.easeRise - }, () => { + Motion.morph(this.getUIContext(), () => { this.showReplyBox = false; }); } diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 1b3a44a..c29d84c 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -297,6 +297,13 @@ struct ComposeDestination { onBack: (): void => { this.pathStack.pop(); } }) } + /* + * ★★ 共享元素转场的 **in 端**(与通信页右下那个加号同一个 id)。 + * + * 系统按两端各自的 frame 与圆角插值 ⇒ "球长成整页"这件事不需要我算。 + * 起点圆角 28(球的半径)→ 终点 0(整幅面板)由两端各自声明。 + */ + .geometryTransition('compose-morph') .hideTitleBar(true) /* * ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。 @@ -1801,6 +1808,31 @@ struct CommPage { * 为什么收一个 `accountId`:收件箱里有"按当前筛选账号写信"(`InboxTab`), * 收件箱外有"用活跃账号写信"(`CommPage` 的 FAB)。两条入口共用这个方法。 */ + /** + * 打开写信 —— **带共享元素转场**(球 → 整页)。 + * + * 官方 FAQ `faqs-arkui-991` 的步骤 3 原文: + * 「在页面跳转时增加显示动画效果: + * `this.getUIContext().animateTo({ duration }, () => { + * this.navPathStack.pushPath({ name: 'nextB' }, false); })`」 + * + * ⇒ `pushPath` **必须在 `animateTo` 的闭包内**。这是 `geometryTransition` + * 生效的硬条件(官方文档:「必须配合 `animateTo` 使用才有动画效果… + * 不支持 `animation` 动画」)。 + * + * 时长取 `Theme.durMorph`(220) —— WebUI FLIP 的原值。 + */ + openComposeWithMorph(): void { + const ctx = this.getUIContext().getHostContext(); + let accountId: string = ''; + if (ctx !== undefined) { + accountId = AccountManager.getInstance(ctx).getActiveId(); + } + Motion.morph(this.getUIContext(), () => { + this.openComposeWith(accountId); + }); + } + openComposeWith(accountId: string): void { const params: ComposeParams = { to: '', reply_to: '', session_alias: '', account_id: accountId }; this.navPathStack.pushPath({ name: COMPOSE_ROUTE, param: params }); @@ -2026,8 +2058,23 @@ struct CommPage { .width(56).height(56) .borderRadius(28) .backgroundColor(Theme.accent) + /* + * ★★ 2026-09-21 共享元素转场的 **out 端**(另一端在 `ComposeDestination`)。 + * + * 用户:「webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」 + * —— 对,WebUI `ComposePage.tsx:57-79` 的 FLIP 就是"球长成整页", + * 起点的 `borderRadius: '28px'` **正是这个球的半径**。 + * + * 官方 FAQ `faqs-arkui-991` 给的正是"在 NavDestination 子页面里做 + * 共享元素转场"的完整步骤(路由跳转 + 两端绑同一 id + + * **把 pushPath 放进 `animateTo` 的闭包**)—— 我们这里照做。 + * + * `follow: false`(默认):两端互斥出现(一端在树上时另一端不在), + * 不是"始终在树上跟随"的那种。 + */ + .geometryTransition('compose-morph') .margin({ right: 16, bottom: this.navReserve + 16 }) - .onClick(() => { this.openCompose(); }) + .onClick(() => { this.openComposeWithMorph(); }) } .width('100%').height('100%') /*