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]
54pub fn LiquidNavBar(
55    modifier: Modifier,
56    spec: LiquidNavBarSpec,
57    scroll: ScrollState,
58    leading: impl FnMut() + 'static,
59    trailing: impl FnMut() + 'static,
60) {
61    let colors = liquid_colors();
62    let typography = liquid_typography();
63    let leading = Rc::new(RefCell::new(leading));
64    let trailing = Rc::new(RefCell::new(trailing));
65
66    let collapse_range = spec.collapse_range;
67    if !cranpose_core::env_flag!("CRANPOSE_DISABLE_NAV_SNAP") {
68        scroll.set_settle_policy(Some(large_title_settle_policy(collapse_range)));
69    }
70    let scroll_offset = scroll.value();
71
72    let progress = (scroll_offset / spec.collapse_range.max(1.0)).clamp(0.0, 1.0);
73    let title = spec.title;
74
75    Box(modifier, BoxSpec::default(), move || {
76        let large_alpha = (1.0 - progress * 1.6).clamp(0.0, 1.0);
77        if large_alpha > 0.0 {
78            let style = TextStyle {
79                span_style: SpanStyle {
80                    color: Some(colors.label.with_alpha(large_alpha)),
81                    ..typography.large_title.span_style.clone()
82                },
83                ..typography.large_title.clone()
84            };
85            let slide = -scroll_offset.min(collapse_range);
86            let large_title = title.clone();
87            Box(
88                Modifier::empty()
89                    .offset(16.0, BAR_HEIGHT)
90                    .height(LARGE_TITLE_HEIGHT)
91                    .graphics_layer(move || GraphicsLayer {
92                        translation_y: slide,
93                        ..Default::default()
94                    }),
95                BoxSpec::default().content_alignment(Alignment::new(
96                    cranpose_ui_layout::HorizontalAlignment::Start,
97                    cranpose_ui_layout::VerticalAlignment::CenterVertically,
98                )),
99                move || {
100                    let modifier = if progress < 0.5 {
101                        Modifier::empty()
102                    } else {
103                        Modifier::empty().hide_from_accessibility()
104                    };
105                    Text(large_title.clone(), modifier, style.clone());
106                },
107            );
108        }
109
110        let background = colors.background;
111        let band = Modifier::empty()
112            .fill_max_width()
113            .height(BAR_HEIGHT)
114            .backdrop_gradient_blur(
115                Dp(BAND_BLUR_TOP_DP * progress),
116                Dp(BAND_BLUR_BOTTOM_DP * progress),
117                GradientBlurDirection::TopToBottom,
118            )
119            .draw_behind(move |scope| {
120                scope.draw_rect(Brush::vertical_gradient(
121                    vec![
122                        background.with_alpha(SCRIM_TOP_ALPHA * progress),
123                        background.with_alpha(SCRIM_MIDDLE_ALPHA * progress),
124                        background.with_alpha(0.0),
125                    ],
126                    0.0,
127                    scope.size().height,
128                ));
129            });
130        Box(band, BoxSpec::default(), || {});
131
132        let inline_alpha = ((progress - 0.5) * 2.0).clamp(0.0, 1.0);
133        let inline_title = title.clone();
134        let inline_typography = typography.clone();
135        let leading = Rc::clone(&leading);
136        let trailing = Rc::clone(&trailing);
137        Row(
138            Modifier::empty()
139                .fill_max_width()
140                .height(BAR_HEIGHT)
141                .padding_horizontal(10.0),
142            RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
143            move || {
144                (leading.borrow_mut())();
145                let style = TextStyle {
146                    span_style: SpanStyle {
147                        color: Some(colors.label.with_alpha(inline_alpha)),
148                        ..inline_typography.headline.span_style.clone()
149                    },
150                    ..inline_typography.headline.clone()
151                };
152                let inline_title = inline_title.clone();
153                Box(
154                    Modifier::empty().weight(1.0),
155                    BoxSpec::default().content_alignment(Alignment::CENTER),
156                    move || {
157                        let modifier = if progress >= 0.5 {
158                            Modifier::empty()
159                        } else {
160                            Modifier::empty().hide_from_accessibility()
161                        };
162                        Text(inline_title.clone(), modifier, style.clone());
163                    },
164                );
165                (trailing.borrow_mut())();
166            },
167        );
168    });
169}
170
171pub fn large_title_settle_policy(collapse_range: f32) -> cranpose_ui::ScrollSettlePolicy {
172    let range = collapse_range.max(1.0);
173    Rc::new(move |proposed: f32, _velocity: f32| {
174        if proposed <= 0.0 || proposed >= range {
175            proposed
176        } else if proposed < range * 0.5 {
177            0.0
178        } else {
179            range
180        }
181    })
182}
183
184/// Total height the bar family occupies while expanded (inline bar + large
185/// title) — content's top padding.
186pub fn liquid_nav_bar_expanded_height() -> f32 {
187    BAR_HEIGHT + LARGE_TITLE_HEIGHT
188}
189
190#[cfg(test)]
191#[path = "tests/nav_bar_tests.rs"]
192mod tests;