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