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        .indication(crate::ripple::ripple(crate::ripple::RippleConfig {
293            color: Some(theme().on_surface),
294            bounded: true,
295            ..Default::default()
296        }))
297        .then(config.modifier);
298
299    if config.tonal_elevation > 0.0 || config.dragged_elevation > 0.0 {
300        let dragged_elev = if config.dragged_elevation > 0.0 {
301            config.dragged_elevation
302        } else {
303            config.tonal_elevation
304        };
305        modifier = modifier.state_elevation(StateElevation {
306            default: config.tonal_elevation,
307            hovered: config.tonal_elevation,
308            focused: config.tonal_elevation,
309            pressed: config.tonal_elevation,
310            dragged: dragged_elev,
311            disabled: 0.0,
312        });
313    }
314    if config.shadow_elevation > 0.0 {
315        modifier = modifier.shadow(config.shadow_elevation, 0.0);
316    }
317
318    if on_click.is_some() || on_long_click.is_some() {
319        modifier = modifier.clickable();
320        if let Some(cb) = on_click {
321            let cb = cb.clone();
322            modifier = modifier.on_click(move || {
323                if is_enabled {
324                    cb();
325                }
326            });
327        }
328        if let Some(cb) = &on_long_click {
329            let cb = cb.clone();
330            modifier = modifier.on_long_click(move || {
331                if is_enabled {
332                    cb();
333                }
334            });
335        }
336    }
337
338    let wrap_icon = |color: Color, v: View| -> View { with_content_color(color, move || v) };
339
340    Row(modifier).child((
341        leading
342            .map(|v| {
343                Box(Modifier::new().padding_values(PaddingValues {
344                    left: 0.0,
345                    right: 16.0,
346                    top: 0.0,
347                    bottom: 0.0,
348                }))
349                .child(wrap_icon(ld_col, v))
350            })
351            .unwrap_or(Box(Modifier::new())),
352        Column(
353            Modifier::new()
354                .flex_grow(1.0)
355                .justify_content(JustifyContent::CENTER),
356        )
357        .child((
358            overline_text
359                .map(|ot| {
360                    Text(ot)
361                        .color(ol_col)
362                        .size(th.typography.label_small)
363                        .single_line()
364                })
365                .unwrap_or(Box(Modifier::new())),
366            Text(headline)
367                .color(hd_col)
368                .size(th.typography.body_large)
369                .single_line(),
370            supporting_text
371                .map(|st| {
372                    Text(st)
373                        .color(sp_col)
374                        .size(th.typography.body_medium)
375                        .max_lines(2)
376                        .overflow_ellipsize()
377                })
378                .unwrap_or(Box(Modifier::new())),
379        )),
380        trailing
381            .map(|v| {
382                Box(Modifier::new().padding_values(PaddingValues {
383                    left: 16.0,
384                    right: 0.0,
385                    top: 0.0,
386                    bottom: 0.0,
387                }))
388                .child(wrap_icon(tr_col, v))
389            })
390            .unwrap_or(Box(Modifier::new())),
391    ))
392}
393
394/// M3 Selectable List Item -> single-selection variant with `selected` state and
395/// `Role::RadioButton` semantics.
396pub fn SelectableListItem(
397    headline: impl Into<String>,
398    selected: bool,
399    supporting_text: Option<String>,
400    overline_text: Option<String>,
401    leading: Option<View>,
402    trailing: Option<View>,
403    on_click: Option<Rc<dyn Fn()>>,
404    mut config: ListItemConfig,
405) -> View {
406    config.selected = selected;
407    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
408    m = m.then(config.modifier);
409    config.modifier = m;
410    ListItem(
411        headline,
412        supporting_text,
413        overline_text,
414        leading,
415        trailing,
416        on_click,
417        None,
418        config,
419    )
420}
421
422/// M3 Toggleable List Item -> multi-selection variant with `checked` state and
423/// `Role::Checkbox` semantics. Clicking toggles the checked state.
424pub fn ToggleableListItem(
425    headline: impl Into<String>,
426    checked: bool,
427    on_checked_change: impl Fn(bool) + 'static,
428    supporting_text: Option<String>,
429    overline_text: Option<String>,
430    leading: Option<View>,
431    trailing: Option<View>,
432    config: ListItemConfig,
433) -> View {
434    let mut cfg = config.clone();
435    cfg.selected = checked;
436    let cb = Rc::new(on_checked_change);
437    let cb2 = cb.clone();
438    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
439    m = m.then(cfg.modifier);
440    cfg.modifier = m;
441    ListItem(
442        headline,
443        supporting_text,
444        overline_text,
445        leading,
446        trailing,
447        Some(Rc::new(move || (cb2)(!checked))),
448        None,
449        cfg,
450    )
451}
452
453/// Compute per-index corner radii `[BL, BR, TR, TL]` for a segmented list item.
454fn segmented_item_radii(index: usize, count: usize, r: f32) -> [f32; 4] {
455    if count <= 1 {
456        [r, r, r, r]
457    } else if index == 0 {
458        [0.0, 0.0, r, r]
459    } else if index == count - 1 {
460        [r, r, 0.0, 0.0]
461    } else {
462        [0.0, 0.0, 0.0, 0.0]
463    }
464}
465
466/// M3 Segmented List Item -> clickable variant with segmented (per-index) corner radii.
467pub fn SegmentedListItem(
468    index: usize,
469    count: usize,
470    headline: impl Into<String>,
471    supporting_text: Option<String>,
472    overline_text: Option<String>,
473    leading: Option<View>,
474    trailing: Option<View>,
475    on_click: Option<Rc<dyn Fn()>>,
476    mut config: ListItemConfig,
477) -> View {
478    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
479    ListItem(
480        headline,
481        supporting_text,
482        overline_text,
483        leading,
484        trailing,
485        on_click,
486        None,
487        config,
488    )
489}
490
491/// M3 Segmented List Item -> single-selection variant.
492pub fn SegmentedSelectableListItem(
493    index: usize,
494    count: usize,
495    headline: impl Into<String>,
496    selected: bool,
497    supporting_text: Option<String>,
498    overline_text: Option<String>,
499    leading: Option<View>,
500    trailing: Option<View>,
501    on_click: Option<Rc<dyn Fn()>>,
502    mut config: ListItemConfig,
503) -> View {
504    config.selected = selected;
505    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
506    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
507    m = m.then(config.modifier);
508    config.modifier = m;
509    ListItem(
510        headline,
511        supporting_text,
512        overline_text,
513        leading,
514        trailing,
515        on_click,
516        None,
517        config,
518    )
519}
520
521/// M3 Segmented List Item -> multi-selection (toggleable) variant.
522pub fn SegmentedToggleableListItem(
523    index: usize,
524    count: usize,
525    headline: impl Into<String>,
526    checked: bool,
527    on_checked_change: impl Fn(bool) + 'static,
528    supporting_text: Option<String>,
529    overline_text: Option<String>,
530    leading: Option<View>,
531    trailing: Option<View>,
532    config: ListItemConfig,
533) -> View {
534    let mut cfg = config.clone();
535    cfg.selected = checked;
536    cfg.shape_radii = Some(segmented_item_radii(index, count, cfg.shape_radius));
537    let cb2 = Rc::new(on_checked_change);
538    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
539    m = m.then(cfg.modifier);
540    cfg.modifier = m;
541    ListItem(
542        headline,
543        supporting_text,
544        overline_text,
545        leading,
546        trailing,
547        Some(Rc::new(move || (cb2)(!checked))),
548        None,
549        cfg,
550    )
551}