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