Skip to main content

cranpose_liquid/widgets/
segmented.rs

1//! Segmented control with a liquid selection blob: the glass pill behind the
2//! selected segment runs its leading and trailing edges on different springs,
3//! so it stretches like a droplet while traveling and settles round. Touching
4//! it lifts the indicator into a magnifying glass lens that chases the finger
5//! across the segments (the reference control swipes, it doesn't just tap).
6
7use 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;
27/// How far the interaction lens pokes past the track vertically.
28const LENS_OVERFLOW: f32 = 8.0;
29/// Glass node span beyond the lens shape (rim glow + bulge live here).
30const LENS_PAD: f32 = 10.0;
31/// Pointer travel below this is a tap, not a swipe.
32const TAP_SLOP: f32 = 4.0;
33
34/// A segmented control. `labels` are equal-width segments; `selected` is the
35/// active index; `on_select` receives the committed index. Segments tap AND
36/// swipe: dragging slides the indicator with the finger as a glass lens.
37#[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    // Some(finger x) while dragging; the indicator target follows it.
53    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            // While dragging, the indicator target is the finger-centered
78            // segment position (clamped inside the track); at rest it is the
79            // committed segment. Both edges keep their droplet springs, so a
80            // release settles from the drag with velocity preserved.
81            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            // Lens presence: up while touched, lingering decay on release
99            // (the indicator stays liquid through the settle flight).
100            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                        // The plain fill hides while the lens is up.
131                        alpha: (1.0 - lens_for_indicator.get()).clamp(0.0, 1.0),
132                        // Scale from the leading edge so translation stays exact.
133                        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            // Labels row on top of the indicator. The cells keep button
149            // semantics (robot/a11y); pointer handling lives on the swipe
150            // surface below.
151            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            // Swipe/tap surface across the whole control.
190            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            // The interaction lens riding the indicator: a glass capsule that
253            // magnifies the label under it and bulges along the travel.
254            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: taller than the track; the fixed-height
262                    // host keeps the control's layout put.
263                    .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}