Skip to main content

tessera_components/
extended_floating_action_button.rs

1//! Extended floating action buttons with animated labels.
2//!
3//! ## Usage
4//!
5//! Emphasize a primary action with an icon and a descriptive label.
6
7use tessera_ui::{
8    AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FrameNanosControl, LayoutResult,
9    MeasurementError, Modifier, Px, PxPosition, current_frame_nanos,
10    layout::{MeasureScope, layout},
11    receive_frame_nanos, remember, tessera, use_context,
12};
13
14use crate::{
15    animation::Animatable,
16    floating_action_button::{
17        FloatingActionButtonElevation, FloatingActionButtonSize, floating_action_button,
18    },
19    modifier::{ModifierExt, SemanticsArgs},
20    painter::Painter,
21    shape_def::Shape,
22    text::text,
23    theme::{MaterialTheme, TextStyle},
24};
25
26/// Expressive size of an extended floating action button.
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub enum ExtendedFloatingActionButtonSize {
29    /// A 56dp container with a 24dp icon and title-medium label.
30    #[default]
31    Small,
32    /// An 80dp container with a 28dp icon and title-large label.
33    Medium,
34    /// A 96dp container with a 36dp icon and headline-small label.
35    Large,
36}
37
38impl ExtendedFloatingActionButtonSize {
39    /// Returns the container height and collapsed width.
40    pub const fn height(self) -> Dp {
41        match self {
42            Self::Small => Dp(56.0),
43            Self::Medium => Dp(80.0),
44            Self::Large => Dp(96.0),
45        }
46    }
47
48    /// Returns the icon size.
49    pub const fn icon_size(self) -> Dp {
50        match self {
51            Self::Small => Dp(24.0),
52            Self::Medium => Dp(28.0),
53            Self::Large => Dp(36.0),
54        }
55    }
56
57    fn padding(self) -> Dp {
58        match self {
59            Self::Small => Dp(16.0),
60            Self::Medium => Dp(26.0),
61            Self::Large => Dp(28.0),
62        }
63    }
64
65    fn gap(self) -> Dp {
66        match self {
67            Self::Small => Dp(8.0),
68            Self::Medium => Dp(12.0),
69            Self::Large => Dp(16.0),
70        }
71    }
72
73    fn base_size(self) -> FloatingActionButtonSize {
74        match self {
75            Self::Small => FloatingActionButtonSize::Standard,
76            Self::Medium => FloatingActionButtonSize::Medium,
77            Self::Large => FloatingActionButtonSize::Large,
78        }
79    }
80
81    fn text_style(self, theme: &MaterialTheme) -> TextStyle {
82        match self {
83            Self::Small => theme.typography.title_medium,
84            Self::Medium => theme.typography.title_large,
85            Self::Large => theme.typography.headline_small,
86        }
87    }
88}
89
90struct ExpansionMotion {
91    width: Animatable,
92    opacity: Animatable,
93    frame_nanos: u64,
94}
95
96impl ExpansionMotion {
97    fn new(expanded: bool, frame_nanos: u64) -> Self {
98        let value = if expanded { 1.0 } else { 0.0 };
99        Self {
100            width: Animatable::spring(value, 800.0_f32.sqrt(), 0.6),
101            opacity: Animatable::spring(value, 3800.0_f32.sqrt(), 1.0),
102            frame_nanos,
103        }
104    }
105
106    fn advance(&mut self, frame_nanos: u64) {
107        if frame_nanos > self.frame_nanos {
108            let dt = (frame_nanos - self.frame_nanos) as f32 / 1_000_000_000.0;
109            self.width.tick(dt);
110            self.opacity.tick(dt);
111            self.frame_nanos = frame_nanos;
112        }
113    }
114
115    fn set_expanded(&mut self, expanded: bool, frame_nanos: u64) {
116        self.advance(frame_nanos);
117        let target = if expanded { 1.0 } else { 0.0 };
118        self.width.set_target(target);
119        self.opacity.set_target(target);
120    }
121
122    fn is_idle(&self) -> bool {
123        self.width.is_idle() && self.opacity.is_idle()
124    }
125}
126
127#[derive(Clone, PartialEq)]
128struct ExtendedFabContent {
129    label: String,
130    icon: Option<Painter>,
131    size: ExtendedFloatingActionButtonSize,
132    style: TextStyle,
133}
134
135#[derive(Clone, Copy, PartialEq)]
136struct ExtendedFabLayout {
137    size: ExtendedFloatingActionButtonSize,
138    has_icon: bool,
139    progress: f32,
140}
141
142impl tessera_ui::LayoutModifierNode for ExtendedFabLayout {
143    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
144        let children = input.children();
145        let height = input
146            .parent_constraint()
147            .height()
148            .clamp(self.size.height().to_px());
149        let padding = self.size.padding().to_px();
150        let icon_size = self.size.icon_size().to_px();
151        let gap = if self.has_icon {
152            self.size.gap().to_px()
153        } else {
154            Px::ZERO
155        };
156        let icon_width = if self.has_icon { icon_size } else { Px::ZERO };
157        let label_constraint = Constraint::new(
158            AxisConstraint::new(
159                Px::ZERO,
160                input
161                    .parent_constraint()
162                    .width()
163                    .resolve_max()
164                    .map(|max| (max - padding * 2 - icon_width - gap).max(Px::ZERO)),
165            ),
166            AxisConstraint::new(Px::ZERO, Some(height)),
167        );
168        let label = children[usize::from(self.has_icon)];
169        let label_size = label.measure(input, &label_constraint)?;
170        let expanded_width =
171            (padding * 2 + icon_width + gap + label_size.width).max(self.size.height().to_px());
172        let collapsed_width = self.size.height().to_px();
173        let width = input.parent_constraint().width().clamp(Px::new(
174            (collapsed_width.to_f32()
175                + (expanded_width - collapsed_width).to_f32() * self.progress.max(0.0))
176            .round() as i32,
177        ));
178        let mut result = LayoutResult::new(ComputedData { width, height });
179        if self.has_icon {
180            let child = children[0];
181            child.measure(
182                input,
183                &Constraint::new(
184                    AxisConstraint::exact(icon_size),
185                    AxisConstraint::exact(icon_size),
186                ),
187            )?;
188            let collapsed_start = (collapsed_width - icon_size).to_f32() / 2.0;
189            let icon_x = Px::new(
190                (collapsed_start + (padding.to_f32() - collapsed_start) * self.progress).round()
191                    as i32,
192            );
193            result.place_child(child, PxPosition::new(icon_x, (height - icon_size) / 2));
194            result.place_child(
195                label,
196                PxPosition::new(icon_x + icon_size + gap, (height - label_size.height) / 2),
197            );
198        } else {
199            result.place_child(
200                label,
201                PxPosition::new(
202                    (width - label_size.width) / 2,
203                    (height - label_size.height) / 2,
204                ),
205            );
206        }
207        Ok(result)
208    }
209}
210
211/// # extended_floating_action_button
212///
213/// Show a primary action with an animated label in one of three Expressive
214/// sizes.
215///
216/// ## Usage
217///
218/// Keep primary actions recognizable and collapse their labels when space is
219/// limited.
220///
221/// ## Parameters
222///
223/// - `label` — action name, retained as the accessible name while collapsed.
224/// - `size` — small, medium, or large container and typography.
225/// - `icon` — optional vector or raster icon, sized automatically.
226/// - `expanded` — show the label beside the icon; label-only buttons stay
227///   expanded.
228/// - `modifier` — positioning and size constraints.
229/// - `on_click`, `enabled` — activation callback and enabled state.
230/// - `container_color`, `content_color` — optional theme color overrides.
231/// - `shape`, `elevation` — optional shape and interaction elevation overrides.
232/// - `accessibility_label` — optional spoken name replacing the visible label.
233///
234/// ## Examples
235///
236/// ```
237/// use tessera_components::{
238///     extended_floating_action_button::extended_floating_action_button,
239///     material_icons::filled,
240/// };
241/// use tessera_ui::{remember, tessera};
242///
243/// #[tessera]
244/// fn compose_action() {
245///     let expanded = remember(|| true);
246///     assert!(expanded.get());
247///     extended_floating_action_button("Compose")
248///         .icon(filled::EDIT_SVG)
249///         .expanded(expanded.get())
250///         .on_click(move || expanded.set(!expanded.get()));
251/// }
252/// ```
253#[tessera]
254pub fn extended_floating_action_button(
255    #[prop(into)] label: String,
256    size: Option<ExtendedFloatingActionButtonSize>,
257    #[prop(into)] icon: Option<Painter>,
258    expanded: Option<bool>,
259    modifier: Option<Modifier>,
260    on_click: Option<Callback>,
261    enabled: Option<bool>,
262    container_color: Option<Color>,
263    content_color: Option<Color>,
264    shape: Option<Shape>,
265    elevation: Option<FloatingActionButtonElevation>,
266    #[prop(into)] accessibility_label: Option<String>,
267) {
268    let theme = use_context::<MaterialTheme>()
269        .expect("MaterialTheme must be provided")
270        .get();
271    let size = size.unwrap_or_default();
272    let expanded = icon.is_none() || expanded.unwrap_or(true);
273    let frame = current_frame_nanos();
274    let motion = remember(|| ExpansionMotion::new(expanded, frame));
275    let was_expanded = remember(|| expanded);
276    if was_expanded.get() != expanded {
277        motion.with_mut(|motion| motion.set_expanded(expanded, frame));
278        was_expanded.set(expanded);
279    }
280    if !motion.with(ExpansionMotion::is_idle) {
281        receive_frame_nanos(move |frame| {
282            motion.with_mut(|motion| motion.advance(frame));
283            if motion.with(ExpansionMotion::is_idle) {
284                FrameNanosControl::Stop
285            } else {
286                FrameNanosControl::Continue
287            }
288        });
289    }
290    let current = ExtendedFabContent {
291        label: label.clone(),
292        icon,
293        size,
294        style: size.text_style(&theme),
295    };
296    let content = remember(|| current.clone());
297    if content.with(|content| content != &current) {
298        content.set(current);
299    }
300    floating_action_button()
301        .size(size.base_size())
302        .modifier(modifier.unwrap_or_default())
303        .on_click_optional(on_click)
304        .enabled(enabled.unwrap_or(true))
305        .container_color_optional(container_color)
306        .content_color_optional(content_color)
307        .shape_optional(shape)
308        .elevation_optional(elevation)
309        .accessibility_label(accessibility_label.unwrap_or(label))
310        .content(move || {
311            let snapshot = content.get();
312            let progress = motion.with(|motion| motion.width.value());
313            layout()
314                .modifier(
315                    Modifier::new()
316                        .clip_to_bounds()
317                        .clear_and_set_semantics(SemanticsArgs {
318                            hidden: true,
319                            test_tag: cfg!(test).then(|| "extended-fab-content".into()),
320                            ..Default::default()
321                        }),
322                )
323                .policy(ExtendedFabLayout {
324                    size: snapshot.size,
325                    has_icon: snapshot.icon.is_some(),
326                    progress,
327                })
328                .child(move || {
329                    let snapshot = content.get();
330                    if let Some(painter) = snapshot.icon {
331                        crate::icon::icon()
332                            .painter(painter)
333                            .size(snapshot.size.icon_size());
334                    }
335                    text(snapshot.label).style(snapshot.style).modifier(
336                        Modifier::new()
337                            .alpha(motion.with(|motion| motion.opacity.value().clamp(0.0, 1.0))),
338                    );
339                });
340        });
341}
342
343#[cfg(test)]
344mod tests {
345    use tessera_ui::{
346        CursorEventContent, GestureState, PointerChange, PressKeyEventType, State,
347        testing::{
348            layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
349            start_layout_test_session,
350        },
351    };
352
353    use crate::{
354        boxed::boxed, column::column, material_icons::filled, spacer::spacer, theme::material_theme,
355    };
356
357    use super::*;
358
359    #[derive(Clone)]
360    struct FixtureConfig {
361        label: String,
362        size: ExtendedFloatingActionButtonSize,
363    }
364
365    #[derive(Clone, Default)]
366    struct FixtureHandles {
367        expanded: Option<State<bool>>,
368        config: Option<State<FixtureConfig>>,
369    }
370
371    fn tag(name: &str) -> Modifier {
372        Modifier::new().semantics(SemanticsArgs {
373            test_tag: Some(name.into()),
374            ..Default::default()
375        })
376    }
377
378    #[tessera]
379    fn fixture(
380        size: ExtendedFloatingActionButtonSize,
381        expanded: bool,
382        has_icon: bool,
383        enabled: bool,
384        probe: Option<tessera_ui::testing::TestProbe<FixtureHandles>>,
385    ) {
386        let expanded = remember(|| expanded);
387        let config = remember(|| FixtureConfig {
388            label: "Compose".into(),
389            size,
390        });
391        if let Some(probe) = &probe {
392            probe.with_mut(|handles| handles.expanded = Some(expanded));
393        }
394        if let Some(probe) = &probe {
395            probe.with_mut(|handles| handles.config = Some(config));
396        }
397        material_theme().child(move || {
398            column().children(move || {
399                boxed().modifier(tag("fab")).children(move || {
400                    let config = config.get();
401                    extended_floating_action_button(config.label)
402                        .size(config.size)
403                        .icon_optional(if has_icon {
404                            Some(filled::EDIT_SVG.into())
405                        } else {
406                            None
407                        })
408                        .expanded(expanded.get())
409                        .enabled(enabled)
410                        .on_click(move || expanded.set(!expanded.get()));
411                });
412            });
413        });
414    }
415
416    #[tessera]
417    fn geometry_fixture(size: ExtendedFloatingActionButtonSize) {
418        column().children(move || {
419            layout()
420                .modifier(tag("geometry"))
421                .policy(ExtendedFabLayout {
422                    size,
423                    has_icon: true,
424                    progress: 1.0,
425                })
426                .child(move || {
427                    spacer().modifier(tag("icon").size(size.icon_size(), size.icon_size()));
428                    spacer().modifier(tag("label").size(Dp(80.0), Dp(24.0)));
429                });
430        });
431    }
432
433    #[test]
434    fn expressive_geometry_uses_current_padding_gaps_and_icon_sizes() {
435        for (size, width, height, icon_x, label_x, icon_size) in [
436            (ExtendedFloatingActionButtonSize::Small, 144, 56, 16, 48, 24),
437            (
438                ExtendedFloatingActionButtonSize::Medium,
439                172,
440                80,
441                26,
442                66,
443                28,
444            ),
445            (ExtendedFloatingActionButtonSize::Large, 188, 96, 28, 80, 36),
446        ] {
447            let snapshot = layout_test(move || {
448                geometry_fixture(size);
449            })
450            .viewport_px(500, 400)
451            .run();
452            snapshot.node("geometry").size(width, height);
453            snapshot.node("icon").size(icon_size, icon_size);
454            assert_eq!(snapshot.node("icon").bounds().x, Px::new(icon_x));
455            assert_eq!(snapshot.node("label").bounds().x, Px::new(label_x));
456        }
457    }
458
459    #[test]
460    fn all_sizes_collapse_to_square_containers() {
461        for (size, side) in [
462            (ExtendedFloatingActionButtonSize::Small, 56),
463            (ExtendedFloatingActionButtonSize::Medium, 80),
464            (ExtendedFloatingActionButtonSize::Large, 96),
465        ] {
466            let snapshot = layout_test(move || {
467                fixture(size, false, true, true);
468            })
469            .viewport_px(500, 400)
470            .run();
471            snapshot.node("fab").size(side, side);
472        }
473    }
474
475    #[test]
476    fn label_only_buttons_remain_expanded_without_an_icon() {
477        let snapshot = layout_test(|| {
478            fixture(ExtendedFloatingActionButtonSize::Small, false, false, true);
479        })
480        .viewport_px(500, 400)
481        .run();
482        let bounds = snapshot.node("fab").bounds();
483        assert!(bounds.width > Px::new(56));
484        assert_eq!(bounds.height, Px::new(56));
485    }
486
487    #[test]
488    fn changing_size_and_label_updates_retained_content() {
489        let probe = tessera_ui::testing::TestProbe::<FixtureHandles>::default();
490
491        let mut session = start_layout_test_session(
492            layout_test(|| {
493                fixture(ExtendedFloatingActionButtonSize::Small, true, true, true)
494                    .probe(probe.clone());
495            })
496            .viewport_px(500, 400),
497        );
498        let before = pump_layout_test_session(&mut session, 0)
499            .node("fab")
500            .bounds();
501        probe
502            .get()
503            .config
504            .expect("fixture state")
505            .with_mut(|config| {
506                config.size = ExtendedFloatingActionButtonSize::Medium;
507                config.label = "Create a document".into();
508            });
509        let after = pump_layout_test_session(&mut session, 16_666_667)
510            .node("fab")
511            .bounds();
512        assert!(after.width > before.width);
513        assert_eq!(after.height, Px::new(80));
514    }
515
516    #[test]
517    fn click_collapse_animates_and_disabled_buttons_ignore_input() {
518        let probe = tessera_ui::testing::TestProbe::<FixtureHandles>::default();
519
520        for enabled in [true, false] {
521            let mut session = start_layout_test_session(
522                layout_test(|| {
523                    fixture(ExtendedFloatingActionButtonSize::Small, true, true, enabled)
524                        .probe(probe.clone());
525                })
526                .viewport_px(500, 400),
527            );
528            let initial = pump_layout_test_session(&mut session, 0)
529                .node("fab")
530                .bounds();
531            let cursor = Some(PxPosition::from_f32_xy(
532                initial.x.to_f32() + 28.0,
533                initial.y.to_f32() + 28.0,
534            ));
535            for (index, event) in [
536                CursorEventContent::Pressed(PressKeyEventType::Left),
537                CursorEventContent::Released(PressKeyEventType::Left),
538            ]
539            .into_iter()
540            .enumerate()
541            {
542                pump_layout_test_session_with_input(
543                    &mut session,
544                    (index as u64 + 1) * 16_666_667,
545                    vec![PointerChange::new(
546                        tessera_ui::time::Instant::now(),
547                        1,
548                        event,
549                        GestureState::TapCandidate,
550                    )],
551                    cursor,
552                );
553            }
554            pump_layout_test_session(&mut session, 50_000_000);
555            let intermediate = pump_layout_test_session(&mut session, 83_333_333)
556                .node("fab")
557                .bounds();
558            if enabled {
559                assert!(!probe.get().expanded.expect("fixture state").get());
560                assert!(intermediate.width < initial.width);
561                assert!(intermediate.width > Px::new(56));
562            } else {
563                assert!(probe.get().expanded.expect("fixture state").get());
564                assert_eq!(intermediate.width, initial.width);
565            }
566            let mut final_snapshot = pump_layout_test_session(&mut session, 100_000_000);
567            for frame in 7..120 {
568                final_snapshot = pump_layout_test_session(&mut session, frame * 16_666_667);
569            }
570            assert_eq!(
571                final_snapshot.node("fab").bounds().width,
572                if enabled { Px::new(56) } else { initial.width }
573            );
574        }
575    }
576
577    #[test]
578    fn reversing_expansion_keeps_position_and_velocity_then_settles() {
579        let mut motion = ExpansionMotion::new(false, 0);
580        motion.set_expanded(true, 0);
581        motion.advance(33_333_333);
582        let before = (motion.width.value(), motion.width.velocity());
583        motion.set_expanded(false, 33_333_333);
584        assert_eq!((motion.width.value(), motion.width.velocity()), before);
585        for frame in 3..120 {
586            motion.advance(frame * 16_666_667);
587        }
588        assert!(motion.is_idle());
589        assert_eq!(motion.width.value(), 0.0);
590        assert_eq!(motion.opacity.value(), 0.0);
591    }
592
593    #[cfg(not(target_family = "wasm"))]
594    #[test]
595    #[ignore = "Requires a native GPU adapter"]
596    fn rendered_labels_restore_after_a_full_collapse_and_expansion() {
597        let probe = tessera_ui::testing::TestProbe::<FixtureHandles>::default();
598        let observed = probe.clone();
599
600        use tessera_ui::headless::{HeadlessConfig, HeadlessRenderer};
601
602        let mut renderer = HeadlessRenderer::new(
603            Box::new(move || {
604                fixture(ExtendedFloatingActionButtonSize::Small, true, true, true)
605                    .probe(observed.clone());
606            }),
607            vec![Box::new(crate::TesseraComponents)],
608            HeadlessConfig {
609                width: 500,
610                height: 400,
611                ..Default::default()
612            },
613        )
614        .expect("headless GPU renderer");
615        renderer.render_frames(3);
616        let expanded = renderer.read_pixels().expect("expanded pixels");
617        probe.get().expanded.expect("fixture state").set(false);
618        renderer.render_frames(120);
619        let collapsed = renderer.read_pixels().expect("collapsed pixels");
620        assert_ne!(expanded, collapsed);
621        probe.get().expanded.expect("fixture state").set(true);
622        renderer.render_frames(120);
623        let restored = renderer.read_pixels().expect("restored pixels");
624        crate::test_utils::assert_pixels_near(&expanded, &restored, 500);
625    }
626}