diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 8a4e31a..37f0664 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -374,22 +374,85 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵 const theme = code(join(HARMONY_ETS, 'common', 'Theme.ets')); const mainCode = stripComments(main); - // 令牌:三个数与 WebUI 逐字一致 - assert.match(theme, /durFast:\s*number\s*=\s*120/, - '★ durFast 必须是 120(WebUI `--dur-fast: 120ms`)'); - assert.match(theme, /durBase:\s*number\s*=\s*180/, - '★ durBase 必须是 180(WebUI `--dur-base: 180ms`)'); - assert.match(theme, /curves\.cubicBezierCurve\(\s*0\.22\s*,\s*1\s*,\s*0\.36\s*,\s*1\s*\)/, - '★ 缓动必须是 cubic-bezier(0.22, 1, 0.36, 1)(WebUI `--ease-out-soft`)——' + - '用 Curve.EaseOut 之类枚举是**另一根**曲线,观感对不上'); - // 上浮量与 WebUI @keyframes rise-in 的 translateY(4px) 一致 + /* + * ★★ 2026-09-18 重写:这条判据原先锚在**令牌存在**上,于是它对真正的 bug 全绿。 + * + * 原先的断言是:`durBase: number = 180`、`curves.cubicBezierCurve(0.22, 1, 0.36, 1)`、 + * `riseInOffset: number = 4` —— 这三个数确实在 `index.css` 里存在,**但都不是动画值**: + * + * --dur-base (180ms) + --ease-out-soft (0.22,1,0.36,1) + * → 只用在**壁纸淡入**与**控件变色 transition** 上; + * @keyframes rise-in / cal-in + * → 硬编码 **150ms / 200ms** + **cubic-bezier(0.22, 0.61, 0.36, 1)** + * + * 而当时的代码正是用 `durBase` + `easeOutSoft` 做窗格入场的 ⇒ 比 WebUI 慢 30ms + * 且曲线偏软(`(0.22,1,…)` 与 `(0.22,0.61,…)` 是两根曲线:全仓前者只出现 1 次 + * =令牌定义处,后者出现 5 次 = 5 个真实动画)。 + * + * **令牌存在** ≠ **动画用了它**。所以下面改成从 WebUI 源码**读出动画的真实取值**, + * 再断言鸿蒙的动画构造器引的是对应那一个 —— 而不是“仓库里有没有这个数”。 + */ + const css = prose(join(ROOT, 'client', 'electron', 'src', 'index.css')); + const webuiRise = /animation:\s*rise-in\s+(\d+)ms\s+cubic-bezier\(([^)]*)\)/.exec(css); + assert.ok(webuiRise, 'WebUI 里要有 rise-in 的动画声明(本判据的参照物)'); + const [, riseMs, riseCurve] = webuiRise; + assert.equal(riseMs, '150', `WebUI rise-in 实测是 150ms,读到 ${riseMs}ms —— 参照物变了,要重核两端`); + + // 鸿蒙的 durRise 必须= WebUI rise-in 的真实时长(不是 --dur-base 那个 180) + assert.match(theme, new RegExp(`durRise:\\s*number\\s*=\\s*${riseMs}\\b`), + `★ paneRiseIn 的时长必须等于 WebUI rise-in 的真实值 ${riseMs}ms。` + + '写 180(=--dur-base)是拿“壁纸淡入的时长”当“面板入场的时长”——那是两块不同的东西。'); + + // 曲线:必须与 rise-in 那条**逐字**一致(而不是与 --ease-out-soft 一致) + const curveNums = riseCurve.split(',').map(s => s.trim().replace(/\.0+$/, '')); + assert.equal(curveNums.length, 4, 'cubic-bezier 要有 4 个参数'); + assert.ok( + new RegExp(`cubicBezierCurve\\(\\s*${curveNums.map(n => n.replace('.', '\\.')).join('\\s*,\\s*')}\\s*\\)`).test(theme), + `★ 动画曲线必须与 WebUI rise-in 的 cubic-bezier(${riseCurve}) 逐字一致。` + + '用 --ease-out-soft 那条 (0.22,1,0.36,1) 是**另一根**曲线:那条只给 transition 用。'); + + // 两个时长/曲线令牌要**分开**存在(一个给 transition、一个给动画) + assert.match(theme, /easeOutSoft:\s*ICurve/, '要保留 transition 用的 easeOutSoft'); + assert.match(theme, /easeRise:\s*ICurve/, + '★ 要有**单独一个**给 @keyframes 动画用的曲线令牌(easeRise);' + + '没有它的话,"transition 与动画各用一根曲线"这件事在代码里无处表达,下次还会被合并回去'); + + /* + * 上浮量与 WebUI @keyframes rise-in 的 translateY(4px) 一致 + */ assert.match(theme, /riseInOffset:\s*number\s*=\s*4/, '★ 入场上浮量必须是 4(WebUI `@keyframes rise-in` 的 `translateY(4px)`)'); + /* + * ★★ 关键:上面那些令牌必须在 `paneRiseIn()` **函数体里真的被引用**。 + * + * 这一步不能省 —— 我第一版就是漏了它:断言了 `durRise = 150` 存在、 + * 也断言了曲线字面量存在,**但没断言 `paneRiseIn` 用的是它们**。 + * 于是把函数体换回旧的 `durBase + easeOutSoft`(真正的 bug)后, + * 判据**仍然全绿** —— 因为令牌还在,只是没人用。 + * 变异自检当场抓住了这一点。 + * + * 与今天修的另一处同源:**判据要锚在“这个东西被用在哪”,不是“它存在”**。 + * 所以这里把函数体抠出来单独断言。 + */ + const riseBody = /paneRiseIn\(\):\s*TransitionEffect\s*\{([\s\S]*?)\n \}/.exec(theme); + assert.ok(riseBody, '要能取到 paneRiseIn() 的函数体(它必须是这个方法,形状别改)'); + const body = riseBody[1]; + assert.match(body, /duration:\s*Theme\.durRise/, + '★ `paneRiseIn` 里入场时长必须引用 `Theme.durRise`(= WebUI rise-in 的 150ms)。' + + '写成 `Theme.durBase` 是拿“壁纸淡入的时长”当“面板入场的时长” —— 正是 2026-09-18 修的那个 bug。'); + assert.match(body, /curve:\s*Theme\.easeRise/, + '★ `paneRiseIn` 里曲线必须引用 `Theme.easeRise`(= rise-in 那条 0.22,0.61,0.36,1)。' + + '写成 `Theme.easeOutSoft` 是**另一根**曲线(那是给 transition 用的)。'); + assert.ok(!/Theme\.durBase/.test(body), + '★ paneRiseIn 里不得出现 `durBase`(180ms 是壁纸淡入的时长,不是面板入场)'); + assert.ok(!/Theme\.easeOutSoft/.test(body), + '★ paneRiseIn 里不得出现 `easeOutSoft`(那是 transition 的曲线,不是 @keyframes 的)'); + // transition 构造器存在,且入场/出场**不对称**(同长会闪) assert.match(theme, /paneRiseIn\(\):\s*TransitionEffect/, '要有 paneRiseIn() 这个过渡构造器'); - assert.match(theme, /TransitionEffect\.asymmetric\(/, + assert.match(body, /TransitionEffect\.asymmetric\(/, '★ 入场/出场必须 asymmetric(出场更快)—— 同长会让新旧两层半透明叠着,看起来像"闪一下"'); // ② 关键:transition 要挂在 if/else 的**分支根**上 diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index b22e70a..25ba189 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -267,29 +267,62 @@ export class Theme { static readonly fontBody: number = 14; /* - * ── 动画令牌(对照 WebUI `index.css` 的 `--dur-*` / `--ease-out-soft`)── + * ── 动画令牌(对照 WebUI `index.css`)── * * 用户(2026-09-17):「一方面一点动画都没有」。 * 实测确认:改造前全仓 `animateTo` / `transition` / `animation` **一次都没用过** —— * 所有页面切换、详情推入、面板展开都是硬切。 * - * 三个数**与 WebUI 逐字一致**(`index.css:280-282`): - * --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1) - * --dur-fast: 120ms (控件变色:hover / active) - * --dur-base: 180ms (面板与内容入场) - * 这样两端的“快/慢”手感是同一个尺子,而不是各自拍一个数。 + * ★★ 2026-09-18 更正:这里原先写着「三个数与 WebUI **逐字一致**」,**那句是错的**。 + * 它把「令牌存在」当成了「动画用了那个令牌」。实测(`client/electron/src/index.css`): + * + * `--dur-base: 180ms` 只用在**壁纸淡入**(:747 `transition: opacity …`) + * `--ease-out-soft (0.22,1,0.36,1)` 只用在 **transition**(壁纸、控件变色 :1169) + * 而**所有 @keyframes 动画**用的是 150ms + `cubic-bezier(0.22, 0.61, 0.36, 1)` + * + * 全仓 `(0.22,0.61,0.36,1)` 出现 **5 次**(rise-in ×3 / cal-in ×2), + * `(0.22,1,0.36,1)` 只出现 **1 次**(就是令牌定义处)。**这是两根不同的曲线。** + * 旧代码把面板入场按 `(0.22,1,0.36,1)` + 180ms 做 ⇒ 比 WebUI **慢 30ms 且曲线偏软**, + * 而当时的判据(`harmony-nav` "缓动必须是 0.22,1,0.36,1")锚在**令牌**上, + * 所以这个错它一辈子抓不到。 + * + * 所以下面分**两组**,各自对齐各自该对齐的东西: + * 控件类(transition) —— `durFast 120` + `easeOutSoft` + * 动画类(@keyframes)—— 各自时长 + `easeRise` */ + /** 控件变色(hover / active)—— WebUI `--dur-fast: 120ms` */ static readonly durFast: number = 120; + /** + * 壁纸淡入 —— WebUI `--dur-base: 180ms`。 + * + * ★ **只用于壁纸/整体淡入,不是面板入场**(面板入场是 `durRise`)。 + * 这个区分是 2026-09-18 查出来的,之前两者被当成同一个数。 + */ static readonly durBase: number = 180; + /** 面板/内容入场 —— WebUI `@keyframes rise-in` 实测 **150ms** */ + static readonly durRise: number = 150; + /** 弹层入场 —— WebUI `.animate-menu-in` 实测 **140ms** */ + static readonly durMenu: number = 140; + /** 日历翻月 —— WebUI `.cal-slide-next/prev` 实测 **200ms** */ + static readonly durCal: number = 200; /** - * 缓动曲线:`cubic-bezier(0.22, 1, 0.36, 1)` —— 与 WebUI `--ease-out-soft` 同一根曲线。 + * **transition** 用的缓动:`cubic-bezier(0.22, 1, 0.36, 1)` = WebUI `--ease-out-soft`。 * * 用 `curves.cubicBezierCurve` 而不是 `Curve.EaseOut` 之类的枚举: - * 枚举是系统预设的**另一根**曲线,观感与 WebUI 对不上(而“对齐 WebUI”正是本项要求)。 + * 枚举是系统预设的**另一根**曲线,观感与 WebUI 对不上。 */ static readonly easeOutSoft: ICurve = curves.cubicBezierCurve(0.22, 1, 0.36, 1); + /** + * **@keyframes 动画**用的缓动:`cubic-bezier(0.22, 0.61, 0.36, 1)`。 + * + * ★ 与 `easeOutSoft` **不是同一根曲线**(前者第二段控制点是 0.61,后者是 1)。 + * 两者别互换:WebUI 里 transition 用 `--ease-out-soft`,而所有动画 + * (rise-in / cal-in)硬编码用的是这一根。 + */ + static readonly easeRise: ICurve = curves.cubicBezierCurve(0.22, 0.61, 0.36, 1); + /** * 窗格入场时的上浮量(vp)。 * @@ -307,24 +340,60 @@ export class Theme { * (`combine`/`animation` 会就地改自身),多处共用同一个实例会互相干扰; * 每处调用各拿一个新的。 * - * ★ 两边时长**不对称**:出场(120ms)比入场(180ms)快。 - * 同长会让新旧两层半透明地叠着,看起来像"闪一下"—— - * 用户对"闪"敏感(09-14 否掉过整屏淡入)。 + * ★ 时长/曲线用 `durRise` + `easeRise`(**150ms + 0.22,0.61,0.36,1**)—— + * 即 WebUI `rise-in` 的**真实取值**。改之前用的是 `durBase(180)` + `easeOutSoft`, + * 那是令牌值而不是动画值(见上面令牌区的更正说明)。 * - * # 实测如何验证它真的生效 - * - * `snapshot_display` 有 ~1s 往返,180ms 的过渡**抓不到**(连拍三帧全一样, - * 会得出"动画没做"的错误结论)。做法:临时把 `durBase`/`durFast` 改成 20000, - * 再过 6s 截图 —— 正常应看到新旧两层**同时半透明**地叠着 - * (实测已确认:收件箱与日历交叉淡入,样例点 (628,1450) 是混合值 164,165,167)。 - * 验完把时长还原成 120/180。 + * ★ 两边**不对称**:出场用 `durFast`(120ms)比入场快。 + * WebUI 那边 `rise-in` 是对称的(一个 @keyframes 两用)—— 因为它是**逐个元素** + * 挂载即播,新旧两棵子树不会同时可见。而 ArkUI 这里是 `if/else` 换子树, + * 两层会**同时半透明地叠着**,同长看起来就是“闪一下”(实测 6 秒取样确认), + * 所以出场必须更快地让位。这是两端机制不同带来的**必要差异**,不是随手拍数。 */ static paneRiseIn(): TransitionEffect { return TransitionEffect.asymmetric( TransitionEffect.opacity(0).combine( TransitionEffect.translate({ y: Theme.riseInOffset }) - ).animation({ duration: Theme.durBase, curve: Theme.easeOutSoft }), - TransitionEffect.opacity(0).animation({ duration: Theme.durFast, curve: Theme.easeOutSoft }) + ).animation({ duration: Theme.durRise, curve: Theme.easeRise }), + TransitionEffect.opacity(0).animation({ duration: Theme.durFast, curve: Theme.easeRise }) + ); + } + + /** + * 弹层入场:下移 4vp + 缩到 0.985 + 淡入(对齐 WebUI `@keyframes menu-in`)。 + * + * WebUI `.animate-menu-in` 的注释把适用范围钉得很窄,这里照搬同一个口径: + * 「只给**真正是弹层**的东西(候选/下拉菜单自己穿)」—— + * 它原本还挂着 `html.view-switch .glass-control`,于是**每次切视图页面上所有 + * 按钮与输入框一起淡入位移**(几十个元素同时动),2026-09-15 被摘掉。 + * 所以本方法**只给弹层**(账号选择器、下拉候选),不要挂到常驻控件上。 + * + * 入场方向是**从上往下**(`translateY(-4px)` → 0):弹层从触发点的下方展开, + * 从上方“落”下来。与 `rise-in` 的**上浮**方向相反,别混。 + */ + static menuIn(): TransitionEffect { + return TransitionEffect.opacity(0).combine( + TransitionEffect.translate({ y: -4 }) + ).combine( + TransitionEffect.scale({ x: 0.985, y: 0.985 }) + ).animation({ duration: Theme.durMenu, curve: Theme.easeRise }); + } + + /** + * 日历翻月的横向滑入(对齐 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 { + return TransitionEffect.asymmetric( + TransitionEffect.opacity(0).combine( + TransitionEffect.translate({ x: forward ? '12%' : '-12%' }) + ).animation({ duration: Theme.durCal, curve: Theme.easeRise }), + TransitionEffect.opacity(0).animation({ duration: Theme.durFast, curve: Theme.easeRise }) ); } } diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets index c36035f..862e2a5 100644 --- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -201,9 +201,25 @@ export struct CalendarPage { /** 翻月走 `addMonths`(纯逻辑判据钉过跨年两个方向),不自己算 month+1 */ private shiftMonth(delta: number): void { - const ym: number[] = addMonths(this.year, this.month, delta); - this.year = ym[0]; - this.month = ym[1]; + /* + * 翻月要有**方向感的横向滑入**(对齐 WebUI `@keyframes cal-in-next/prev`)。 + * + * WebUI 那边靠 `cal-slide-next` / `cal-slide-prev` 两个类 + 200ms 动画; + * ArkUI 这里靠 `animateTo` 开窗口 + 格子挂 `calendarSlide(forward)` 过渡。 + * + * ★ 两个都**不能少**(实测过:只包 animateTo 与硬切看不出区别)—— + * 与 `switchIndex` 里那条注释是同一回事。 + */ + const forward: boolean = delta > 0; + this.getUIContext().animateTo({ + duration: Theme.durCal, + curve: Theme.easeRise + }, () => { + this.slideForward = forward; + const ym: number[] = addMonths(this.year, this.month, delta); + this.year = ym[0]; + this.month = ym[1]; + }); this.loadEvents(); } @@ -220,6 +236,28 @@ export struct CalendarPage { return monthGrid(this.year, this.month, WEEK_START, this.todayIso); } + /** + * 翻月方向 —— 只给**过场动画**用(`calendarSlide(forward)` 决定从哪一侧滑入)。 + * + * 不参与任何数据计算:日期推进走 `addMonths`(纯函数、已被单测钉住)。 + * 把它单独抽出来是因为 `animateTo` 的闭包里要能读到"这次是前进还是后退", + * 而 `delta` 是参数、闭包外已不可见。 + */ + @State slideForward: boolean = true; + + /** + * 换一个"格子内容"的身份 —— `ForEach` 的键挂上它,翻月时才会重放入场过渡。 + * + * ★ 为什么需要这个:ArkUI 的过渡只在**节点被替换**时播。`rows()` 直接由 + * `this.year/month` 算出来,键是 `c${cell.iso}#${cell.label}` —— 翻月后 + * 日期确实变了(键也会变),所以过渡会播。但同一个键在"跨年同月同名"等 + * 边角下可能不变,那时动画会静默不播。带上 `ym` 前缀把这个可能性去掉: + * 月份一变,所有键必然全变。 + */ + private monthKey(): string { + return `${this.year}-${this.month}`; + } + /** 这一天有几条日程(画事件点用)。数量很小,线性扫即可,不引入第二份索引。 */ private eventCountOf(iso: string): number { if (iso.length === 0) { @@ -750,15 +788,26 @@ export struct CalendarPage { } .width('100%') - /* 月网格:行 ← monthGrid,列 ← 7 格(嵌套 ForEach 的项名必须不同) */ - ForEach(this.rows(), (row: DayCell[], rowIndex: number) => { - Row() { - ForEach(row, (cell: DayCell) => { - this.DayCellView(cell) - }, (cell: DayCell) => `c${cell.iso}#${cell.label}`) - } - .width('100%') - }, (row: DayCell[], rowIndex: number) => `r${rowIndex}`) + /* + * 月网格:行 ← monthGrid,列 ← 7 格(嵌套 ForEach 的项名必须不同)。 + * + * ★ 外面这层 `Column` 是**为了挂翻月过渡**而加的(原先网格是个裸 `ForEach`)。 + * 过渡必须挂在**整块网格**的外层容器上:挂在每行上会出现"7 行各滑各的" + * 交错效果(每行入场时间几乎同步但微差,看起来像撕裂)。 + * 键带 `monthKey()` 前缀 ⇒ 月份一变所有键全变 ⇒ 节点重建 ⇒ 过渡必播。 + */ + Column() { + ForEach(this.rows(), (row: DayCell[], rowIndex: number) => { + Row() { + ForEach(row, (cell: DayCell) => { + this.DayCellView(cell) + }, (cell: DayCell) => `c${cell.iso}#${cell.label}`) + } + .width('100%') + }, (row: DayCell[], rowIndex: number) => `r${this.monthKey()}#${rowIndex}`) + } + .width('100%') + .transition(Theme.calendarSlide(this.slideForward)) if (this.error.length > 0) { Text(this.error) diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index f0fcba3..a534ceb 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -257,6 +257,19 @@ struct ComposePage { this.selectedAccountId = acct.id; this.showAccountPicker = false; }) + /* + * 弹层入场(对齐 WebUI `@keyframes menu-in`:下移 4vp + 缩到 0.985 + 淡入)。 + * + * ★ 只给**弹层**挂 —— WebUI 那条 `.animate-menu-in` 的注释把适用范围 + * 钉得很窄("只给真正是弹层的东西"),因为它曾经挂着 `glass-control`, + * 于是每次切视图页面上**所有**按钮与输入框一起淡入位移(几十个元素同时动), + * 2026-09-15 被摘掉。别把这个放到常驻控件上。 + * + * 挂在 `Row` 上(即 `ForEach` 的每一项):整张候选列表逐项轻落, + * 与 WebUI 里"整块列表一起动"略有差别 —— 但 ArkUI 的 `ForEach` 每项 + * 是独立节点,逐项入场反而更接近"列表展开"的观感,且不会让整层不可交互。 + */ + .transition(Theme.menuIn()) }, (acct: AccountInfo) => acct.id) } @@ -341,5 +354,14 @@ struct ComposePage { } .width('100%').height('100%') .backgroundColor(Theme.pageBg) + /* + * 整页入场(对齐 WebUI 写信页的 `rise-in`:4vp 上浮 + 淡入)。 + * + * WebUI 实测(`ComposePage.tsx:174`):`fromRect ? '' : 'rise-in …'` —— + * 没有动画起点时(例如刷新后直接进写信页)才挂 `rise-in`; + * 有"从 FAB 长出来"的起点时改走另一条(两条都动 transform/opacity,同挂会打架)。 + * 本仓没有"从 FAB 长出来"那条,所以直接挂就是它的"无起点"分支。 + */ + .transition(Theme.paneRiseIn()) } } \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 748cf51..e832fa6 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -507,6 +507,15 @@ export struct MailDetailView { .padding(16) .backgroundColor(Theme.surface) .borderRadius({ topLeft: 12, topRight: 12 }) + /* + * 回复框入场(对齐 WebUI `MailView.tsx:410` 那条 `rise-in`:上浮 4vp + 淡入)。 + * + * ★ 挂在**回复框本身**(下半部那块表面),不是外层遮罩容器: + * WebUI 那处 `rise-in` 也挂在回复框的 div 上,遮罩是立即出现的。 + * 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 —— + * 那看起来是"页面在动",而不是"回复框弹出来"。 + */ + .transition(Theme.paneRiseIn()) } .width('100%').height('100%') .position({ x: 0, y: 0 }) diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 0f142b0..1913a02 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -2189,17 +2189,20 @@ struct MainPage { * ★ 换窗格要**真的**有过渡:`animateTo` 只开了一个动画窗口, * 被换掉的那棵子树自己不声明 `.transition(...)` 的话什么都不会动 * (实测:只包 `animateTo` 和硬切看不出差别)。 - * 过场动画挂在**内容容器**上(见 build 里 `Column().transition(...)`), + * 过场动画挂在**内容容器**上(见 build 里各分支的 `.transition(Theme.paneRiseIn())`), * 这里只负责把 index 变掉;系统在动画窗口内跑那条 transition。 * - * 时长/曲线用令牌:`durFast`(120) —— WebUI 对应物是 - * `.rise-in { animation: rise-in 150ms cubic-bezier(0.22,0.61,0.36,1) }`, - * 同一档(百毫秒级、不拖)。曲线用令牌里那条 `easeOutSoft`, - * 与 WebUI 的 `.pane-rise` 同一根尺子。 + * ★★ 2026-09-18 更正:这里原先用 `durBase(180)` + `easeOutSoft(0.22,1,0.36,1)`, + * 并写着"与 WebUI 的 .pane-rise 同一根尺子" —— **那两个数都不是动画值**。 + * 实测 WebUI:`.rise-in`/`.pane-rise` 都是 **150ms + cubic-bezier(0.22, 0.61, 0.36, 1)**; + * 180ms 与 `(0.22,1,0.36,1)` 只用在**壁纸淡入**与**控件变色 transition** 上。 + * 所以旧代码的窗格入场比 WebUI 慢 30ms 且曲线偏软。 + * 现在两边都用 `durRise` + `easeRise`,与 `paneRiseIn()` 内那份保持一致 + * (同一场过场被两个地方描述,数值必须同源 —— 所以都引令牌,不写数)。 */ this.getUIContext().animateTo({ - duration: Theme.durBase, - curve: Theme.easeOutSoft + duration: Theme.durRise, + curve: Theme.easeRise }, () => { this.currentIndex = target; });