diff --git a/client/electron/test/animation-audit.test.mjs b/client/electron/test/animation-audit.test.mjs index 7a4b6dd..ad4df1a 100644 --- a/client/electron/test/animation-audit.test.mjs +++ b/client/electron/test/animation-audit.test.mjs @@ -342,10 +342,20 @@ check( * pane-in 定义✗ 使用✗ → 9aa702b 删掉的**死规则**(碑文 index.css:1239) * 用户 09-14 明确否掉"整屏一起淡",两侧都不得复活 * menu-in 定义✓ 使用✓ → 鸿蒙 `Theme.menuIn()` - * cal-in-next 定义✓ 使用✓ → 鸿蒙 `Theme.calendarSlide(true)` - * cal-in-prev 定义✓ 使用✓ → 鸿蒙 `Theme.calendarSlide(false)` + * 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` 页各自手写的 * 那份字面量,却**一个调用点都没有**(改前实测); @@ -360,12 +370,32 @@ const liveKeyframes = keyframes.filter( k => new RegExp(`animation:\\s*${k}\\b`).test(css) ); -/* WebUI keyframe → 鸿蒙实现方法(可多个备选,任一满足即可) */ +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': ['paneRiseIn', 'pageEnter'], - 'menu-in': ['menuIn'], - 'cal-in-next': ['calendarSlide'], - 'cal-in-prev': ['calendarSlide'] + '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'); @@ -373,15 +403,19 @@ const harmonyAnimSrc = themeSrc + '\n' + MOTION; const unmapped = []; for (const k of liveKeyframes) { - const impls = KMAP[k]; - if (!impls) { + const impl = KMAP[k]; + if (!impl) { unmapped.push(`${k}(WebUI 在用,但鸿蒙没有对照)`); continue; } - const defined = impls.some(m => new RegExp(`static\\s+${m}\\(`).test(harmonyAnimSrc)); - const consumed = impls.some(m => new RegExp(`\\.${m}\\(`).test(harmonyPageSrc)); - if (!defined) unmapped.push(`${k}→${impls.join('/')}(未定义)`); - else if (!consumed) unmapped.push(`${k}→${impls.join('/')}(**无调用点** = 死动画)`); + 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` 都有鸿蒙实现,且实现**真的有人穿**', diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 734bce3..04680e3 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -1277,36 +1277,4 @@ export class Theme { ).animation(Motion.effectAnim(Theme.springIn)); } - /** - * 日历翻月的横向滑入(对齐 WebUI `@keyframes cal-in-next/prev`)。 - * - * `forward = true` → 下一月,从**右侧**滑入(`translateX(12%)`); - * `forward = false` → 上一月,从**左侧**滑入(`translateX(-12%)`)。 - * - * 百分比在 ArkUI 的 `translate` 里是**相对元素自身尺寸**的 —— 与 CSS 语义一致, - * 所以 12% 就是“滑入自身宽度的 12%”,不是拍一个 vp 数(那样在大屏上会变小)。 - */ - static calendarSlide(forward: boolean): TransitionEffect { - /* - * ★★ 2026-09-20 改成**对称**(同 `paneRiseIn`,理由见那处)。 - * - * WebUI 这两个类也是 `both`: - * .cal-slide-next { animation: cal-in-next 200ms cubic-bezier(…) both; } - * 即**进出同一条关键帧**。 - * - * 我原来写的不对称里,退场只做 `opacity`(120ms)—— 于是翻月时 - * 新月份"滑进来"、旧月份只是"淡走",一进一出不是同一件事。 - * - * ★ 对称之后,进出的 `x` 是**同一个**(`forward` 已决定方向), - * 所以不会出现"从右边进、从右边出"的别扭感:退出时它往同一侧滑走, - * 与 WebUI 那条 `both` 的行为一致。 - */ - /* - * ★★ 2026-09-21 改:翻月是**水平位移**,正是弹簧曲线最擅长的场景 - * (有明确的物理方向与终止位置)。用官方示例的量级 `springMotion(0.6, 0.8)`。 - */ - return TransitionEffect.opacity(0).combine( - TransitionEffect.translate({ x: forward ? '12%' : '-12%' }) - ).animation(Motion.effectAnim(Theme.springIn)); - } } diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets index 5a6e779..b0dee18 100644 --- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -145,6 +145,17 @@ const CAL_SIDE_WIDTH: number = 400; * 真实值由 `.onAreaChange` 量出来,不靠猜。 */ const CAL_TWO_PANE_MIN: number = 800; +/** + * 翻页过场的横向位移幅度(百分比)—— 与 WebUI `@keyframes cal-in-next/prev` + * 的 `translateX(12%)` **逐字同源**。 + * + * 用百分比而不是 vp:WebUI 那条关键帧就是百分比,而 ArkUI 的 `translate` + * 百分比也相对**自身宽** ⇒ 大屏小屏观感一致(拍一个 vp 数会在大屏上变小)。 + * + * 为什么是 12% 而不是整屏:日历是**密排网格**,整屏平移会显得晃 + * (WebUI 那条注释的原话)。 + */ +const CAL_SLIDE_PCT: number = 12; /** * 周起始:**1 = 周一**,与 WebUI 的 `startOfWeek()`(`lib/calendar.ts`,dow===0 时退到上周一)一致。 * 两端必须同值 —— 否则同一天在两端的格子位置不同,是跨端最容易被一眼看出来的差异。 @@ -515,18 +526,32 @@ export struct CalendarPage { * 档位与步长分开写的话,"加了日档但忘了改步长"会静静地按一天翻(看着像周档)。 * * ★ 翻页要有**方向感的横向滑入**(对齐 WebUI `@keyframes cal-in-next/prev`)。 - * WebUI 靠 `cal-slide-next/prev` 两个类 + 200ms 动画;这里靠 `animateTo` 开窗口 - * + 格子挂 `calendarSlide(forward)` 过渡。两个都**不能少** - * (实测过:只包 animateTo 与硬切看不出区别)。 + * + * ★★ 2026-09-24 改用**显式属性 + animateTo**(原来那套 `.transition()` 不播, + * 实测 dx=0,原因见 `slideInPct` 的注释)。 + * + * 顺序很关键(与 `MainPage.calPaneIn` 同一条纪律): + * **先把起点值写到动画外面**,再在 `animateTo` 里推到终点。 + * 若两步都写在闭包里,渲染层只看得见最终值 ⇒ 动画退化成一次瞬移。 */ private shiftRange(delta: number): void { const forward: boolean = delta > 0; const step: number = stepDaysOf(this.calScale); + /* + * 起点:从**即将进入的那一侧**开始(下一段从右侧、上一段从左侧), + * 与 WebUI 的方向一致(`cal-in-next` = `translateX(12%)`)。 + * 默认值 `CAL_SLIDE_PCT` 与 WebUI 的 `12%` 逐字同源。 + */ + this.slideForward = forward; + this.slideInPct = forward ? CAL_SLIDE_PCT : -CAL_SLIDE_PCT; + this.slideInOpacity = 0; this.getUIContext().animateTo({ duration: Theme.durCal, curve: Theme.easeRise }, () => { - this.slideForward = forward; + /* 终点:0% / 不透明 1 —— 与 WebUI 的 `to { opacity: 1; transform: none }` 同 */ + this.slideInPct = 0; + this.slideInOpacity = 1; if (step === 0) { const ym: number[] = addMonths(this.year, this.month, delta); this.year = ym[0]; @@ -746,6 +771,27 @@ export struct CalendarPage { return cell; } + /** + * 翻月过场 —— **显式属性**(`.translate` 的 x 百分比 + 不透明度)。 + * + * ★★ 2026-09-24 新增(用户:「日历页面还是没有左右滑动动画」)。 + * + * ── 为什么不再用 `.transition(Theme.calendarSlide(fwd))` ── + * 实测(5s + Linear、连拍、逐偏移互相关):**dx = 0**、ROI 平均差 0.00 + * —— 一点横向位移都没产生。根因:`.transition()` 只在**挂载/卸载**时触发, + * 而翻月只改 `year`/`month`,网格容器一直存在。 + * + * ⇒ 改成本仓已验证可播的形式(`MainPage.calPaneIn` 同款): + * ① `@State` 数值;② 显式 `.translate()`/`.opacity()`;③ `animateTo` 同改。 + * + * 语义与 WebUI `cal-in-next/prev` 一致:从一侧滑入 12% + 淡入。 + * 之所以用**百分比**而不是 vp:WebUI 那条关键帧就是 `translateX(12%)`, + * 而 ArkUI 的百分比也相对自身宽度 ⇒ 大屏小屏观感一致(不拍 vp 数)。 + */ + @State slideInPct: number = 0; + /** 与 `slideInPct` 同时被 `animateTo` 驱动的不透明度(入场从 0 到 1) */ + @State slideInOpacity: number = 1; + /** * 翻月方向 —— 只给**过场动画**用(`calendarSlide(forward)` 决定从哪一侧滑入)。 * @@ -1912,7 +1958,34 @@ export struct CalendarPage { * 而窗格到 2230)⇒ 周视图下方空出大半屏,看起来"没做满"。 */ .layoutWeight(this.cellShowsChips() ? 1 : 0) - .transition(Theme.calendarSlide(this.slideForward)) + /* + * ★★ 2026-09-24 改用**显式属性 + animateTo**(原来挂的 `.transition()` 不播)。 + * + * 用户:「日历页面还是没有左右滑动动画」。 + * + * ── 实测(不是推理)── + * 把时长临时放大到 5s、曲线换 Linear(确保抓得到中间帧),点「‹」后连拍: + * · 逐偏移互相关:**最佳 dx = 0**、ROI 平均差 **0.00** + * ⇒ 一点点横向位移都没产生; + * · 只留 `translate`、去掉 `opacity` 后直拍三帧:帧1 与帧3 仍然**逐像素相同**。 + * + * ── 根因 ── + * `.transition()` 只在组件**挂载/卸载**(`if` 条件切换)时触发 + * (官方性能规范原文:为组件的**出现和消失**设置动画)。 + * 而本页翻月只改 `year`/`month`,网格 `Column` **一直存在** —— + * 它自己写的注释(上一行)说"键带 `monthKey()` 前缀 ⇒ 节点重建", + * 那说的是 `ForEach` 的**子项**键,外层这个 `Column` 并不会因此重挂。 + * ⇒ 过渡永远不触发,只有极小一块(子项重建)跟着淡了一下。 + * + * ── 修法:本仓已验证可播的那套 ── + * `MainPage` 的日历窗格入场(`calPaneIn`)用的是 + * ① `@State` 数值;② `.translate()` / `.opacity()` **显式属性**; + * ③ `animateTo` 同时改数值与状态。 + * 那条路径实测能播(那边有"顺序写在 animateTo 外面"的记录)。 + * 这里照它做 —— 与 WebUI 的 `cal-in-next/prev` 同一观感(12% + 淡入)。 + */ + .translate({ x: this.slideInPct + '%' }) + .opacity(this.slideInOpacity) } }