鸿蒙|日历翻月滑动动画修好(.transition 不播 → 显式属性驱动)

用户:「日历页面还是没有左右滑动动画」。

## 实测定位(不是推理)

把动画时长临时放大到 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。
This commit is contained in:
2026-09-24 13:56:52 +08:00
parent f0dc342c1f
commit eeecaad79e
3 changed files with 126 additions and 51 deletions

View File

@ -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` 都有鸿蒙实现,且实现**真的有人穿**',

View File

@ -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));
}
}

View File

@ -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)
}
}