1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum FloatingActionButtonSize {
26 Small,
28 #[default]
30 Standard,
31 Medium,
33 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#[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 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
135pub struct FloatingActionButtonDefaults;
137
138impl FloatingActionButtonDefaults {
139 pub const STANDARD_SIZE: Dp = Dp(56.0);
141 pub const SMALL_SIZE: Dp = Dp(40.0);
143 pub const MEDIUM_SIZE: Dp = Dp(80.0);
145 pub const LARGE_SIZE: Dp = Dp(96.0);
147 pub const STANDARD_ICON_SIZE: Dp = Dp(24.0);
149 pub const SMALL_ICON_SIZE: Dp = Dp(24.0);
151 pub const MEDIUM_ICON_SIZE: Dp = Dp(28.0);
153 pub const LARGE_ICON_SIZE: Dp = Dp(36.0);
155 pub const DEFAULT_ELEVATION: Dp = Dp(6.0);
157 pub const PRESSED_ELEVATION: Dp = Dp(6.0);
159 pub const HOVERED_ELEVATION: Dp = Dp(8.0);
161 pub const FOCUSED_ELEVATION: Dp = Dp(6.0);
163
164 pub fn container_size(size: FloatingActionButtonSize) -> Dp {
166 size.container_size()
167 }
168
169 pub fn icon_size(size: FloatingActionButtonSize) -> Dp {
171 size.icon_size()
172 }
173
174 pub fn shape(size: FloatingActionButtonSize) -> Shape {
176 shape_from_size(size, &MaterialShapes::default())
177 }
178
179 pub fn container_color(scheme: &MaterialColorScheme) -> Color {
181 scheme.primary_container
182 }
183
184 pub fn content_color(scheme: &MaterialColorScheme) -> Color {
186 scheme.on_primary_container
187 }
188
189 pub fn disabled_container_color(scheme: &MaterialColorScheme) -> Color {
191 scheme
192 .on_surface
193 .with_alpha(MaterialAlpha::DISABLED_CONTAINER)
194 }
195
196 pub fn disabled_content_color(scheme: &MaterialColorScheme) -> Color {
198 scheme
199 .on_surface
200 .with_alpha(MaterialAlpha::DISABLED_CONTENT)
201 }
202
203 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#[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 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 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 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}