pub mod effect;
pub mod parity;
pub mod rubber_band;
pub mod simulation;
use std::rc::Rc;
pub fn default_physics() -> Rc<dyn ScrollPhysics> {
if cfg!(target_os = "android") {
Rc::new(parity::Clamping::new())
} else {
Rc::new(parity::Bouncing::new())
}
}
pub fn default_overscroll_effect() -> effect::OverscrollEffect {
if cfg!(target_os = "android") {
effect::OverscrollEffect::Stretch
} else {
effect::OverscrollEffect::Translate
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScrollMetrics {
pub pixels: f64,
pub min_scroll_extent: f64,
pub max_scroll_extent: f64,
pub viewport_dimension: f64,
pub device_pixel_ratio: f64,
}
impl ScrollMetrics {
pub fn out_of_range(&self) -> bool {
self.pixels < self.min_scroll_extent || self.pixels > self.max_scroll_extent
}
pub fn overscroll_past_leading(&self) -> f64 {
(self.min_scroll_extent - self.pixels).max(0.0)
}
pub fn overscroll_past_trailing(&self) -> f64 {
(self.pixels - self.max_scroll_extent).max(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tolerance {
pub velocity: f64,
pub distance: f64,
}
impl Tolerance {
pub fn for_device_pixel_ratio(dpr: f64) -> Self {
Tolerance {
velocity: 1.0 / (0.050 * dpr),
distance: 1.0 / dpr,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpringDescription {
pub mass: f64,
pub stiffness: f64,
pub damping: f64,
}
impl SpringDescription {
pub fn with_damping_ratio(mass: f64, stiffness: f64, ratio: f64) -> Self {
SpringDescription {
mass,
stiffness,
damping: 2.0 * ratio * (mass * stiffness).sqrt(),
}
}
pub fn default_scroll_spring() -> Self {
Self::with_damping_ratio(0.5, 100.0, 1.1)
}
}
pub trait Simulation {
fn x(&self, time: f64) -> f64;
fn dx(&self, time: f64) -> f64;
fn is_done(&self, time: f64) -> bool;
}
pub const MIN_FLING_VELOCITY: f64 = 50.0;
pub const MAX_FLING_VELOCITY: f64 = 8000.0;
pub(crate) const MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR: f64 = 0.5;
pub trait ScrollPhysics: std::fmt::Debug {
fn parent(&self) -> Option<&dyn ScrollPhysics> {
None
}
fn apply_physics_to_user_offset(&self, metrics: &ScrollMetrics, offset: f64) -> f64 {
self.parent().map_or(offset, |parent| {
parent.apply_physics_to_user_offset(metrics, offset)
})
}
fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
self.parent().map_or(0.0, |parent| {
parent.apply_boundary_conditions(metrics, value)
})
}
fn create_ballistic_simulation(
&self,
metrics: &ScrollMetrics,
velocity: f64,
) -> Option<Box<dyn Simulation>> {
self.parent()
.and_then(|parent| parent.create_ballistic_simulation(metrics, velocity))
}
fn should_accept_user_offset(&self, metrics: &ScrollMetrics) -> bool {
self.parent().map_or_else(
|| metrics.max_scroll_extent > metrics.min_scroll_extent,
|parent| parent.should_accept_user_offset(metrics),
)
}
fn carried_momentum(&self, existing_velocity: f64) -> f64 {
self.parent()
.map_or(0.0, |parent| parent.carried_momentum(existing_velocity))
}
fn min_fling_velocity(&self) -> f64 {
self.parent()
.map_or(MIN_FLING_VELOCITY, |parent| parent.min_fling_velocity())
}
fn max_fling_velocity(&self) -> f64 {
self.parent()
.map_or(MAX_FLING_VELOCITY, |parent| parent.max_fling_velocity())
}
fn tolerance_for(&self, metrics: &ScrollMetrics) -> Tolerance {
self.parent().map_or_else(
|| Tolerance::for_device_pixel_ratio(metrics.device_pixel_ratio),
|parent| parent.tolerance_for(metrics),
)
}
fn spring(&self) -> SpringDescription {
self.parent()
.map_or_else(SpringDescription::default_scroll_spring, |parent| {
parent.spring()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn metrics(pixels: f64, max: f64, dpr: f64) -> ScrollMetrics {
ScrollMetrics {
pixels,
min_scroll_extent: 0.0,
max_scroll_extent: max,
viewport_dimension: 100.0,
device_pixel_ratio: dpr,
}
}
#[test]
fn tolerance_matches_flutter_formula() {
let t = Tolerance::for_device_pixel_ratio(2.0);
assert_eq!(t.velocity, 10.0);
assert_eq!(t.distance, 0.5);
}
#[test]
fn spring_damping_ratio_math() {
let spring = SpringDescription::with_damping_ratio(0.5, 100.0, 1.1);
let expected = 2.0 * 1.1 * (50.0f64).sqrt();
assert!(
(spring.damping - expected).abs() < 1e-9,
"damping {} did not match expected {}",
spring.damping,
expected
);
}
#[derive(Debug)]
struct Bare;
impl ScrollPhysics for Bare {}
#[derive(Debug)]
struct TestParent;
impl ScrollPhysics for TestParent {
fn apply_boundary_conditions(&self, _metrics: &ScrollMetrics, _value: f64) -> f64 {
7.0
}
}
#[derive(Debug)]
struct DelegatingChild {
parent: Box<dyn ScrollPhysics>,
}
impl ScrollPhysics for DelegatingChild {
fn parent(&self) -> Option<&dyn ScrollPhysics> {
Some(self.parent.as_ref())
}
}
#[derive(Debug)]
struct OverridingChild {
parent: Box<dyn ScrollPhysics>,
}
impl ScrollPhysics for OverridingChild {
fn parent(&self) -> Option<&dyn ScrollPhysics> {
Some(self.parent.as_ref())
}
fn min_fling_velocity(&self) -> f64 {
999.0
}
}
#[test]
fn chaining_delegates_through_parent() {
let m = metrics(10.0, 100.0, 1.0);
assert_eq!(Bare.apply_boundary_conditions(&m, 50.0), 0.0);
let child = DelegatingChild {
parent: Box::new(TestParent),
};
assert_eq!(child.apply_boundary_conditions(&m, 50.0), 7.0);
let overriding = OverridingChild {
parent: Box::new(TestParent),
};
assert_eq!(overriding.min_fling_velocity(), 999.0);
assert_eq!(overriding.apply_boundary_conditions(&m, 50.0), 7.0);
}
#[cfg(not(target_os = "android"))]
#[test]
fn non_android_default_is_bouncing_plus_translate() {
let physics = default_physics();
assert!(
format!("{physics:?}").starts_with("Bouncing"),
"the desktop/iOS default is Bouncing, got {physics:?}"
);
assert_eq!(
physics.min_fling_velocity(),
parity::Bouncing::MIN_FLING_VELOCITY
);
let m = metrics(0.0, 500.0, 1.0);
assert_eq!(
physics.apply_boundary_conditions(&m, -30.0),
0.0,
"a bouncing surface rejects nothing — it holds the displacement"
);
let mapped = physics.apply_physics_to_user_offset(&m, -20.0);
assert!(
(mapped - -20.0 * parity::DecelerationRate::NORMAL_FRICTION).abs() < 1e-12,
"the past-edge pull is scaled by the normal-rate friction: {mapped}"
);
assert_eq!(
default_overscroll_effect(),
effect::OverscrollEffect::Translate
);
}
#[cfg(target_os = "android")]
#[test]
fn android_default_is_clamping_plus_stretch() {
let physics = default_physics();
assert!(
format!("{physics:?}").starts_with("Clamping"),
"the Android default is Clamping, got {physics:?}"
);
let m = metrics(0.0, 500.0, 1.0);
assert_eq!(
physics.apply_boundary_conditions(&m, -30.0),
-30.0,
"a clamping surface rejects the whole past-edge excess"
);
assert_eq!(
physics.apply_physics_to_user_offset(&m, -20.0),
-20.0,
"…and resists the drag itself not at all (the trait's identity map)"
);
assert_eq!(
default_overscroll_effect(),
effect::OverscrollEffect::Stretch
);
}
#[test]
fn default_should_accept_requires_scrollable_content() {
let scrollable = metrics(0.0, 500.0, 1.0);
assert!(Bare.should_accept_user_offset(&scrollable));
let not_scrollable = metrics(0.0, 0.0, 1.0);
assert!(!Bare.should_accept_user_offset(¬_scrollable));
}
}