时间与弹簧——两种动画范式的完整实现¶
在上一篇文章里,我们讲了动画引擎的抽象架构。这篇文章聊聊两种具体的动画实现:基于时间的动画和基于弹簧的动画。
ICFTimingAnimation:时间驱动的动画¶
ICFTimingAnimation 是基于时间的动画,它的核心思想是:给定一个时长和一条缓动曲线,在规定的时间内从 from 值过渡到 to 值。
class ICFTimingAnimation : public ICFAbstractAnimation {
public:
explicit ICFTimingAnimation(IMotionSpec* spec, QObject* parent = nullptr);
virtual void setRange(float from, float to) {
m_from = from;
m_to = to;
}
virtual float currentValue() const = 0;
protected:
IMotionSpec* motion_spec_ = nullptr; // 用于获取时长和缓动
float m_from = 0.0f;
float m_to = 1.0f;
int m_elapsed = 0; // 已过时间(毫秒)
};
注意 motion_spec_ 是一个原始指针。这是一个设计决策:IMotionSpec 的生命周期必须比动画长。在 CFMaterialAnimationFactory 中,这个条件是满足的,因为工厂持有对主题的引用,而主题拥有 MotionSpec。
tick() 实现逻辑¶
ICFTimingAnimation 的 tick() 实现大致如下:
bool ICFTimingAnimation::tick(int dt) {
m_elapsed += dt;
// 从 MotionSpec 获取时长
int duration = motion_spec_->queryDuration(m_motionToken);
float progress = std::min(m_elapsed / (float)duration, 1.0f);
// 从 MotionSpec 获取缓动类型
int easingType = motion_spec_->queryEasing(m_motionToken);
QEasingCurve curve = Easing::fromEasingType(static_cast<Easing::Type>(easingType));
// 应用缓动曲线
float easedProgress = curve.valueForProgress(progress);
// 计算当前值
m_value = lerp(m_from, m_to, easedProgress);
// 更新进度并发出信号
m_progress = easedProgress;
emit progressChanged(m_progress);
return m_elapsed < duration; // 返回 false 表示动画结束
}
这里的关键是 valueForProgress(),它根据缓动曲线将 [0, 1] 的线性进度映射到非线性进度。
缓动曲线的应用¶
Material Design 3 定义了几种标准缓动:
- Emphasized:快速启动,缓慢结束(强调进入)
- Standard:适中的加速减速
- Linear:匀速运动
- Legacy:Material Design 2 的缓动(兼容性考虑)
在 Layer 1 我们实现了这些缓动的 QEasingCurve 封装。动画直接使用这些预定义的曲线,确保整个应用的动画风格一致。
ICFSpringAnimation:弹簧驱动的动画¶
ICFSpringAnimation 使用弹簧物理来模拟自然的弹性运动。与时间驱动的动画不同,弹簧动画没有固定的时长——它会"自然地"收敛到目标值。
class ICFSpringAnimation : public ICFAbstractAnimation {
public:
ICFSpringAnimation(const Easing::SpringPreset& easing, QObject* parent = nullptr);
virtual void setTarget(float target) { m_target = target; }
virtual void setInitialVelocity(float velocity) { m_velocity = velocity; }
virtual float currentValue() const = 0;
bool tick(int dt) override; // 使用 springStep
protected:
Easing::SpringPreset easing_; // 包含 stiffness 和 damping
float m_position = 0.0f;
float m_velocity = 0.0f;
float m_target = 1.0f;
};
springStep 物理模拟¶
弹簧动画的核心是 springStep 函数,它使用半隐式欧拉积分法模拟弹簧物理:
std::pair<float, float> springStep(float position, float velocity, float target,
float stiffness, float damping, float dt) {
// 计算弹簧力
float force = (target - position) * stiffness;
// 计算阻尼力
float dampingForce = -velocity * damping;
// 总加速度
float acceleration = force + dampingForce;
// 更新速度(半隐式欧拉)
float newVelocity = velocity + acceleration * dt;
// 更新位置
float newPosition = position + newVelocity * dt;
return {newPosition, newVelocity};
}
这个算法在 Layer 1 讲过,关键点是:
- 力与加速度成正比:胡克定律 F = kx
- 阻尼力与速度成正比:防止永远振荡
- 半隐式欧拉:先更新速度,再更新位置,比标准欧拉法更稳定
收敛判断¶
弹簧动画没有固定的时长,所以需要一个收敛判断:
bool ICFSpringAnimation::tick(int dt) {
// 转换 dt 到秒
float dtSeconds = dt / 1000.0f;
// 执行物理步进
auto [newPos, newVel] = springStep(
m_position, m_velocity, m_target,
easing_.stiffness, easing_.damping,
dtSeconds
);
m_position = newPos;
m_velocity = newVel;
// 计算当前进度(近似)
float displacement = m_target - m_from;
float currentDisp = m_position - m_from;
m_progress = (displacement != 0) ? currentDisp / displacement : 1.0f;
emit progressChanged(m_progress);
// 收敛判断:速度很小且接近目标
bool isConverged = std::abs(m_velocity) < 0.01f &&
std::abs(m_target - m_position) < 0.01f;
return !isConverged;
}
收敛的条件是速度足够小且距离目标足够近。阈值 0.01 是经验值,可以根据需要调整。
SpringPreset 弹簧预设¶
Material Design 3 定义了几种弹簧预设:
namespace Easing {
struct SpringPreset {
float stiffness;
float damping;
};
SpringPreset springGentle(); // 温和的弹性
SpringPreset springBouncy(); // 明显的弹性
SpringPreset springStiff(); // 僵硬的弹性
}
不同的预设适用于不同的场景:按钮点击用 gentle,对话框进入用 bouncy,列表滚动用 stiff。
选择哪种动画?¶
一个常见的问题是:什么时候用 TimingAnimation,什么时候用 SpringAnimation?
粗略的原则是:
- UI 过渡(淡入淡出、滑动):用 TimingAnimation
- 物理交互(拖拽释放、弹性动画):用 SpringAnimation
- 标准场景:优先用 TimingAnimation(更可控)
- 强调效果:用 SpringAnimation(更生动)
实际使用示例¶
控件中使用这两种动画的方式类似:
// TimingAnimation:淡入效果
auto fadeAnim = factory->getAnimation("md.animation.fadeIn");
if (fadeAnim) {
connect(fadeAnim.get(), &ICFAbstractAnimation::progressChanged,
this, [this](float progress) {
m_opacity = progress;
update();
});
fadeAnim->start();
}
// SpringAnimation:弹性缩放
// 需要先注册自定义弹簧动画
// 或者使用预设的弹簧动画 token
总结¶
TimingAnimation 和 SpringAnimation 是两种互补的动画范式。前者提供可预测的时间驱动动画,后者提供自然的物理驱动动画。它们共享同一个基类接口,可以无缝切换。
但有了具体的动画类型还不够,我们需要一个系统来创建和管理这些动画——这就是工厂和策略模式的作用。
接下来,我们聊聊动画工厂的设计。
相关文档