Skip to main content

repose_material/material3/
list_item.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use repose_core::*;
7use repose_ui::{
8    Box, Column, Row, Text, TextStyle,
9    ViewExt,
10    anim::animate_color,
11};
12
13use super::*;
14
15/// Colors for [`ListItem`] -> matches Compose Material3 `ListItemColors` with
16/// 4 state groups (default, disabled, selected, dragged) × 6 slots each.
17#[derive(Clone, Debug)]
18pub struct ListItemColors {
19    pub container_color: Color,
20    pub headline_color: Color,
21    pub supporting_color: Color,
22    pub overline_color: Color,
23    pub leading_icon_color: Color,
24    pub trailing_icon_color: Color,
25
26    pub disabled_container_color: Color,
27    pub disabled_headline_color: Color,
28    pub disabled_supporting_color: Color,
29    pub disabled_overline_color: Color,
30    pub disabled_leading_icon_color: Color,
31    pub disabled_trailing_icon_color: Color,
32
33    pub selected_container_color: Color,
34    pub selected_headline_color: Color,
35    pub selected_supporting_color: Color,
36    pub selected_overline_color: Color,
37    pub selected_leading_icon_color: Color,
38    pub selected_trailing_icon_color: Color,
39
40    pub dragged_container_color: Color,
41    pub dragged_headline_color: Color,
42    pub dragged_supporting_color: Color,
43    pub dragged_overline_color: Color,
44    pub dragged_leading_icon_color: Color,
45    pub dragged_trailing_icon_color: Color,
46}
47
48impl ListItemColors {
49    pub fn container(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
50        if !enabled {
51            self.disabled_container_color
52        } else if dragged {
53            self.dragged_container_color
54        } else if selected {
55            self.selected_container_color
56        } else {
57            self.container_color
58        }
59    }
60    pub fn headline(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
61        if !enabled {
62            self.disabled_headline_color
63        } else if dragged {
64            self.dragged_headline_color
65        } else if selected {
66            self.selected_headline_color
67        } else {
68            self.headline_color
69        }
70    }
71    pub fn supporting(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
72        if !enabled {
73            self.disabled_supporting_color
74        } else if dragged {
75            self.dragged_supporting_color
76        } else if selected {
77            self.selected_supporting_color
78        } else {
79            self.supporting_color
80        }
81    }
82    pub fn overline(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
83        if !enabled {
84            self.disabled_overline_color
85        } else if dragged {
86            self.dragged_overline_color
87        } else if selected {
88            self.selected_overline_color
89        } else {
90            self.overline_color
91        }
92    }
93    pub fn leading_icon(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
94        if !enabled {
95            self.disabled_leading_icon_color
96        } else if dragged {
97            self.dragged_leading_icon_color
98        } else if selected {
99            self.selected_leading_icon_color
100        } else {
101            self.leading_icon_color
102        }
103    }
104    pub fn trailing_icon(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
105        if !enabled {
106            self.disabled_trailing_icon_color
107        } else if dragged {
108            self.dragged_trailing_icon_color
109        } else if selected {
110            self.selected_trailing_icon_color
111        } else {
112            self.trailing_icon_color
113        }
114    }
115}
116
117impl Default for ListItemColors {
118    fn default() -> Self {
119        Self {
120            container_color: Color::TRANSPARENT,
121            headline_color: ListItemDefaults::headline_color(),
122            supporting_color: ListItemDefaults::supporting_color(),
123            overline_color: ListItemDefaults::overline_color(),
124            leading_icon_color: ListItemDefaults::leading_icon_color(),
125            trailing_icon_color: ListItemDefaults::trailing_icon_color(),
126            disabled_container_color: ListItemDefaults::disabled_container_color(),
127            disabled_headline_color: ListItemDefaults::disabled_headline_color(),
128            disabled_supporting_color: ListItemDefaults::disabled_supporting_color(),
129            disabled_overline_color: ListItemDefaults::disabled_overline_color(),
130            disabled_leading_icon_color: ListItemDefaults::disabled_leading_icon_color(),
131            disabled_trailing_icon_color: ListItemDefaults::disabled_trailing_icon_color(),
132            selected_container_color: ListItemDefaults::selected_container_color(),
133            selected_headline_color: ListItemDefaults::selected_headline_color(),
134            selected_supporting_color: ListItemDefaults::selected_supporting_color(),
135            selected_overline_color: ListItemDefaults::selected_overline_color(),
136            selected_leading_icon_color: ListItemDefaults::selected_leading_icon_color(),
137            selected_trailing_icon_color: ListItemDefaults::selected_trailing_icon_color(),
138            dragged_container_color: ListItemDefaults::dragged_container_color(),
139            dragged_headline_color: ListItemDefaults::dragged_headline_color(),
140            dragged_supporting_color: ListItemDefaults::dragged_supporting_color(),
141            dragged_overline_color: ListItemDefaults::dragged_overline_color(),
142            dragged_leading_icon_color: ListItemDefaults::dragged_leading_icon_color(),
143            dragged_trailing_icon_color: ListItemDefaults::dragged_trailing_icon_color(),
144        }
145    }
146}
147
148/// Configuration for [`ListItem`].
149#[derive(Clone, Debug)]
150pub struct ListItemConfig {
151    pub modifier: Modifier,
152    /// When false, renders disabled colors and suppresses clicks.
153    pub enabled: bool,
154    pub selected: bool,
155    pub colors: ListItemColors,
156    pub state_colors: StateColors,
157    pub tonal_elevation: f32,
158    pub shadow_elevation: f32,
159    pub shape_radius: f32,
160    /// Per-corner radii `[BL, BR, TR, TL]`. When set, overrides `shape_radius`.
161    pub shape_radii: Option<[f32; 4]>,
162    pub horizontal_padding: f32,
163    pub trailing_padding: f32,
164    pub one_line_height: f32,
165    pub two_line_height: f32,
166    pub three_line_height: f32,
167    pub interaction_source: Option<MutableInteractionSource>,
168}
169
170impl Default for ListItemConfig {
171    fn default() -> Self {
172        Self {
173            modifier: Modifier::new(),
174            enabled: true,
175            selected: false,
176            colors: ListItemColors::default(),
177            state_colors: ListItemDefaults::state_colors_default(),
178            tonal_elevation: 0.0,
179            shadow_elevation: 0.0,
180            shape_radius: 0.0,
181            shape_radii: None,
182            horizontal_padding: ListItemDefaults::HORIZONTAL_PADDING,
183            trailing_padding: ListItemDefaults::TRAILING_PADDING,
184            one_line_height: ListItemDefaults::ONE_LINE_HEIGHT,
185            two_line_height: ListItemDefaults::TWO_LINE_HEIGHT,
186            three_line_height: ListItemDefaults::THREE_LINE_HEIGHT,
187            interaction_source: None,
188        }
189    }
190}
191
192static LISTITEM_COUNTER: AtomicU64 = AtomicU64::new(0);
193
194/// M3 List Item - a single row in a list with optional leading/trailing content,
195/// overline text, and click handling.
196pub fn ListItem(
197    headline: impl Into<String>,
198    supporting_text: Option<String>,
199    overline_text: Option<String>,
200    leading: Option<View>,
201    trailing: Option<View>,
202    on_click: Option<Rc<dyn Fn()>>,
203    on_long_click: Option<Rc<dyn Fn()>>,
204    config: ListItemConfig,
205) -> View {
206    let th = theme();
207    let is_enabled = config.enabled;
208    let is_selected = config.selected;
209    let c = &config.colors;
210    let id = remember(|| LISTITEM_COUNTER.fetch_add(1, Ordering::Relaxed));
211    let spec = th.motion.color;
212
213    let hd_col = animate_color(
214        format!("li_hd_{}", id),
215        c.headline(is_enabled, is_selected, false),
216        spec,
217    );
218    let sp_col = animate_color(
219        format!("li_sp_{}", id),
220        c.supporting(is_enabled, is_selected, false),
221        spec,
222    );
223    let ol_col = animate_color(
224        format!("li_ol_{}", id),
225        c.overline(is_enabled, is_selected, false),
226        spec,
227    );
228    let ld_col = animate_color(
229        format!("li_ld_{}", id),
230        c.leading_icon(is_enabled, is_selected, false),
231        spec,
232    );
233    let tr_col = animate_color(
234        format!("li_tr_{}", id),
235        c.trailing_icon(is_enabled, is_selected, false),
236        spec,
237    );
238    let bg = animate_color(
239        format!("li_bg_{}", id),
240        c.container(is_enabled, is_selected, false),
241        spec,
242    );
243
244    let line_count = match (overline_text.is_some(), supporting_text.is_some()) {
245        (true, true) => 3,
246        (true, false) | (false, true) => 2,
247        (false, false) => 1,
248    };
249    let min_h = match line_count {
250        3 => config.three_line_height,
251        2 => config.two_line_height,
252        _ => config.one_line_height,
253    };
254    let top_bottom_padding = match line_count {
255        3 => 12.0,
256        _ => 8.0,
257    };
258
259    let vert_align = if min_h >= config.three_line_height {
260        AlignItems::START
261    } else {
262        AlignItems::CENTER
263    };
264
265    let li_source: Rc<MutableInteractionSource> = config
266        .interaction_source
267        .clone()
268        .map(Rc::new)
269        .unwrap_or_else(|| remember(MutableInteractionSource::new));
270
271    let mut modifier = Modifier::new()
272        .min_width(200.0)
273        .min_height(min_h)
274        .background(bg);
275    match config.shape_radii {
276        Some(r) => modifier = modifier.clip_rounded_radii(r),
277        None => modifier = modifier.clip_rounded(config.shape_radius),
278    }
279    modifier = modifier
280        .state_colors(config.state_colors)
281        .padding_values(PaddingValues {
282            left: config.horizontal_padding,
283            right: config.trailing_padding,
284            top: top_bottom_padding,
285            bottom: top_bottom_padding,
286        })
287        .align_items(vert_align)
288        .interaction_source(&*li_source)
289        .then(config.modifier);
290
291    if config.tonal_elevation > 0.0 {
292        modifier = modifier.state_elevation(StateElevation {
293            default: config.tonal_elevation,
294            hovered: config.tonal_elevation,
295            pressed: config.tonal_elevation,
296            disabled: 0.0,
297        });
298    }
299    if config.shadow_elevation > 0.0 {
300        modifier = modifier.shadow(config.shadow_elevation, 0.0);
301    }
302
303    if on_click.is_some() || on_long_click.is_some() {
304        modifier = modifier.clickable();
305        if let Some(cb) = on_click {
306            let cb = cb.clone();
307            modifier = modifier.on_click(move || {
308                if is_enabled {
309                    cb();
310                }
311            });
312        }
313        if let Some(cb) = &on_long_click {
314            let cb = cb.clone();
315            modifier = modifier.on_long_click(move || {
316                if is_enabled {
317                    cb();
318                }
319            });
320        }
321    }
322
323    let wrap_icon = |color: Color, v: View| -> View { with_content_color(color, move || v) };
324
325    Row(modifier).child((
326        leading
327            .map(|v| {
328                Box(Modifier::new().padding_values(PaddingValues {
329                    left: 0.0,
330                    right: 16.0,
331                    top: 0.0,
332                    bottom: 0.0,
333                }))
334                .child(wrap_icon(ld_col, v))
335            })
336            .unwrap_or(Box(Modifier::new())),
337        Column(
338            Modifier::new()
339                .flex_grow(1.0)
340                .justify_content(JustifyContent::CENTER),
341        )
342        .child((
343            overline_text
344                .map(|ot| {
345                    Text(ot)
346                        .color(ol_col)
347                        .size(th.typography.label_small)
348                        .single_line()
349                })
350                .unwrap_or(Box(Modifier::new())),
351            Text(headline)
352                .color(hd_col)
353                .size(th.typography.body_large)
354                .single_line(),
355            supporting_text
356                .map(|st| {
357                    Text(st)
358                        .color(sp_col)
359                        .size(th.typography.body_medium)
360                        .max_lines(2)
361                        .overflow_ellipsize()
362                })
363                .unwrap_or(Box(Modifier::new())),
364        )),
365        trailing
366            .map(|v| {
367                Box(Modifier::new().padding_values(PaddingValues {
368                    left: 16.0,
369                    right: 0.0,
370                    top: 0.0,
371                    bottom: 0.0,
372                }))
373                .child(wrap_icon(tr_col, v))
374            })
375            .unwrap_or(Box(Modifier::new())),
376    ))
377}
378
379/// M3 Selectable List Item -> single-selection variant with `selected` state and
380/// `Role::RadioButton` semantics.
381pub fn SelectableListItem(
382    headline: impl Into<String>,
383    selected: bool,
384    supporting_text: Option<String>,
385    overline_text: Option<String>,
386    leading: Option<View>,
387    trailing: Option<View>,
388    on_click: Option<Rc<dyn Fn()>>,
389    mut config: ListItemConfig,
390) -> View {
391    config.selected = selected;
392    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
393    m = m.then(config.modifier);
394    config.modifier = m;
395    ListItem(
396        headline,
397        supporting_text,
398        overline_text,
399        leading,
400        trailing,
401        on_click,
402        None,
403        config,
404    )
405}
406
407/// M3 Toggleable List Item -> multi-selection variant with `checked` state and
408/// `Role::Checkbox` semantics. Clicking toggles the checked state.
409pub fn ToggleableListItem(
410    headline: impl Into<String>,
411    checked: bool,
412    on_checked_change: impl Fn(bool) + 'static,
413    supporting_text: Option<String>,
414    overline_text: Option<String>,
415    leading: Option<View>,
416    trailing: Option<View>,
417    config: ListItemConfig,
418) -> View {
419    let mut cfg = config.clone();
420    cfg.selected = checked;
421    let cb = Rc::new(on_checked_change);
422    let cb2 = cb.clone();
423    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
424    m = m.then(cfg.modifier);
425    cfg.modifier = m;
426    ListItem(
427        headline,
428        supporting_text,
429        overline_text,
430        leading,
431        trailing,
432        Some(Rc::new(move || (cb2)(!checked))),
433        None,
434        cfg,
435    )
436}
437
438/// Compute per-index corner radii `[BL, BR, TR, TL]` for a segmented list item.
439fn segmented_item_radii(index: usize, count: usize, r: f32) -> [f32; 4] {
440    if count <= 1 {
441        [r, r, r, r]
442    } else if index == 0 {
443        [0.0, 0.0, r, r]
444    } else if index == count - 1 {
445        [r, r, 0.0, 0.0]
446    } else {
447        [0.0, 0.0, 0.0, 0.0]
448    }
449}
450
451/// M3 Segmented List Item -> clickable variant with segmented (per-index) corner radii.
452pub fn SegmentedListItem(
453    index: usize,
454    count: usize,
455    headline: impl Into<String>,
456    supporting_text: Option<String>,
457    overline_text: Option<String>,
458    leading: Option<View>,
459    trailing: Option<View>,
460    on_click: Option<Rc<dyn Fn()>>,
461    mut config: ListItemConfig,
462) -> View {
463    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
464    ListItem(
465        headline,
466        supporting_text,
467        overline_text,
468        leading,
469        trailing,
470        on_click,
471        None,
472        config,
473    )
474}
475
476/// M3 Segmented List Item -> single-selection variant.
477pub fn SegmentedSelectableListItem(
478    index: usize,
479    count: usize,
480    headline: impl Into<String>,
481    selected: bool,
482    supporting_text: Option<String>,
483    overline_text: Option<String>,
484    leading: Option<View>,
485    trailing: Option<View>,
486    on_click: Option<Rc<dyn Fn()>>,
487    mut config: ListItemConfig,
488) -> View {
489    config.selected = selected;
490    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
491    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
492    m = m.then(config.modifier);
493    config.modifier = m;
494    ListItem(
495        headline,
496        supporting_text,
497        overline_text,
498        leading,
499        trailing,
500        on_click,
501        None,
502        config,
503    )
504}
505
506/// M3 Segmented List Item -> multi-selection (toggleable) variant.
507pub fn SegmentedToggleableListItem(
508    index: usize,
509    count: usize,
510    headline: impl Into<String>,
511    checked: bool,
512    on_checked_change: impl Fn(bool) + 'static,
513    supporting_text: Option<String>,
514    overline_text: Option<String>,
515    leading: Option<View>,
516    trailing: Option<View>,
517    config: ListItemConfig,
518) -> View {
519    let mut cfg = config.clone();
520    cfg.selected = checked;
521    cfg.shape_radii = Some(segmented_item_radii(index, count, cfg.shape_radius));
522    let cb2 = Rc::new(on_checked_change);
523    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
524    m = m.then(cfg.modifier);
525    cfg.modifier = m;
526    ListItem(
527        headline,
528        supporting_text,
529        overline_text,
530        leading,
531        trailing,
532        Some(Rc::new(move || (cb2)(!checked))),
533        None,
534        cfg,
535    )
536}