Files
classhelper-island-refactor…/parts/14-animations.md
T
ClassIsland Prompt Kit 22c1784bab feat: ClassIsland / ClassHelper 灵动岛提示词套件
- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构)
- ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本)
- 投喂指南、分卷、逐节文件
- 调研记录、一致性基线、生成与质检脚本
2026-11-12 21:59:58 +08:00

347 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 14. 动画与动效总表
本节是「动效施工图」。实现者必须逐条实现下表的触发、属性、起止值、时长、曲线、延迟与实现方式,**不得**自行「优化」时长或曲线。所有数值均来自 ClassIsland 源码;标注【推断】的为源码未直接给出、由同类动画或 Web 平台换算得出。
### 14.0 全局动效规则
#### 14.0.1 动画级别(AnimationLevel)
必须实现 3 档,默认 **标准(1)**(`ClassIsland/Models/Settings.cs:1851`;`Views/SettingPages/GeneralSettingsPage.axaml:280-293` 的三项为「禁用 / 标准 / 华丽」,绑定 `SelectedIndex`):
| 级别 | 文案 | 行为要求 | 证据 |
|---|---|---|---|
| 0 | 禁用 | `IThemeService.IsTransientDisabled = true`(禁用过渡动画),Popup 入场、抽屉/竖抽屉动画、Workflow 等全部关闭;但**主界面**的提醒动画与部分控件不在此列(设置项描述明确写「禁用动画选项不适用于应用主界面和部分控件」) | `App.axaml.cs:725`;`IThemeService.cs:44`;`PopupIntroAnimationBehavior.cs:67`;`DrawerHost.axaml.cs:70`;`GeneralSettingsPage.axaml:283` |
| 1 | 标准(默认) | 列表进入动画关闭、Popup 入场可用;不启用「行/组件淡入」「WrapPanel 尺寸过渡」等高成本动画 | `StackPanelIntroAnimationBehavior.cs:46`;`DrawerHost.axaml.cs:70` |
| 2 | 华丽 | 额外启用:组件/行淡入(250–300ms)、WrapPanel 子项位置隐式动画(225ms 默认) | `MainWindowLine.cs:382-400`;`ComponentPresenter.axaml.cs:224-243`;`WrapPanelResizingAnimationAssist.cs:51-56` |
实现细节:
- 该设置必须持久化并在启动时同步到全局单例(对齐 `IThemeService.AnimationLevel` 静态属性),任何动画入口先查级别再播放(源码模式见 `MainWindowLine.cs:384`)。
- 「禁用动画等待」(`IsWaitForTransientDisabled`)是另一独立开关:默认阻塞操作会等待动画播完,开启后不等待(`IThemeService.cs:46-49`;`Settings.cs:773-782`)。Web 实现等价为「不 await 动画 Promise」。
- 必须同时响应系统 `prefers-reduced-motion: reduce`:将其视为「级别 0 + 时长压到 1ms」(与 ClassHelper `renderer/styles/index.css:909-917` 一致),且**加载指示器必须放慢而不是停**(`index.css:919-930`)。
#### 14.0.2 缓动别名表(必须集中定义,禁止各组件手写)
| 别名 | cubic-bezier | 用途 | 来源 |
|---|---|---|---|
| `--ci-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | 绝大多数进出场(ClassHelper 主缓动) | `ClassHelper/packages/shared/src/motion.ts:24` |
| `--ci-ease-in-out` | `cubic-bezier(0.77, 0, 0.175, 1)` | 对称来回(抽屉推拉、内容互换) | `motion.ts:26` |
| `--ci-ease-drawer` | `cubic-bezier(0.32, 0.72, 0, 1)` | 侧向抽屉 | `motion.ts:28` |
| `--ci-ease-fl-1` | `cubic-bezier(0.65, 0, 0.35, 1)` | Fluent 行宽过渡/L 型 S 曲线 | `FluentTheme/Styles.axaml:269` |
| `--ci-ease-fl-2` | `cubic-bezier(0.25, 1, 0.5, 1)` | 遮罩文字淡入、行淡入 | `FluentTheme/Styles.axaml:333`;`MainWindowLine.cs:397` |
| `--ci-ease-fl-3` | `cubic-bezier(0.5, 0, 0.75, 0)` | 遮罩退出缩放 | `FluentTheme/Styles.axaml:389` |
| `--ci-ease-popup` | `cubic-bezier(0.22, 1, 0.36, 1)` | Popup 入场(Semi 风格) | `PopupIntroAnimationBehavior.cs:77,86` |
| `--ci-ease-tutorial` | `cubic-bezier(0.16, 1, 0.3, 1)` | 教程高亮/聚光 | `TutorialHighlightControl.axaml:13` |
| `--ci-ease-list` | `cubic-bezier(0.00, 1.00, 0.00, 1.00)` | 列表错峰入场 | `StackPanelIntroAnimation.axaml:22` |
| `--ci-ease-text` | `cubic-bezier(0.33, 1, 0.68, 1)` | OOBE 文字收拢、字符翻入 | `OobeIntroAnimationControl.axaml:93,105`;`.axaml.cs:151` |
| `--ci-ease-block` | `cubic-bezier(0.76, 0, 0.24, 1)` | OOBE 方块放大 | `OobeIntroAnimationControl.axaml:48` |
| `--ci-ease-root` | `cubic-bezier(0.00, 0.83, 0.17, 0.90)` | OOBE 整场缩放 | `OobeIntroAnimationControl.axaml:16` |
| `--ci-ease-char-out` | `cubic-bezier(0.32, 0, 0.67, 0)` | OOBE 字符翻出 | `OobeIntroAnimationControl.axaml.cs:117` |
| `--ci-ease-spin` | `cubic-bezier(0.50, 0.25, 0.50, 0.75)` | 更新图标旋转 | `UpdateSettingsPage.axaml:90` |
| `--ci-ease-overshoot` | `cubic-bezier(0.00, 1.00, 0.50, 1.10)` | 经典主题行宽过冲 | `ClassicTheme/Styles.axaml:207` |
| `--ci-ease-step` | `cubic-bezier(0, 0, 0, 1)` | 抽屉/关于页 (`0,0 0,1`) | `DrawerHost.axaml:46-48`;`AboutSettingsPage.axaml:137` |
| Avalonia `SineEaseIn` | `cubic-bezier(0.12, 0, 0.39, 0)`【推断】 | 遮罩层淡入/淡出 | `FluentTheme/Styles.axaml:299` |
| Avalonia `SineEaseOut` | `cubic-bezier(0.61, 1, 0.88, 1)`【推断】 | 图标宽度/进度透明度 | `AnimatedIconButton.axaml:24` |
| Avalonia `SineEaseInOut` | `cubic-bezier(0.37, 0, 0.63, 1)`【推断】 | 呼吸/进度过渡 | `WorkflowProgressIndicatorItemControl.axaml:19,32,60` |
| Avalonia `QuadraticEaseIn` | `cubic-bezier(0.11, 0, 0.5, 0)`【推断】 | 经典遮罩下压、涟漪放大 | `ClassicTheme/Styles.axaml:271`;`RippleEffect.axaml.cs:94` |
| Avalonia `QuadraticEaseOut` | `cubic-bezier(0.5, 1, 0.89, 1)`【推断】 | 经典遮罩上滑 | `ClassicTheme/Styles.axaml:311` |
| Avalonia `ExponentialEaseOut` | `cubic-bezier(0.16, 1, 0.3, 1)`【推断】 | 闪屏滑入 | `SplashWindow.axaml.cs:111,116` |
| Avalonia `CubicEaseOut` | `cubic-bezier(0.33, 1, 0.68, 1)`【推断】 | 闪屏进度 | `SplashWindow.axaml.cs:75` |
| Avalonia `CubicEaseInOut` | `cubic-bezier(0.65, 0, 0.35, 1)`【推断】 | WrapPanel 默认隐式动画 | `WrapPanelResizingAnimationAssist.cs:40` |
#### 14.0.3 时长常量表
| 常量 | 值 | 使用场景 |
|---|---|---|
| `--ci-dur-instant` | 90ms | 按压反馈、行透明度 |
| `--ci-dur-fast` | 100–140ms | 行/组件淡入淡出、抽屉内容、遮罩退出 |
| `--ci-dur-base` | 150–250ms | Popup、遮罩文字、列表滑入 |
| `--ci-dur-slow` | 300–360ms | 行宽过渡(Fluent)、遮罩分块、组件淡入 |
| `--ci-dur-overshoot` | 550ms | 行宽过渡(经典) |
| `--ci-dur-long` | 750ms | OOBE 字符、教程高亮、遮罩缩放 |
| `--ci-dur-loop` | 1000–3000ms | 循环/呼吸/旋转/OOBE 整场 |
#### 14.0.4 动效编排原则(实现时逐条遵守)
1. **进场慢、退场快**:进场承担「让用户理解发生了什么」的职责,可以 200–300ms;退场承担「别挡路」的职责,必须 100–150ms 结束。源码中提醒遮罩文字淡入 250ms、淡出 200ms;抽屉 250ms;弹窗退出 140ms(`index.css:638-644`)都遵循此规律。实现者不得把退场做得比进场更慢。
2. **延迟用于建立因果**:遮罩分块先扫入(0–360ms),文字随后(延迟 260ms),投影最后(延迟 600ms);OOBE 字符按正弦曲线错峰。延迟必须表达「谁先谁后」,**不得**所有元素同一时刻启动。
3. **错峰必须封顶**:列表入场 25ms/项(源码)、ClassHelper 35ms/项且第 9 项起封顶、页面块 30ms/块且封顶 180ms(`motion.ts:175-179`;`index.css:857-883`)。超过 10 项仍线性累加会让最后一项等超过 1 秒,属于缺陷。
4. **状态变化必须可打断**:任何「进/出」成对动画必须共用同一元素、同一属性、可反向;反向时用新动画直接覆盖旧动画(不要排队)。遮罩的 in/out 伪类互斥实现(`MainWindowLine.cs:726-791`)就是范例。
5. **不在动画里做业务**:动画只表达状态,不得在 `animationend` 里触发网络请求、不得用动画时长做业务计时;业务定时器与动画解耦。
6. **动效不得改变布局语义**:动画结束后元素的最终几何必须与「无动画直接跳到终态」完全一致;判定方法:动画播放中与播放后分别测量 `getBoundingClientRect`,最终值必须逐像素相等。
7. **一致性优先于炫技**:同一个交互(如「抽屉打开」)在全应用只能有一种时长与曲线;新交互必须复用 `--ci-ease-*` 与 `--ci-dur-*`,不得新增私有曲线。
### 14.1 A. 主界面(岛)动效
主界面动效分三类:**状态过渡**(A1–A4、A8–A9)、**几何过渡**(A5–A7、A11)、**数据过渡**(A10、A12)。状态过渡可直接用 CSS transition;几何过渡必须避免回流(见 14.7 第 1 条);数据过渡(进度条)必须做「比例插值」而不是「每帧重算 DOM」。三类不得混用同一个动画实例,否则打断时会互相污染。
| 编号 | 名称 | 触发 | 变化属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| A1 | 窗口背景换色 | 应用明暗主题切换 | `background` | 旧色 → 新色 | 200ms | 默认线性 | 无 | CSS `transition: background-color 200ms` | `MainWindow.axaml:40-44` |
| A2 | 行淡入 | 行由隐藏变可见 | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 无 | WAAPI/composition `opacity`,`AnimationLevel≥2` | `MainWindowLine.cs:336-344,382-401` |
| A3 | 行透明度过渡 | 悬停淡出开关生效 | `opacity` | 1 ↔ 0.05 | 100ms | 默认 | 无 | CSS transition | `MainWindowLine.axaml:51,73-75`;`FluentTheme/Styles.axaml:268` |
| A4 | 组件淡入 | 组件挂载/由隐藏变可见 | `opacity` | 0 → 1 | 300ms | `(0.65,0,0.35,1)` | 无 | composition `opacity`,`AnimationLevel≥2` | `ComponentPresenter.axaml.cs:224-243,263-266` |
| A5 | 行宽过渡(Fluent) | 组件内容变化导致背景宽度变化 | `width` | 旧宽 → 新宽 | 300ms | `(0.65,0,0.35,1)` | 无 | CSS transition(背景层绝对定位,禁止回流文字) | `FluentTheme/Styles.axaml:264-273` |
| A6 | 行宽过渡(经典) | 同上 | `width` | 旧宽 → 新宽 | 550ms | `(0,1,0.5,1.1)` 过冲 | 无 | 同上;曲线必须带过冲,不得改成 ease-out | `ClassicTheme/Styles.axaml:202-211` |
| A7 | WrapPanel 子项位置过渡 | 子项增删/重排 | `offset`(x/y) | 旧位置 → 新位置 | 默认 225ms;主界面覆盖 300ms | `CubicEaseInOut`;主界面 `(0.65,0,0.35,1)` | 无 | composition 隐式动画;Web 用 FLIP(先读后写)| `WrapPanelResizingAnimationAssist.cs:45-49,130-137`;`MainWindow.axaml:197-198` |
| A8 | 编辑模式背景切换 | 进入/退出编辑模式 | 窗口背景、`backdrop-filter` | 透明 → 亚克力 | 200ms | 默认 | 无 | CSS transition(只动背景层) | `MainWindow.axaml:177-181`;`MainWindow.axaml.cs:510-517` |
| A9 | 拖动项抬升 | 拖动开始/结束 | `opacity`、`z-index` | 1 → 0.5(拖动中) | 即时 | — | 无 | 直接切 class(不做动画) | `MainWindow.axaml:478-481` |
| A10 | 缩放重置 | 点击「重置视图」 | `transform: scale` | 当前 → 1.0 | 200ms【推断】 | `(0.16,1,0.3,1)` | 无 | WAAPI 动画 transform | `MainWindow.axaml:606-609`;ZoomBorder `MinZoom 0.5/MaxZoom 3/ZoomSpeed 1.2`(`:199-209`) |
| A11 | 分体主界面切换 | 开启/关闭分体 | 行内边距、圆角、背景宽 | 合并 ↔ 分体 | 与 A5 同步 300ms | 同 A5 | 无 | class 切换 + 宽度过渡 | `FluentTheme/Styles.axaml:245-259` |
| A12 | 进度条值过渡 | 每秒/每 50ms 更新 | `value`/`transform: scaleX` | 旧比例 → 新比例 | 50ms | 线性 | 无 | CSS transition `transform` | `FluentTheme/Styles.axaml:137-145`;`ClassicTheme/Styles.axaml:123-131` |
### 14.2 B. 提醒与强提醒动效
提醒是「岛」最重要的动效场景,必须实现两套完全不同的遮罩形态(Fluent/经典),并共用同一套队列与遮罩生命周期状态机。状态机必须严格按源码实现:`:mask-anim` → `:mask-in` →(可选)`:overlay-anim`/`:overlay-in` → `:mask-out` → `:overlay-out`,任一状态切换都通过类名驱动(`Controls/MainWindowLine.cs:724-791`)。**禁止**用 JS 计时器手动改元素 style 来「模拟」状态机,否则队列切换时会错乱。
强提醒特效是**独立置顶窗口**(`TopmostEffectWindow`),不属于岛 DOM;特效元素播放完成后自行移除,窗口内元素清空即隐藏窗口(`TopmostEffectWindow.axaml.cs:50-67,90-98`)。特效必须支持 `NotificationEffectRenderingScale` 缩放(默认 1.0,大屏自动降到 0.75/0.5,见 part 03 表)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| B1 | Fluent 遮罩斜切扫入 | `:mask-in` | 5 个区段宽度进度 | 0 → 1 | 每段 360ms | `QuarticEaseOut`(≈`(0.16,1,0.3,1)`【推断】) | 段间 120ms:区 0/4 同起,1/3 延迟 90ms,2 延迟 120ms | canvas/SVG 自绘 + rAF 或 WAAPI 驱动进度 | `SlantedMaskControl.cs:166-202,225-251`;`FluentTheme/Styles.axaml:175-180` |
| B2 | Fluent 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS animation `both` | `FluentTheme/Styles.axaml:329-340` |
| B3 | Fluent 遮罩文字缩放 | `:mask-in` | `scale` | 1.1 → 1.0 | 750ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS transform animation | `FluentTheme/Styles.axaml:341-352` |
| B4 | Fluent 遮罩投影淡入 | `:mask-in` | 背景层 `opacity`、`box-shadow` | 0 → 1;阴影 `0 8 32 #66000000` | 100ms | 默认 | 延迟 600ms | CSS animation + `transition: box-shadow 100ms` | `FluentTheme/Styles.axaml:169-173,355-367` |
| B5 | Fluent 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 200ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:376-386` |
| B6 | Fluent 遮罩文字放大退出 | `:mask-out` | `scale` | 1.0 → 1.1 | 200ms | `(0.5,0,0.75,0)` | 无 | CSS animation | `FluentTheme/Styles.axaml:387-398` |
| B7 | Fluent 遮罩背景淡出 | `:mask-out` | 背景层 `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:401-409` |
| B8 | 覆盖层淡入/淡出 | `:overlay-in`/`:overlay-out` | `opacity` | 0 ↔ 1 | 200ms | `SineEaseIn`≈`(0.12,0,0.39,0)`【推断】 | 无 | CSS transition | `FluentTheme/Styles.axaml:419-451` |
| B9 | 内容上滑进入(遮罩层内) | 遮罩内内容切换 | `translateY` | `-50` → 0 | 200ms | `SineEaseIn` | 无 | CSS transition | `ClassicTheme/Styles.axaml:237-249,266-281` 相关;主窗口 `TranslateTransform Y=-50`(`ClassicTheme/Styles.axaml:87,148`) |
| B10 | Classic 遮罩下压 | `:mask-in` | `translateY` | -50 → 0 | 200ms | `QuadraticEaseIn` | 无 | CSS animation `both` | `ClassicTheme/Styles.axaml:265-281` |
| B11 | Classic 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 100ms | 默认 | 延迟 200ms | CSS animation | `ClassicTheme/Styles.axaml:288-300` |
| B12 | Classic 遮罩上滑退出 | `:mask-out` | `translateY` | 0 → 50 | 200ms | `QuadraticEaseOut` | 延迟 100ms | CSS animation `forward` | `ClassicTheme/Styles.axaml:305-318` |
| B13 | Classic 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `ClassicTheme/Styles.axaml:319-331` |
| B14 | Classic 行投影淡入 | `:mask-in` | `effect`(`DropShadowEffect`) | opacity 0 → 1,blur 8 | 100ms | 线性 | 无 | CSS `box-shadow` transition(禁止 filter) | `ClassicTheme/Styles.axaml:34-45,283-287` |
| B15 | 提醒进度条 | 提醒剩余时间更新 | `value` | 旧 → 新 | 50ms | 线性 | 无 | CSS transition `transform: scaleX` | `FluentTheme/Styles.axaml:137-145` |
| B16 | 强提醒涟漪扩散 | 提醒开始(特效开启) | `scale` | 0 → 1 | 600ms | `QuadraticEaseIn` | 无 | WAAPI `transform`,原点=岛中心 | `RippleEffect.axaml.cs:86-96` |
| B17 | 强提醒涟漪淡出 | 同上 | `opacity` | 1 → 0 | 600ms | `SineEaseIn` | 无 | WAAPI `opacity` | `RippleEffect.axaml.cs:102-106` |
| B18 | 强提醒结束回调 | 特效完成 | — | — | 总 750ms | — | 600ms 动画后等 150ms | `setTimeout(750)` 后移除特效元素 | `RippleEffect.axaml.cs:108-109`;`TopmostEffectWindow.axaml.cs:90-98` |
| B19 | 逐字滚动提醒 | 提醒内容超宽 | `offset.x` | 右边界 → 左边界循环 | `Duration / RepeatCount` | 线性 | 无 | WAAPI 无限循环,每帧线性位移 | `RollingTextTemplate.axaml.cs:39-62`;`RollingTextTemplateData.RepeatCount` 默认 2 |
| B20 | 滚动提醒重排修正 | 容器尺寸变化 | `offset.x` | 按当前进度重算 | 即时重播 | 线性 | 无 | 重新 `startAnimation`(源码在 LayoutUpdated 重设) | `RollingTextTemplate.axaml.cs:75-80` |
### 14.3 C. 启动、OOBE、欢迎动效
启动链路是「闪屏 → 主界面」两段式:闪屏自身有滑入与进度动画,主界面出现时行/组件按动画级别淡入(A2/A4)。OOBE 是唯一允许使用 3 秒级长动画的场景,必须整段可跳过(用户点击任意处立即进入下一步),且在 `AnimationLevel=0` 时直接呈现终态(`OobeIntroAnimationControl.axaml.cs:161-164` 只在 Loaded 时播放;实现时须先判断级别)。
OOBE 的字符动画是「11 个方块 + 11 个字符」双轨并行的复杂编排,必须按源码公式逐字计算延迟(C6),不得改成固定 50ms 等间隔——源码用正弦分布让中部字符更密、两端更疏(`OobeIntroAnimationControl.axaml.cs:37`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| C1 | 闪屏 Logo 滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | `ExponentialEaseInOut`≈`(0.16,1,0.3,1)`【推断】 | 0ms | WAAPI transform | `SplashWindow.axaml.cs:101-119` |
| C2 | 闪屏状态栏滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | 同上 | 150ms | WAAPI transform | `SplashWindow.axaml.cs:53-54,109-119` |
| C3 | 闪屏淡入 | 闪屏启动 | `opacity` | 0 → 1 | 300ms | `CubicEaseOut` | 与 C1/C2 同延迟 | WAAPI opacity | `SplashWindow.axaml.cs:114-120` |
| C4 | 闪屏进度条 | 启动进度更新 | `translateX` | `-400·(1-p)` 到目标 | `max((Δp)/8, 0.5)s`;结束时 150ms | `CubicEaseOut` | 无 | WAAPI transform | `SplashWindow.axaml.cs:64-82,126` |
| C5 | OOBE 整场进场 | 欢迎页加载 | `scale`、`opacity` | 1.25 → 1.0;0 → 1 | 3000ms | `(0,0.83,0.17,0.90)` | 无 | CSS animation `both` | `OobeIntroAnimationControl.axaml:14-30` |
| C6 | OOBE 字符错峰翻入 | 进场后逐字 | `translateY`、`opacity` | 50 → 0;0 → 1 | `delay·9·1.5 + 750` ms | `(0.25,1,0.5,1)`(入) | 逐字延迟 `sin((i+2)/(n+2)·π/2)·500/n` ms | WAAPI 逐元素动画,串行 await | `OobeIntroAnimationControl.axaml.cs:30-54,65-124` |
| C7 | OOBE 字符翻出 | 同一字符动画后段 | `rotate3D.angleX`、`opacity` | 0 → 90°;1 → 0 | 同上 | `(0.32,0,0.67,0)` | 内嵌于 C6 关键帧 | WAAPI 关键帧(0%/timeMs/timeMs+750/100%) | `OobeIntroAnimationControl.axaml.cs:92-118` |
| C8 | OOBE 文字块翻入 | 字符对转向后 | `rotate3D.angleX`、`opacity` | -90° → 0;0 → 1 | `delay·9·2 + 750` ms | `(0.33,1,0.68,1)` | 内嵌 | WAAPI 关键帧 | `OobeIntroAnimationControl.axaml.cs:126-158` |
| C9 | OOBE 方块放大 | 第 7 个方块 | `scaleX`、`background` | 1 → 2.17;灰 → `#00BFFF` | 250ms | `(0.76,0,0.24,1)` | 延迟 307ms | CSS animation | `OobeIntroAnimationControl.axaml:44-58` |
| C10 | OOBE 文字收拢 | 动画末尾 | `gap`/`letter-spacing`、`min-width` | 4px → 0;32px → 0 | 700ms | `(0.33,1,0.68,1)` | 无 | CSS animation | `OobeIntroAnimationControl.axaml:88-116` |
| C11 | 欢迎页内容淡入 | 欢迎页显示 | `opacity` | 0 → 1 | 200ms | 默认 | 无 | CSS animation `both` | `Views/WelcomePages/WelcomePage.axaml:11-24` |
| C12 | 欢迎页 Logo 布局 | 欢迎页显示 | 尺寸 | 80×80 | — | — | — | 静态,非动画 | `WelcomePage.axaml:29-33` |
### 14.4 D. 弹层、抽屉、列表与设置动效
本组是「应用外壳」动效,必须复用 ClassHelper 既有 token 与 Element Plus 覆盖层(`renderer/styles/index.css:567-756` 已把弹窗/抽屉/浮层/消息条的过渡统一替换为弹簧与 `--ch-ease-out`)。实现时**不得**再写第二套弹窗动画;新弹层一律继承既有 class。
列表错峰(D12)必须是「真实逐项启动」而不是一次性 `animation-delay` 批量:源码用 25ms 间隔的 DispatcherTimer 逐项置位(`StackPanelIntroAnimationBehavior.cs:74-90`),逐项启动才能保证「列表在动画过程中被重新排序/过滤」时新项不等待旧延迟。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| D1 | Popup 淡入 | 任意 Popup 打开 | `opacity` | 0 → 1 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI(`AnimationLevel≥1`) | `PopupIntroAnimationBehavior.cs:73-78` |
| D2 | Popup 缩放淡入 | 同上 | `scale` | 0.925 → 1.0 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI;`transform-origin` 按 placement 决定(Bottom→上中、Right→左中、Pointer→左上、Center→中心) | `PopupIntroAnimationBehavior.cs:80-112` |
| D3 | 横抽屉推入 | 抽屉打开 | `translateX`、`opacity` | 抽屉宽 → 0;0 → 1 | 250ms | `cubic-bezier(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:42-59` |
| D4 | 横抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:52-58,68-70` |
| D5 | 竖抽屉推入 | 抽屉打开/收起 | `translateY`(Canvas.Bottom)、`height` | 关闭高度 → 0 | 250ms | `(0,0,0,1)` | 无 | CSS transition(用 transform 代替 bottom/height) | `VerticalDrawer.axaml:29-36,136-155` |
| D6 | 竖抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `VerticalDrawer.axaml:126-134,150-153` |
| D7 | 图标按钮展开 | 展开/收起 | `width`、文字 `opacity` | 图标宽 ↔ 全宽;0 ↔ 1 | `Duration` 属性(默认【推断】200ms) | `SineEaseOut`(宽)/`SineEaseInOut`(文字) | 无 | CSS transition | `AnimatedIconButton.axaml:22-46` |
| D8 | 图标按钮底色 | 悬停/主题变化 | `background` | 旧色 → 新色 | 150ms | 默认 | 无 | CSS transition | `AnimatedIconButton.axaml:81-88` |
| D9 | Shimmer 骨架淡出 | 内容加载完成 | `opacity` | 1 → 0(骨架);0 → 1(内容) | 200ms | `(0.65,0,0.35,1)` | 无 | CSS transition | `Shimmer.axaml:22-38,43-54` |
| D10 | 列表滑入 | 选中项变化 | `translateY`、`opacity` | -40 → 0;0 → 1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `SlidingListBox.axaml:38-55` |
| D11 | 列表滑出 | 同上 | `translateY`、`opacity` | 0 → 40;1 → 0 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation | `SlidingListBox.axaml:57-74` |
| D12 | 堆叠面板错峰入场 | 面板首次加载 | `translateY`、`opacity` | 50 → 0;0 → 1 | 750ms | `(0,1,0,1)` | **每项间隔 25ms**(DispatcherTimer) | rAF/`setInterval(25ms)` 逐项加 class | `StackPanelIntroAnimationBehavior.cs:74-90`;`StackPanelIntroAnimation.axaml:11-35` |
| D13 | 工作流进度条颜色 | 状态变化 | `background` | 灰 ↔ accent | 500ms | `SineEaseInOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:16-22` |
| D14 | 工作流不确定态呼吸 | 进行中且无进度 | `opacity` | 0.85 → 0.4 → 0.85 | 750ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `WorkflowProgressIndicatorItemControl.axaml:28-44` |
| D15 | 工作流确定性进度 | 有进度值 | `value` | 旧 → 新 | 180ms | `SineEaseInOut` | 无 | CSS transition transform | `WorkflowProgressIndicatorItemControl.axaml:57-67` |
| D16 | 工作流完成淡入 | 完成后 | `opacity` | 0 → 1 | 300ms | `SineEaseOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:63-65` |
| D17 | TeachingTip 箭头循环 | 教学提示显示 | `translateY`、`rotateY` | 0→-8→0;0→180° | 1000ms | `(0.5,1,0.89,1)`/`(0.11,0,0.5,0)`/`(0.66,0,0.34,1)` | 无限 | CSS animation infinite | `MyTeachingTipStyles.axaml:223-251` |
| D18 | 教程高亮入场 | 教程步骤开始 | `margin`、`opacity` | -64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation `both` | `TutorialHighlightControl.axaml:11-24` |
| D19 | 教程高亮呼吸循环 | 教程步骤持续 | `margin`、`opacity` | -64→0→0;0→1→1 | 1750ms | `(0.16,1,0.3,1)` | 延迟 1750ms,无限 | CSS animation infinite | `TutorialHighlightControl.axaml:25-44` |
| D20 | 教程聚光入场 | 教程步骤开始 | `margin`、`opacity` | 64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `TutorialSpotlightControl.axaml:11-35` |
| D21 | 更新图标旋转 | 下载更新中 | `rotate` | 0 → 360° | 1000ms | `(0.5,0.25,0.5,0.75)` | 无限 | CSS animation infinite | `UpdateSettingsPage.axaml:88-99` |
| D22 | 关于页头部淡入 | 页面加载 | `opacity` | 0 → 1 | 200ms | `(0,0,0,1)` | 延迟 250ms | CSS animation `both` | `AboutSettingsPage.axaml:133-147` |
| D23 | 拖拽高亮脉冲 | 拖动组件中 | `opacity` | 0.1 → 0.6 → 0.1 | 1000ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `EditableComponentsListBox.axaml:307-335` |
| D24 | 设置面板显隐 | 子设置展开 | `opacity` | 0 ↔ 1 | 200ms | `(0,1,0,1)` | 无 | CSS transition | `WeatherSettingsPage.axaml:207-214` |
| D25 | 自动化项显隐 | 条件切换 | `opacity` | 0 ↔ 1 | 100ms | 默认 | 无 | CSS transition | `AutomationSettingsPage.axaml:140-142` |
| D26 | 列表项淡入(课表) | 课表项状态变化 | `opacity` | 0 ↔ 1 | 150ms | 默认 | 无 | CSS transition | `LessonsListBox.axaml:69-77` |
### 14.5 E. 组件动效
组件动效的原则是「**动内容不动容器**」:滚动、滑动、倒计时进度都在组件内部完成,绝不触发行高/行宽变化(否则会触发 A5/A6 的行宽过渡,产生连锁抖动)。滚动组件的速度单位是「像素/秒」,必须由设置项驱动并做下限保护(`Math.Clamp(SpeedPixelPerSecond, 1, int.MaxValue)`,`RollingComponent.axaml.cs:96`),暂停时长默认 10 秒,暂停位置默认 0(`RollingComponentSettings.cs`)。
时钟、天气图标、时间线色块**不做**动画:它们是高频更新的信息元素,任何过渡都会让「时间在走」变得可感知延迟。源码只在字号/颜色上做静态样式(`ClockComponent.axaml:14`、`WeatherComponent.axaml:261-305`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| E1 | 滚动组件滚动 | 内容宽于容器 | `translateX` | 0 → -(W+offset) → 容器宽 → 0 | `(暂停+滚动)` 秒;速度 40px/s | 线性 | 暂停 10s(默认) | WAAPI 关键帧无限循环 | `Controls/Components/RollingComponent.axaml.cs:85-135`;`RollingComponentSettings.cs`(`SpeedPixelPerSecond=40`、`PauseSeconds=10`) |
| E2 | 滚动组件重置 | 停止滚动/规则命中 | `translateX` | 归零 | 1s(空动画占位) | 线性 | 无 | 用 1s 静止关键帧覆盖旧动画 | `RollingComponent.axaml.cs:137-157` |
| E3 | 滚动组件停止规则 | 规则命中 | 滚动状态 | 滚动 → 停止 | 即时 | — | 无 | 取消动画并归位 | `RollingComponent.axaml.cs:171-179` |
| E4 | 滑动组件切换 | 选中项变化 | `translateY`、`opacity` | -40→0 / 0→40;0↔1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `Controls/Components/SlideComponent.axaml:69-107` |
| E5 | 滑动组件禁用动画 | 设置关闭动画 | — | 立即切换 | 0ms | — | — | 关闭时直接切 `display` | `SlideComponent.axaml:51-53` |
| E6 | 倒计时进度 | 计时更新 | 进度值 | 旧 → 新 | 50ms(同 A12) | 线性 | 无 | CSS transition;降低精度时按 `max(10, 总秒/500)` 取整 | `CountDownComponent.axaml.cs:288-295`;`Settings.cs:850` |
| E7 | 时钟秒变化 | 秒变化 | 文本内容 | — | 无动画 | — | — | 直接替换文本(不要做数字翻转动画) | `ClockComponent.axaml:14`(仅字号) |
| E8 | 天气图标切换 | 天气数据更新 | 图标字形 | 旧 → 新 | 无动画 | — | — | 直接替换(4 套模板,见 part 15) | `Controls/WeatherIcons/*.axaml` |
| E9 | 时间线色块 | 时间点类型 | 颜色/高度 | 类型色 | 无动画 | — | — | 静态 | `Themes/Generic.xaml:65-128` |
| E10 | 时间线装饰器出现 | 悬停/选中 | 透明度 | 0 → 1 | 200ms【推断】 | 默认 | 无 | 由行为控制(`TimeLineListItemBehavior`),Web 用 CSS transition | `Themes/Generic.xaml:190-290` |
| E11 | 提醒遮罩分块绘制 | 遮罩打开 | 5 段平行四边形宽度 | 0 → 全宽 | 同 B1 | 同 B1 | 同 B1 | canvas 绘制(每帧按进度重绘) | `SlantedMaskControl.cs:273-343` |
### 14.6 F. ClassHelper 既有动效(必须复用,不得重造)
ClassHelper 已经沉淀了一套完整动效体系,**必须原样复用**,只在「ClassIsland 特有场景」上做增量:
| 既有能力 | 位置 | 复用方式 |
|---|---|---|
| 动效 token 注入 | `packages/shared/src/motion.ts` → 两端 `composables/motion.ts` | 在 `app.mount()` **之前**写入 `:root`(首帧即就位,无 FOUC) |
| 弹簧曲线转 CSS `linear()` | `motion.ts:152-164` | 直接调用 `springLinearEasing()`,禁止手写采样点 |
| 减少动态效果 | `composables/motion.ts:53-72` | 主界面/管理中心统一使用 `useReducedMotion()` |
| 悬停能力检测 | `composables/motion.ts:81-99` | 所有 hover-only 效果必须先过 `useHoverCapable()`(教室触摸屏幽灵 hover) |
| 数字滚动 | `composables/motion.ts:129-167` | 倒计时/统计数字直接复用 `useCountUp()` |
| 错峰入场 | `composables/motion.ts:175-179` | 列表/卡片直接复用 `useStaggerIn()` |
| 整页换肤 | `composables/motion.ts:201-237` | 应用主题切换复用 `useThemeReveal()` |
硬约束:**不得**在岛渲染进程里引入主渲染进程的 `--ch-*` 动效 token(ClassHelper 明确「岛是另一个渲染进程,刻意不参与这里的动效体系」,`motion.ts:29-31`)。岛必须使用自己的 `--island-*` 与 `SpringValue`。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| F1 | 岛尺寸弹簧 | 模式/尺寸变化 | `width`、`height`、`radius` | 当前 → 目标 | 由物理积分决定 | 弹簧 `stiffness=0.10`、`damping=0.68` | 无 | 每帧 `SpringValue.step()`,60fps 单位 | `ClassHelper/src/island/spring.ts:26-114`;`IslandApp.vue:216-322` |
| F2 | 岛换向限速 | 目标方向反转 | `velocity` | 反向保留 35%,帧位移 ≤ 剩余 20% | 每帧 | — | 无 | `redirectVelocity` | `spring.ts:66-82` |
| F3 | 岛零过冲钳制 | 越过目标 | `value`、`velocity` | 吸附目标、速度清零 | 即时 | — | 无 | `step()` 内判定 | `spring.ts:100-107` |
| F4 | 岛淡入(窗口) | 窗口显示 | 窗口 `opacity` | 当前 → 目标 | 每帧 +0.16,16ms 步进(约 60–110ms) | 线性 | 无 | 主进程 `setOpacity` 定时步进 | `src/main/island.ts:1674-1691` |
| F5 | 岛淡出(窗口) | 窗口隐藏 | 窗口 `opacity` | 当前 → 0 | 每帧 -0.2,16ms 步进(约 50–80ms) | 线性 | 无 | 主进程 `setOpacity`,到 ≤0.02 后 `hide()` | `island.ts:1693-1715` |
| F6 | 岛显示方式 | 显示 | 焦点 | `showInactive()` 不抢焦点 | 即时 | — | 无 | 主进程 | `island.ts:1426,1458,1490` |
| F7 | 弹窗弹簧入场 | 弹窗打开 | `transform`、`opacity` | `translateY(-12px) scale(.97)` → 0/1 | `SPRING_PANEL` 自然落定(约 463ms) | 弹簧 `linear()` 或 `cubic-bezier(.16,1,.3,1)` 回退 | 无 | CSS animation | `index.css:596-605,634-636`;`motion.ts:52,220-230` |
| F8 | 弹窗快速退出 | 弹窗关闭 | `transform`、`opacity` | → `translateY(-6px) scale(.985)` | 140ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `index.css:607-616,642-644` |
| F9 | 抽屉弹簧 | 抽屉打开/关闭 | `transform`、`opacity` | 侧边 ↔ 0 | `SPRING_PANEL` 463ms / `--ch-dur-base` 200ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:699-703` |
| F10 | 浮层缩放 | 下拉/选择器 | `transform`、`opacity` | `scale(.95)` → 1 | `SPRING_SWAP` 约 263ms | 弹簧 | 无 | CSS transition | `index.css:710-737`;`motion.ts:50,228-229` |
| F11 | 消息条进出 | 消息出现/消失 | `transform`、`opacity`、`top/bottom` | 位移 | 弹簧 263ms / 140ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:739-756` |
| F12 | 按钮按压 | 按下/抬起 | `transform: scale` | 1 → 0.97 → 1 | 按下 90ms;抬起 `SPRING_PRESS` 约 194ms【推断】 | 按下 ease-out;抬起弹簧 | 无 | CSS transition(按下与基础规则分离) | `index.css:769-782`;`motion.ts:48,178` |
| F13 | 卡片抬升 | 悬停(仅 hover 设备) | `transform: translateY`、`box-shadow` | 0 → -1px | 263ms | 弹簧/ease-out | 无 | CSS transition,**必须**包在 `@media (hover:hover) and (pointer:fine)` 内 | `index.css:791-802` |
| F14 | 列表错峰入场 | 列表渲染 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 每项 35ms,第 9 项起封顶 | CSS animation + 内联 `--ch-reveal-delay` | `index.css:814-828`;`motion.ts:175-179` |
| F15 | 页面块错峰入场 | 路由切换 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 30ms/块,封顶 180ms | CSS animation `nth-child` | `index.css:846-883` |
| F16 | 整页换肤扩散 | 切换主题 | `clip-path: circle()` | 0px → 最远角半径 | 480ms(320×1.5) | `(0.16,1,0.3,1)` | 无 | View Transition API + WAAPI | `composables/motion.ts:201-237` |
| F17 | 侧栏折叠 | 折叠/展开 | `width`、`gap` | 全宽 ↔ 64–80px | 260ms | `cubic-bezier(0.4,0,0.2,1)` | 无 | CSS transition | `index.css:56-75` |
| F18 | 减少动态效果 | 系统偏好 | 全部时长 | → 1ms | 1ms | — | — | 全局媒体查询覆盖(加载指示器例外,放慢到 1.6s) | `index.css:904-930` |
### 14.7 实现方式规范(必须遵守)
1. **优先使用合成器友好属性**:只对 `transform` 与 `opacity` 启 CSS transition/WAAPI。源码中 `BackgroundWidth`(A5/A6)与 `Margin`(D18/D19)属于布局属性,Web 实现必须做等价转换:
- 行宽过渡:背景层绝对定位于内容层之下,宽度用 `clip-path: inset(0 calc(100% - var(--w)) 0 0 round <r>)` 或 `transform: scaleX()`(配合 `transform-origin: left`)实现,**不得**对包含文字的容器直接 `transition: width`(会导致逐帧重排)。
- 教程高亮:把 `margin` 动画换成 `transform: scale()`(围绕中心放大)或 `inset` 的 `clip-path` 动画,视觉等价。
2. **弹簧动画**:岛尺寸/圆角必须用 ClassHelper 的 `SpringValue`(F1–F3),不得改用 CSS 弹簧曲线;两条路径不能混用(ClassHelper `motion.ts:29-33` 明确「岛是另一个渲染进程,不参与本动效体系」)。
3. **弹簧曲线用于 CSS 时**:过冲曲线只能用于 `transform`/`clip-path`,**禁止用于 `opacity`**(会被裁剪到 [0,1],观感变成「到顶就停」)(`motion.ts:148-151`)。
4. **`filter` 禁忌**:卡片类元素禁止用 `filter: drop-shadow` 做投影(会新建渲染表面,开合瞬间闪帧),必须用 `box-shadow`(`classic-island.css:114-120`)。
5. **`will-change`**:只允许在动画进行中的元素上临时添加 `will-change: transform, opacity`,动画结束(`animationend`/`transitionend`)立即移除;禁止全局常驻。
6. **禁止动画的属性**:`z-index`、`display`(用 `visibility`/`opacity` 或先 `display` 后下一帧启动)、`pointer-events`。需要「显隐 + 动画」时用双 rAF 或 `requestAnimationFrame` 起手。
7. **中断语义**:任何进场动画被反向触发时必须能平滑打断(`FillMode.Both/Forward` 对应 `animation-fill-mode`;用 `getAnimations().forEach(a => a.cancel())` 清理旧动画)。
8. **统一调度**:动画必须在 `AnimationLevel=0` 时统一短路(除主界面提醒);同一时刻同一元素不得叠加两条竞争动画。
9. **帧率与预算**:岛内动画(弹簧步进)必须跑在独立渲染进程的 rAF 上;主渲染进程的 60fps 预算为 16.7ms,任何单帧主线程任务禁止超过 8ms;动画期间不得读取 `offsetWidth` 等强制同步布局属性。
10. **禁止「看起来像动画的定时器」**:所有时长必须来自 14.0.3 常量表,不得在组件里写 `300`、`0.3s` 之类字面量。
#### 14.7.1 几何动画的 FLIP 流程(必须按此实现)
源码大量动画布局属性(宽度、间距、边距),Web 端必须用 FLIP 换算成合成器动画:
1. **First**:在状态改变前读取元素 `getBoundingClientRect()` 并缓存(只在必要时读一次,避免强制同步布局)。
2. **Last**:改完 class/数据后,下一帧再读一次新几何。
3. **Invert**:计算 `dx = old.left - new.left`、`dy = old.top - new.top`、`sx = old.width / new.width`,对元素设置 `transform: translate(dx,dy) scale(sx)` 并临时关掉 transition。
4. **Play**:强制一次重排(读 `offsetHeight` 或 `getBoundingClientRect`),再恢复 transition 并清空 transform,让浏览器从「反演位置」过渡到「最终位置」。
FLIP 的硬性要求:整个流程必须在**同一帧内**完成读取与写入的分离(先批量读、再批量写),否则会触发多次回流;对列表批量移动时必须逐个元素做,不得在父容器上做 scale(会把文字一起拉伸)。
#### 14.7.2 行宽过渡的推荐实现(替换源码的 BackgroundWidth 动画)
源码动画的是背景层 `Width`(`FluentTheme/Styles.axaml:81`、`ClassicTheme/Styles.axaml:63`),因为背景层只承载底色与描边,不承载文字。Web 端必须照此分层:
```html
<div class="island-line" style="--line-w: 420px; --line-r: 8px">
<div class="island-bg"></div> <!-- 绝对定位;只动画这一层 -->
<div class="island-content">…</div>
</div>
```
```css
.island-bg{
position:absolute; inset:0 auto 0 0; width:var(--line-w);
border-radius:var(--line-r); background:var(--ci-island-bg);
transition: width var(--ci-line-w-dur) var(--ci-line-w-ease);
will-change: width; /* 仅动画期间由 JS 加上 */
}
```
- Fluent:`--ci-line-w-dur:300ms; --ci-line-w-ease:cubic-bezier(.65,0,.35,1)`。
- 经典:`--ci-line-w-dur:550ms; --ci-line-w-ease:cubic-bezier(0,1,0.5,1.1)`。
- 内容层宽度变化必须瞬时(不加过渡),否则文字会在动画中抖动。
- 背景层宽度变化会触发布局,但因为它只有底色、且 `contain: strict` 可限制影响范围,实测可接受;若追求纯合成器动画,可改用 `clip-path: inset(0 calc(100% - var(--line-w)) 0 0 round var(--line-r))`,但圆角与描边的贴合需要额外验证。
#### 14.7.3 斜切遮罩的 canvas 实现要点
若选择 canvas 复刻 `SlantedMaskControl`:
- 每个区段的几何:中心点 `rCenter`、当前宽度 `currentW = fullW × progress`、斜切偏移 `offset = tan(30°) × h`(`SlantedMaskControl.cs:281-343`)。
- 绘制顺序:区 0/4(两端)→ 区 1/3 → 区 2(中间),与 B1 的交错延迟一致。
- 进度驱动:`requestAnimationFrame` 中累加时间,按 `progress = easeOutQuart(t)` 重算并 `ctx.clearRect` + 重绘;`RegionDurationMs=360`、`StageStaggerMs=120` 必须可配置(主题可覆盖,见 part 09 的 `--ci-island-mask-*`)。
- 绘制完成后必须停止 rAF,不得常驻渲染循环;遮罩关闭时按 B5–B7 反向播放。
- 若用 DOM/SVG 实现,5 个 `polygon` 的 `points` 用 CSS 变量驱动 `clip-path`,但注意 `clip-path` 的百分比无法表达斜切角,必须用 `path()` 或 `polygon()` 的绝对像素值并在 resize 时重算。
#### 14.7.4 动效的测试钩子(必须提供)
为便于验收,实现必须暴露以下测试接口(仅开发构建可用):
- `window.__ciMotion.getState(id)`:返回某动画的 `{playing, elapsed, duration, easing}`。
- `window.__ciMotion.list()`:返回当前所有运行中动画的 id 与元素选择器。
- `window.__ciMotion.setLevel(0|1|2)`:运行时切换动画级别。
- `window.__ciMotion.disable()`:等价 `prefers-reduced-motion`。
这些钩子必须不影响生产构建(通过 `import.meta.env.DEV` 或独立 dev 入口注入)。
### 14.8 与源码的差异说明(必须写进实现注释)
- 源码用 Avalonia `Transitions`(属性级隐式动画)与 `Animation`(关键帧)两套机制;Web 端对应 `transition` 与 `@keyframes`/WAAPI。二者的「延迟 + 填充模式」语义必须一一对应,特别是 `FillMode="Both"`(对应 `animation-fill-mode: both`),漏掉会在延迟期间闪出终态(`StackPanelIntroAnimation.axaml:22`、`FluentTheme/Styles.axaml:332`)。
- 源码 `ClassicTheme` 的行宽过渡带过冲(`1.10`),这是「经典主题手感」的一部分,**不得**为了「统一」改成 Fluent 的曲线。
- 源码 `SlantedMaskControl` 用 `canvas` 逐帧重绘 5 段平行四边形(`SlantedMaskControl.cs:273-343`),Web 端可以用 canvas 2D 或 5 个 `clip-path` 的多边形,但**分块权重必须保持** `[0.0, 0.1, 0.2, 0.4, 0.2, 0.1]`(`SlantedMaskControl.cs:298`),且斜切角固定 60°(偏移 `tan(30°)·h`,`:284`)。
- 源码的时间轴用 `DispatcherTimer(25ms)` 驱动列表项逐个入场(`StackPanelIntroAnimationBehavior.cs:74-90`);Web 端用 `setTimeout` 链或 `requestAnimationFrame` 时间片,间隔仍然 25ms,不得改成 CSS `animation-delay` 批量(会与「列表顺序变化」脱节)。
#### 14.8.1 性能预算与降级矩阵
| 场景 | 目标帧率 | 单帧主线程预算 | 降级顺序 | 说明 |
|---|---|---|---|---|
| 岛弹簧(独立渲染进程) | 60fps | 该进程 8ms | ① 降低步进频率到 30fps ② 关闭圆角形变只动尺寸 ③ 直接落位 | 必须在独立进程,不得与主界面共用事件循环 |
| 提醒遮罩(canvas 重绘) | 60fps | 4ms | ① 减到 3 段 ② 降到 30fps ③ 改为纯透明度过渡 | 重绘面积大,必须先算好路径再绘制 |
| 列表错峰入场 | 60fps | 8ms | ① 只动画 opacity ② 减少同屏项数 ③ 直接显示 | 禁止对每项做 FLIP |
| 窗口级淡入淡出(主进程) | 30fps 足够 | — | 直接落位 | 主进程 `setOpacity` 步进不占渲染线程 |
| 整页换肤(View Transition) | 60fps | 16ms | ① 缩短到 320ms ② 关掉圆形扩散改交叉淡入 ③ 直接切换 | 文档不可见时必须同步切换(`motion.ts:195-200`) |
降级必须是**自动**的:连续 30 帧低于 45fps 时自动降一级,并在 5 秒后尝试恢复;降级不得改变最终视觉终态,只改变过程。
#### 14.8.2 可访问性要求
1. **减少动态效果**:必须响应系统偏好,且提供应用内「动画级别=禁用」作为等效手段(两者任一命中即降级)。
2. **不得用闪烁传达信息**:任何闪烁频率必须低于 3Hz;源码没有任何闪烁动画,实现也不得新增。
3. **动画不得是唯一反馈**:按钮按压、开关切换等必须同时有颜色/状态变化,不能只靠缩放。
4. **焦点不丢**:动画期间焦点元素不得被移除;抽屉/弹窗关闭后焦点必须回到触发按钮。
5. **文字可读**:动画过程中文字不得低于 4.5:1 对比度(遮罩淡入时文字必须等遮罩底色到位后再出现,源码用延迟 260ms 实现,见 B2)。
6. **超大字号**:用户把 4 档字号调到 40px 时,所有含固定高度的动画容器必须允许内容溢出或自适应,不得裁切文字。
### 14.9 验收标准
1. **逐条对照**:本节 A1–A12、B1–B20、C1–C12、D1–D26、E1–E11、F1–F18 共 **99 条**动效全部实现;抽测任意 10 条,时长误差 ≤ 1 帧(16.7ms),曲线肉眼无差异。
2. **动画级别**:切到「禁用」后,抽屉/Popup/列表错峰动画不再播放(主界面提醒动画保留);切到「华丽」后行淡入与组件淡入出现;切回「标准」后二者消失。
3. **减少动态效果**:系统开启 `prefers-reduced-motion` 后,全部动画在 1 帧内完成,页面不出现闪烁或「终态先行」;加载指示器仍在转(放慢至 1.6s)。
4. **打断测试**:连续快速开关同一抽屉/Popup/遮罩 10 次,无卡死、无重影、无「停在半路」;遮罩快速 in→out 切换后元素状态正确复位。
5. **性能**:动画期间主线程帧耗时 P95 < 8ms、掉帧 < 2 帧/秒;岛弹簧动画在独立渲染进程内不掉帧(对照 ClassHelper 的实现位置)。
6. **填充模式**:所有带延迟的动画在延迟期间不显示终态(检查 D12、D18、B2、C6)。
7. **曲线一致性**:`--ci-ease-*` 只在一处定义;`grep` 组件样式不得出现 `cubic-bezier(` 字面量(动画表定义处除外)。
8. **层叠与裁剪**:遮罩动画期间文字不被裁切、圆角外的像素不泄漏;分体主界面下每行动画独立,不出现整排一起抖动。
9. **焦点与命中**:动画期间点击穿透状态正确(非交互态不拦截点击,交互态可点);窗口淡出后不残留不可见但可点击的层。
10. **可复现**:同一操作重复 20 次,动画起始/结束时刻的视觉状态一致,无累积误差(尤其是 A5/A6 的行宽与 E1 的滚动偏移)。