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#[derive(Clone, Debug, PartialEq)]
16pub struct LiquidNavBarSpec {
17 pub title: String,
18 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#[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
175pub 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}