Skip to main content

tessera_components/
floating_action_button.rs

1//! Floating action button component.
2//!
3//! ## Usage
4//!
5//! Emphasize a primary action with a prominent floating button.
6use tessera_ui::{
7    Callback, Color, Dp, FrameNanosControl, Modifier, RenderSlot, State, accesskit::Role,
8    current_frame_nanos, provide_context, receive_frame_nanos, remember, tessera, use_context,
9};
10
11use crate::{
12    alignment::Alignment,
13    animation::Animatable,
14    modifier::{InteractionState, ModifierExt as _},
15    shape_def::Shape,
16    surface::{SurfaceStyle, surface},
17    theme::{
18        ContentColor, MaterialAlpha, MaterialColorScheme, MaterialShapes, MaterialTheme,
19        content_color_for,
20    },
21};
22
23/// Sizes supported by [`floating_action_button`].
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum FloatingActionButtonSize {
26    /// Small floating action button (40dp).
27    Small,
28    /// Standard floating action button (56dp).
29    #[default]
30    Standard,
31    /// Medium floating action button (80dp).
32    Medium,
33    /// Large floating action button (96dp).
34    Large,
35}
36
37impl FloatingActionButtonSize {
38    fn container_size(self) -> Dp {
39        match self {
40            FloatingActionButtonSize::Small => FloatingActionButtonDefaults::SMALL_SIZE,
41            FloatingActionButtonSize::Standard => FloatingActionButtonDefaults::STANDARD_SIZE,
42            FloatingActionButtonSize::Medium => FloatingActionButtonDefaults::MEDIUM_SIZE,
43            FloatingActionButtonSize::Large => FloatingActionButtonDefaults::LARGE_SIZE,
44        }
45    }
46
47    fn icon_size(self) -> Dp {
48        match self {
49            FloatingActionButtonSize::Small => FloatingActionButtonDefaults::SMALL_ICON_SIZE,
50            FloatingActionButtonSize::Standard => FloatingActionButtonDefaults::STANDARD_ICON_SIZE,
51            FloatingActionButtonSize::Medium => FloatingActionButtonDefaults::MEDIUM_ICON_SIZE,
52            FloatingActionButtonSize::Large => FloatingActionButtonDefaults::LARGE_ICON_SIZE,
53        }
54    }
55}
56
57/// Elevation values used by floating action buttons across interaction states.
58#[derive(Clone, PartialEq, Copy, Debug)]
59pub struct FloatingActionButtonElevation {
60    default_elevation: Dp,
61    pressed_elevation: Dp,
62    hovered_elevation: Dp,
63    focused_elevation: Dp,
64}
65
66impl FloatingActionButtonElevation {
67    /// Creates a custom elevation profile for a floating action button.
68    pub const fn new(
69        default_elevation: Dp,
70        pressed_elevation: Dp,
71        hovered_elevation: Dp,
72        focused_elevation: Dp,
73    ) -> Self {
74        Self {
75            default_elevation,
76            pressed_elevation,
77            hovered_elevation,
78            focused_elevation,
79        }
80    }
81
82    fn tonal_elevation(self) -> Dp {
83        self.default_elevation
84    }
85
86    fn shadow_elevation(
87        self,
88        enabled: bool,
89        interaction_state: Option<State<InteractionState>>,
90    ) -> Dp {
91        if !enabled {
92            return self.default_elevation;
93        }
94
95        let Some(state) = interaction_state else {
96            return self.default_elevation;
97        };
98
99        state.with(|state| {
100            if state.is_pressed() {
101                self.pressed_elevation
102            } else if state.is_focused() {
103                self.focused_elevation
104            } else if state.is_hovered() {
105                self.hovered_elevation
106            } else {
107                self.default_elevation
108            }
109        })
110    }
111}
112
113struct ElevationMotion {
114    motion: Animatable,
115    frame_nanos: u64,
116}
117
118impl ElevationMotion {
119    fn new(elevation: Dp, frame_nanos: u64) -> Self {
120        Self {
121            motion: Animatable::spring(elevation.0 as f32, 3800.0_f32.sqrt(), 1.0),
122            frame_nanos,
123        }
124    }
125
126    fn advance(&mut self, frame_nanos: u64) {
127        if frame_nanos > self.frame_nanos {
128            self.motion
129                .tick((frame_nanos - self.frame_nanos) as f32 / 1_000_000_000.0);
130            self.frame_nanos = frame_nanos;
131        }
132    }
133}
134
135/// Material Design defaults for [`floating_action_button`].
136pub struct FloatingActionButtonDefaults;
137
138impl FloatingActionButtonDefaults {
139    /// Default container size for a standard floating action button.
140    pub const STANDARD_SIZE: Dp = Dp(56.0);
141    /// Container size for a small floating action button.
142    pub const SMALL_SIZE: Dp = Dp(40.0);
143    /// Container size for a medium floating action button.
144    pub const MEDIUM_SIZE: Dp = Dp(80.0);
145    /// Container size for a large floating action button.
146    pub const LARGE_SIZE: Dp = Dp(96.0);
147    /// Recommended icon size for a standard floating action button.
148    pub const STANDARD_ICON_SIZE: Dp = Dp(24.0);
149    /// Recommended icon size for a small floating action button.
150    pub const SMALL_ICON_SIZE: Dp = Dp(24.0);
151    /// Recommended icon size for a medium floating action button.
152    pub const MEDIUM_ICON_SIZE: Dp = Dp(28.0);
153    /// Recommended icon size for a large floating action button.
154    pub const LARGE_ICON_SIZE: Dp = Dp(36.0);
155    /// Default resting elevation for floating action buttons.
156    pub const DEFAULT_ELEVATION: Dp = Dp(6.0);
157    /// Elevation used while the button is pressed.
158    pub const PRESSED_ELEVATION: Dp = Dp(6.0);
159    /// Elevation used while the button is hovered.
160    pub const HOVERED_ELEVATION: Dp = Dp(8.0);
161    /// Elevation used while the button is focused.
162    pub const FOCUSED_ELEVATION: Dp = Dp(6.0);
163
164    /// Returns the container size for the provided FAB size.
165    pub fn container_size(size: FloatingActionButtonSize) -> Dp {
166        size.container_size()
167    }
168
169    /// Returns the recommended icon size for the provided FAB size.
170    pub fn icon_size(size: FloatingActionButtonSize) -> Dp {
171        size.icon_size()
172    }
173
174    /// Returns the default shape for the provided FAB size.
175    pub fn shape(size: FloatingActionButtonSize) -> Shape {
176        shape_from_size(size, &MaterialShapes::default())
177    }
178
179    /// Default container color for floating action buttons.
180    pub fn container_color(scheme: &MaterialColorScheme) -> Color {
181        scheme.primary_container
182    }
183
184    /// Default content color for floating action buttons.
185    pub fn content_color(scheme: &MaterialColorScheme) -> Color {
186        scheme.on_primary_container
187    }
188
189    /// Default disabled container color for floating action buttons.
190    pub fn disabled_container_color(scheme: &MaterialColorScheme) -> Color {
191        scheme
192            .on_surface
193            .with_alpha(MaterialAlpha::DISABLED_CONTAINER)
194    }
195
196    /// Default disabled content color for floating action buttons.
197    pub fn disabled_content_color(scheme: &MaterialColorScheme) -> Color {
198        scheme
199            .on_surface
200            .with_alpha(MaterialAlpha::DISABLED_CONTENT)
201    }
202
203    /// Default elevation profile for floating action buttons.
204    pub fn elevation() -> FloatingActionButtonElevation {
205        FloatingActionButtonElevation::new(
206            Self::DEFAULT_ELEVATION,
207            Self::PRESSED_ELEVATION,
208            Self::HOVERED_ELEVATION,
209            Self::FOCUSED_ELEVATION,
210        )
211    }
212}
213
214/// # floating_action_button
215///
216/// Render a floating action button for primary actions and quick access tasks.
217///
218/// ## Usage
219///
220/// Use for a screen's most important action, typically shown above the main
221/// content.
222///
223/// ## Parameters
224///
225/// - `size` - optional floating action button size variant.
226/// - `modifier` - modifier chain applied to the button.
227/// - `enabled` - optional enabled flag.
228/// - `container_color` - optional container color override.
229/// - `content_color` - optional content color override.
230/// - `shape` - optional shape override.
231/// - `elevation` - optional elevation profile override.
232/// - `on_click` - optional click callback.
233/// - `disabled_container_color` - optional disabled container color override.
234/// - `disabled_content_color` - optional disabled content color override.
235/// - `interaction_state` - optional shared interaction state.
236/// - `ripple_color` - optional ripple tint override.
237/// - `accessibility_label` - optional accessibility label.
238/// - `accessibility_description` - optional accessibility description.
239/// - `content` - optional content render slot, typically an icon.
240///
241/// ## Examples
242///
243/// ```
244/// use tessera_components::floating_action_button::floating_action_button;
245/// use tessera_ui::{Callback, remember, tessera};
246/// # use tessera_components::theme::{MaterialTheme, material_theme};
247///
248/// #[tessera]
249/// fn component() {
250/// #     material_theme()
251/// #         .theme(|| MaterialTheme::default())
252/// #         .child(|| {
253///     let clicked = remember(|| false);
254///     let on_click = Callback::new({ move || clicked.with_mut(|value| *value = true) });
255///     let on_click_shared = on_click.clone();
256///     on_click.call();
257///
258///     assert!(clicked.with(|value| *value));
259///
260///     floating_action_button()
261///         .on_click_shared(on_click_shared)
262///         .content(|| {});
263/// #         });
264/// }
265///
266/// # tessera_ui::testing::layout_test(component).run();
267/// ```
268#[tessera]
269pub fn floating_action_button(
270    size: Option<FloatingActionButtonSize>,
271    modifier: Option<Modifier>,
272    enabled: Option<bool>,
273    container_color: Option<Color>,
274    content_color: Option<Color>,
275    shape: Option<Shape>,
276    elevation: Option<FloatingActionButtonElevation>,
277    on_click: Option<Callback>,
278    disabled_container_color: Option<Color>,
279    disabled_content_color: Option<Color>,
280    interaction_state: Option<State<InteractionState>>,
281    ripple_color: Option<Color>,
282    #[prop(into)] accessibility_label: Option<String>,
283    #[prop(into)] accessibility_description: Option<String>,
284    content: Option<RenderSlot>,
285) {
286    let theme = use_context::<MaterialTheme>()
287        .expect("MaterialTheme must be provided")
288        .get();
289    let size = size.unwrap_or_default();
290    let modifier = modifier.unwrap_or_default();
291    let enabled = enabled.unwrap_or(true);
292    let container_color = container_color
293        .unwrap_or_else(|| FloatingActionButtonDefaults::container_color(&theme.color_scheme));
294    let elevation = elevation.unwrap_or_else(FloatingActionButtonDefaults::elevation);
295    let disabled_container_color = disabled_container_color.unwrap_or_else(|| {
296        FloatingActionButtonDefaults::disabled_container_color(&theme.color_scheme)
297    });
298    let disabled_content_color = disabled_content_color.unwrap_or_else(|| {
299        FloatingActionButtonDefaults::disabled_content_color(&theme.color_scheme)
300    });
301    let shape = shape.unwrap_or_else(|| shape_from_size(size, &theme.shapes));
302    let typography = theme.typography;
303    let scheme = theme.color_scheme;
304    let inherited_content_color = use_context::<ContentColor>()
305        .map(|c| c.get().current)
306        .unwrap_or_else(|| ContentColor::default().current);
307    let size = FloatingActionButtonDefaults::container_size(size);
308
309    let container_color = if enabled {
310        container_color
311    } else {
312        disabled_container_color
313    };
314
315    let content_color = if enabled {
316        content_color.unwrap_or_else(|| {
317            content_color_for(container_color, &scheme).unwrap_or(inherited_content_color)
318        })
319    } else {
320        disabled_content_color
321    };
322
323    let ripple_color = ripple_color.unwrap_or(content_color);
324    let interactive = enabled && on_click.is_some();
325    let interaction_state = if interactive {
326        Some(match interaction_state {
327            Some(value) => value,
328            None => remember(InteractionState::new),
329        })
330    } else {
331        interaction_state
332    };
333
334    let target_elevation = elevation.shadow_elevation(enabled, interaction_state);
335    let frame = current_frame_nanos();
336    let elevation_motion = remember(|| ElevationMotion::new(target_elevation, frame));
337    if elevation_motion.with(|motion| motion.motion.target()) != target_elevation.0 as f32 {
338        elevation_motion.with_mut(|motion| {
339            motion.advance(frame);
340            motion.motion.set_target(target_elevation.0 as f32);
341        });
342    }
343    if !elevation_motion.with(|motion| motion.motion.is_idle()) {
344        receive_frame_nanos(move |frame| {
345            elevation_motion.with_mut(|motion| motion.advance(frame));
346            if elevation_motion.with(|motion| motion.motion.is_idle()) {
347                FrameNanosControl::Stop
348            } else {
349                FrameNanosControl::Continue
350            }
351        });
352    }
353    let shadow_elevation = Dp(elevation_motion.with(|motion| motion.motion.value()) as f64);
354    let tonal_elevation = elevation.tonal_elevation();
355
356    surface()
357        .modifier(modifier.default_min_size(size, size))
358        .style(SurfaceStyle::Filled {
359            color: container_color,
360        })
361        .shape(shape)
362        .content_alignment(Alignment::Center)
363        .content_color(content_color)
364        .enabled(enabled)
365        .ripple_color(ripple_color)
366        .elevation(shadow_elevation)
367        .tonal_elevation(tonal_elevation)
368        .accessibility_role(Role::Button)
369        .accessibility_focusable(enabled)
370        .interaction_state_optional(interaction_state)
371        .on_click_optional(on_click)
372        .accessibility_label_optional(accessibility_label)
373        .accessibility_description_optional(accessibility_description)
374        .child({
375            move || {
376                provide_context(
377                    || typography.label_large,
378                    move || {
379                        if let Some(content) = content.as_ref() {
380                            content.render();
381                        }
382                    },
383                );
384            }
385        });
386}
387
388impl FloatingActionButtonBuilder {
389    /// Applies the small floating action button preset.
390    pub fn small(&self, on_click: impl Fn() + Send + Sync + 'static) -> Self {
391        self.on_click(on_click)
392            .size(FloatingActionButtonSize::Small)
393    }
394
395    /// Applies the large floating action button preset.
396    pub fn large(&self, on_click: impl Fn() + Send + Sync + 'static) -> Self {
397        self.on_click(on_click)
398            .size(FloatingActionButtonSize::Large)
399    }
400
401    /// Applies the medium floating action button preset.
402    pub fn medium(&self, on_click: impl Fn() + Send + Sync + 'static) -> Self {
403        self.on_click(on_click)
404            .size(FloatingActionButtonSize::Medium)
405    }
406}
407
408fn shape_from_size(size: FloatingActionButtonSize, shapes: &MaterialShapes) -> Shape {
409    match size {
410        FloatingActionButtonSize::Small => shapes.medium,
411        FloatingActionButtonSize::Standard => shapes.large,
412        FloatingActionButtonSize::Medium => shapes.large_increased,
413        FloatingActionButtonSize::Large => shapes.extra_large,
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use tessera_ui::{Px, PxSize};
420
421    use crate::shape_def::ResolvedShape;
422
423    use super::*;
424
425    #[test]
426    fn medium_fab_uses_an_80dp_container_28dp_icon_and_20dp_corners() {
427        assert_eq!(
428            FloatingActionButtonDefaults::container_size(FloatingActionButtonSize::Medium),
429            Dp(80.0)
430        );
431        assert_eq!(
432            FloatingActionButtonDefaults::icon_size(FloatingActionButtonSize::Medium),
433            Dp(28.0)
434        );
435        let ResolvedShape::Rounded {
436            corner_radii,
437            corner_g2,
438        } = FloatingActionButtonDefaults::shape(FloatingActionButtonSize::Medium)
439            .resolve_for_size(PxSize::new(Px::new(80), Px::new(80)))
440        else {
441            panic!("expected rounded rectangle");
442        };
443        assert_eq!(corner_radii, [20.0; 4]);
444        assert_eq!(corner_g2, [2.0; 4]);
445    }
446
447    #[test]
448    fn elevation_effect_retargets_continuously_and_settles_without_overshoot() {
449        let mut motion = ElevationMotion::new(Dp(6.0), 0);
450        motion.motion.set_target(8.0);
451        motion.advance(16_666_667);
452        let initial = motion.motion.value();
453        assert!(initial > 6.0 && initial < 8.0);
454        motion.motion.set_target(6.0);
455        assert_eq!(motion.motion.value(), initial);
456        for frame in 2..120 {
457            motion.advance(frame * 16_666_667);
458            assert!(motion.motion.value() >= 6.0 && motion.motion.value() <= 8.0);
459        }
460        assert!(motion.motion.is_idle());
461        assert_eq!(motion.motion.value(), 6.0);
462    }
463}