cranpose-liquid 0.9.30

Glass controls, themes and motion for Cranpose apps
Documentation
use std::{cell::RefCell, rc::Rc};

use cranpose_macros::composable;
use cranpose_ui::{
    Dp, Modifier, ScrollState,
    text::{SpanStyle, TextStyle},
    widgets::{Box, BoxSpec, Row, RowSpec, Text},
};
use cranpose_ui_graphics::{Brush, GradientBlurDirection, GraphicsLayer};
use cranpose_ui_layout::{Alignment, VerticalAlignment};

use crate::theme::{liquid_colors, liquid_typography};

/// Configuration for [`LiquidNavBar`].
#[derive(Clone, Debug, PartialEq)]
pub struct LiquidNavBarSpec {
    pub title: String,
    /// Scroll distance (dp) over which the large title collapses.
    pub collapse_range: f32,
}

impl LiquidNavBarSpec {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            collapse_range: 52.0,
        }
    }
}

const BAR_HEIGHT: f32 = 52.0;
const LARGE_TITLE_HEIGHT: f32 = 52.0;
const BAND_BLUR_TOP_DP: f32 = 18.0;
const BAND_BLUR_BOTTOM_DP: f32 = 0.35;
const SCRIM_TOP_ALPHA: f32 = 0.82;
const SCRIM_MIDDLE_ALPHA: f32 = 0.40;

/// A large-title navigation bar driven by the content's [`ScrollState`]
/// (offset 0 = top). `leading`/`trailing` compose the bar buttons.
///
/// The bar installs a settle policy on the scroll so it can never rest inside
/// the large-title collapse band — releasing (or wheel-idling) mid-band snaps
/// to fully expanded or fully collapsed, exactly like `UINavigationBar`. The
/// large title composes UNDER the band, so mid-collapse it slides beneath
/// the blur instead of floating readable next to the inline title.
///
/// The band is the crown the showcase and cranscan headers wear: a gradient
/// blur, frosted at the top edge and sharp where the content emerges, under
/// a scrim of the background colour, both fading in with the collapse.
///
/// Place the bar *after* the scrolling content inside a `Box` so its blur
/// reads the content sliding underneath.
#[composable]
pub fn LiquidNavBar(
    modifier: Modifier,
    spec: LiquidNavBarSpec,
    scroll: ScrollState,
    leading: impl FnMut() + 'static,
    trailing: impl FnMut() + 'static,
) {
    let colors = liquid_colors();
    let typography = liquid_typography();
    let leading = Rc::new(RefCell::new(leading));
    let trailing = Rc::new(RefCell::new(trailing));

    let collapse_range = spec.collapse_range;
    if !cranpose_core::env_flag!("CRANPOSE_DISABLE_NAV_SNAP") {
        scroll.set_settle_policy(Some(large_title_settle_policy(collapse_range)));
    }
    let scroll_offset = scroll.value();

    let progress = (scroll_offset / spec.collapse_range.max(1.0)).clamp(0.0, 1.0);
    let title = spec.title;

    Box(modifier, BoxSpec::default(), move || {
        let large_alpha = (1.0 - progress * 1.6).clamp(0.0, 1.0);
        if large_alpha > 0.0 {
            let style = TextStyle {
                span_style: SpanStyle {
                    color: Some(colors.label.with_alpha(large_alpha)),
                    ..typography.large_title.span_style.clone()
                },
                ..typography.large_title.clone()
            };
            let slide = -scroll_offset.min(collapse_range);
            let large_title = title.clone();
            Box(
                Modifier::empty()
                    .offset(16.0, BAR_HEIGHT)
                    .height(LARGE_TITLE_HEIGHT)
                    .graphics_layer(move || GraphicsLayer {
                        translation_y: slide,
                        ..Default::default()
                    }),
                BoxSpec::default().content_alignment(Alignment::new(
                    cranpose_ui_layout::HorizontalAlignment::Start,
                    cranpose_ui_layout::VerticalAlignment::CenterVertically,
                )),
                move || {
                    let modifier = if progress < 0.5 {
                        Modifier::empty()
                    } else {
                        Modifier::empty().hide_from_accessibility()
                    };
                    Text(large_title.clone(), modifier, style.clone());
                },
            );
        }

        let background = colors.background;
        let band = Modifier::empty()
            .fill_max_width()
            .height(BAR_HEIGHT)
            .backdrop_gradient_blur(
                Dp(BAND_BLUR_TOP_DP * progress),
                Dp(BAND_BLUR_BOTTOM_DP * progress),
                GradientBlurDirection::TopToBottom,
            )
            .draw_behind(move |scope| {
                scope.draw_rect(Brush::vertical_gradient(
                    vec![
                        background.with_alpha(SCRIM_TOP_ALPHA * progress),
                        background.with_alpha(SCRIM_MIDDLE_ALPHA * progress),
                        background.with_alpha(0.0),
                    ],
                    0.0,
                    scope.size().height,
                ));
            });
        Box(band, BoxSpec::default(), || {});

        let inline_alpha = ((progress - 0.5) * 2.0).clamp(0.0, 1.0);
        let inline_title = title.clone();
        let inline_typography = typography.clone();
        let leading = Rc::clone(&leading);
        let trailing = Rc::clone(&trailing);
        Row(
            Modifier::empty()
                .fill_max_width()
                .height(BAR_HEIGHT)
                .padding_horizontal(10.0),
            RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
            move || {
                (leading.borrow_mut())();
                let style = TextStyle {
                    span_style: SpanStyle {
                        color: Some(colors.label.with_alpha(inline_alpha)),
                        ..inline_typography.headline.span_style.clone()
                    },
                    ..inline_typography.headline.clone()
                };
                let inline_title = inline_title.clone();
                Box(
                    Modifier::empty().weight(1.0),
                    BoxSpec::default().content_alignment(Alignment::CENTER),
                    move || {
                        let modifier = if progress >= 0.5 {
                            Modifier::empty()
                        } else {
                            Modifier::empty().hide_from_accessibility()
                        };
                        Text(inline_title.clone(), modifier, style.clone());
                    },
                );
                (trailing.borrow_mut())();
            },
        );
    });
}

pub fn large_title_settle_policy(collapse_range: f32) -> cranpose_ui::ScrollSettlePolicy {
    let range = collapse_range.max(1.0);
    Rc::new(move |proposed: f32, _velocity: f32| {
        if proposed <= 0.0 || proposed >= range {
            proposed
        } else if proposed < range * 0.5 {
            0.0
        } else {
            range
        }
    })
}

/// Total height the bar family occupies while expanded (inline bar + large
/// title) — content's top padding.
pub fn liquid_nav_bar_expanded_height() -> f32 {
    BAR_HEIGHT + LARGE_TITLE_HEIGHT
}

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