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 follows 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, PointerId};
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, GlassSurfaceProfile, GraphicsLayer};
21use cranpose_ui_layout::Alignment;
22use std::rc::Rc;
23
24const SEGMENT_HEIGHT: f32 = 36.0;
25const TRACK_PADDING: f32 = 2.0;
26/// How far the interaction lens pokes past the track vertically.
27const LENS_OVERFLOW: f32 = 8.0;
28/// Glass node span beyond the lens shape (rim glow + bulge live here).
29const LENS_PAD: f32 = 10.0;
30/// Pointer travel below this is a tap, not a swipe.
31const TAP_SLOP: f32 = 4.0;
32
33fn plain_indicator_alpha(lens_progress: f32) -> f32 {
34    ((0.18 - lens_progress) / 0.16).clamp(0.0, 1.0)
35}
36
37fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
38    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
39}
40
41/// A segmented control. `labels` are equal-width segments; `selected` is the
42/// active index; `on_select` receives the committed index. Segments tap AND
43/// swipe: dragging slides the indicator with the finger as a glass lens.
44#[composable]
45#[allow(non_snake_case)]
46pub fn LiquidSegmentedControl(
47    modifier: Modifier,
48    labels: Vec<String>,
49    selected: usize,
50    on_select: impl Fn(usize) + 'static,
51) {
52    let colors = liquid_colors();
53    let typography = liquid_typography();
54    let count = labels.len().max(1);
55    let selected = selected.min(count - 1);
56    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
57    let labels = Rc::new(labels);
58
59    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
60
61    let track_fill = colors.fill;
62    let track = Modifier::empty()
63        .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
64        .draw_behind(move |scope| {
65            scope.draw_round_rect(
66                Brush::solid(track_fill),
67                CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
68            );
69        });
70
71    Box(track.then(modifier), BoxSpec::default(), move || {
72        let labels = Rc::clone(&labels);
73        let typography = typography.clone();
74        let on_select = Rc::clone(&on_select);
75        BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
76            let labels = Rc::clone(&labels);
77            let typography = typography.clone();
78            let on_select = Rc::clone(&on_select);
79            let total_width = scope.constraints().max_width.max(1.0);
80            let segment_width = total_width / count as f32;
81            let selected_x = segment_width * selected as f32;
82            let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
83            lens_axis.settle_to(selected_x, LiquidMotion::glide());
84            let lens_x = lens_axis.value();
85
86            // The resting indicator belongs to controlled state. The
87            // interaction lens has a separate direct-drag axis: it reads the
88            // raw pointer while held and only springs after release.
89            let leading = animateFloatAsState(
90                selected_x,
91                LiquidMotion::blob_leading(),
92                "segmented-leading",
93            );
94            let trailing = animateFloatAsState(
95                selected_x + segment_width,
96                LiquidMotion::blob_trailing(),
97                "segmented-trailing",
98            );
99
100            // Lens presence: up while touched, lingering decay on release
101            // (the indicator stays liquid through the settle flight).
102            let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
103            let lens_target = if pressed.get() || lens_settling {
104                1.0
105            } else {
106                0.0
107            };
108            let lens_progress = animateFloatAsState(
109                lens_target,
110                if pressed.get() {
111                    spring(0.9, 1400.0)
112                } else {
113                    spring(1.0, 170.0)
114                },
115                "segmented-lens",
116            );
117
118            let indicator_color = if colors.is_dark {
119                Color::from_rgba_u8(90, 90, 96, 240)
120            } else {
121                Color::WHITE
122            };
123            let lens_for_indicator = lens_progress;
124            let indicator = Modifier::empty()
125                .size(Size::new(segment_width, SEGMENT_HEIGHT))
126                .graphics_layer(move || {
127                    let lead = leading.get();
128                    let trail = trailing.get().max(lead + 1.0);
129                    GraphicsLayer {
130                        translation_x: lead,
131                        scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
132                        // The plain fill hides while the lens is up.
133                        alpha: plain_indicator_alpha(lens_for_indicator.get()),
134                        // Scale from the leading edge so translation stays exact.
135                        transform_origin: cranpose_ui_graphics::TransformOrigin {
136                            pivot_fraction_x: 0.0,
137                            pivot_fraction_y: 0.5,
138                        },
139                        ..Default::default()
140                    }
141                })
142                .draw_behind(move |scope| {
143                    scope.draw_round_rect(
144                        Brush::solid(indicator_color),
145                        CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
146                    );
147                });
148            Box(indicator, BoxSpec::default(), || {});
149
150            // Labels row on top of the indicator. The cells keep button
151            // semantics (robot/a11y); pointer handling lives on the swipe
152            // surface below.
153            Row(Modifier::empty(), RowSpec::default(), move || {
154                for (index, label) in labels.iter().enumerate() {
155                    let is_selected = index == selected;
156                    let style = TextStyle {
157                        span_style: SpanStyle {
158                            color: Some(if is_selected {
159                                colors.label
160                            } else {
161                                colors.secondary_label
162                            }),
163                            font_weight: Some(if is_selected {
164                                FontWeight::SEMI_BOLD
165                            } else {
166                                FontWeight::MEDIUM
167                            }),
168                            ..typography.subheadline.span_style.clone()
169                        },
170                        ..typography.subheadline.clone()
171                    };
172                    let label_for_semantics = label.clone();
173                    let cell = Modifier::empty()
174                        .size(Size::new(segment_width, SEGMENT_HEIGHT))
175                        .semantics(move |config| {
176                            config.is_button = true;
177                            config.is_clickable = true;
178                            config.content_description = Some(label_for_semantics.clone());
179                        });
180                    let label = label.clone();
181                    Box(
182                        cell,
183                        BoxSpec::default().content_alignment(Alignment::CENTER),
184                        move || {
185                            Text(label.clone(), Modifier::empty(), style.clone());
186                        },
187                    );
188                }
189            });
190
191            // Swipe/tap surface across the whole control.
192            let gesture = Modifier::empty()
193                .size(Size::new(total_width, SEGMENT_HEIGHT))
194                .pointer_input(selected, {
195                    let on_select = Rc::clone(&on_select);
196                    let lens_axis = Rc::clone(&lens_axis);
197                    move |scope: PointerInputScope| {
198                        let on_select = Rc::clone(&on_select);
199                        let lens_axis = Rc::clone(&lens_axis);
200                        async move {
201                            scope
202                                .await_pointer_event_scope(|await_scope| async move {
203                                    let mut down_x = 0.0f32;
204                                    let mut active_pointer = Option::<PointerId>::None;
205                                    loop {
206                                        let event = await_scope.await_pointer_event().await;
207                                        match event.kind {
208                                            PointerEventKind::Down if active_pointer.is_none() => {
209                                                active_pointer = Some(event.id);
210                                                down_x = event.position.x;
211                                                pressed.set(true);
212                                                lens_axis.begin(
213                                                    segment_lens_left(
214                                                        event.position.x,
215                                                        segment_width,
216                                                        count,
217                                                    ),
218                                                    event.time_ms,
219                                                );
220                                                default_haptics()
221                                                    .perform(HapticFeedback::Selection);
222                                                event.consume();
223                                            }
224                                            PointerEventKind::Move
225                                                if active_pointer == Some(event.id) =>
226                                            {
227                                                lens_axis.move_to(
228                                                    segment_lens_left(
229                                                        event.position.x,
230                                                        segment_width,
231                                                        count,
232                                                    ),
233                                                    event.time_ms,
234                                                );
235                                                event.consume();
236                                            }
237                                            PointerEventKind::Up
238                                                if active_pointer == Some(event.id) =>
239                                            {
240                                                active_pointer = None;
241                                                pressed.set(false);
242                                                let travelled =
243                                                    (event.position.x - down_x).abs() > TAP_SLOP;
244                                                let position = if travelled {
245                                                    event.position.x
246                                                } else {
247                                                    down_x
248                                                };
249                                                let index =
250                                                    ((position / segment_width).floor().max(0.0)
251                                                        as usize)
252                                                        .min(count - 1);
253                                                lens_axis.release_to(
254                                                    segment_width * index as f32,
255                                                    event.time_ms,
256                                                    LiquidMotion::glide(),
257                                                );
258                                                default_haptics()
259                                                    .perform(HapticFeedback::ImpactLight);
260                                                on_select(index);
261                                                event.consume();
262                                            }
263                                            PointerEventKind::Cancel
264                                                if active_pointer == Some(event.id) =>
265                                            {
266                                                active_pointer = None;
267                                                pressed.set(false);
268                                                lens_axis.release_to(
269                                                    selected_x,
270                                                    event.time_ms,
271                                                    LiquidMotion::glide(),
272                                                );
273                                                event.consume();
274                                            }
275                                            _ => {}
276                                        }
277                                    }
278                                })
279                                .await;
280                        }
281                    }
282                });
283            Box(gesture, BoxSpec::default(), || {});
284
285            // The interaction lens riding the indicator: a glass capsule that
286            // magnifies the label under it and bulges along the travel.
287            if lens_progress.get() > 0.01 {
288                let lens_h = SEGMENT_HEIGHT + LENS_OVERFLOW;
289                let deformation_headroom =
290                    crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
291                let node_w = (segment_width + 4.0) * deformation_headroom
292                    + crate::dynamics::BULGE_MAX
293                    + LENS_PAD * 2.0;
294                let node_h =
295                    lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + LENS_PAD * 2.0;
296                let lens_for_layer = lens_progress;
297                let physics_axis = Rc::clone(&lens_axis);
298                let lens = Modifier::empty()
299                    // required_size: taller than the track; the fixed-height
300                    // host keeps the control's layout put.
301                    .required_size(Size::new(node_w, node_h))
302                    .offset(
303                        (segment_width - node_w) * 0.5,
304                        (SEGMENT_HEIGHT - node_h) * 0.5,
305                    )
306                    .graphics_layer(move || GraphicsLayer {
307                        translation_x: lens_x,
308                        alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
309                        ..Default::default()
310                    })
311                    .glass_effect_with(
312                        Glass::lens()
313                            .shape(LiquidShape::Capsule)
314                            .tint(Color::rgba(1.0, 1.0, 1.0, 0.05))
315                            .surface_profile(
316                                GlassSurfaceProfile::lens()
317                                    .with_depth(6.8)
318                                    .expect("segmented surface depth is valid"),
319                            )
320                            .highlight(0.52)
321                            .chromatic_aberration(2.4)
322                            .displacement(24.0)
323                            .content_recolor(colors.accent, 1.0)
324                            .no_clip(),
325                        move || {
326                            let grow = lens_for_layer.get().clamp(0.0, 1.2);
327                            let base_w = segment_width + 4.0 * grow;
328                            let base_h = SEGMENT_HEIGHT + LENS_OVERFLOW * grow;
329                            // Droplet law over the indicator ride
330                            // (crate::dynamics): speed stretches the capsule
331                            // along the travel, braking swells its front.
332                            let pose = physics_axis.liquid_pose();
333                            GlassDynamics {
334                                morph: Some(GlassMorph {
335                                    node_size: (node_w, node_h),
336                                    primary: (node_w * 0.5, node_h * 0.5, base_w, base_h, -1.0),
337                                    shapes: Vec::new(),
338                                    glue: 0.0,
339                                    wobble_amplitude: 0.0,
340                                    wobble_phase: 0.0,
341                                    bulge_amplitude: pose.bulge_amplitude.min(6.0),
342                                    bulge_direction: pose.bulge_direction,
343                                    ellipse_blend: 0.0,
344                                    deformation: Some(pose.deformation()),
345                                }),
346                                surface_depth_boost: 0.18,
347                                ..Default::default()
348                            }
349                        },
350                    );
351                Box(lens, BoxSpec::default(), || {});
352            }
353        });
354    });
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn pointer_position_is_the_clamped_lens_center() {
363        let width = 100.0;
364        assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
365        assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
366        assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
367        assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
368        assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
369    }
370
371    #[test]
372    fn plain_indicator_stays_hidden_until_the_lens_is_almost_gone() {
373        assert_eq!(plain_indicator_alpha(1.0), 0.0);
374        assert_eq!(plain_indicator_alpha(0.5), 0.0);
375        assert_eq!(plain_indicator_alpha(0.2), 0.0);
376        assert!(plain_indicator_alpha(0.05) > 0.8);
377        assert_eq!(plain_indicator_alpha(0.0), 1.0);
378    }
379}