Skip to main content

cranpose_liquid/widgets/
tab_bar.rs

1//! The floating glass tab bar: a capsule of tabs over live content with a
2//! liquid selection blob (dual-spring stretch), plus an optional detached
3//! circular accessory (the iOS 26 search button).
4
5use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt};
6use crate::motion::LiquidMotion;
7use crate::theme::{liquid_colors, liquid_typography};
8use cranpose_animation::animateFloatAsState;
9use cranpose_macros::composable;
10use cranpose_services::{default_haptics, HapticFeedback};
11use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
12use cranpose_ui::widgets::{
13    Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row, RowSpec,
14    Text,
15};
16use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope, Size};
17use cranpose_ui_graphics::{Brush, CornerRadii, GraphicsLayer};
18use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
19use std::cell::RefCell;
20use std::rc::Rc;
21
22/// One tab: icon path data (24×24 viewBox) + label.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct LiquidTab {
25    pub icon: &'static str,
26    pub label: &'static str,
27}
28
29impl LiquidTab {
30    pub fn new(icon: &'static str, label: &'static str) -> Self {
31        Self { icon, label }
32    }
33}
34
35const BAR_HEIGHT: f32 = 64.0;
36const BLOB_HEIGHT: f32 = 52.0;
37const BLOB_MARGIN: f32 = 6.0;
38/// Width allotted to each tab inside the pill.
39const TAB_WIDTH: f32 = 76.0;
40/// Tab icon size (the reference bar draws ~24pt glyphs over 11pt labels).
41const TAB_ICON_SIZE: f32 = 24.0;
42const TAB_LABEL_SIZE: f32 = 11.0;
43/// The drag lens overflows the pill vertically like the reference bubble
44/// (the pressed lens pokes visibly past the bar even before it moves).
45/// The drag lens is markedly taller than the bar (reference ≈ 1.35×).
46const TAP_SLOP: f32 = 6.0;
47
48/// A floating glass tab bar. Place it in an overlay `Box` above scrolling
49/// content — the glass lenses whatever moves beneath it. `accessory`
50/// composes a detached element to the right (pass `|| {}` for none).
51#[composable]
52#[allow(non_snake_case)]
53pub fn LiquidTabBar(
54    modifier: Modifier,
55    tabs: Vec<LiquidTab>,
56    selected: usize,
57    on_select: impl Fn(usize) + 'static,
58    accessory: impl FnMut() + 'static,
59) {
60    let colors = liquid_colors();
61    let typography = liquid_typography();
62    let count = tabs.len().max(1);
63    let selected = selected.min(count - 1);
64    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
65    let tabs = Rc::new(tabs);
66    let accessory = Rc::new(RefCell::new(accessory));
67
68    Row(
69        modifier,
70        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
71        move || {
72            let tabs = Rc::clone(&tabs);
73            let typography = typography.clone();
74            let on_select = Rc::clone(&on_select);
75            let accessory = Rc::clone(&accessory);
76
77            // The main pill (wrapped in a stack so the drag lens can float
78            // ABOVE the finished bar and magnify icons + glass together).
79            // Lighter frost than a menu: the reference bar keeps the list
80            // beneath readable.
81            let lens_x_outer =
82                cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32, 0.0f32)))
83                    .with(|state| *state);
84            // The stack is pinned to the pill height so the mounting lens
85            // (taller than the bar) can never inflate it — an unpinned stack
86            // grew on press and the centering Row shifted the whole bar down.
87            Box(
88                Modifier::empty().height(BAR_HEIGHT),
89                BoxSpec::default(),
90                move || {
91                    let tabs = Rc::clone(&tabs);
92                    let typography = typography.clone();
93                    let on_select = Rc::clone(&on_select);
94                    // The bar's edge is defined by shadow and contrast, not a
95                    // bright rim stroke.
96                    let pill = Modifier::empty()
97                        .glass_effect(Glass::regular().blur_radius(14.0).highlight(0.55))
98                        .height(BAR_HEIGHT);
99                    Box(pill, BoxSpec::default(), move || {
100                        let tabs = Rc::clone(&tabs);
101                        let typography = typography.clone();
102                        let on_select = Rc::clone(&on_select);
103                        BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
104                            let tabs = Rc::clone(&tabs);
105                            let typography = typography.clone();
106                            let on_select = Rc::clone(&on_select);
107                            let constrained = scope.constraints().max_width;
108                            let tab_width = if constrained.is_finite() && constrained > 1.0 {
109                                (constrained / count as f32).min(TAB_WIDTH * 1.4)
110                            } else {
111                                TAB_WIDTH
112                            };
113
114                            // Liquid selection blob: the edge facing the target runs
115                            // a stiffer spring than the one behind, so the highlight
116                            // stretches like a droplet in motion and settles round —
117                            // velocity carries across quick tab hops. The direction
118                            // latches per hop so the springs keep their roles while
119                            // settling.
120                            let previous =
121                                cranpose_core::remember(|| cranpose_core::mutableStateOf(selected))
122                                    .with(|state| *state);
123                            let moving_right =
124                                cranpose_core::remember(|| cranpose_core::mutableStateOf(true))
125                                    .with(|state| *state);
126                            if previous.get() != selected {
127                                moving_right.set(selected > previous.get());
128                                previous.set(selected);
129                            }
130                            let (left_spec, right_spec) = if moving_right.get() {
131                                (LiquidMotion::blob_trailing(), LiquidMotion::blob_leading())
132                            } else {
133                                (LiquidMotion::blob_leading(), LiquidMotion::blob_trailing())
134                            };
135                            let leading = animateFloatAsState(
136                                tab_width * selected as f32,
137                                left_spec,
138                                "tabbar-blob-leading",
139                            );
140                            let trailing = animateFloatAsState(
141                                tab_width * (selected + 1) as f32,
142                                right_spec,
143                                "tabbar-blob-trailing",
144                            );
145
146                            // Interactive lens: engages at the CURRENT selection on
147                            // touch-down, chases the finger while swiping, flies to
148                            // the committed tab on release and then dissolves. The
149                            // gray pill hides while the lens is up.
150                            let lens_drag_x = cranpose_core::remember(|| {
151                                cranpose_core::mutableStateOf(Option::<f32>::None)
152                            })
153                            .with(|state| *state);
154                            let lens_pressed =
155                                cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
156                                    .with(|state| *state);
157                            let lens_target_x = match lens_drag_x.get() {
158                                // The reference lens travels PAST the bar's end
159                                // cap toward the search circle (that overdrag is
160                                // what brings the two glass rims into glue
161                                // reach); it snaps back to a real tab on release.
162                                Some(x) => (x - tab_width * 0.5)
163                                    .clamp(-tab_width * 0.2, tab_width * (count as f32 - 0.45)),
164                                None => tab_width * selected as f32,
165                            };
166                            let lens_x = animateFloatAsState(
167                                lens_target_x,
168                                LiquidMotion::snappy(),
169                                "tabbar-lens-x",
170                            );
171                            let lens_settling = (lens_x.get() - lens_target_x).abs() > 1.0;
172                            let lens_alpha_target = if lens_pressed.get()
173                                || (lens_drag_x.get().is_none() && lens_settling)
174                            {
175                                1.0
176                            } else {
177                                0.0
178                            };
179                            let lens_alpha = animateFloatAsState(
180                                lens_alpha_target,
181                                LiquidMotion::smooth(),
182                                "tabbar-lens-alpha",
183                            );
184
185                            // The selection pill is a plain gray fill a step darker
186                            // than the bar glass (iOS systemFill), not more glass.
187                            let blob_color = if colors.is_dark {
188                                cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 84)
189                            } else {
190                                cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 44)
191                            };
192                            let blob = Modifier::empty()
193                                .size(Size::new(tab_width, BLOB_HEIGHT))
194                                .graphics_layer(move || {
195                                    let lead = leading.get();
196                                    let trail = trailing.get().max(lead + 1.0);
197                                    GraphicsLayer {
198                                        translation_x: lead,
199                                        scale_x: ((trail - lead) / tab_width.max(1.0)).max(0.01),
200                                        alpha: (1.0 - lens_alpha.get()).clamp(0.0, 1.0),
201                                        transform_origin: cranpose_ui_graphics::TransformOrigin {
202                                            pivot_fraction_x: 0.0,
203                                            pivot_fraction_y: 0.5,
204                                        },
205                                        ..Default::default()
206                                    }
207                                })
208                                .draw_behind(move |scope| {
209                                    scope.draw_round_rect(
210                                        Brush::solid(blob_color),
211                                        CornerRadii::uniform(BLOB_HEIGHT * 0.5),
212                                    );
213                                });
214                            Box(blob, BoxSpec::default(), || {});
215
216                            let cells_tabs = Rc::clone(&tabs);
217                            Row(Modifier::empty(), RowSpec::default(), move || {
218                                for (index, tab) in cells_tabs.iter().enumerate() {
219                                    let is_selected = index == selected;
220                                    // Unselected tabs use the full label color (the
221                                    // reference bar draws them black, not gray).
222                                    let color = if is_selected {
223                                        colors.accent
224                                    } else {
225                                        colors.label
226                                    };
227                                    let label_for_semantics = tab.label;
228                                    let cell = Modifier::empty()
229                                        .size(Size::new(tab_width, BLOB_HEIGHT))
230                                        .semantics(move |config| {
231                                            config.is_button = true;
232                                            config.is_clickable = true;
233                                            config.content_description =
234                                                Some(label_for_semantics.to_string());
235                                        });
236                                    let icon = tab.icon;
237                                    let label = tab.label;
238                                    let label_style = TextStyle {
239                                        span_style: SpanStyle {
240                                            color: Some(color),
241                                            font_size: cranpose_ui::text::TextUnit::Sp(
242                                                TAB_LABEL_SIZE,
243                                            ),
244                                            font_weight: Some(FontWeight::MEDIUM),
245                                            ..typography.caption1.span_style.clone()
246                                        },
247                                        ..typography.caption1.clone()
248                                    };
249                                    Box(
250                                        cell,
251                                        BoxSpec::default().content_alignment(Alignment::CENTER),
252                                        move || {
253                                            let label_style = label_style.clone();
254                                            Column(
255                                                Modifier::empty(),
256                                                ColumnSpec::default().horizontal_alignment(
257                                                    HorizontalAlignment::CenterHorizontally,
258                                                ),
259                                                move || {
260                                                    crate::icons::Icon(icon, TAB_ICON_SIZE, color);
261                                                    Text(
262                                                        label,
263                                                        Modifier::empty(),
264                                                        label_style.clone(),
265                                                    );
266                                                },
267                                            );
268                                        },
269                                    );
270                                }
271                            });
272
273                            // Swipe/tap surface across the whole pill interior.
274                            let row_width = tab_width * count as f32;
275                            let gesture = Modifier::empty()
276                                .size(Size::new(row_width, BLOB_HEIGHT))
277                                .pointer_input((), {
278                                    let on_select = Rc::clone(&on_select);
279                                    move |scope: PointerInputScope| {
280                                        let on_select = Rc::clone(&on_select);
281                                        async move {
282                                            scope
283                                                .await_pointer_event_scope(
284                                                    |await_scope| async move {
285                                                        let mut down_x = 0.0f32;
286                                                        let mut active = false;
287                                                        loop {
288                                                            let event = await_scope
289                                                                .await_pointer_event()
290                                                                .await;
291                                                            match event.kind {
292                                                                PointerEventKind::Down => {
293                                                                    active = true;
294                                                                    down_x = event.position.x;
295                                                                    lens_pressed.set(true);
296                                                                    default_haptics().perform(
297                                                                        HapticFeedback::Selection,
298                                                                    );
299                                                                    event.consume();
300                                                                }
301                                                                PointerEventKind::Move
302                                                                    if active =>
303                                                                {
304                                                                    if (event.position.x - down_x)
305                                                                        .abs()
306                                                                        > TAP_SLOP
307                                                                    {
308                                                                        lens_drag_x.set(Some(
309                                                                            event.position.x,
310                                                                        ));
311                                                                    }
312                                                                    event.consume();
313                                                                }
314                                                                PointerEventKind::Up
315                                                                | PointerEventKind::Cancel
316                                                                    if active =>
317                                                                {
318                                                                    active = false;
319                                                                    lens_pressed.set(false);
320                                                                    let commit_x = lens_drag_x
321                                                                        .get()
322                                                                        .unwrap_or(
323                                                                            event.position.x,
324                                                                        );
325                                                                    lens_drag_x.set(None);
326                                                                    let index = ((commit_x
327                                                                        / tab_width)
328                                                                        .floor()
329                                                                        as isize)
330                                                                        .clamp(
331                                                                            0,
332                                                                            count as isize - 1,
333                                                                        )
334                                                                        as usize;
335                                                                    default_haptics().perform(
336                                                                        HapticFeedback::ImpactLight,
337                                                                    );
338                                                                    on_select(index);
339                                                                    event.consume();
340                                                                }
341                                                                _ => {}
342                                                            }
343                                                        }
344                                                    },
345                                                )
346                                                .await;
347                                        }
348                                    }
349                                });
350                            Box(gesture, BoxSpec::default(), || {});
351
352                            // Publish the lens springs for the overlay rendered
353                            // ABOVE the finished bar (outside this glass layer, so
354                            // the lens magnifies icons and glass together).
355                            let published = (lens_x.get(), lens_alpha.get(), tab_width);
356                            if lens_x_outer.get() != published {
357                                lens_x_outer.set(published);
358                            }
359                        });
360                    });
361
362                    // The lens bubble, floating above the whole pill. While it
363                    // moves fast it pulls rounder and taller (surface tension —
364                    // the reference mid-swipe bubble is nearly a circle poking
365                    // past the bar) and magnifies harder; at rest it relaxes
366                    // into a capsule hugging the cell. The search accessory's
367                    // circle joins its liquid field: drag the lens to the bar's
368                    // end and the two glue through a smooth-union neck.
369                    let (lens_px, lens_a, lens_tab_w) = lens_x_outer.get();
370                    if lens_a > 0.01 {
371                        let lens_w = lens_tab_w;
372                        let lens_h = BAR_HEIGHT * 1.35;
373                        // Node headroom for the moving lens's taller/rounder
374                        // shape and its rim glow.
375                        let node_w = lens_w + 28.0;
376                        let node_h = lens_h + 22.0;
377                        let node_x = BLOB_MARGIN + lens_px - (node_w - lens_w) * 0.5;
378                        let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
379                        // Accessory circle center in lens-node-local coords.
380                        let accessory_cx = pill_w + 10.0 + BAR_HEIGHT * 0.5 - node_x;
381                        let accessory_cy = node_h * 0.5;
382                        let last_px =
383                            cranpose_core::remember(|| Rc::new(std::cell::Cell::new(f32::NAN)))
384                                .with(Rc::clone);
385                        let lens = Modifier::empty()
386                            // required_size: the stack is pinned to BAR_HEIGHT so
387                            // the taller lens can never inflate the bar; the node
388                            // still measures (and draws) at its full size and the
389                            // offset centers it on the pill.
390                            .required_size(Size::new(node_w, node_h))
391                            .offset(node_x, BLOB_MARGIN + (BLOB_HEIGHT - node_h) * 0.5)
392                            .graphics_layer_value(GraphicsLayer {
393                                alpha: lens_a.clamp(0.0, 1.0),
394                                ..Default::default()
395                            })
396                            .glass_effect_with(
397                                // Near-invisible construction: the bar lens is
398                                // defined by refraction, not by rim strokes or
399                                // milk (the reference lens has no drawn edge).
400                                Glass::lens()
401                                    .no_clip()
402                                    .lift(-0.03)
403                                    .highlight(0.55)
404                                    .chromatic_aberration(1.2)
405                                    .displacement(32.0),
406                                move || {
407                                    // Per-frame travel speed shapes the droplet.
408                                    let prev = last_px.replace(lens_px);
409                                    let speed = if prev.is_nan() {
410                                        0.0
411                                    } else {
412                                        (lens_px - prev).abs()
413                                    };
414                                    let roundness = (speed * 0.10).min(1.0);
415                                    let w = lens_w - (lens_w - lens_h * 0.92) * roundness * 0.55;
416                                    let h = lens_h + 14.0 * roundness;
417                                    // Continuous-curvature read: the resting lens
418                                    // is a flattened squircle, not a stadium; it
419                                    // rounds toward a capsule only at speed.
420                                    let radius = h * (0.42 + 0.08 * roundness);
421                                    let bulge = (speed * 0.9).min(8.0);
422                                    let dir = if lens_px >= prev || prev.is_nan() {
423                                        0.0
424                                    } else {
425                                        std::f32::consts::PI
426                                    };
427                                    // The search circle joins the liquid field only
428                                    // when the lens edge actually gets within glue
429                                    // reach — passing glue, not a permanent overdraw
430                                    // (a far shape re-rendered by this node would
431                                    // paint a spurious rim over the real button).
432                                    let glue = 20.0;
433                                    let edge_gap = (accessory_cx - node_w * 0.5).abs()
434                                        - w * 0.5
435                                        - BAR_HEIGHT * 0.5;
436                                    // Join only when nearly touching: a parked lens
437                                    // one cell away must not repaint the circle.
438                                    let shapes = if edge_gap < 10.0 {
439                                        vec![(
440                                            accessory_cx,
441                                            accessory_cy,
442                                            BAR_HEIGHT,
443                                            BAR_HEIGHT,
444                                            -1.0,
445                                        )]
446                                    } else {
447                                        Vec::new()
448                                    };
449                                    GlassDynamics {
450                                        morph: Some(GlassMorph {
451                                            node_size: (node_w, node_h),
452                                            primary: (node_w * 0.5, node_h * 0.5, w, h, radius),
453                                            shapes,
454                                            glue,
455                                            wobble_amplitude: (speed * 0.35).min(3.5),
456                                            wobble_phase: lens_px * 0.11,
457                                            bulge_amplitude: bulge,
458                                            bulge_direction: dir,
459                                        }),
460                                        // The reference lens magnifies its cell hard
461                                        // even while pressed-still, harder in motion.
462                                        magnify_boost: 0.25 + 0.4 * roundness,
463                                        ..Default::default()
464                                    }
465                                },
466                            );
467                        Box(lens, BoxSpec::default(), || {});
468                    }
469                },
470            );
471
472            // Detached circular accessory (e.g. the search button).
473            Box(Modifier::empty().width(10.0), BoxSpec::default(), || {});
474            (accessory.borrow_mut())();
475        },
476    );
477}
478
479/// The standard detached accessory: a circular glass search button.
480#[composable]
481#[allow(non_snake_case)]
482pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
483    // The reference search circle is nearly flush with the bar height.
484    crate::widgets::GlassIconButton(
485        Modifier::empty(),
486        crate::widgets::GlassButtonSpec::glass(),
487        BAR_HEIGHT * 0.94,
488        on_click,
489        crate::icons::SEARCH,
490    );
491}