Skip to main content

frus_widgets/
expansionpanel.rs

1//! [`ExpansionPanelList`]: a column of expansion panels drawn as **one surface** that
2//! splits as a panel opens.
3//!
4//! [`ExpansionTile`](crate::ExpansionTile) opens one row on its own, and a column of them
5//! is already an accordion as far as the state goes — each asks to change and the
6//! application answers, which is what its own documentation says lets a column of them
7//! behave as one. What a column of them is not is **one object**: it is a stack of
8//! independent rows, each with its own corners, and nothing about it says the shut ones
9//! belong together.
10//!
11//! That is what this adds, and it is the reference's `MergeableMaterial` rather than its
12//! `ExpansionPanelList`: adjacent shut panels are **one card**, divided by a hairline,
13//! and an open one is lifted out of the run with a gap either side and corners of its
14//! own. A settings screen, an FAQ, a checkout with steps.
15//!
16//! ```ignore
17//! ExpansionPanelList::radio(app.open, Msg::OpenPanel)
18//!     .panel(ExpansionPanel::new("Delivery", Text::new("Two parcels.")))
19//!     .panel(ExpansionPanel::new("Payment", Text::new("Visa ••4242.")))
20//! ```
21//!
22//! # Two modes, two messages
23//!
24//! The state is the application's, as everywhere here, and the two modes want two
25//! different states — so they say two different things rather than one thing twice:
26//!
27//! - [`new`](ExpansionPanelList::new) is the free one. Any number open, `on_toggle(index,
28//!   now_open)`, and the application keeps a set.
29//! - [`radio`](ExpansionPanelList::radio) is the exclusive one. It takes an
30//!   `Option<usize>` and its message **carries the resulting state**, not the index that
31//!   was pressed: opening the third sends `Some(2)` while the second was open, and
32//!   pressing the open one sends `None`. So *only one at a time* is a property of what the
33//!   widget sends, which an application cannot get wrong by writing its reducer in the
34//!   obvious way.
35//!
36//! # What is not animated
37//!
38//! The split is **not tweened**. A panel's body appears in one frame, as `ExpansionTile`'s
39//! always has, and the gap and the corners follow it in the same frame — so the surface is
40//! never wrong, but it does not grow into place the way the reference's does. The tween
41//! belongs to `ExpansionTile` first: a list that animated its gaps around a body that
42//! snapped would look worse than one that does neither.
43
44use std::cell::{OnceCell, RefCell};
45use std::rc::Rc;
46
47use frus_core::{BorderRadius, Color, Rect, Scene};
48use frus_layout::{FlexDirection, Style};
49
50use crate::interaction::Status;
51use crate::theme::Theme;
52use crate::widget::Widget;
53
54/// The gap an open panel is given either side of it, and the radius its card takes.
55/// The reference's `kMaterialGap` and a card's corner.
56const GAP: f32 = 16.0;
57const RADIUS: f32 = 12.0;
58/// How far the cards sit off the page: the reference's 2 (its expansion panel list, line
59/// 195), cast under each card as its mergeable material casts one under each run of panels
60/// (lines 678–699).
61const ELEVATION: f32 = 2.0;
62
63/// One panel of an [`ExpansionPanelList`]: a header and what it hides.
64pub struct ExpansionPanel<Msg = crate::callback::Callback> {
65    title: Option<String>,
66    header: RefCell<Option<Box<dyn Widget<Msg>>>>,
67    subtitle: Option<String>,
68    body: RefCell<Option<Box<dyn Widget<Msg>>>>,
69}
70
71impl<Msg> ExpansionPanel<Msg> {
72    /// A panel with a title, hiding `body`.
73    pub fn new(title: impl Into<String>, body: impl Widget<Msg> + 'static) -> Self {
74        Self {
75            title: Some(title.into()),
76            header: RefCell::new(None),
77            subtitle: None,
78            body: RefCell::new(Some(Box::new(body))),
79        }
80    }
81
82    /// A panel whose header is a **widget** — the reference's `headerBuilder`, for a row
83    /// of chips, a rich span, a badge beside a name.
84    ///
85    /// The chevron and the tap target stay the list's: what this replaces is the line of
86    /// text, not the row it sits in.
87    pub fn with_header(header: impl Widget<Msg> + 'static, body: impl Widget<Msg> + 'static) -> Self
88    where
89        Msg: 'static,
90    {
91        Self {
92            title: None,
93            header: RefCell::new(Some(Box::new(header))),
94            subtitle: None,
95            body: RefCell::new(Some(Box::new(body))),
96        }
97    }
98
99    /// A second line under the title.
100    #[must_use]
101    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
102        self.subtitle = Some(subtitle.into());
103        self
104    }
105}
106
107/// How a list decides what is open, and what it says when that should change.
108enum Mode<Msg> {
109    /// Any number open at once: the indices, and `(index, now_open)`.
110    Free(Vec<usize>, Rc<dyn Fn(usize, bool) -> Msg>),
111    /// One at a time: which, and the **resulting** choice.
112    Radio(Option<usize>, Rc<dyn Fn(Option<usize>) -> Msg>),
113}
114
115/// A column of [`ExpansionPanel`]s drawn as **one surface** that splits as a panel opens.
116///
117/// A column of [`ExpansionTile`](crate::ExpansionTile)s is already an accordion as far as
118/// the state goes. What it is not is one *object*: it is a stack of independent rows, and
119/// nothing about it says the shut ones belong together. Here, adjacent shut panels are one
120/// card divided by a hairline, and an open one is lifted out of the run with a gap either
121/// side and corners of its own — the reference's `MergeableMaterial`.
122///
123/// Two modes, and they say two different things because they want two different states:
124/// [`new`](Self::new) is the free one (`on_toggle(index, now_open)`, any number open), and
125/// [`radio`](Self::radio) is the exclusive one, whose message carries the **resulting**
126/// choice rather than the index pressed — so *one at a time* is a property of what the
127/// widget sends rather than a rule the application has to remember.
128///
129/// **The split is not tweened.** A panel's body appears in one frame, as `ExpansionTile`'s
130/// always has, and the gap and the corners follow it in the same frame; what is missing
131/// against the reference is the growing, and it belongs to `ExpansionTile` first.
132pub struct ExpansionPanelList<Msg = crate::callback::Callback> {
133    panels: Vec<ExpansionPanel<Msg>>,
134    mode: Mode<Msg>,
135    gap: Option<f32>,
136    radius: Option<f32>,
137    background: Option<Color>,
138    divider_color: Option<Color>,
139    elevation: Option<f32>,
140    /// Assembled on the way down, under the theme the list actually sits in — as
141    /// [`ExpansionTile`](crate::ExpansionTile) assembles its row.
142    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
143}
144
145impl<Msg: Clone + 'static> ExpansionPanelList<Msg> {
146    /// A list where **any number** of panels can be open: the ones that are, and
147    /// `on_toggle(index, now_open)` when a header is pressed.
148    pub fn new(open: &[usize], on_toggle: impl Fn(usize, bool) -> Msg + 'static) -> Self {
149        Self::with_mode(Mode::Free(open.to_vec(), Rc::new(on_toggle)))
150    }
151
152    /// A list where **one** panel is open at a time: which one, and `on_open(next)` when a
153    /// header is pressed — carrying the resulting choice rather than the index pressed,
154    /// so an application that stores what it is handed cannot end up with two open.
155    ///
156    /// Pressing the open one sends `None`, which shuts it. The reference's radio list
157    /// cannot be shut at all; this one can, because a panel a reader has opened and read
158    /// is a panel they may want out of the way, and there is no other control for it.
159    pub fn radio(open: Option<usize>, on_open: impl Fn(Option<usize>) -> Msg + 'static) -> Self {
160        Self::with_mode(Mode::Radio(open, Rc::new(on_open)))
161    }
162
163    fn with_mode(mode: Mode<Msg>) -> Self {
164        Self {
165            panels: Vec::new(),
166            mode,
167            gap: None,
168            radius: None,
169            background: None,
170            divider_color: None,
171            elevation: None,
172            built: OnceCell::new(),
173        }
174    }
175
176    /// Adds a panel, after the ones already there.
177    pub fn panel(mut self, panel: ExpansionPanel<Msg>) -> Self {
178        self.panels.push(panel);
179        self
180    }
181
182    /// The room an open panel is given either side of it — what makes it read as lifted
183    /// out of the card rather than merely coloured differently. 16 by default.
184    #[must_use]
185    pub fn gap(mut self, gap: f32) -> Self {
186        self.gap = Some(gap.max(0.0));
187        self
188    }
189
190    /// The corner radius of a card — the run's outer corners, and all four of an open
191    /// panel's.
192    #[must_use]
193    pub fn radius(mut self, radius: f32) -> Self {
194        self.radius = Some(radius.max(0.0));
195        self
196    }
197
198    /// The surface the panels are drawn on.
199    #[must_use]
200    pub fn background(mut self, color: Color) -> Self {
201        self.background = Some(color);
202        self
203    }
204
205    /// The hairline between two shut panels of the same card.
206    #[must_use]
207    pub fn divider_color(mut self, color: Color) -> Self {
208        self.divider_color = Some(color);
209        self
210    }
211
212    /// How far the cards sit off the page: each run of shut panels, and each open panel,
213    /// casts one shadow. 2 by default; `0.0` is flat.
214    #[must_use]
215    pub fn elevation(mut self, elevation: f32) -> Self {
216        self.elevation = Some(elevation.max(0.0));
217        self
218    }
219
220    /// Whether panel `index` is showing.
221    fn is_open(&self, index: usize) -> bool {
222        match &self.mode {
223            Mode::Free(open, _) => open.contains(&index),
224            Mode::Radio(open, _) => *open == Some(index),
225        }
226    }
227
228    /// What pressing panel `index`'s header asks for.
229    fn message(&self, index: usize) -> Msg {
230        let open = self.is_open(index);
231        match &self.mode {
232            Mode::Free(_, toggle) => toggle(index, !open),
233            Mode::Radio(_, choose) => choose(match open {
234                true => None,
235                false => Some(index),
236            }),
237        }
238    }
239
240    /// **Where the card splits.** There is a gap between two panels when **either** of
241    /// them is open: an open panel is a card of its own, so it parts from both its
242    /// neighbours. None at the ends, where there is nothing to part from.
243    fn gap_before(&self, index: usize) -> bool {
244        index > 0 && (self.is_open(index) || self.is_open(index - 1))
245    }
246
247    fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
248        let t = &theme.widgets.expansion_panel_list;
249        let gap = self.gap.or(t.gap).unwrap_or(GAP);
250        let radius = self.radius.or(t.radius).unwrap_or(RADIUS);
251        let background = self
252            .background
253            .or(t.background)
254            .unwrap_or(theme.scheme.surface_container_low);
255        let divider = self
256            .divider_color
257            .or(t.divider_color)
258            .unwrap_or(theme.scheme.outline_variant);
259        let depth = self.elevation.or(t.elevation).unwrap_or(ELEVATION);
260
261        // **One shadow a card, not one a panel.** Two shut panels are one card, and a shadow
262        // under each would lay the lower one's across the upper one's surface. So a run's
263        // panels are gathered into a column first and the run casts, which is what the
264        // reference's mergeable material does: a shadow under each slice, all of them before
265        // any slice is painted.
266        let card = |run: crate::Flex<Msg>| CardShadow {
267            children: vec![Box::new(run) as Box<dyn Widget<Msg>>],
268            depth,
269            radius,
270        };
271        let mut column = crate::Flex::column();
272        let mut run: Option<crate::Flex<Msg>> = None;
273        for (index, panel) in self.panels.iter().enumerate() {
274            let open = self.is_open(index);
275            let split_above = self.gap_before(index);
276            let split_below = index + 1 == self.panels.len() || self.gap_before(index + 1);
277            if split_above {
278                if let Some(done) = run.take() {
279                    column = column.child(card(done));
280                }
281                column = column.child(crate::Container::<Msg>::new().height(gap));
282            }
283            let mut current = run.take().unwrap_or_else(crate::Flex::column);
284            if index > 0 && !split_above {
285                // Two shut panels of one card: a hairline, which is what says they are
286                // separate rows of the same thing rather than one long row.
287                current = current.child(crate::Divider::new().color(divider));
288            }
289
290            // The **head** of a run rounds its top, the **foot** rounds its bottom, and a
291            // panel that is both — an open one, or an only one — is a whole card.
292            let corners = BorderRadius {
293                top_left: if split_above || index == 0 {
294                    radius
295                } else {
296                    0.0
297                },
298                top_right: if split_above || index == 0 {
299                    radius
300                } else {
301                    0.0
302                },
303                bottom_right: if split_below { radius } else { 0.0 },
304                bottom_left: if split_below { radius } else { 0.0 },
305            };
306
307            let mut tile = crate::ExpansionTile::new(
308                panel.title.clone().unwrap_or_default(),
309                open,
310                self.message(index),
311            );
312            if let Some(header) = panel.header.borrow_mut().take() {
313                tile = tile.title_child_boxed(header);
314            }
315            if let Some(subtitle) = &panel.subtitle {
316                tile = tile.subtitle(subtitle.clone());
317            }
318            if let Some(body) = panel.body.borrow_mut().take() {
319                tile = tile.content(crate::ConstrainedBox::new_boxed(body));
320            }
321            current = current.child(
322                crate::Container::new()
323                    .color(background)
324                    .radius(corners)
325                    .clip()
326                    .child(tile),
327            );
328            run = Some(current);
329        }
330        if let Some(done) = run {
331            column = column.child(card(done));
332        }
333        vec![Box::new(column) as Box<dyn Widget<Msg>>]
334    }
335}
336
337/// A card of the list — a run of shut panels, or an open one — and the shadow it casts.
338///
339/// **Not clipped**, which is why it is a node of its own: the panels inside clip to their
340/// corners, and a clip takes the clipping widget's own paint with it, so a shadow drawn by
341/// a clipped container is cut away at that container's edge.
342struct CardShadow<Msg> {
343    children: Vec<Box<dyn Widget<Msg>>>,
344    depth: f32,
345    radius: f32,
346}
347
348impl<Msg: Clone + 'static> Widget<Msg> for CardShadow<Msg> {
349    fn style(&self) -> Style {
350        Style {
351            flex_direction: FlexDirection::Column,
352            ..Default::default()
353        }
354    }
355
356    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
357        &self.children
358    }
359
360    /// Drawn the way `Card` draws a height: the blur grows with the depth and the drop is
361    /// half of it.
362    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
363        if self.depth <= 0.0 {
364            return;
365        }
366        // The reference's shadows for this height (milestone 606).
367        frus_core::paint_elevation(
368            scene,
369            bounds,
370            BorderRadius::uniform(self.radius),
371            self.depth,
372            theme.scheme.shadow.fade(status.opacity),
373        );
374    }
375
376    fn on_click(&self) -> Option<Msg> {
377        None
378    }
379
380    fn debug_name(&self) -> &'static str {
381        "ExpansionPanelCard"
382    }
383}
384
385impl<Msg: Clone + 'static> Widget<Msg> for ExpansionPanelList<Msg> {
386    fn style(&self) -> Style {
387        Style {
388            flex_direction: FlexDirection::Column,
389            ..Default::default()
390        }
391    }
392
393    fn build_themed(&self, theme: &Theme) {
394        self.built.get_or_init(|| self.assemble(theme));
395    }
396
397    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
398        self.built.get().map(|v| &v[..]).unwrap_or(&[])
399    }
400
401    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
402
403    fn on_click(&self) -> Option<Msg> {
404        None
405    }
406
407    fn debug_name(&self) -> &'static str {
408        "ExpansionPanelList"
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::{build_ui, Runtime, Text};
416    use frus_core::{Primitive, Size};
417
418    #[derive(Clone, Debug, PartialEq)]
419    enum Msg {
420        Toggle(usize, bool),
421        Open(Option<usize>),
422    }
423
424    fn three(list: ExpansionPanelList<Msg>) -> ExpansionPanelList<Msg> {
425        list.panel(ExpansionPanel::new("One", Text::new("body one")))
426            .panel(ExpansionPanel::new("Two", Text::new("body two")))
427            .panel(ExpansionPanel::new("Three", Text::new("body three")))
428    }
429
430    fn ui(list: &ExpansionPanelList<Msg>) -> crate::Ui<Msg> {
431        build_ui(
432            list,
433            Size::new(320.0, 400.0),
434            &Runtime::default(),
435            &Theme::default(),
436        )
437    }
438
439    /// Every primitive drawn, layers unwrapped — each panel is a clipped card, so
440    /// everything a reader sees is one level down.
441    fn flat(ui: &crate::Ui<Msg>) -> Vec<Primitive> {
442        fn walk(from: &[Primitive], out: &mut Vec<Primitive>) {
443            for p in from {
444                match p {
445                    Primitive::Layer { primitives, .. } => walk(primitives, out),
446                    other => out.push(other.clone()),
447                }
448            }
449        }
450        let mut out = Vec::new();
451        walk(ui.scene().primitives(), &mut out);
452        out
453    }
454
455    /// The words drawn, in paint order.
456    fn texts(ui: &crate::Ui<Msg>) -> Vec<String> {
457        flat(ui)
458            .iter()
459            .filter_map(|p| match p {
460                Primitive::Text { text, .. } => Some(text.clone()),
461                _ => None,
462            })
463            .collect()
464    }
465
466    /// Presses panel `index`'s header and returns what it asked for.
467    fn press(list: &ExpansionPanelList<Msg>, title: &str) -> Msg {
468        let ui = ui(list);
469        let row = flat(&ui)
470            .iter()
471            .find_map(|p| match p {
472                Primitive::Text { text, position, .. } if text == title => Some(*position),
473                _ => None,
474            })
475            .expect("the header is drawn");
476        ui.hit(frus_core::Point::new(row.x + 4.0, row.y + 4.0))
477            .and_then(|id| ui.msg_for(id))
478            .expect("the header answers a press")
479    }
480
481    /// **Opening the third closes the second**, in the exclusive mode — and it is the
482    /// message that says so, not a rule the application has to remember. Driven end to
483    /// end: press, apply what came back the obvious way, and look at what is drawn.
484    #[test]
485    fn in_radio_mode_opening_one_shuts_the_other() {
486        let mut open = Some(1);
487        let list = three(ExpansionPanelList::radio(open, Msg::Open));
488        assert!(texts(&ui(&list)).contains(&"body two".to_string()));
489
490        let asked = press(&list, "Three");
491        assert_eq!(
492            asked,
493            Msg::Open(Some(2)),
494            "the resulting choice, not the index"
495        );
496        let Msg::Open(next) = asked else {
497            unreachable!()
498        };
499        open = next;
500
501        let after = three(ExpansionPanelList::radio(open, Msg::Open));
502        let drawn = texts(&ui(&after));
503        assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
504        assert!(
505            !drawn.contains(&"body two".to_string()),
506            "the second shut itself: {drawn:?}"
507        );
508    }
509
510    /// **And leaves it open in the free mode**, where the two panels have nothing to do
511    /// with each other.
512    #[test]
513    fn in_free_mode_opening_one_leaves_the_other() {
514        let mut open = vec![1];
515        let list = three(ExpansionPanelList::new(&open, Msg::Toggle));
516        let asked = press(&list, "Three");
517        assert_eq!(asked, Msg::Toggle(2, true));
518        let Msg::Toggle(index, now) = asked else {
519            unreachable!()
520        };
521        if now {
522            open.push(index);
523        }
524
525        let after = three(ExpansionPanelList::new(&open, Msg::Toggle));
526        let drawn = texts(&ui(&after));
527        assert!(drawn.contains(&"body two".to_string()), "{drawn:?}");
528        assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
529    }
530
531    /// **Pressing the open one shuts it.** The reference's radio list cannot be shut at
532    /// all; a panel a reader has read is one they may want out of the way.
533    #[test]
534    fn pressing_the_open_panel_shuts_it() {
535        let list = three(ExpansionPanelList::radio(Some(1), Msg::Open));
536        assert_eq!(press(&list, "Two"), Msg::Open(None));
537    }
538
539    /// **Shut panels are one card and an open one is lifted out of it.** The gap is the
540    /// whole of what makes the set read as one object that splits, rather than as three
541    /// rows that happen to be stacked.
542    #[test]
543    fn the_card_splits_only_where_a_panel_is_open() {
544        let shut = three(ExpansionPanelList::new(&[], Msg::Toggle));
545        assert!(!shut.gap_before(1) && !shut.gap_before(2), "one card");
546
547        let second = three(ExpansionPanelList::radio(Some(1), Msg::Open));
548        assert!(second.gap_before(1), "parted from the one above it");
549        assert!(second.gap_before(2), "and from the one below");
550
551        let first = three(ExpansionPanelList::radio(Some(0), Msg::Open));
552        assert!(
553            first.gap_before(1),
554            "the open first panel parts from the second"
555        );
556        assert!(!first.gap_before(2), "and the two shut ones stay one card");
557    }
558
559    /// The gap is real room on the screen, not only a number: the second panel's card
560    /// starts lower once the first is open.
561    #[test]
562    fn the_gap_is_room_on_the_screen() {
563        let card_tops = |list: &ExpansionPanelList<Msg>| -> Vec<f32> {
564            let ui = ui(list);
565            let mut tops: Vec<f32> = flat(&ui)
566                .iter()
567                .filter_map(|p| match p {
568                    // Crisp ones only: a card's shadow is rounded too, and sits above it.
569                    Primitive::Rect {
570                        rect, radius, blur, ..
571                    } if radius.top_left > 0.0 && *blur == 0.0 => Some(rect.y),
572                    _ => None,
573                })
574                .collect();
575            tops.dedup();
576            tops
577        };
578        let shut = card_tops(&three(ExpansionPanelList::new(&[], Msg::Toggle)));
579        let open = card_tops(&three(ExpansionPanelList::radio(Some(0), Msg::Open)));
580        assert!(
581            open.len() > shut.len(),
582            "an open panel makes a second card: {shut:?} against {open:?}"
583        );
584    }
585
586    /// **Each card casts one shadow, and a card is a run.** The reference's list stands at
587    /// 2, and its mergeable material casts a shadow under each run of panels rather than
588    /// under each panel. Three shut panels are one card and one shadow; with the middle one
589    /// open they are three cards and three. None was cast here.
590    #[test]
591    fn each_card_casts_one_shadow() {
592        let shadows = |list: &ExpansionPanelList<Msg>, theme: &Theme| {
593            let ui = build_ui(list, Size::new(320.0, 400.0), &Runtime::default(), theme);
594            crate::shadowprobe::heights(&flat(&ui))
595        };
596        let theme = Theme::default();
597        assert_eq!(
598            shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &theme),
599            vec![2.0],
600            "one card, one shadow"
601        );
602        assert_eq!(
603            shadows(
604                &three(ExpansionPanelList::radio(Some(1), Msg::Open)),
605                &theme
606            ),
607            vec![2.0; 3],
608            "three cards, three"
609        );
610        assert!(shadows(
611            &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(0.0)),
612            &theme
613        )
614        .is_empty());
615
616        let mut level = Theme::default();
617        level.widgets.expansion_panel_list.elevation = Some(0.0);
618        assert!(
619            shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &level).is_empty(),
620            "the theme's word"
621        );
622        assert_eq!(
623            shadows(
624                &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(4.0)),
625                &level
626            ),
627            vec![4.0],
628            "and the list's over it"
629        );
630    }
631}