cranpose-liquid 0.9.5

Liquid UI — cranpose's first-party glass component library (iOS-26-style materials, spring motion)
Documentation
use std::{
    cell::{Cell, RefCell},
    rc::Rc,
};

use cranpose_animation::{Animatable, AnimationType, SpringSpec};
use cranpose_core::{RuntimeHandle, with_current_composer};
use cranpose_foundation::PointerId;
use cranpose_macros::composable;

const DRAG_THRESHOLD: f32 = 10.0;
const FLING_THRESHOLD: f32 = 100.0;
const PROJECTION_SECONDS: f32 = 0.099;

#[derive(Clone, Copy)]
pub(super) struct SliderDrag {
    pub(super) id: PointerId,
    pub(super) value: f32,
    origin_x: f32,
    origin_value: f32,
    threshold: Option<f32>,
    previous_x: f32,
    previous_time: Option<i64>,
    previous_velocity: f32,
    velocity: f32,
}

impl SliderDrag {
    pub(super) fn new(id: PointerId, x: f32, value: f32, time: Option<i64>) -> Self {
        Self {
            id,
            value,
            origin_x: x,
            origin_value: value,
            threshold: None,
            previous_x: x,
            previous_time: time,
            previous_velocity: 0.0,
            velocity: 0.0,
        }
    }

    pub(super) fn fraction(&mut self, x: f32, width: f32) -> f32 {
        let distance = x - self.origin_x;
        if self.threshold.is_none() && distance.abs() > DRAG_THRESHOLD {
            self.threshold = Some(DRAG_THRESHOLD.copysign(distance));
        }
        if let Some(threshold) = self.threshold {
            self.value = (self.origin_value + (distance - threshold) / width).clamp(0.0, 1.0);
        }
        self.value
    }

    pub(super) fn moved(&mut self, x: f32, time: Option<i64>) {
        if let (Some(previous), Some(now)) = (self.previous_time, time) {
            let elapsed = now - previous;
            if elapsed > 0 {
                let velocity = (x - self.previous_x) * 1000.0 / elapsed as f32;
                self.velocity = if elapsed <= 100 {
                    0.8 * self.previous_velocity + 0.2 * velocity
                } else {
                    0.0
                };
                self.previous_velocity = velocity;
            }
        }
        self.previous_x = x;
        self.previous_time = time;
    }

    pub(super) fn release_velocity(&self) -> f32 {
        if self.threshold.is_some() {
            self.velocity
        } else {
            0.0
        }
    }
}

pub(super) struct SliderFling {
    animation: RefCell<Animatable<f32>>,
    active: Cell<bool>,
    controlled: Cell<f32>,
    reported: Cell<f32>,
    runtime: RuntimeHandle,
}

impl SliderFling {
    fn new(value: f32, runtime: RuntimeHandle) -> Self {
        Self {
            animation: RefCell::new(Animatable::new(value, runtime.clone())),
            active: Cell::new(false),
            controlled: Cell::new(value),
            reported: Cell::new(value),
            runtime,
        }
    }

    pub(super) fn synchronize(&self, value: f32) {
        if self.controlled.replace(value) != value
            && self.active.get()
            && self.reported.get() != value
        {
            self.cancel();
        }
    }

    pub(super) fn cancel(&self) {
        if self.active.replace(false) {
            let mut animation = self.animation.borrow_mut();
            let value = animation.state().get();
            animation.snapTo(value);
        }
    }

    pub(super) fn release(&self, value: f32, velocity: f32, width: f32, time: Option<u64>) {
        self.cancel();
        if velocity.abs() < FLING_THRESHOLD {
            return;
        }
        let velocity = velocity / width;
        let target = (value + velocity * PROJECTION_SECONDS).clamp(0.0, 1.0);
        if target == value {
            return;
        }
        self.active.set(true);
        self.reported.set(value);
        let mut animation = self.animation.borrow_mut();
        animation.snapTo(value);
        let spec = AnimationType::Spring(SpringSpec {
            damping_ratio: 1.0,
            stiffness: 25.0 * std::f32::consts::PI * std::f32::consts::PI,
            position_threshold: 0.001,
            velocity_threshold: 0.01,
            delay_millis: 20,
        });
        if let Some(time) = time.or_else(|| self.runtime.last_frame_time_nanos()) {
            animation.animate_to_with_velocity_at(target, velocity, spec, time);
        } else {
            animation.animate_to_with_velocity(target, velocity, spec);
        }
    }

    pub(super) fn sample(&self) -> Option<f32> {
        let value = self.animation.borrow().state().get().clamp(0.0, 1.0);
        self.active.get().then_some(value)
    }

    pub(super) fn publish(&self, value: f32, on_change: &dyn Fn(f32)) {
        if !self.active.get() {
            return;
        }
        let finished = !self.animation.borrow().is_running();
        if self.reported.replace(value) != value {
            on_change(value);
        }
        if finished {
            self.active.set(false);
            self.animation.borrow_mut().snapTo(self.controlled.get());
        }
    }
}

#[composable]
pub(super) fn remember_slider_fling(value: f32) -> Rc<SliderFling> {
    with_current_composer(|composer| {
        let runtime = composer.runtime_handle();
        composer
            .remember(move || Rc::new(SliderFling::new(value, runtime)))
            .with(Rc::clone)
    })
}

#[cfg(test)]
#[path = "tests/slider_motion_tests.rs"]
mod tests;