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, spring};
use cranpose_core::{RuntimeHandle, State, with_current_composer};
use cranpose_macros::composable;
use cranpose_ui_graphics::Size;

const STRAIN_LIMIT: f32 = 0.35;
pub(super) const MAX_LENS_PROJECTION: f32 = 1.0 + STRAIN_LIMIT;

pub(super) fn tab_lens_activity_motion(raised: bool) -> AnimationType {
    if raised {
        spring(1.0, 600.0)
    } else {
        spring(1.0, 650.0).with_delay(8)
    }
}

pub(super) struct TabContactMotion {
    runtime: RuntimeHandle,
    bar: RefCell<Animatable<f32>>,
    lens: RefCell<Animatable<f32>>,
    glow: RefCell<Animatable<f32>>,
    local_glow_factor: RefCell<Animatable<f32>>,
}

impl TabContactMotion {
    fn new(runtime: RuntimeHandle) -> Self {
        Self {
            bar: RefCell::new(Animatable::new(0.0, runtime.clone())),
            lens: RefCell::new(Animatable::new(0.0, runtime.clone())),
            glow: RefCell::new(Animatable::new(0.0, runtime.clone())),
            local_glow_factor: RefCell::new(Animatable::new(1.0, runtime.clone())),
            runtime,
        }
    }

    fn retarget(
        &self,
        value: &RefCell<Animatable<f32>>,
        target: f32,
        animation: AnimationType,
        event_time: Option<u64>,
    ) {
        let mut value = value.borrow_mut();
        if value.target() == target {
            return;
        }
        if let Some(time) = event_time.or_else(|| self.runtime.last_frame_time_nanos()) {
            value.animate_to_at(target, animation, time);
        } else {
            value.animateTo(target, animation);
        }
    }

    pub(super) fn pressed(&self, down: bool, event_time: Option<u64>) {
        if down {
            self.local_glow_factor.borrow_mut().snapTo(1.0);
        }
        self.retarget(
            &self.glow,
            f32::from(down),
            spring(1.0, if down { 4000.0 } else { 160.0 }),
            event_time,
        );
        self.retarget(
            &self.bar,
            f32::from(down),
            spring(0.65, 500.0).with_delay(16),
            event_time,
        );
        self.retarget(
            &self.lens,
            f32::from(down),
            tab_lens_activity_motion(down),
            event_time,
        );
    }

    pub(super) fn glow_state(&self) -> State<f32> {
        self.glow.borrow().state()
    }

    pub(super) fn local_glow_factor_state(&self) -> State<f32> {
        self.local_glow_factor.borrow().state()
    }

    pub(super) fn crossed_tab(&self) {
        self.retarget(&self.local_glow_factor, 0.5, spring(1.0, 160.0), None);
    }

    pub(super) fn bar_state(&self) -> State<f32> {
        self.bar.borrow().state()
    }

    pub(super) fn lens_state(&self) -> State<f32> {
        self.lens.borrow().state()
    }
}

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

struct ShapeParameters {
    damping: f32,
    stiffness: f32,
    relaxation_rate: f32,
    viscous_response: f32,
    inertial_response: f32,
}

struct ShapeResponse {
    relaxed_speed: Cell<f32>,
    strain: RefCell<Animatable<f32>>,
    relaxation_rate: f32,
    viscous_response: f32,
    inertial_response: f32,
    animation: AnimationType,
}

impl ShapeResponse {
    fn new(runtime: RuntimeHandle, parameters: ShapeParameters) -> Self {
        let ShapeParameters {
            damping,
            stiffness,
            relaxation_rate,
            viscous_response,
            inertial_response,
        } = parameters;
        Self {
            relaxed_speed: Cell::new(0.0),
            strain: RefCell::new(Animatable::new(0.0, runtime)),
            relaxation_rate,
            viscous_response,
            inertial_response,
            animation: AnimationType::Spring(cranpose_animation::SpringSpec {
                damping_ratio: damping,
                stiffness,
                position_threshold: 0.0001,
                velocity_threshold: 0.001,
                ..Default::default()
            }),
        }
    }

    fn advance(&self, speed: f32, dt: f32, now: u64) {
        let relaxed =
            speed + (self.relaxed_speed.get() - speed) * (-self.relaxation_rate * dt).exp();
        let relaxed = if speed == 0.0 && relaxed < 0.01 {
            0.0
        } else {
            relaxed
        };
        self.relaxed_speed.set(relaxed);
        let acceleration = self.relaxation_rate * (speed - relaxed);
        let target = self.viscous_response * relaxed + self.inertial_response * acceleration;
        let mut strain = self.strain.borrow_mut();
        if (strain.target() - target).abs() > 0.0000001 || (target == 0.0 && strain.target() != 0.0)
        {
            strain.animate_to_at(target, self.animation, now);
        }
    }

    fn value(&self) -> f32 {
        self.strain.borrow().state().value()
    }
}

#[derive(Clone, Copy, PartialEq)]
pub(super) enum LensShapeKind {
    Tab,
    Thumb,
    Segment,
}

pub(super) struct LensShapeMotion {
    runtime: RuntimeHandle,
    previous: Cell<Option<(u64, f32)>>,
    width: ShapeResponse,
    height: Option<ShapeResponse>,
    height_factor: f32,
}

impl LensShapeMotion {
    fn new(runtime: RuntimeHandle, kind: LensShapeKind) -> Self {
        Self {
            width: ShapeResponse::new(
                runtime.clone(),
                if kind == LensShapeKind::Tab {
                    ShapeParameters {
                        damping: 0.430087,
                        stiffness: 164.807,
                        relaxation_rate: 6.059487,
                        viscous_response: 0.00001663016,
                        inertial_response: 0.00004602218,
                    }
                } else {
                    ShapeParameters {
                        damping: 0.24,
                        stiffness: 140.0,
                        relaxation_rate: 3.7,
                        viscous_response: 0.0,
                        inertial_response: 0.000064,
                    }
                },
            ),
            height: (kind == LensShapeKind::Tab).then(|| {
                ShapeResponse::new(
                    runtime.clone(),
                    ShapeParameters {
                        damping: 0.38644,
                        stiffness: 198.37253,
                        relaxation_rate: 3.179867,
                        viscous_response: -0.00008953011,
                        inertial_response: -0.00008536202,
                    },
                )
            }),
            height_factor: if kind == LensShapeKind::Segment {
                -1.2
            } else {
                -1.02
            },
            previous: Cell::new(None),
            runtime,
        }
    }

    pub(super) fn sample(&self, position: f32) -> Size {
        let width = self.width.value();
        let value = Size::new(
            width,
            self.height
                .as_ref()
                .map_or(width * self.height_factor, ShapeResponse::value),
        );
        let Some(now) = self.runtime.last_frame_time_nanos() else {
            return value;
        };
        let Some((last, previous_position)) = self.previous.get() else {
            self.previous.set(Some((now, position)));
            return value;
        };
        if now <= last {
            return value;
        }
        let dt = (now - last) as f32 / 1e9;
        let speed = ((position - previous_position) / dt).abs().min(8000.0);
        self.previous.set(Some((now, position)));
        self.width.advance(speed, dt, now);
        if let Some(height) = &self.height {
            height.advance(speed, dt, now);
        }
        value
    }

    pub(super) fn projection(&self, position: f32) -> (f32, f32) {
        let strain = self.sample(position);
        (
            1.0 + strain.width.clamp(-STRAIN_LIMIT, STRAIN_LIMIT),
            1.0 + strain.height.clamp(-STRAIN_LIMIT, STRAIN_LIMIT),
        )
    }
}

#[composable]
pub(super) fn remember_lens_shape(kind: LensShapeKind) -> Rc<LensShapeMotion> {
    with_current_composer(|composer| {
        let runtime = composer.runtime_handle();
        composer
            .remember(move || Rc::new(LensShapeMotion::new(runtime, kind)))
            .with(Rc::clone)
    })
}

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