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,
    sync::{Once, OnceLock},
};

use cranpose_animation::{Animatable, AnimationType, spring};
use cranpose_core::{RuntimeHandle, State, remember, with_current_composer};
use cranpose_foundation::{SemanticsCustomAction, SemanticsWidgetRole};
use cranpose_macros::composable;
use cranpose_services::{HapticFeedback, default_haptics};
use cranpose_ui::{
    Modifier, PointerEvent, PointerEventKind, PointerInputScope,
    widgets::{Box, BoxSpec},
};
use cranpose_ui_graphics::{
    GraphicsLayer, RUNTIME_SHADER_PRELUDE_WGSL, RenderEffect, RuntimeShader, ShaderTarget,
    ShaderWarmUp, Size,
};
use cranpose_ui_layout::Alignment;

use crate::material::{Glass, GlassDynamics, LiquidModifierExt};

pub(super) fn floating_button_content(
    modifier: Modifier,
    transform: Modifier,
    surface: Modifier,
    foreground: Modifier,
    content: impl FnMut() + Clone + 'static,
) {
    Box(
        modifier,
        BoxSpec::default().propagate_min_constraints(true),
        move || {
            let surface = surface.clone();
            let foreground = foreground.clone();
            let content = content.clone();
            Box(
                transform.clone(),
                BoxSpec::default().propagate_min_constraints(true),
                move || {
                    let foreground = foreground.clone();
                    let content = content.clone();
                    Box(
                        surface.clone(),
                        BoxSpec::default().content_alignment(Alignment::CENTER),
                        move || {
                            Box(
                                foreground.clone(),
                                BoxSpec::default().content_alignment(Alignment::CENTER),
                                content.clone(),
                            );
                        },
                    );
                },
            );
        },
    );
}

fn lighting_shader() -> RuntimeShader {
    static SHADER: OnceLock<RuntimeShader> = OnceLock::new();
    SHADER
        .get_or_init(|| {
            let mut shader = RuntimeShader::new(&format!(
                "{RUNTIME_SHADER_PRELUDE_WGSL}\n{}\n{}",
                cranpose_ui_graphics::LIQUID_GLASS_GEOMETRY_WGSL,
                include_str!("floating_lighting.wgsl")
            ));
            shader.set_position_independent(true);
            shader
        })
        .clone()
}

fn contact_lighting(
    size: Size,
    center: (f32, f32),
    radius: f32,
    strength: f32,
) -> Option<RenderEffect> {
    if strength <= 0.0 {
        return None;
    }
    let mut shader = lighting_shader();
    shader.set_float4(0, size.width, size.height, center.0, center.1);
    shader.set_float2(4, strength.clamp(0.0, 1.0), radius);
    Some(RenderEffect::runtime_shader(shader))
}

struct Contact {
    runtime: RuntimeHandle,
    channels: RefCell<[Animatable<f32>; 4]>,
    origin: Cell<(f32, f32)>,
}

impl Contact {
    fn new(runtime: RuntimeHandle) -> Self {
        Self {
            channels: RefCell::new(std::array::from_fn(|_| {
                Animatable::new(0.0, runtime.clone())
            })),
            runtime,
            origin: Cell::new((0.0, 0.0)),
        }
    }

    fn target(&self, channel: usize, value: f32, animation: AnimationType, time: Option<u64>) {
        let mut channels = self.channels.borrow_mut();
        let channel = &mut channels[channel];
        if channel.target() == value {
            return;
        }
        if let Some(time) = time.or_else(|| self.runtime.last_frame_time_nanos()) {
            channel.animate_to_at(value, animation, time);
        } else {
            channel.animateTo(value, animation);
        }
    }

    fn pressed(&self, down: bool, time: Option<u64>) {
        let geometry = if down {
            spring(0.65, 625.0).with_delay(28)
        } else {
            spring(0.38, 250.0)
        };
        self.target(0, f32::from(down), geometry, time);
        self.target(
            1,
            f32::from(down),
            spring(1.0, if down { 4000.0 } else { 160.0 }),
            time,
        );
        if !down {
            self.moved(0.0, 0.0, time);
        }
    }

    fn moved(&self, x: f32, y: f32, time: Option<u64>) {
        self.target(2, x, spring(1.0, 400.0), time);
        self.target(3, y, spring(1.0, 400.0), time);
    }

    fn states(&self) -> [State<f32>; 4] {
        let channels = self.channels.borrow();
        std::array::from_fn(|index| channels[index].state())
    }
}

#[composable]
pub(super) fn FloatingButtonSurface(
    material: Option<Glass>,
    native_material: bool,
    hit_inset: f32,
    feedback: HapticFeedback,
    tint_progress: Option<(State<f32>, cranpose_ui_graphics::Color)>,
    on_click: impl Fn() + 'static,
) -> (Modifier, Modifier, Modifier, Modifier, State<f32>) {
    let size = remember(|| Rc::new(Cell::new(Size::ZERO))).with(Rc::clone);
    let content_size = remember(|| Rc::new(Cell::new(Size::ZERO))).with(Rc::clone);
    let runtime = with_current_composer(|c| c.runtime_handle());
    let contact = remember(move || Rc::new(Contact::new(runtime))).with(Rc::clone);
    let [progress, light, x, y] = contact.states();
    let scale_size = Rc::clone(&size);
    let native_lighting = native_material && material.is_some();
    if native_lighting {
        static WARM_UP: Once = Once::new();
        WARM_UP.call_once(|| {
            cranpose_ui_graphics::request_shader_warm_ups([ShaderWarmUp {
                shader: lighting_shader(),
                target: ShaderTarget::Layer,
            }]);
        });
    }
    let transform = Modifier::empty().graphics_layer(move || {
        let size = scale_size.get();
        let lift = if size.width > 0.0 {
            1.0 + 16.0 * progress.get() / size.width
        } else {
            1.0
        };
        let dx = x.get();
        let dy = y.get();
        let distance = dx.hypot(dy);
        let strain = 0.025 * (distance / 60.0).min(1.0).powi(2) * progress.get();
        let direction = if distance > 0.0 {
            (dx / distance, dy / distance)
        } else {
            (0.0, 0.0)
        };
        let axial = strain * (direction.0 * direction.0 - direction.1 * direction.1);
        GraphicsLayer {
            scale_x: lift * (1.0 + axial),
            scale_y: lift * (1.0 - axial),
            translation_x: size.width * lift * strain * direction.0,
            translation_y: size.height * lift * strain * direction.1,
            ..Default::default()
        }
    });
    let content = if native_lighting {
        let source_size = Rc::clone(&content_size);
        let control_size = Rc::clone(&size);
        let origin = Rc::clone(&contact);
        Modifier::empty()
            .report_size(content_size)
            .graphics_layer(move || {
                let size = source_size.get();
                let start = origin.origin.get();
                GraphicsLayer {
                    render_effect: contact_lighting(
                        size,
                        (
                            size.width * 0.5 + start.0 + x.get(),
                            size.height * 0.5 + start.1 + y.get(),
                        ),
                        control_size.get().height * 0.75,
                        light.get(),
                    ),
                    ..Default::default()
                }
            })
    } else {
        Modifier::empty()
    };
    let mut surface = Modifier::empty();
    if let Some(glass) = material {
        let glass_size = Rc::clone(&size);
        let origin = Rc::clone(&contact);
        surface = surface.glass_effect_with(glass, move || {
            let size = glass_size.get();
            let center = (size.width * 0.5, size.height * 0.5);
            if native_material {
                let start = origin.origin.get();
                let mut dynamics = super::glass_surface::floating_dynamics(
                    size,
                    progress.get(),
                    light.get(),
                    (center.0 + start.0 + x.get(), center.1 + start.1 + y.get()),
                );
                dynamics.tint_crossfade = tint_progress.map(|(state, color)| (color, state.get()));
                dynamics
            } else {
                GlassDynamics::default().touched_up(
                    light.get(),
                    Some((center.0 + x.get(), center.1 + y.get())),
                    center,
                )
            }
        });
    }
    let gesture = floating_gesture(size, contact, hit_inset, feedback, on_click);
    (transform, surface, gesture, content, light)
}

fn floating_gesture(
    size: Rc<Cell<Size>>,
    contact: Rc<Contact>,
    hit_inset: f32,
    feedback: HapticFeedback,
    on_click: impl Fn() + 'static,
) -> Modifier {
    let on_click = Rc::new(on_click);
    let activate = {
        let on_click = Rc::clone(&on_click);
        SemanticsCustomAction::new("", move || {
            default_haptics().perform(feedback);
            on_click();
        })
    };
    Modifier::empty()
        .report_size(Rc::clone(&size))
        .stable_semantics(move |config| {
            config.role = Some(SemanticsWidgetRole::Button);
            config.is_clickable = true;
            config.merge_descendants = true;
            config.on_click = Some(activate.clone());
        })
        .focusable()
        .pointer_input(hit_inset.to_bits(), move |scope: PointerInputScope| {
            let size = Rc::clone(&size);
            let contact = Rc::clone(&contact);
            let on_click = Rc::clone(&on_click);
            async move {
                scope
                    .await_pointer_event_scope(|input| async move {
                        let mut down = None;
                        let mut previous: Option<PointerEvent> = None;
                        loop {
                            let event = input.await_pointer_event().await;
                            if down.is_some_and(|(id, _)| id != event.id) {
                                continue;
                            }
                            let claimed = previous.take().is_some_and(|event| event.is_consumed());
                            if down.is_some() && (claimed || event.is_consumed()) {
                                down = None;
                                contact.pressed(false, event.animation_time_nanos);
                                continue;
                            }
                            if advance_contact(
                                &contact,
                                size.get(),
                                &mut down,
                                &event,
                                70.0 - hit_inset,
                            ) {
                                default_haptics().perform(feedback);
                                on_click();
                            }
                            previous = Some(event);
                        }
                    })
                    .await;
            }
        })
}

fn advance_contact(
    contact: &Contact,
    size: Size,
    down: &mut Option<(u64, cranpose_ui_graphics::Point)>,
    event: &PointerEvent,
    margin: f32,
) -> bool {
    match event.kind {
        PointerEventKind::Down if down.is_none() && !event.is_consumed() => {
            *down = Some((event.id, event.global_position));
            contact.origin.set((
                event.position.x - size.width * 0.5,
                event.position.y - size.height * 0.5,
            ));
            contact.pressed(true, event.animation_time_nanos);
        }
        PointerEventKind::Move => {
            if let Some((_, origin)) = *down {
                contact.moved(
                    event.global_position.x - origin.x,
                    event.global_position.y - origin.y,
                    event.animation_time_nanos,
                );
            }
        }
        PointerEventKind::Up if down.is_some() => {
            *down = None;
            contact.pressed(false, event.animation_time_nanos);
            event.consume();
            return event.position.x > -margin
                && event.position.x < size.width + margin
                && event.position.y > -margin
                && event.position.y < size.height + margin;
        }
        PointerEventKind::Cancel if down.is_some() => {
            *down = None;
            contact.pressed(false, event.animation_time_nanos);
        }
        _ => {}
    }
    false
}