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 !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
184pub 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;