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