跨端: 鸿蒙动画补齐 —— 并且发现原来的「逐字一致」是假的(令牌存在 ≠ 动画用了它)

用户:「A,同时把鸿蒙app的动画补齐」。

## 先纠一条错的前提(这是本轮最有价值的发现)

`Theme.ets` 的注释与 `harmony-nav` 的判据**都**写着:「三个数与 WebUI **逐字一致**:
`--ease-out-soft: cubic-bezier(0.22,1,0.36,1)`、`--dur-fast: 120ms`、`--dur-base: 180ms`」,
`paneRiseIn()` 就按 `durBase(180)` + `easeOutSoft` 做。

三个令牌**确实存在**(`index.css:310-312`)—— 但这句话把「令牌存在」当成了「动画用了它」:

| WebUI 里 | 真实用途 |
|---|---|
| `--dur-base: 180ms` | **只**用在壁纸淡入(`:747`) |
| `--ease-out-soft (0.22,1,0.36,1)` | **只**用在 transition(壁纸、控件变色) |
| **所有 @keyframes 动画** | 硬编码 **150ms / 200ms** + `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 次**(令牌定义处)。**是两根不同的曲线** —— 旧代码的面板入场比 WebUI 慢 30ms 且曲线偏软。

所以令牌拆成两组,各对齐各的:控件类 `durFast + easeOutSoft`;动画类
`durRise(150)/durMenu(140)/durCal(200) + easeRise(0.22,0.61,0.36,1)`。

## 补的动画(对齐 WebUI 三个 @keyframes)

- `menuIn()` —— 弹层:下移 4vp + 缩到 0.985 + 淡入(`@keyframes menu-in`)。
  ★ 适用范围照搬 WebUI 注释那条窄口径(「只给真正是弹层的东西」):那条规则曾挂着
  `glass-control`,于是每次切视图**所有按钮与输入框一起淡入位移**,09-15 被摘掉。
  挂到写邮件页的账号候选列表上。
- `calendarSlide(forward)` —— 日历翻月:从 ±12% 横向滑入(`cal-in-next/prev`)。
  方向由新增 `@State slideForward` 带进 `animateTo` 闭包;网格键加 `monthKey()` 前缀
  保证月份一变所有键全变(否则"跨年同名月"那类边角会静默不播)。
- 写信页整页 `rise-in`、回复框 `rise-in`(WebUI 挂在回复框本身,不是外层遮罩 ——
  挂遮罩上会让整个屏幕一起位移,看起来是"页面在动"而不是"框弹出来")。

**实测确认真的会播**(不是"编译过了"):按本仓记录的手法把 `durCal` 临时改成 8000
做慢动作,连拍三帧 —— 截图硬证**两张月历同时在屏**(九月淡出、十月从右侧 12% 滑入),
验完还原成 200ms。

## 判据:从「令牌存在」改成「动画真的用了那个令牌」

`harmony-nav` 那条判据原文锚在令牌上,所以它对上面那个 bug **一辈子全绿**。
重写为:
- 从 WebUI `index.css` **读出** `rise-in` 的真实时长与曲线(`150ms` + 那条 bezier),
  再断言鸿蒙的 `durRise` 与曲线字面量与之逐字一致 —— 而不是"仓库里有没有 180 这个数";
- ★ 把 `paneRiseIn()` 的**函数体抠出来**单独断言它引的是 `durRise/easeRise`,
  且**不得出现** `durBase/easeOutSoft`。

**这一步不能省 —— 我第一版就漏了它**:断言了令牌存在、也断言了曲线字面量存在,
但没断言函数用了它们;于是把函数体换回旧的错值后判据**仍然全绿**。
变异自检抓住了这一点,补上函数体断言后同一个变异 ⇒ 判红 ✓。
与今天修的另一处同源:**判据要锚在"这个东西被用在哪",不是"它存在"**。
This commit is contained in:
2026-09-18 10:56:57 +08:00
parent a87a88ea2a
commit af2d2b5cad
6 changed files with 264 additions and 49 deletions

View File

@ -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 的**分支根**上

View File

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

View File

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

View File

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

View File

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

View File

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