鸿蒙|日历翻月滑动动画修好(.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:
@ -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` 都有鸿蒙实现,且实现**真的有人穿**',
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user