1use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt, LiquidShape};
8use crate::motion::LiquidMotion;
9use crate::theme::{liquid_colors, liquid_typography};
10use cranpose_animation::{animateFloatAsState, spring};
11use cranpose_core::{mutableStateOf, remember};
12use cranpose_foundation::PointerEventKind;
13use cranpose_macros::composable;
14use cranpose_services::{default_haptics, HapticFeedback};
15use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
16use cranpose_ui::widgets::{
17 Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text,
18};
19use cranpose_ui::{Modifier, PointerInputScope, Size};
20use cranpose_ui_graphics::{Brush, Color, CornerRadii, GraphicsLayer};
21use cranpose_ui_layout::Alignment;
22use std::cell::Cell;
23use std::rc::Rc;
24
25const SEGMENT_HEIGHT: f32 = 36.0;
26const TRACK_PADDING: f32 = 2.0;
27const LENS_OVERFLOW: f32 = 8.0;
29const LENS_PAD: f32 = 10.0;
31const TAP_SLOP: f32 = 4.0;
33
34#[composable]
38#[allow(non_snake_case)]
39pub fn LiquidSegmentedControl(
40 modifier: Modifier,
41 labels: Vec<String>,
42 selected: usize,
43 on_select: impl Fn(usize) + 'static,
44) {
45 let colors = liquid_colors();
46 let typography = liquid_typography();
47 let count = labels.len().max(1);
48 let selected = selected.min(count - 1);
49 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
50 let labels = Rc::new(labels);
51
52 let drag_x = remember(|| mutableStateOf(Option::<f32>::None)).with(|s| *s);
54 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
55
56 let track_fill = colors.fill;
57 let track = Modifier::empty()
58 .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
59 .draw_behind(move |scope| {
60 scope.draw_round_rect(
61 Brush::solid(track_fill),
62 CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
63 );
64 });
65
66 Box(track.then(modifier), BoxSpec::default(), move || {
67 let labels = Rc::clone(&labels);
68 let typography = typography.clone();
69 let on_select = Rc::clone(&on_select);
70 BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
71 let labels = Rc::clone(&labels);
72 let typography = typography.clone();
73 let on_select = Rc::clone(&on_select);
74 let total_width = scope.constraints().max_width.max(1.0);
75 let segment_width = total_width / count as f32;
76
77 let indicator_target = match drag_x.get() {
82 Some(x) => {
83 (x - segment_width * 0.5).clamp(0.0, segment_width * (count as f32 - 1.0))
84 }
85 None => segment_width * selected as f32,
86 };
87 let leading = animateFloatAsState(
88 indicator_target,
89 LiquidMotion::blob_leading(),
90 "segmented-leading",
91 );
92 let trailing = animateFloatAsState(
93 indicator_target + segment_width,
94 LiquidMotion::blob_trailing(),
95 "segmented-trailing",
96 );
97
98 let lens_settling = (leading.get() - indicator_target).abs() > 1.0;
101 let lens_target = if pressed.get() || (drag_x.get().is_none() && lens_settling) {
102 1.0
103 } else {
104 0.0
105 };
106 let lens_progress = animateFloatAsState(
107 lens_target,
108 if pressed.get() {
109 spring(0.9, 1400.0)
110 } else {
111 spring(1.0, 170.0)
112 },
113 "segmented-lens",
114 );
115
116 let indicator_color = if colors.is_dark {
117 Color::from_rgba_u8(90, 90, 96, 240)
118 } else {
119 Color::WHITE
120 };
121 let lens_for_indicator = lens_progress;
122 let indicator = Modifier::empty()
123 .size(Size::new(segment_width, SEGMENT_HEIGHT))
124 .graphics_layer(move || {
125 let lead = leading.get();
126 let trail = trailing.get().max(lead + 1.0);
127 GraphicsLayer {
128 translation_x: lead,
129 scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
130 alpha: (1.0 - lens_for_indicator.get()).clamp(0.0, 1.0),
132 transform_origin: cranpose_ui_graphics::TransformOrigin {
134 pivot_fraction_x: 0.0,
135 pivot_fraction_y: 0.5,
136 },
137 ..Default::default()
138 }
139 })
140 .draw_behind(move |scope| {
141 scope.draw_round_rect(
142 Brush::solid(indicator_color),
143 CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
144 );
145 });
146 Box(indicator, BoxSpec::default(), || {});
147
148 Row(Modifier::empty(), RowSpec::default(), move || {
152 for (index, label) in labels.iter().enumerate() {
153 let is_selected = index == selected;
154 let style = TextStyle {
155 span_style: SpanStyle {
156 color: Some(if is_selected {
157 colors.label
158 } else {
159 colors.secondary_label
160 }),
161 font_weight: Some(if is_selected {
162 FontWeight::SEMI_BOLD
163 } else {
164 FontWeight::MEDIUM
165 }),
166 ..typography.subheadline.span_style.clone()
167 },
168 ..typography.subheadline.clone()
169 };
170 let label_for_semantics = label.clone();
171 let cell = Modifier::empty()
172 .size(Size::new(segment_width, SEGMENT_HEIGHT))
173 .semantics(move |config| {
174 config.is_button = true;
175 config.is_clickable = true;
176 config.content_description = Some(label_for_semantics.clone());
177 });
178 let label = label.clone();
179 Box(
180 cell,
181 BoxSpec::default().content_alignment(Alignment::CENTER),
182 move || {
183 Text(label.clone(), Modifier::empty(), style.clone());
184 },
185 );
186 }
187 });
188
189 let gesture = Modifier::empty()
191 .size(Size::new(total_width, SEGMENT_HEIGHT))
192 .pointer_input((), {
193 let on_select = Rc::clone(&on_select);
194 move |scope: PointerInputScope| {
195 let on_select = Rc::clone(&on_select);
196 async move {
197 scope
198 .await_pointer_event_scope(|await_scope| async move {
199 let mut down_x = 0.0f32;
200 let mut active = false;
201 loop {
202 let event = await_scope.await_pointer_event().await;
203 if event.id != 0 {
204 continue;
205 }
206 match event.kind {
207 PointerEventKind::Down => {
208 active = true;
209 down_x = event.position.x;
210 pressed.set(true);
211 drag_x.set(Some(event.position.x));
212 default_haptics()
213 .perform(HapticFeedback::Selection);
214 event.consume();
215 }
216 PointerEventKind::Move if active => {
217 drag_x.set(Some(event.position.x));
218 event.consume();
219 }
220 PointerEventKind::Up | PointerEventKind::Cancel
221 if active =>
222 {
223 active = false;
224 pressed.set(false);
225 let travelled =
226 (event.position.x - down_x).abs() > TAP_SLOP;
227 let position = if travelled {
228 event.position.x
229 } else {
230 down_x
231 };
232 let index =
233 ((position / segment_width).floor().max(0.0)
234 as usize)
235 .min(count - 1);
236 drag_x.set(None);
237 default_haptics()
238 .perform(HapticFeedback::ImpactLight);
239 on_select(index);
240 event.consume();
241 }
242 _ => {}
243 }
244 }
245 })
246 .await;
247 }
248 }
249 });
250 Box(gesture, BoxSpec::default(), || {});
251
252 if lens_progress.get() > 0.01 {
255 let lens_h = SEGMENT_HEIGHT + LENS_OVERFLOW;
256 let node_w = segment_width + LENS_PAD * 2.0;
257 let node_h = lens_h + LENS_PAD * 2.0;
258 let lens_for_layer = lens_progress;
259 let last_x = remember(|| Rc::new(Cell::new(f32::NAN))).with(Rc::clone);
260 let lens = Modifier::empty()
261 .required_size(Size::new(node_w, node_h))
264 .offset(-LENS_PAD, (SEGMENT_HEIGHT - node_h) * 0.5)
265 .graphics_layer(move || GraphicsLayer {
266 translation_x: leading.get(),
267 alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
268 ..Default::default()
269 })
270 .glass_effect_with(
271 Glass::lens().shape(LiquidShape::Capsule).no_clip(),
272 move || {
273 let grow = lens_for_layer.get().clamp(0.0, 1.2);
274 let w = segment_width + 4.0 * grow;
275 let h = SEGMENT_HEIGHT + LENS_OVERFLOW * grow;
276 let x = leading.get();
277 let prev = last_x.replace(x);
278 let vx = if prev.is_nan() { 0.0 } else { x - prev };
279 let bulge = (vx.abs() * 0.9).min(6.0);
280 let dir = if vx >= 0.0 { 0.0 } else { std::f32::consts::PI };
281 GlassDynamics {
282 morph: Some(GlassMorph {
283 node_size: (node_w, node_h),
284 primary: (node_w * 0.5, node_h * 0.5, w, h, -1.0),
285 shapes: Vec::new(),
286 glue: 0.0,
287 wobble_amplitude: 0.0,
288 wobble_phase: 0.0,
289 bulge_amplitude: bulge,
290 bulge_direction: dir,
291 }),
292 magnify_boost: 0.18,
293 ..Default::default()
294 }
295 },
296 );
297 Box(lens, BoxSpec::default(), || {});
298 }
299 });
300 });
301}