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