cranpose_liquid/widgets/
nav_bar.rs1use 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]
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 std::env::var_os("CRANPOSE_DISABLE_NAV_SNAP").is_none() {
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 Text(large_title.clone(), Modifier::empty(), style.clone());
101 },
102 );
103 }
104
105 let background = colors.background;
106 let band = Modifier::empty()
107 .fill_max_width()
108 .height(BAR_HEIGHT)
109 .backdrop_gradient_blur(
110 Dp(BAND_BLUR_TOP_DP * progress),
111 Dp(BAND_BLUR_BOTTOM_DP * progress),
112 GradientBlurDirection::TopToBottom,
113 )
114 .draw_behind(move |scope| {
115 scope.draw_rect(Brush::vertical_gradient(
116 vec![
117 background.with_alpha(SCRIM_TOP_ALPHA * progress),
118 background.with_alpha(SCRIM_MIDDLE_ALPHA * progress),
119 background.with_alpha(0.0),
120 ],
121 0.0,
122 scope.size().height,
123 ));
124 });
125 Box(band, BoxSpec::default(), || {});
126
127 let inline_alpha = ((progress - 0.5) * 2.0).clamp(0.0, 1.0);
128 let inline_title = title.clone();
129 let inline_typography = typography.clone();
130 let leading = Rc::clone(&leading);
131 let trailing = Rc::clone(&trailing);
132 Row(
133 Modifier::empty()
134 .fill_max_width()
135 .height(BAR_HEIGHT)
136 .padding_horizontal(10.0),
137 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
138 move || {
139 (leading.borrow_mut())();
140 let style = TextStyle {
141 span_style: SpanStyle {
142 color: Some(colors.label.with_alpha(inline_alpha)),
143 ..inline_typography.headline.span_style.clone()
144 },
145 ..inline_typography.headline.clone()
146 };
147 let inline_title = inline_title.clone();
148 Box(
149 Modifier::empty().weight(1.0),
150 BoxSpec::default().content_alignment(Alignment::CENTER),
151 move || {
152 Text(inline_title.clone(), Modifier::empty(), style.clone());
153 },
154 );
155 (trailing.borrow_mut())();
156 },
157 );
158 });
159}
160
161pub fn large_title_settle_policy(collapse_range: f32) -> cranpose_ui::ScrollSettlePolicy {
162 let range = collapse_range.max(1.0);
163 Rc::new(move |proposed: f32, _velocity: f32| {
164 if proposed <= 0.0 || proposed >= range {
165 proposed
166 } else if proposed < range * 0.5 {
167 0.0
168 } else {
169 range
170 }
171 })
172}
173
174pub fn liquid_nav_bar_expanded_height() -> f32 {
177 BAR_HEIGHT + LARGE_TITLE_HEIGHT
178}
179
180#[cfg(test)]
181#[path = "tests/nav_bar_tests.rs"]
182mod tests;