use super::simulation::{
BouncingScrollSimulation, ClampingScrollSimulation, ScrollSpringSimulation,
};
use super::{ScrollMetrics, ScrollPhysics, Simulation, SpringDescription};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DecelerationRate {
#[default]
Normal,
Fast,
}
impl DecelerationRate {
pub const NORMAL_FRICTION: f64 = 0.52;
pub const FAST_FRICTION: f64 = 0.26;
pub const FAST_CONSTANT_DECELERATION: f64 = 1400.0;
fn friction_coefficient(self) -> f64 {
match self {
DecelerationRate::Normal => Self::NORMAL_FRICTION,
DecelerationRate::Fast => Self::FAST_FRICTION,
}
}
fn constant_deceleration(self) -> f64 {
match self {
DecelerationRate::Normal => 0.0,
DecelerationRate::Fast => Self::FAST_CONSTANT_DECELERATION,
}
}
fn spring(self) -> SpringDescription {
match self {
DecelerationRate::Normal => SpringDescription::default_scroll_spring(),
DecelerationRate::Fast => SpringDescription::with_damping_ratio(0.3, 75.0, 1.3),
}
}
}
fn split_overscroll_travel(start: f64, end: f64) -> (f64, f64) {
if start * end < 0.0 {
(-start, end)
} else if end.abs() >= start.abs() {
(0.0, end - start)
} else {
(end - start, 0.0)
}
}
#[derive(Debug, Default)]
pub struct Bouncing {
rate: DecelerationRate,
parent: Option<Box<dyn ScrollPhysics>>,
}
impl Bouncing {
pub const MIN_FLING_VELOCITY: f64 = super::MIN_FLING_VELOCITY * 2.0;
const MOMENTUM_COEFFICIENT: f64 = 0.000_816;
const MOMENTUM_EXPONENT: f64 = 1.967;
const MOMENTUM_CAP: f64 = 40_000.0;
pub fn new() -> Self {
Self::default()
}
pub fn with_rate(rate: DecelerationRate) -> Self {
Self { rate, parent: None }
}
pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
Self {
rate: self.rate,
parent: Some(Box::new(parent)),
}
}
pub fn friction_factor(&self, overscroll_fraction: f64) -> f64 {
let remaining = 1.0 - overscroll_fraction;
self.rate.friction_coefficient() * remaining * remaining
}
fn overscroll_fraction(metrics: &ScrollMetrics, displacement: f64) -> f64 {
if metrics.viewport_dimension > 0.0 {
displacement.abs() / metrics.viewport_dimension
} else {
0.0
}
}
}
impl ScrollPhysics for Bouncing {
fn parent(&self) -> Option<&dyn ScrollPhysics> {
self.parent.as_deref()
}
fn apply_physics_to_user_offset(&self, metrics: &ScrollMetrics, offset: f64) -> f64 {
let min = metrics.min_scroll_extent;
let max = metrics.max_scroll_extent;
let start = metrics.pixels;
let end = start + offset;
let in_range = end.clamp(min, max) - start.clamp(min, max);
let displacement_start = start - start.clamp(min, max);
let displacement_end = end - end.clamp(min, max);
let (easing, tensioning) = split_overscroll_travel(displacement_start, displacement_end);
let friction = self.friction_factor(Self::overscroll_fraction(metrics, displacement_start));
in_range + easing + tensioning * friction
}
fn apply_boundary_conditions(&self, _metrics: &ScrollMetrics, _value: f64) -> f64 {
0.0
}
fn create_ballistic_simulation(
&self,
metrics: &ScrollMetrics,
velocity: f64,
) -> Option<Box<dyn Simulation>> {
if !metrics.out_of_range() && velocity.abs() < self.min_fling_velocity() {
return None;
}
Some(Box::new(BouncingScrollSimulation::new(
metrics.pixels,
velocity,
metrics.min_scroll_extent,
metrics.max_scroll_extent,
self.spring(),
self.tolerance_for(metrics),
self.rate.constant_deceleration(),
)))
}
fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
true
}
fn carried_momentum(&self, existing_velocity: f64) -> f64 {
let magnitude =
Self::MOMENTUM_COEFFICIENT * existing_velocity.abs().powf(Self::MOMENTUM_EXPONENT);
existing_velocity.signum() * magnitude.min(Self::MOMENTUM_CAP)
}
fn min_fling_velocity(&self) -> f64 {
Self::MIN_FLING_VELOCITY
}
fn spring(&self) -> SpringDescription {
self.rate.spring()
}
}
#[derive(Debug, Default)]
pub struct Clamping {
parent: Option<Box<dyn ScrollPhysics>>,
}
impl Clamping {
pub fn new() -> Self {
Self::default()
}
pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
Self {
parent: Some(Box::new(parent)),
}
}
}
impl ScrollPhysics for Clamping {
fn parent(&self) -> Option<&dyn ScrollPhysics> {
self.parent.as_deref()
}
fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
if value < metrics.min_scroll_extent {
value - metrics.min_scroll_extent
} else if value > metrics.max_scroll_extent {
value - metrics.max_scroll_extent
} else {
0.0
}
}
fn create_ballistic_simulation(
&self,
metrics: &ScrollMetrics,
velocity: f64,
) -> Option<Box<dyn Simulation>> {
let tolerance = self.tolerance_for(metrics);
if metrics.out_of_range() {
let end = if metrics.pixels > metrics.max_scroll_extent {
metrics.max_scroll_extent
} else {
metrics.min_scroll_extent
};
return Some(Box::new(ScrollSpringSimulation::new(
self.spring(),
metrics.pixels,
end,
velocity,
tolerance,
)));
}
if velocity.abs() < self.min_fling_velocity() {
return None;
}
if velocity > 0.0 && metrics.pixels >= metrics.max_scroll_extent {
return None;
}
if velocity < 0.0 && metrics.pixels <= metrics.min_scroll_extent {
return None;
}
Some(Box::new(ClampingScrollSimulation::new(
metrics.pixels,
velocity,
ClampingScrollSimulation::DEFAULT_FRICTION,
tolerance,
)))
}
}
#[derive(Debug, Default)]
pub struct AlwaysScrollable {
parent: Option<Box<dyn ScrollPhysics>>,
}
impl AlwaysScrollable {
pub fn new() -> Self {
Self::default()
}
pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
Self {
parent: Some(Box::new(parent)),
}
}
}
impl ScrollPhysics for AlwaysScrollable {
fn parent(&self) -> Option<&dyn ScrollPhysics> {
self.parent.as_deref()
}
fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
true
}
}
#[derive(Debug, Default)]
pub struct NeverScrollable {
parent: Option<Box<dyn ScrollPhysics>>,
}
impl NeverScrollable {
pub fn new() -> Self {
Self::default()
}
pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
Self {
parent: Some(Box::new(parent)),
}
}
}
impl ScrollPhysics for NeverScrollable {
fn parent(&self) -> Option<&dyn ScrollPhysics> {
self.parent.as_deref()
}
fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::physics::Tolerance;
fn metrics(pixels: f64, max: f64) -> ScrollMetrics {
ScrollMetrics {
pixels,
min_scroll_extent: 0.0,
max_scroll_extent: max,
viewport_dimension: 100.0,
device_pixel_ratio: 1.0,
}
}
fn tol() -> Tolerance {
Tolerance::for_device_pixel_ratio(1.0)
}
fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
assert!(
(actual - expected).abs() < epsilon,
"{what}: {actual} is not within {epsilon} of {expected}"
);
}
#[test]
fn bouncing_friction_factor_curve() {
let normal = Bouncing::new();
assert_close(normal.friction_factor(0.0), 0.52, 1e-12, "normal at rest");
assert_close(normal.friction_factor(0.5), 0.13, 1e-12, "normal half out");
assert_close(
normal.friction_factor(1.0),
0.0,
1e-12,
"normal a viewport out",
);
let fast = Bouncing::with_rate(DecelerationRate::Fast);
assert_close(fast.friction_factor(0.0), 0.26, 1e-12, "fast at rest");
assert_close(fast.friction_factor(0.5), 0.065, 1e-12, "fast half out");
assert_eq!(DecelerationRate::NORMAL_FRICTION, 0.52);
assert_eq!(DecelerationRate::FAST_FRICTION, 0.26);
}
#[test]
fn bouncing_resists_increasing_overscroll_only() {
let physics = Bouncing::new();
assert_eq!(
physics.apply_physics_to_user_offset(&metrics(100.0, 500.0), 30.0),
30.0
);
assert_eq!(
physics.apply_physics_to_user_offset(&metrics(100.0, 500.0), -50.0),
-50.0
);
assert_close(
physics.apply_physics_to_user_offset(&metrics(0.0, 500.0), -20.0),
-20.0 * 0.52,
1e-12,
"tensioning off the leading edge",
);
assert_close(
physics.apply_physics_to_user_offset(&metrics(500.0, 500.0), 20.0),
20.0 * 0.52,
1e-12,
"tensioning off the trailing edge",
);
assert_close(
physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), -10.0),
-10.0 * 0.52 * 0.49,
1e-12,
"tensioning deeper",
);
assert_eq!(
physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), 10.0),
10.0
);
assert_eq!(
physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), 50.0),
50.0
);
assert_close(
physics.apply_physics_to_user_offset(&metrics(5.0, 500.0), -25.0),
-5.0 + -20.0 * 0.52,
1e-12,
"straddling the leading edge",
);
}
#[test]
fn bouncing_carried_momentum_formula() {
let physics = Bouncing::new();
let expected = 0.000_816 * 1000.0_f64.powf(1.967);
let carried = physics.carried_momentum(1000.0);
assert!(
(carried - expected).abs() / expected < 1e-6,
"carried {carried} is not within 1e-6 relative of {expected}"
);
assert_close(
physics.carried_momentum(-1000.0),
-expected,
1e-9,
"negative carry",
);
assert_eq!(physics.carried_momentum(0.0), 0.0);
assert_eq!(physics.carried_momentum(1e6), 40_000.0);
assert_eq!(physics.carried_momentum(-1e6), -40_000.0);
}
#[test]
fn bouncing_min_fling_is_100() {
assert_eq!(Bouncing::new().min_fling_velocity(), 100.0);
assert_eq!(
Bouncing::MIN_FLING_VELOCITY,
crate::physics::MIN_FLING_VELOCITY * 2.0
);
assert_eq!(Clamping::new().min_fling_velocity(), 50.0);
}
#[test]
fn bouncing_spring_per_rate() {
let normal = Bouncing::new().spring();
assert_eq!(normal.mass, 0.5);
assert_eq!(normal.stiffness, 100.0);
assert_close(
normal.damping,
2.0 * 1.1 * (0.5 * 100.0_f64).sqrt(),
1e-12,
"normal damping",
);
assert_eq!(normal, SpringDescription::default_scroll_spring());
let fast = Bouncing::with_rate(DecelerationRate::Fast).spring();
assert_eq!(fast.mass, 0.3);
assert_eq!(fast.stiffness, 75.0);
assert_close(
fast.damping,
2.0 * 1.3 * (0.3 * 75.0_f64).sqrt(),
1e-12,
"fast damping",
);
}
#[test]
fn bouncing_ballistic_in_range_low_velocity_is_none() {
let physics = Bouncing::new();
let m = metrics(100.0, 500.0);
assert!(physics.create_ballistic_simulation(&m, 0.0).is_none());
assert!(physics.create_ballistic_simulation(&m, 99.0).is_none());
assert!(physics.create_ballistic_simulation(&m, 100.0).is_some());
}
#[test]
fn bouncing_ballistic_out_of_range_is_some() {
let physics = Bouncing::new();
let sim = physics
.create_ballistic_simulation(&metrics(-30.0, 500.0), 0.0)
.expect("an overscrolled release must spring back");
assert_close(sim.x(0.0), -30.0, 1e-9, "starts where released");
assert!(
sim.x(0.1) > sim.x(0.0),
"must travel back toward the leading extent"
);
assert_close(sim.x(3.0), 0.0, 1e-9, "settles on the extent");
assert!(sim.is_done(3.0), "the bounce-back never settled");
}
#[test]
fn bouncing_fast_rate_carries_the_constant_deceleration() {
assert_eq!(DecelerationRate::FAST_CONSTANT_DECELERATION, 1400.0);
let m = metrics(100.0, 5000.0);
let fast = Bouncing::with_rate(DecelerationRate::Fast)
.create_ballistic_simulation(&m, 2000.0)
.expect("a fast fling must be ballistic");
let expected = BouncingScrollSimulation::new(
100.0,
2000.0,
0.0,
5000.0,
SpringDescription::with_damping_ratio(0.3, 75.0, 1.3),
tol(),
1400.0,
);
assert_close(fast.x(0.3), expected.x(0.3), 1e-9, "fast position");
assert_close(fast.dx(0.3), expected.dx(0.3), 1e-9, "fast velocity");
let normal = Bouncing::new()
.create_ballistic_simulation(&m, 2000.0)
.expect("a normal fling must be ballistic");
assert!(
normal.x(0.3) > fast.x(0.3),
"the fast rate reached {}, no nearer than the normal rate's {}",
fast.x(0.3),
normal.x(0.3)
);
}
#[test]
fn clamping_boundary_conditions_reject_excess() {
let physics = Clamping::new();
let m = metrics(0.0, 500.0);
assert_eq!(physics.apply_boundary_conditions(&m, -10.0), -10.0);
assert_eq!(physics.apply_boundary_conditions(&m, 510.0), 10.0);
assert_eq!(physics.apply_boundary_conditions(&m, 250.0), 0.0);
assert_eq!(physics.apply_boundary_conditions(&m, 0.0), 0.0);
assert_eq!(physics.apply_boundary_conditions(&m, 500.0), 0.0);
}
#[test]
fn clamping_ballistic_returns_android_curve_sim() {
let physics = Clamping::new();
let m = metrics(0.0, 500.0);
let sim = physics
.create_ballistic_simulation(&m, 3000.0)
.expect("a fast in-range fling must be ballistic");
let expected = ClampingScrollSimulation::new(
0.0,
3000.0,
ClampingScrollSimulation::DEFAULT_FRICTION,
tol(),
);
assert_close(sim.dx(0.0), 3000.0, 1e-9, "release velocity");
assert_close(sim.x(0.1), expected.x(0.1), 1e-9, "position on the curve");
assert_close(sim.x(0.5), expected.x(0.5), 1e-9, "position later on");
let mut overshot = false;
for step in 0..=200 {
let time = f64::from(step) / 100.0;
let raw = sim.x(time);
overshot |= raw > 500.0;
let painted = raw - physics.apply_boundary_conditions(&m, raw);
assert!(
(0.0..=500.0).contains(&painted),
"painted offset {painted} left the range at t={time}s (raw {raw})"
);
}
assert!(
overshot,
"the raw curve must overshoot for this test to mean anything"
);
assert!(
physics
.create_ballistic_simulation(&metrics(500.0, 500.0), 3000.0)
.is_none()
);
assert!(
physics
.create_ballistic_simulation(&metrics(0.0, 500.0), -3000.0)
.is_none()
);
assert!(physics.create_ballistic_simulation(&m, 40.0).is_none());
}
#[test]
fn clamping_out_of_range_springs_back() {
let sim = Clamping::new()
.create_ballistic_simulation(&metrics(560.0, 500.0), 0.0)
.expect("an out-of-range release must spring back");
assert_close(sim.x(0.0), 560.0, 1e-9, "starts where released");
assert_close(sim.x(3.0), 500.0, 1e-9, "settles on the nearest extent");
}
#[test]
fn always_scrollable_accepts_on_short_content() {
let short = metrics(0.0, 0.0);
assert!(!Clamping::new().should_accept_user_offset(&short));
assert!(AlwaysScrollable::new().should_accept_user_offset(&short));
}
#[test]
fn never_scrollable_rejects_user_offset() {
let physics = NeverScrollable::new();
assert!(!physics.should_accept_user_offset(&metrics(0.0, 500.0)));
let chained = NeverScrollable::new().chain(Bouncing::new());
assert!(!chained.should_accept_user_offset(&metrics(0.0, 500.0)));
}
#[test]
fn chain_composition_defers_boundary_to_parent() {
let physics = AlwaysScrollable::new().chain(Clamping::new());
assert!(physics.should_accept_user_offset(&metrics(0.0, 0.0)));
let m = metrics(0.0, 500.0);
assert_eq!(physics.apply_boundary_conditions(&m, -10.0), -10.0);
assert_eq!(physics.apply_boundary_conditions(&m, 510.0), 10.0);
assert_eq!(physics.apply_boundary_conditions(&m, 250.0), 0.0);
assert!(physics.create_ballistic_simulation(&m, 3000.0).is_some());
assert!(physics.create_ballistic_simulation(&m, 40.0).is_none());
assert_eq!(physics.min_fling_velocity(), 50.0);
let bouncing = AlwaysScrollable::new().chain(Bouncing::new());
assert_eq!(bouncing.min_fling_velocity(), Bouncing::MIN_FLING_VELOCITY);
assert_eq!(bouncing.apply_boundary_conditions(&m, 510.0), 0.0);
}
}