Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/keyframes.rs
T
AskaEth a4cdd61fe5 fix(iced/feedback): 复审 r3 的 A/B/D/E/F 项 + C 项待 rebase 后改 view_at
(先落一个中间提交,rebase main 之后再按 tower 的修正把空态图标改成 32/view_at)
2026-11-02 15:56:46 +08:00

98 lines
4.4 KiB
Rust
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.
//! 不定态的**节奏**:把官方的 keyframes 摊成纯函数 `phase → 进度`。
//!
//! ## 为什么组件不计时
//!
//! 契约 §2:组件不持有时钟/状态所有权。`indeterminate` 的相位由**调用方**给(`phase` 0..1,
//! 由页面的 `Subscription` 每帧/每 tick 推进)。好处不只「符合契约」:截图 harness 也才能拍到
//! 确定的中间帧——否则每张图都是一次抓阄。
//!
//! ## 数据来源(A1,`javap` 读本机 AAR 得到,不是凭记忆)
//!
//! ```text
//! ~/.gradle/caches/.../material3-android/1.4.0/.../material3.aar → classes.jar
//! javap -p -c -cp classes.jar androidx.compose.material3.ProgressIndicatorKt
//! ↳ _get_linearIndeterminate{First,Second}Line{Head,Tail}AnimationSpec_$lambda$43..46
//! ```
//!
//! Compose 的 `KeyframesSpec.at(value, time).using(easing)` 是「**从该点出发的那一段**用这条缓动」,
//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;最后一点之后一律**保持末值**
//! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。
//!
//! ## 首点的两种合法情况(复审 P1-B 的教训)
//!
//! Compose 在 keyframes **缺 0ms 关键帧**时会自己补一个 `t = 0`、值 = 动画**初值**的点
//! (`VectorizedKeyframesSpec` 构造里 `Map.containsKey(0) == false` 就 `timestamps.add(0, 0)`,
//! 且 `playTime <= 0 → startValue`)。本求值器对这种情况按「保持首值」处理,而它**只等价于**
//! Compose 的「初值」—— 所以守卫是「首点不在 0ms **且** 首值 ≠ 0」才 `debug_assert` 炸:
//!
//! - `SEG_TAIL_1/2`(首点 250/900ms、首值 **0**)与 Compose 的「初值 0」等价,**合法**;
//! - `ROTATION` 曾经首点 300ms/**0.25**:`[0, 0.05)` 被恒定成 90°、周期末尾 360° → 下周期
//! 90° 瞬时倒跳 270°。现在首点显式补成 0ms/0°。
use unlucky_design::motion::Easing;
/// 一个关键帧:`ms` 时刻的值 + **从它出发那一段**的缓动。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeyPoint {
/// 相对周期起点的毫秒数。
pub ms: u32,
/// 该时刻的进度(0..1,环形是「弧长占比」,旋转是「度数 / 360」)。
pub value: f32,
/// 从本点出发到下一个点的那一段使用的缓动。
pub easing: Easing,
}
/// 一条 keyframes 曲线(一个周期内可采样任意相位)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Keyframes {
/// 周期长度(毫秒)。`phase` 按它折算成时间,再对周期取模(相位可越界/可循环)。
pub cycle_ms: u32,
/// 关键帧(按 `ms` 升序)。
pub points: &'static [KeyPoint],
}
impl Keyframes {
/// 采样:`phase`(任意实数,按周期取模)→ 进度。
///
/// 空列表给 0;`cycle_ms == 0` 时直接给首点的值(没有可循环的时间轴)。
pub fn sample(&self, phase: f32) -> f32 {
let Some(first) = self.points.first() else {
return 0.0;
};
if self.cycle_ms == 0 || !phase.is_finite() {
return first.value;
}
let cycle = self.cycle_ms as f32;
// 首点不在 0ms 时,`[0, first.ms)` 按「保持首值」算;Compose 那里放的是动画**初值**。
// 两者只在「首值 = 初值 = 0」时等价 —— 不等价就会画出一段不存在的静止(ROTATION 曾因此
// 每周期末尾倒跳 270°)。所以这里只拦「首点不在 0 且首值不是 0」。
debug_assert!(
first.ms == 0 || first.value == 0.0,
"首点 {}ms 的值是 {}:Compose 在 [0, {}ms) 用的是动画初值 0,不是首值",
first.ms,
first.value,
first.ms
);
let ms = phase.rem_euclid(1.0) * cycle;
if ms <= first.ms as f32 {
return first.value;
}
for pair in self.points.windows(2) {
let (from, to) = (pair[0], pair[1]);
if ms > to.ms as f32 {
continue;
}
let span = to.ms as f32 - from.ms as f32;
if span <= 0.0 {
return to.value;
}
let t = (ms - from.ms as f32) / span;
return from.value + (to.value - from.value) * from.easing.sample(t);
}
self.points[self.points.len() - 1].value
}
}