Skip to main content

cranpose_liquid/widgets/
nav_bar.rs

1use std::{cell::RefCell, rc::Rc};
2
3use cranpose_macros::composable;
4use cranpose_ui::{
5    Modifier, ScrollState,
6    text::{SpanStyle, TextStyle},
7    widgets::{Box, BoxSpec, Row, RowSpec, Text},
8};
9use cranpose_ui_graphics::GraphicsLayer;
10use cranpose_ui_layout::{Alignment, VerticalAlignment};
11
12use crate::{
13    material::{Glass, GlassDynamics, LiquidModifierExt, LiquidShape},
14    theme::{liquid_colors, liquid_typography},
15};
16
17/// Configuration for [`LiquidNavBar`].
18#[derive(Clone, Debug, PartialEq)]
19pub struct LiquidNavBarSpec {
20    pub title: String,
21    /// Scroll distance (dp) over which the large title collapses.
22    pub collapse_range: f32,
23}
24
25impl LiquidNavBarSpec {
26    pub fn new(title: impl Into<String>) -> Self {
27        Self {
28            title: title.into(),
29            collapse_range: 52.0,
30        }
31    }
32}
33
34const BAR_HEIGHT: f32 = 52.0;
35const LARGE_TITLE_HEIGHT: f32 = 52.0;
36
37/// A large-title navigation bar driven by the content's [`ScrollState`]
38/// (offset 0 = top). `leading`/`trailing` compose the bar buttons.
39///
40/// The bar installs a settle policy on the scroll so it can never rest inside
41/// the large-title collapse band — releasing (or wheel-idling) mid-band snaps
42/// to fully expanded or fully collapsed, exactly like `UINavigationBar`. The
43/// large title composes UNDER the glass band, so mid-collapse it slides
44/// beneath the frost instead of floating readable next to the inline title.
45///
46/// Place the bar *after* the scrolling content inside a `Box` so its glass
47/// samples the content sliding underneath.
48#[composable]
49#[allow(non_snake_case)]
50pub fn LiquidNavBar(
51    modifier: Modifier,
52    spec: LiquidNavBarSpec,
53    scroll: ScrollState,
54    leading: impl FnMut() + 'static,
55    trailing: impl FnMut() + 'static,
56) {
57    let colors = liquid_colors();
58    let typography = liquid_typography();
59    let leading = Rc::new(RefCell::new(leading));
60    let trailing = Rc::new(RefCell::new(trailing));
61
62    let collapse_range = spec.collapse_range;
63    if std::env::var_os("CRANPOSE_DISABLE_NAV_SNAP").is_none() {
64        scroll.set_settle_policy(Some(large_title_settle_policy(collapse_range)));
65    }
66    let scroll_offset = scroll.value();
67
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        let large_alpha = (1.0 - progress * 1.6).clamp(0.0, 1.0);
73        if large_alpha > 0.0 {
74            let style = TextStyle {
75                span_style: SpanStyle {
76                    color: Some(colors.label.with_alpha(large_alpha)),
77                    ..typography.large_title.span_style.clone()
78                },
79                ..typography.large_title.clone()
80            };
81            let slide = -scroll_offset.min(collapse_range);
82            let large_title = title.clone();
83            Box(
84                Modifier::empty()
85                    .offset(16.0, BAR_HEIGHT)
86                    .height(LARGE_TITLE_HEIGHT)
87                    .graphics_layer(move || GraphicsLayer {
88                        translation_y: slide,
89                        ..Default::default()
90                    }),
91                BoxSpec::default().content_alignment(Alignment::new(
92                    cranpose_ui_layout::HorizontalAlignment::Start,
93                    cranpose_ui_layout::VerticalAlignment::CenterVertically,
94                )),
95                move || {
96                    Text(large_title.clone(), Modifier::empty(), style.clone());
97                },
98            );
99        }
100
101        let band = Modifier::empty()
102            .fill_max_width()
103            .height(BAR_HEIGHT)
104            .glass_effect_with(
105                Glass::regular()
106                    .shape(LiquidShape::RoundedRect(0.0))
107                    .blur_radius(30.0)
108                    .saturation(1.15)
109                    .refraction_depth(0.0)
110                    .transmission_refraction(0.0)
111                    .highlight(0.18)
112                    .adaptive_frost(colors.label, 0.75)
113                    .shadow(false),
114                move || GlassDynamics {
115                    activity: Some(progress),
116                    ..Default::default()
117                },
118            );
119        Box(band, BoxSpec::default(), || {});
120
121        let inline_alpha = ((progress - 0.5) * 2.0).clamp(0.0, 1.0);
122        let inline_title = title.clone();
123        let inline_typography = typography.clone();
124        let leading = Rc::clone(&leading);
125        let trailing = Rc::clone(&trailing);
126        Row(
127            Modifier::empty()
128                .fill_max_width()
129                .height(BAR_HEIGHT)
130                .padding_horizontal(10.0),
131            RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
132            move || {
133                (leading.borrow_mut())();
134                let style = TextStyle {
135                    span_style: SpanStyle {
136                        color: Some(colors.label.with_alpha(inline_alpha)),
137                        ..inline_typography.headline.span_style.clone()
138                    },
139                    ..inline_typography.headline.clone()
140                };
141                let inline_title = inline_title.clone();
142                Box(
143                    Modifier::empty().weight(1.0),
144                    BoxSpec::default().content_alignment(Alignment::CENTER),
145                    move || {
146                        Text(inline_title.clone(), Modifier::empty(), style.clone());
147                    },
148                );
149                (trailing.borrow_mut())();
150            },
151        );
152    });
153}
154
155pub fn large_title_settle_policy(collapse_range: f32) -> cranpose_ui::ScrollSettlePolicy {
156    let range = collapse_range.max(1.0);
157    Rc::new(move |proposed: f32, _velocity: f32| {
158        if proposed <= 0.0 || proposed >= range {
159            proposed
160        } else if proposed < range * 0.5 {
161            0.0
162        } else {
163            range
164        }
165    })
166}
167
168/// Total height the bar family occupies while expanded (inline bar + large
169/// title) — content's top padding.
170pub fn liquid_nav_bar_expanded_height() -> f32 {
171    BAR_HEIGHT + LARGE_TITLE_HEIGHT
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn expanded_height_covers_the_inline_bar_and_its_large_title() {
180        assert_eq!(
181            liquid_nav_bar_expanded_height(),
182            BAR_HEIGHT + LARGE_TITLE_HEIGHT,
183            "content's top padding has to clear both rows"
184        );
185        assert!(liquid_nav_bar_expanded_height() > BAR_HEIGHT);
186    }
187
188    #[test]
189    fn a_rest_inside_the_collapse_band_snaps_to_the_nearer_edge() {
190        let range = 52.0_f32;
191        let settle = large_title_settle_policy(range);
192        assert_eq!(settle(1.0, 0.0), 0.0, "just past expanded unfolds again");
193        assert_eq!(
194            settle(range - 1.0, 0.0),
195            range,
196            "nearly collapsed finishes collapsing"
197        );
198        assert_eq!(
199            settle(range * 0.5, 0.0),
200            range,
201            "the midpoint commits to collapsed"
202        );
203        assert_eq!(
204            settle(range * 0.5 - 0.1, 0.0),
205            0.0,
206            "a hair under it unfolds"
207        );
208    }
209
210    #[test]
211    fn a_rest_outside_the_collapse_band_is_left_alone() {
212        let range = 52.0_f32;
213        let settle = large_title_settle_policy(range);
214        assert_eq!(settle(0.0, 0.0), 0.0);
215        assert_eq!(
216            settle(-30.0, 0.0),
217            -30.0,
218            "an overscrolled top keeps its bounce"
219        );
220        assert_eq!(settle(range, 0.0), range);
221        assert_eq!(settle(900.0, 0.0), 900.0, "a deep scroll is never rewound");
222    }
223
224    #[test]
225    fn a_degenerate_collapse_range_still_yields_a_usable_policy() {
226        let settle = large_title_settle_policy(0.0);
227        assert_eq!(
228            settle(0.5, 0.0),
229            1.0,
230            "the range floors at one dp, not zero"
231        );
232        assert_eq!(settle(0.0, 0.0), 0.0);
233    }
234}