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#[derive(Clone, Debug, PartialEq)]
19pub struct LiquidNavBarSpec {
20 pub title: String,
21 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#[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
168pub 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}