cranpose_liquid/widgets/
nav_bar.rs1use 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#[derive(Clone, Debug, PartialEq)]
18pub struct LiquidNavBarSpec {
19 pub title: String,
20 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#[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 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);
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 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(30.0)
116 .saturation(1.15)
117 .refraction_depth(0.0)
118 .transmission_refraction(0.0)
119 .highlight(0.18)
120 .adaptive_frost(colors.label, 0.75)
121 .shadow(false),
122 move || GlassDynamics {
123 activity: Some(progress),
124 ..Default::default()
125 },
126 );
127 Box(band, BoxSpec::default(), || {});
128
129 let inline_alpha = ((progress - 0.5) * 2.0).clamp(0.0, 1.0);
131 let inline_title = title.clone();
132 let inline_typography = typography.clone();
133 let leading = Rc::clone(&leading);
134 let trailing = Rc::clone(&trailing);
135 Row(
136 Modifier::empty()
137 .fill_max_width()
138 .height(BAR_HEIGHT)
139 .padding_horizontal(10.0),
140 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
141 move || {
142 (leading.borrow_mut())();
143 let style = TextStyle {
144 span_style: SpanStyle {
145 color: Some(colors.label.with_alpha(inline_alpha)),
146 ..inline_typography.headline.span_style.clone()
147 },
148 ..inline_typography.headline.clone()
149 };
150 let inline_title = inline_title.clone();
151 Box(
152 Modifier::empty().weight(1.0),
153 BoxSpec::default().content_alignment(Alignment::CENTER),
154 move || {
155 Text(inline_title.clone(), Modifier::empty(), style.clone());
156 },
157 );
158 (trailing.borrow_mut())();
159 },
160 );
161 });
162}
163
164pub fn large_title_settle_policy(collapse_range: f32) -> cranpose_ui::ScrollSettlePolicy {
168 let range = collapse_range.max(1.0);
169 Rc::new(move |proposed: f32, _velocity: f32| {
170 if proposed <= 0.0 || proposed >= range {
171 proposed
172 } else if proposed < range * 0.5 {
173 0.0
174 } else {
175 range
176 }
177 })
178}
179
180pub fn liquid_nav_bar_expanded_height() -> f32 {
183 BAR_HEIGHT + LARGE_TITLE_HEIGHT
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn expanded_height_covers_the_inline_bar_and_its_large_title() {
192 assert_eq!(
193 liquid_nav_bar_expanded_height(),
194 BAR_HEIGHT + LARGE_TITLE_HEIGHT,
195 "content's top padding has to clear both rows"
196 );
197 assert!(liquid_nav_bar_expanded_height() > BAR_HEIGHT);
198 }
199
200 #[test]
201 fn a_rest_inside_the_collapse_band_snaps_to_the_nearer_edge() {
202 let range = 52.0_f32;
203 let settle = large_title_settle_policy(range);
204 assert_eq!(settle(1.0, 0.0), 0.0, "just past expanded unfolds again");
205 assert_eq!(
206 settle(range - 1.0, 0.0),
207 range,
208 "nearly collapsed finishes collapsing"
209 );
210 assert_eq!(
211 settle(range * 0.5, 0.0),
212 range,
213 "the midpoint commits to collapsed"
214 );
215 assert_eq!(
216 settle(range * 0.5 - 0.1, 0.0),
217 0.0,
218 "a hair under it unfolds"
219 );
220 }
221
222 #[test]
223 fn a_rest_outside_the_collapse_band_is_left_alone() {
224 let range = 52.0_f32;
225 let settle = large_title_settle_policy(range);
226 assert_eq!(settle(0.0, 0.0), 0.0);
227 assert_eq!(
228 settle(-30.0, 0.0),
229 -30.0,
230 "an overscrolled top keeps its bounce"
231 );
232 assert_eq!(settle(range, 0.0), range);
233 assert_eq!(settle(900.0, 0.0), 900.0, "a deep scroll is never rewound");
234 }
235
236 #[test]
237 fn a_degenerate_collapse_range_still_yields_a_usable_policy() {
238 let settle = large_title_settle_policy(0.0);
239 assert_eq!(
240 settle(0.5, 0.0),
241 1.0,
242 "the range floors at one dp, not zero"
243 );
244 assert_eq!(settle(0.0, 0.0), 0.0);
245 }
246}