Skip to main content

cranpose_liquid/widgets/
nav_bar.rs

1//! Navigation bar with the large-title collapse: the big title shrinks into
2//! the inline bar as content scrolls under, while a progressive glass blur
3//! fades in behind the bar.
4
5use crate::material::{Glass, GlassDynamics, LiquidModifierExt, LiquidShape};
6use crate::theme::{liquid_colors, liquid_typography};
7use cranpose_macros::composable;
8use cranpose_ui::text::{SpanStyle, TextStyle};
9use cranpose_ui::widgets::{Box, BoxSpec, Row, RowSpec, Text};
10use cranpose_ui::{Modifier, ScrollState};
11use cranpose_ui_graphics::GraphicsLayer;
12use cranpose_ui_layout::{Alignment, VerticalAlignment};
13use std::cell::RefCell;
14use std::rc::Rc;
15
16/// Configuration for [`LiquidNavBar`].
17#[derive(Clone, Debug, PartialEq)]
18pub struct LiquidNavBarSpec {
19    pub title: String,
20    /// Scroll distance (dp) over which the large title collapses.
21    pub collapse_range: f32,
22}
23
24impl LiquidNavBarSpec {
25    pub fn new(title: impl Into<String>) -> Self {
26        Self {
27            title: title.into(),
28            collapse_range: 52.0,
29        }
30    }
31}
32
33const BAR_HEIGHT: f32 = 52.0;
34const LARGE_TITLE_HEIGHT: f32 = 52.0;
35
36/// A large-title navigation bar driven by the content's [`ScrollState`]
37/// (offset 0 = top). `leading`/`trailing` compose the bar buttons.
38///
39/// The bar installs a settle policy on the scroll so it can never rest inside
40/// the large-title collapse band — releasing (or wheel-idling) mid-band snaps
41/// to fully expanded or fully collapsed, exactly like `UINavigationBar`. The
42/// large title composes UNDER the glass band, so mid-collapse it slides
43/// beneath the frost instead of floating readable next to the inline title.
44///
45/// Place the bar *after* the scrolling content inside a `Box` so its glass
46/// samples the content sliding underneath.
47#[composable]
48#[allow(non_snake_case)]
49pub fn LiquidNavBar(
50    modifier: Modifier,
51    spec: LiquidNavBarSpec,
52    scroll: ScrollState,
53    leading: impl FnMut() + 'static,
54    trailing: impl FnMut() + 'static,
55) {
56    let colors = liquid_colors();
57    let typography = liquid_typography();
58    let leading = Rc::new(RefCell::new(leading));
59    let trailing = Rc::new(RefCell::new(trailing));
60
61    let collapse_range = spec.collapse_range;
62    if std::env::var_os("CRANPOSE_DISABLE_NAV_SNAP").is_none() {
63        scroll.set_settle_policy(Some(large_title_settle_policy(collapse_range)));
64    }
65    let scroll_offset = scroll.value();
66
67    // 0 = fully large, 1 = collapsed into the inline bar.
68    let progress = (scroll_offset / spec.collapse_range.max(1.0)).clamp(0.0, 1.0);
69    let title = spec.title.clone();
70
71    Box(modifier, BoxSpec::default(), move || {
72        // Large title first (z below the band + inline bar): it slides up
73        // and under the frost as content scrolls.
74        let large_alpha = (1.0 - progress * 1.6).clamp(0.0, 1.0);
75        if large_alpha > 0.0 {
76            let style = TextStyle {
77                span_style: SpanStyle {
78                    color: Some(colors.label.with_alpha(large_alpha)),
79                    ..typography.large_title.span_style.clone()
80                },
81                ..typography.large_title.clone()
82            };
83            let slide = -scroll_offset.min(collapse_range);
84            let large_title = title.clone();
85            Box(
86                Modifier::empty()
87                    .offset(16.0, BAR_HEIGHT)
88                    .height(LARGE_TITLE_HEIGHT)
89                    .graphics_layer(move || GraphicsLayer {
90                        translation_y: slide,
91                        ..Default::default()
92                    }),
93                BoxSpec::default().content_alignment(Alignment::new(
94                    cranpose_ui_layout::HorizontalAlignment::Start,
95                    cranpose_ui_layout::VerticalAlignment::CenterVertically,
96                )),
97                move || {
98                    Text(large_title.clone(), Modifier::empty(), style.clone());
99                },
100            );
101        }
102
103        // Glass band behind the inline bar. Keeping one capture layer mounted
104        // avoids a top-edge flash when scrolling crosses the collapse onset;
105        // the complete material resolves to transparent identity at zero.
106        let band = Modifier::empty()
107            .fill_max_width()
108            .height(BAR_HEIGHT)
109            .glass_effect_with(
110                Glass::regular()
111                    .shape(LiquidShape::RoundedRect(0.0))
112                    .blur_radius(16.0)
113                    .saturation(1.15)
114                    .refraction_depth(0.0)
115                    .transmission_refraction(0.0)
116                    .highlight(0.18)
117                    .adaptive_frost(colors.label, 0.75)
118                    .shadow(false),
119                move || GlassDynamics {
120                    activity: Some(progress),
121                    ..Default::default()
122                },
123            );
124        Box(band, BoxSpec::default(), || {});
125
126        // Inline bar row: leading / inline title / trailing.
127        let inline_alpha = ((progress - 0.5) * 2.0).clamp(0.0, 1.0);
128        let inline_title = title.clone();
129        let inline_typography = typography.clone();
130        let leading = Rc::clone(&leading);
131        let trailing = Rc::clone(&trailing);
132        Row(
133            Modifier::empty()
134                .fill_max_width()
135                .height(BAR_HEIGHT)
136                .padding_horizontal(10.0),
137            RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
138            move || {
139                (leading.borrow_mut())();
140                let style = TextStyle {
141                    span_style: SpanStyle {
142                        color: Some(colors.label.with_alpha(inline_alpha)),
143                        ..inline_typography.headline.span_style.clone()
144                    },
145                    ..inline_typography.headline.clone()
146                };
147                let inline_title = inline_title.clone();
148                Box(
149                    Modifier::empty().weight(1.0),
150                    BoxSpec::default().content_alignment(Alignment::CENTER),
151                    move || {
152                        Text(inline_title.clone(), Modifier::empty(), style.clone());
153                    },
154                );
155                (trailing.borrow_mut())();
156            },
157        );
158    });
159}
160
161/// The nav bar's settle policy: a rest position inside the large-title
162/// collapse band snaps to the nearest edge (expanded or collapsed); deeper
163/// offsets are untouched.
164pub fn large_title_settle_policy(collapse_range: f32) -> cranpose_ui::ScrollSettlePolicy {
165    let range = collapse_range.max(1.0);
166    Rc::new(move |proposed: f32, _velocity: f32| {
167        if proposed <= 0.0 || proposed >= range {
168            proposed
169        } else if proposed < range * 0.5 {
170            0.0
171        } else {
172            range
173        }
174    })
175}
176
177/// Total height the bar family occupies while expanded (inline bar + large
178/// title) — content's top padding.
179pub fn liquid_nav_bar_expanded_height() -> f32 {
180    BAR_HEIGHT + LARGE_TITLE_HEIGHT
181}