98 lines
4.4 KiB
Rust
98 lines
4.4 KiB
Rust
//! 不定态的**节奏**:把官方的 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
|
||
}
|
||
}
|