1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub enum ExtendedFloatingActionButtonSize {
29 #[default]
31 Small,
32 Medium,
34 Large,
36}
37
38impl ExtendedFloatingActionButtonSize {
39 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 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#[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 != ¤t) {
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}