1use tessera_ui::{
8 Callback, CallbackWith, Color, Dp, Modifier, RenderSlot, State, current_frame_nanos,
9 layout::layout, modifier::ModifierCapabilityExt as _, provide_context, receive_frame_nanos,
10 remember, tessera, use_context,
11};
12
13use crate::{
14 alignment::Alignment,
15 animation::shape_motion::ShapeMotion,
16 boxed::boxed,
17 icon::IconSize,
18 modifier::{InteractionState, ModifierExt as _, Padding},
19 shape_def::{RoundedCorner, Shape},
20 spacer::spacer,
21 surface::{SurfaceStyle, surface},
22 text::text,
23 theme::{ContentColor, MaterialAlpha, MaterialColorScheme, MaterialTheme, TextStyle},
24};
25
26use item_layout::ListItemLayout;
27use selection::{ListItemActivation, ListItemSelectionSemantics};
28
29pub use selection::ListItemSelectionMode;
30pub use shapes::{ListItemPosition, ListItemShapes};
31
32mod color_motion;
33mod item_layout;
34mod selection;
35mod shapes;
36
37#[cfg(test)]
38mod tests;
39
40#[cfg(all(test, not(target_family = "wasm")))]
41mod color_tests;
42#[cfg(all(test, not(target_family = "wasm")))]
43mod long_click_tests;
44#[cfg(all(test, not(target_family = "wasm")))]
45mod native_tests;
46#[cfg(all(test, not(target_family = "wasm")))]
47mod reflow_tests;
48#[cfg(all(test, not(target_family = "wasm")))]
49mod selection_tests;
50#[cfg(all(test, not(target_family = "wasm")))]
51mod shape_tests;
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum ListItemVerticalAlignment {
56 #[default]
58 Auto,
59 Top,
61 Center,
63 Bottom,
65}
66
67#[derive(Clone, PartialEq, Copy, Debug)]
69pub struct ListItemColors {
70 pub container_color: Color,
72 pub headline_color: Color,
74 pub overline_color: Color,
76 pub supporting_color: Color,
78 pub leading_color: Color,
80 pub trailing_color: Color,
82 pub disabled_container_color: Color,
84 pub disabled_headline_color: Color,
86 pub disabled_overline_color: Color,
88 pub disabled_supporting_color: Color,
90 pub disabled_leading_color: Color,
92 pub disabled_trailing_color: Color,
94 pub selected_container_color: Color,
96 pub selected_headline_color: Color,
98 pub selected_overline_color: Color,
100 pub selected_supporting_color: Color,
102 pub selected_leading_color: Color,
104 pub selected_trailing_color: Color,
106 pub dragged_container_color: Color,
108 pub dragged_headline_color: Color,
110 pub dragged_overline_color: Color,
112 pub dragged_supporting_color: Color,
114 pub dragged_leading_color: Color,
116 pub dragged_trailing_color: Color,
118}
119
120impl ListItemColors {
121 fn container_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
122 if !enabled {
123 self.disabled_container_color
124 } else if dragged {
125 self.dragged_container_color
126 } else if selected {
127 self.selected_container_color
128 } else {
129 self.container_color
130 }
131 }
132
133 fn headline_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
134 if !enabled {
135 self.disabled_headline_color
136 } else if dragged {
137 self.dragged_headline_color
138 } else if selected {
139 self.selected_headline_color
140 } else {
141 self.headline_color
142 }
143 }
144
145 fn overline_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
146 if !enabled {
147 self.disabled_overline_color
148 } else if dragged {
149 self.dragged_overline_color
150 } else if selected {
151 self.selected_overline_color
152 } else {
153 self.overline_color
154 }
155 }
156
157 fn supporting_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
158 if !enabled {
159 self.disabled_supporting_color
160 } else if dragged {
161 self.dragged_supporting_color
162 } else if selected {
163 self.selected_supporting_color
164 } else {
165 self.supporting_color
166 }
167 }
168
169 fn leading_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
170 if !enabled {
171 self.disabled_leading_color
172 } else if dragged {
173 self.dragged_leading_color
174 } else if selected {
175 self.selected_leading_color
176 } else {
177 self.leading_color
178 }
179 }
180
181 fn trailing_color(self, enabled: bool, selected: bool, dragged: bool) -> Color {
182 if !enabled {
183 self.disabled_trailing_color
184 } else if dragged {
185 self.dragged_trailing_color
186 } else if selected {
187 self.selected_trailing_color
188 } else {
189 self.trailing_color
190 }
191 }
192}
193
194pub struct ListItemDefaults;
196
197impl ListItemDefaults {
198 pub const MIN_HEIGHT_ONE_LINE: Dp = Dp(56.0);
200 pub const MIN_HEIGHT_TWO_LINE: Dp = Dp(72.0);
202 pub const MIN_HEIGHT_THREE_LINE: Dp = Dp(88.0);
204 pub const LEADING_MIN_SIZE: Dp = Dp(24.0);
206 pub const TRAILING_MIN_SIZE: Dp = Dp(24.0);
208 pub const ICON_SIZE: Dp = Dp(20.0);
210 pub const SEGMENTED_GAP: Dp = Dp(2.0);
212 pub const INTERNAL_SPACING: Dp = Dp(12.0);
214 pub const CONTENT_PADDING: Padding = Padding::new(Dp(16.0), Dp(10.0), Dp(16.0), Dp(10.0));
216 pub const TONAL_ELEVATION: Dp = Dp(0.0);
218 pub const SHADOW_ELEVATION: Dp = Dp(0.0);
220
221 pub fn shape() -> Shape {
223 let corner = RoundedCorner::manual(Dp(4.0), 2.0);
224 Shape::RoundedRectangle {
225 top_left: corner,
226 top_right: corner,
227 bottom_right: corner,
228 bottom_left: corner,
229 }
230 }
231
232 pub fn colors(scheme: &MaterialColorScheme) -> ListItemColors {
234 let scheme = scheme.clone();
235 let disabled = scheme
236 .on_surface
237 .with_alpha(MaterialAlpha::DISABLED_CONTENT);
238 ListItemColors {
239 container_color: scheme.surface,
240 headline_color: scheme.on_surface,
241 overline_color: scheme.on_surface_variant,
242 supporting_color: scheme.on_surface_variant,
243 leading_color: scheme.on_surface_variant,
244 trailing_color: scheme.on_surface_variant,
245 disabled_container_color: scheme.surface,
246 disabled_headline_color: disabled,
247 disabled_overline_color: disabled,
248 disabled_supporting_color: disabled,
249 disabled_leading_color: disabled,
250 disabled_trailing_color: disabled,
251 selected_container_color: scheme.secondary_container,
252 selected_headline_color: scheme.on_secondary_container,
253 selected_overline_color: scheme.on_secondary_container,
254 selected_supporting_color: scheme.on_secondary_container,
255 selected_leading_color: scheme.on_secondary_container,
256 selected_trailing_color: scheme.on_secondary_container,
257 dragged_container_color: scheme.tertiary_container,
258 dragged_headline_color: scheme.on_tertiary_container,
259 dragged_overline_color: scheme.on_tertiary_container,
260 dragged_supporting_color: scheme.on_tertiary_container,
261 dragged_leading_color: scheme.on_tertiary_container,
262 dragged_trailing_color: scheme.on_tertiary_container,
263 }
264 }
265
266 pub fn min_height(has_overline: bool, has_supporting: bool) -> Dp {
268 if has_overline && has_supporting {
269 Self::MIN_HEIGHT_THREE_LINE
270 } else if has_overline || has_supporting {
271 Self::MIN_HEIGHT_TWO_LINE
272 } else {
273 Self::MIN_HEIGHT_ONE_LINE
274 }
275 }
276}
277
278#[tessera]
360pub fn list_item(
361 modifier: Option<Modifier>,
362 enabled: Option<bool>,
363 selected: Option<bool>,
364 selection_mode: Option<ListItemSelectionMode>,
365 on_selection_change: Option<CallbackWith<bool>>,
366 #[prop(into)] headline: Option<String>,
367 content: Option<RenderSlot>,
368 overline_content: Option<RenderSlot>,
369 supporting_content: Option<RenderSlot>,
370 #[prop(into)] overline_text: Option<String>,
371 #[prop(into)] supporting_text: Option<String>,
372 leading: Option<RenderSlot>,
373 trailing: Option<RenderSlot>,
374 colors: Option<ListItemColors>,
375 shape: Option<Shape>,
376 shapes: Option<ListItemShapes>,
377 position: Option<ListItemPosition>,
378 dragged: Option<bool>,
379 content_padding: Option<Padding>,
380 tonal_elevation: Option<Dp>,
381 shadow_elevation: Option<Dp>,
382 min_height: Option<Dp>,
383 vertical_alignment: Option<ListItemVerticalAlignment>,
384 on_click: Option<Callback>,
385 on_long_click: Option<Callback>,
386 interaction_state: Option<State<InteractionState>>,
387 #[prop(into)] accessibility_label: Option<String>,
388 #[prop(into)] accessibility_description: Option<String>,
389) {
390 let theme = use_context::<MaterialTheme>()
391 .expect("MaterialTheme must be provided")
392 .get();
393 let typography = theme.typography;
394 let selection_mode = selection_mode.unwrap_or_default();
395 let modifier = modifier
396 .unwrap_or_default()
397 .push_semantics(ListItemSelectionSemantics {
398 mode: selection_mode,
399 selected,
400 });
401 let enabled = enabled.unwrap_or(true);
402 let selected = selected.unwrap_or(false);
403 let action = ListItemActivation {
404 mode: selection_mode,
405 selected,
406 on_click,
407 on_selection_change,
408 };
409 let has_on_click = action.has_handler();
410 let activation = remember(|| action.clone());
411 if activation.get() != action {
412 activation.set(action);
413 }
414 let on_activate = Callback::new(move || activation.get().activate());
415 let dragged = dragged.unwrap_or(false);
416 let headline = headline.unwrap_or_default();
417 let colors = colors.unwrap_or_else(|| ListItemDefaults::colors(&theme.color_scheme));
418 let interaction = if let Some(interaction) = interaction_state {
419 interaction
420 } else {
421 remember(InteractionState::new)
422 };
423 let interactive = enabled && (has_on_click || on_long_click.is_some());
424 let shapes = shapes
425 .unwrap_or_else(|| ListItemShapes::from_theme(&theme))
426 .segmented(position.unwrap_or_default(), theme.shapes.large);
427 let target = if let Some(shape) = shape {
428 shape
429 } else if interactive && interaction.with(InteractionState::is_pressed) {
430 shapes.pressed_shape
431 } else if dragged && enabled {
432 shapes.dragged_shape
433 } else if selected {
434 shapes.selected_shape
435 } else if interactive && interaction.with(InteractionState::is_focused) {
436 shapes.focused_shape
437 } else if interactive && interaction.with(InteractionState::is_hovered) {
438 shapes.hovered_shape
439 } else {
440 shapes.shape
441 };
442 let motion = remember(|| ShapeMotion::new(target));
443 let mut next = motion.get();
444 next.retarget(target, enabled, current_frame_nanos());
445 if next != motion.get() {
446 motion.set(next);
447 }
448 let content_padding = content_padding.unwrap_or(ListItemDefaults::CONTENT_PADDING);
449 let tonal_elevation = tonal_elevation.unwrap_or(ListItemDefaults::TONAL_ELEVATION);
450 let shadow_elevation = shadow_elevation.unwrap_or(ListItemDefaults::SHADOW_ELEVATION);
451
452 let overline_text = overline_text.filter(|value| !value.is_empty());
453 let supporting_text = supporting_text.filter(|value| !value.is_empty());
454 let has_overline = overline_text.is_some() || overline_content.is_some();
455 let has_supporting = supporting_text.is_some() || supporting_content.is_some();
456
457 let target_colors = [
458 colors.container_color(enabled, selected, dragged),
459 colors.headline_color(enabled, selected, dragged),
460 colors.overline_color(enabled, selected, dragged),
461 colors.supporting_color(enabled, selected, dragged),
462 colors.leading_color(enabled, selected, dragged),
463 colors.trailing_color(enabled, selected, dragged),
464 ];
465 let color_motion = remember(|| color_motion::ListColorsMotion::new(target_colors));
466 let mut next_colors = color_motion.get();
467 next_colors.retarget(target_colors, current_frame_nanos());
468 if next_colors != color_motion.get() {
469 color_motion.set(next_colors);
470 }
471 if next.is_animating() || next_colors.is_animating() {
472 receive_frame_nanos(move |frame| {
473 let mut next = motion.get();
474 next.advance(frame);
475 if next != motion.get() {
476 motion.set(next);
477 }
478 let mut next_colors = color_motion.get();
479 next_colors.advance(frame);
480 if next_colors != color_motion.get() {
481 color_motion.set(next_colors);
482 }
483 if next.is_animating() || next_colors.is_animating() {
484 tessera_ui::FrameNanosControl::Continue
485 } else {
486 tessera_ui::FrameNanosControl::Stop
487 }
488 });
489 }
490 let [
491 container_color,
492 headline_color,
493 overline_color,
494 supporting_color,
495 leading_color,
496 trailing_color,
497 ] = next_colors.value();
498
499 let accessibility_label = accessibility_label
500 .or_else(|| (content.is_none() && !headline.is_empty()).then(|| headline.clone()));
501 let accessibility_description = accessibility_description
502 .or_else(|| {
503 if supporting_content.is_none() {
504 supporting_text.clone()
505 } else {
506 None
507 }
508 })
509 .or_else(|| {
510 if overline_content.is_none() {
511 overline_text.clone()
512 } else {
513 None
514 }
515 });
516 let elevation = (shadow_elevation.0 > 0.0).then_some(shadow_elevation);
517
518 surface()
519 .modifier(modifier)
520 .style(SurfaceStyle::Filled {
521 color: container_color,
522 })
523 .shape(motion.get().shape())
524 .content_color(headline_color)
525 .enabled(enabled)
526 .ripple_color(headline_color)
527 .tonal_elevation(tonal_elevation)
528 .accessibility_role(selection_mode.role())
529 .elevation_optional(elevation)
530 .interaction_state(interaction)
531 .on_click_optional(has_on_click.then_some(on_activate))
532 .on_long_click_optional(on_long_click)
533 .accessibility_focusable(interactive)
534 .accessibility_label_optional(accessibility_label)
535 .accessibility_description_optional(accessibility_description)
536 .child(move || {
537 let headline = headline.clone();
538 let overline_text = overline_text.clone();
539 let supporting_text = supporting_text.clone();
540 layout()
541 .policy(ListItemLayout {
542 padding: content_padding,
543 min_height,
544 has_overline,
545 has_supporting,
546 supporting_line_height: typography
547 .body_medium
548 .line_height
549 .unwrap_or(Dp(typography.body_medium.font_size.0 * 1.2)),
550 vertical_alignment: vertical_alignment.unwrap_or_default(),
551 })
552 .child(move || {
553 list_item_slot()
554 .content_optional(leading)
555 .color(leading_color)
556 .style(typography.title_medium)
557 .leading(true);
558 list_item_slot()
559 .content_optional(trailing)
560 .color(trailing_color)
561 .style(typography.label_small);
562 list_item_text_line()
563 .text_value_optional(overline_text.clone())
564 .content_optional(overline_content)
565 .style(typography.label_small)
566 .color(overline_color);
567 list_item_text_line()
568 .text_value(headline.clone())
569 .content_optional(content)
570 .style(typography.body_large)
571 .color(headline_color);
572 list_item_text_line()
573 .text_value_optional(supporting_text.clone())
574 .content_optional(supporting_content)
575 .style(typography.body_medium)
576 .color(supporting_color);
577 });
578 });
579}
580
581#[tessera]
582fn list_item_text_line(
583 #[prop(into)] text_value: Option<String>,
584 content: Option<RenderSlot>,
585 style: Option<crate::theme::TextStyle>,
586 color: Option<Color>,
587) {
588 let text_value = text_value.unwrap_or_default();
589 if let Some(content) = content {
590 list_item_slot()
591 .content_shared(content)
592 .color_optional(color)
593 .style_optional(style)
594 .main(true);
595 } else if text_value.is_empty() {
596 spacer();
597 } else {
598 text(text_value)
599 .style(style.unwrap_or_default())
600 .color(color.unwrap_or(Color::TRANSPARENT));
601 }
602}
603
604#[tessera]
605fn list_item_slot(
606 content: Option<RenderSlot>,
607 color: Option<Color>,
608 style: Option<TextStyle>,
609 leading: Option<bool>,
610 main: Option<bool>,
611) {
612 let color = color.unwrap_or(Color::TRANSPARENT);
613 let style = style.unwrap_or_default();
614 let icon_size = ListItemDefaults::ICON_SIZE;
615 provide_context(
616 || ContentColor { current: color },
617 move || {
618 let current = use_context::<ContentColor>().expect("list item content color");
619 if current.get().current != color {
620 current.set(ContentColor { current: color });
621 }
622 provide_context(
623 || style,
624 move || {
625 let current = use_context::<TextStyle>().expect("list item slot typography");
626 if current.get() != style {
627 current.set(style);
628 }
629 provide_context(
630 || IconSize(icon_size),
631 move || {
632 let current = use_context::<IconSize>().expect("list item icon size");
633 if current.get().0 != icon_size {
634 current.set(IconSize(icon_size));
635 }
636 let padding = if content.is_none() || main.unwrap_or(false) {
637 Padding::default()
638 } else if leading.unwrap_or(false) {
639 Padding::new(
640 Dp::ZERO,
641 Dp::ZERO,
642 ListItemDefaults::INTERNAL_SPACING,
643 Dp::ZERO,
644 )
645 } else {
646 Padding::new(
647 ListItemDefaults::INTERNAL_SPACING,
648 Dp::ZERO,
649 Dp::ZERO,
650 Dp::ZERO,
651 )
652 };
653 boxed()
654 .alignment(if main.unwrap_or(false) {
655 Alignment::TopStart
656 } else {
657 Alignment::Center
658 })
659 .modifier(Modifier::new().padding(padding))
660 .children(move || {
661 if let Some(content) = content {
662 content.render();
663 }
664 });
665 },
666 );
667 },
668 );
669 },
670 );
671}