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    /// Renders the M3 dragged color/elevation roles (e.g. while reordering).
156    pub dragged: bool,
157    pub colors: ListItemColors,
158    pub state_colors: StateColors,
159    pub tonal_elevation: f32,
160    /// Additional elevation applied while `dragged` (M3 drag lift, e.g. Level 4).
161    pub dragged_elevation: f32,
162    pub shadow_elevation: f32,
163    pub shape_radius: f32,
164    /// Per-corner radii `[BL, BR, TR, TL]`. When set, overrides `shape_radius`.
165    pub shape_radii: Option<[f32; 4]>,
166    pub horizontal_padding: f32,
167    pub trailing_padding: f32,
168    pub one_line_height: f32,
169    pub two_line_height: f32,
170    pub three_line_height: f32,
171    pub interaction_source: Option<MutableInteractionSource>,
172}
173
174impl Default for ListItemConfig {
175    fn default() -> Self {
176        Self {
177            modifier: Modifier::new(),
178            enabled: true,
179            selected: false,
180            dragged: false,
181            colors: ListItemColors::default(),
182            state_colors: ListItemDefaults::state_colors_default(),
183            tonal_elevation: 0.0,
184            dragged_elevation: 0.0,
185            shadow_elevation: 0.0,
186            shape_radius: 0.0,
187            shape_radii: None,
188            horizontal_padding: ListItemDefaults::HORIZONTAL_PADDING,
189            trailing_padding: ListItemDefaults::TRAILING_PADDING,
190            one_line_height: ListItemDefaults::ONE_LINE_HEIGHT,
191            two_line_height: ListItemDefaults::TWO_LINE_HEIGHT,
192            three_line_height: ListItemDefaults::THREE_LINE_HEIGHT,
193            interaction_source: None,
194        }
195    }
196}
197
198static LISTITEM_COUNTER: AtomicU64 = AtomicU64::new(0);
199
200/// M3 List Item - a single row in a list with optional leading/trailing content,
201/// overline text, and click handling.
202pub fn ListItem(
203    headline: impl Into<String>,
204    supporting_text: Option<String>,
205    overline_text: Option<String>,
206    leading: Option<View>,
207    trailing: Option<View>,
208    on_click: Option<Rc<dyn Fn()>>,
209    on_long_click: Option<Rc<dyn Fn()>>,
210    config: ListItemConfig,
211) -> View {
212    let th = theme();
213    let is_enabled = config.enabled;
214    let is_selected = config.selected;
215    let is_dragged = config.dragged;
216    let c = &config.colors;
217    let id = remember(|| LISTITEM_COUNTER.fetch_add(1, Ordering::Relaxed));
218    let spec = th.motion.color;
219
220    let hd_col = animate_color(
221        format!("li_hd_{}", id),
222        c.headline(is_enabled, is_selected, is_dragged),
223        spec,
224    );
225    let sp_col = animate_color(
226        format!("li_sp_{}", id),
227        c.supporting(is_enabled, is_selected, is_dragged),
228        spec,
229    );
230    let ol_col = animate_color(
231        format!("li_ol_{}", id),
232        c.overline(is_enabled, is_selected, is_dragged),
233        spec,
234    );
235    let ld_col = animate_color(
236        format!("li_ld_{}", id),
237        c.leading_icon(is_enabled, is_selected, is_dragged),
238        spec,
239    );
240    let tr_col = animate_color(
241        format!("li_tr_{}", id),
242        c.trailing_icon(is_enabled, is_selected, is_dragged),
243        spec,
244    );
245    let bg = animate_color(
246        format!("li_bg_{}", id),
247        c.container(is_enabled, is_selected, is_dragged),
248        spec,
249    );
250
251    let line_count = match (overline_text.is_some(), supporting_text.is_some()) {
252        (true, true) => 3,
253        (true, false) | (false, true) => 2,
254        (false, false) => 1,
255    };
256    let min_h = match line_count {
257        3 => config.three_line_height,
258        2 => config.two_line_height,
259        _ => config.one_line_height,
260    };
261    let top_bottom_padding = match line_count {
262        3 => 12.0,
263        _ => 8.0,
264    };
265
266    let vert_align = if min_h >= config.three_line_height {
267        AlignItems::START
268    } else {
269        AlignItems::CENTER
270    };
271
272    let li_source: Rc<MutableInteractionSource> = config
273        .interaction_source
274        .clone()
275        .map(Rc::new)
276        .unwrap_or_else(|| remember(MutableInteractionSource::new));
277
278    let mut modifier = Modifier::new()
279        .min_width(200.0)
280        .min_height(min_h)
281        .background(bg);
282    match config.shape_radii {
283        Some(r) => modifier = modifier.clip_rounded_radii(r),
284        None => modifier = modifier.clip_rounded(config.shape_radius),
285    }
286    modifier = modifier
287        .state_colors(config.state_colors)
288        .padding_values(PaddingValues {
289            left: config.horizontal_padding,
290            right: config.trailing_padding,
291            top: top_bottom_padding,
292            bottom: top_bottom_padding,
293        })
294        .align_items(vert_align)
295        .interaction_source(&*li_source)
296        .then(config.modifier);
297
298    if config.tonal_elevation > 0.0 || config.dragged_elevation > 0.0 {
299        let dragged_elev = if config.dragged_elevation > 0.0 {
300            config.dragged_elevation
301        } else {
302            config.tonal_elevation
303        };
304        modifier = modifier.state_elevation(StateElevation {
305            default: config.tonal_elevation,
306            hovered: config.tonal_elevation,
307            pressed: config.tonal_elevation,
308            dragged: dragged_elev,
309            disabled: 0.0,
310        });
311    }
312    if config.shadow_elevation > 0.0 {
313        modifier = modifier.shadow(config.shadow_elevation, 0.0);
314    }
315
316    if on_click.is_some() || on_long_click.is_some() {
317        modifier = modifier.clickable();
318        if let Some(cb) = on_click {
319            let cb = cb.clone();
320            modifier = modifier.on_click(move || {
321                if is_enabled {
322                    cb();
323                }
324            });
325        }
326        if let Some(cb) = &on_long_click {
327            let cb = cb.clone();
328            modifier = modifier.on_long_click(move || {
329                if is_enabled {
330                    cb();
331                }
332            });
333        }
334    }
335
336    let wrap_icon = |color: Color, v: View| -> View { with_content_color(color, move || v) };
337
338    Row(modifier).child((
339        leading
340            .map(|v| {
341                Box(Modifier::new().padding_values(PaddingValues {
342                    left: 0.0,
343                    right: 16.0,
344                    top: 0.0,
345                    bottom: 0.0,
346                }))
347                .child(wrap_icon(ld_col, v))
348            })
349            .unwrap_or(Box(Modifier::new())),
350        Column(
351            Modifier::new()
352                .flex_grow(1.0)
353                .justify_content(JustifyContent::CENTER),
354        )
355        .child((
356            overline_text
357                .map(|ot| {
358                    Text(ot)
359                        .color(ol_col)
360                        .size(th.typography.label_small)
361                        .single_line()
362                })
363                .unwrap_or(Box(Modifier::new())),
364            Text(headline)
365                .color(hd_col)
366                .size(th.typography.body_large)
367                .single_line(),
368            supporting_text
369                .map(|st| {
370                    Text(st)
371                        .color(sp_col)
372                        .size(th.typography.body_medium)
373                        .max_lines(2)
374                        .overflow_ellipsize()
375                })
376                .unwrap_or(Box(Modifier::new())),
377        )),
378        trailing
379            .map(|v| {
380                Box(Modifier::new().padding_values(PaddingValues {
381                    left: 16.0,
382                    right: 0.0,
383                    top: 0.0,
384                    bottom: 0.0,
385                }))
386                .child(wrap_icon(tr_col, v))
387            })
388            .unwrap_or(Box(Modifier::new())),
389    ))
390}
391
392/// M3 Selectable List Item -> single-selection variant with `selected` state and
393/// `Role::RadioButton` semantics.
394pub fn SelectableListItem(
395    headline: impl Into<String>,
396    selected: bool,
397    supporting_text: Option<String>,
398    overline_text: Option<String>,
399    leading: Option<View>,
400    trailing: Option<View>,
401    on_click: Option<Rc<dyn Fn()>>,
402    mut config: ListItemConfig,
403) -> View {
404    config.selected = selected;
405    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
406    m = m.then(config.modifier);
407    config.modifier = m;
408    ListItem(
409        headline,
410        supporting_text,
411        overline_text,
412        leading,
413        trailing,
414        on_click,
415        None,
416        config,
417    )
418}
419
420/// M3 Toggleable List Item -> multi-selection variant with `checked` state and
421/// `Role::Checkbox` semantics. Clicking toggles the checked state.
422pub fn ToggleableListItem(
423    headline: impl Into<String>,
424    checked: bool,
425    on_checked_change: impl Fn(bool) + 'static,
426    supporting_text: Option<String>,
427    overline_text: Option<String>,
428    leading: Option<View>,
429    trailing: Option<View>,
430    config: ListItemConfig,
431) -> View {
432    let mut cfg = config.clone();
433    cfg.selected = checked;
434    let cb = Rc::new(on_checked_change);
435    let cb2 = cb.clone();
436    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
437    m = m.then(cfg.modifier);
438    cfg.modifier = m;
439    ListItem(
440        headline,
441        supporting_text,
442        overline_text,
443        leading,
444        trailing,
445        Some(Rc::new(move || (cb2)(!checked))),
446        None,
447        cfg,
448    )
449}
450
451/// Compute per-index corner radii `[BL, BR, TR, TL]` for a segmented list item.
452fn segmented_item_radii(index: usize, count: usize, r: f32) -> [f32; 4] {
453    if count <= 1 {
454        [r, r, r, r]
455    } else if index == 0 {
456        [0.0, 0.0, r, r]
457    } else if index == count - 1 {
458        [r, r, 0.0, 0.0]
459    } else {
460        [0.0, 0.0, 0.0, 0.0]
461    }
462}
463
464/// M3 Segmented List Item -> clickable variant with segmented (per-index) corner radii.
465pub fn SegmentedListItem(
466    index: usize,
467    count: usize,
468    headline: impl Into<String>,
469    supporting_text: Option<String>,
470    overline_text: Option<String>,
471    leading: Option<View>,
472    trailing: Option<View>,
473    on_click: Option<Rc<dyn Fn()>>,
474    mut config: ListItemConfig,
475) -> View {
476    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
477    ListItem(
478        headline,
479        supporting_text,
480        overline_text,
481        leading,
482        trailing,
483        on_click,
484        None,
485        config,
486    )
487}
488
489/// M3 Segmented List Item -> single-selection variant.
490pub fn SegmentedSelectableListItem(
491    index: usize,
492    count: usize,
493    headline: impl Into<String>,
494    selected: bool,
495    supporting_text: Option<String>,
496    overline_text: Option<String>,
497    leading: Option<View>,
498    trailing: Option<View>,
499    on_click: Option<Rc<dyn Fn()>>,
500    mut config: ListItemConfig,
501) -> View {
502    config.selected = selected;
503    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
504    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
505    m = m.then(config.modifier);
506    config.modifier = m;
507    ListItem(
508        headline,
509        supporting_text,
510        overline_text,
511        leading,
512        trailing,
513        on_click,
514        None,
515        config,
516    )
517}
518
519/// M3 Segmented List Item -> multi-selection (toggleable) variant.
520pub fn SegmentedToggleableListItem(
521    index: usize,
522    count: usize,
523    headline: impl Into<String>,
524    checked: bool,
525    on_checked_change: impl Fn(bool) + 'static,
526    supporting_text: Option<String>,
527    overline_text: Option<String>,
528    leading: Option<View>,
529    trailing: Option<View>,
530    config: ListItemConfig,
531) -> View {
532    let mut cfg = config.clone();
533    cfg.selected = checked;
534    cfg.shape_radii = Some(segmented_item_radii(index, count, cfg.shape_radius));
535    let cb2 = Rc::new(on_checked_change);
536    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
537    m = m.then(cfg.modifier);
538    cfg.modifier = m;
539    ListItem(
540        headline,
541        supporting_text,
542        overline_text,
543        leading,
544        trailing,
545        Some(Rc::new(move || (cb2)(!checked))),
546        None,
547        cfg,
548    )
549}