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}