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        let blur = self.depth * 4.0 + 8.0;
367        scene.shadow(
368            Rect::new(
369                bounds.x - blur,
370                bounds.y + self.depth * 2.0 - blur,
371                bounds.width + 2.0 * blur,
372                bounds.height + 2.0 * blur,
373            ),
374            theme.scheme.shadow.with_alpha(0.30).fade(status.opacity),
375            BorderRadius::uniform(self.radius).inflate(blur),
376            blur,
377        );
378    }
379
380    fn on_click(&self) -> Option<Msg> {
381        None
382    }
383
384    fn debug_name(&self) -> &'static str {
385        "ExpansionPanelCard"
386    }
387}
388
389impl<Msg: Clone + 'static> Widget<Msg> for ExpansionPanelList<Msg> {
390    fn style(&self) -> Style {
391        Style {
392            flex_direction: FlexDirection::Column,
393            ..Default::default()
394        }
395    }
396
397    fn build_themed(&self, theme: &Theme) {
398        self.built.get_or_init(|| self.assemble(theme));
399    }
400
401    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
402        self.built.get().map(|v| &v[..]).unwrap_or(&[])
403    }
404
405    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
406
407    fn on_click(&self) -> Option<Msg> {
408        None
409    }
410
411    fn debug_name(&self) -> &'static str {
412        "ExpansionPanelList"
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::{build_ui, Runtime, Text};
420    use frus_core::{Primitive, Size};
421
422    #[derive(Clone, Debug, PartialEq)]
423    enum Msg {
424        Toggle(usize, bool),
425        Open(Option<usize>),
426    }
427
428    fn three(list: ExpansionPanelList<Msg>) -> ExpansionPanelList<Msg> {
429        list.panel(ExpansionPanel::new("One", Text::new("body one")))
430            .panel(ExpansionPanel::new("Two", Text::new("body two")))
431            .panel(ExpansionPanel::new("Three", Text::new("body three")))
432    }
433
434    fn ui(list: &ExpansionPanelList<Msg>) -> crate::Ui<Msg> {
435        build_ui(
436            list,
437            Size::new(320.0, 400.0),
438            &Runtime::default(),
439            &Theme::default(),
440        )
441    }
442
443    /// Every primitive drawn, layers unwrapped — each panel is a clipped card, so
444    /// everything a reader sees is one level down.
445    fn flat(ui: &crate::Ui<Msg>) -> Vec<Primitive> {
446        fn walk(from: &[Primitive], out: &mut Vec<Primitive>) {
447            for p in from {
448                match p {
449                    Primitive::Layer { primitives, .. } => walk(primitives, out),
450                    other => out.push(other.clone()),
451                }
452            }
453        }
454        let mut out = Vec::new();
455        walk(ui.scene().primitives(), &mut out);
456        out
457    }
458
459    /// The words drawn, in paint order.
460    fn texts(ui: &crate::Ui<Msg>) -> Vec<String> {
461        flat(ui)
462            .iter()
463            .filter_map(|p| match p {
464                Primitive::Text { text, .. } => Some(text.clone()),
465                _ => None,
466            })
467            .collect()
468    }
469
470    /// Presses panel `index`'s header and returns what it asked for.
471    fn press(list: &ExpansionPanelList<Msg>, title: &str) -> Msg {
472        let ui = ui(list);
473        let row = flat(&ui)
474            .iter()
475            .find_map(|p| match p {
476                Primitive::Text { text, position, .. } if text == title => Some(*position),
477                _ => None,
478            })
479            .expect("the header is drawn");
480        ui.hit(frus_core::Point::new(row.x + 4.0, row.y + 4.0))
481            .and_then(|id| ui.msg_for(id))
482            .expect("the header answers a press")
483    }
484
485    /// **Opening the third closes the second**, in the exclusive mode — and it is the
486    /// message that says so, not a rule the application has to remember. Driven end to
487    /// end: press, apply what came back the obvious way, and look at what is drawn.
488    #[test]
489    fn in_radio_mode_opening_one_shuts_the_other() {
490        let mut open = Some(1);
491        let list = three(ExpansionPanelList::radio(open, Msg::Open));
492        assert!(texts(&ui(&list)).contains(&"body two".to_string()));
493
494        let asked = press(&list, "Three");
495        assert_eq!(
496            asked,
497            Msg::Open(Some(2)),
498            "the resulting choice, not the index"
499        );
500        let Msg::Open(next) = asked else {
501            unreachable!()
502        };
503        open = next;
504
505        let after = three(ExpansionPanelList::radio(open, Msg::Open));
506        let drawn = texts(&ui(&after));
507        assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
508        assert!(
509            !drawn.contains(&"body two".to_string()),
510            "the second shut itself: {drawn:?}"
511        );
512    }
513
514    /// **And leaves it open in the free mode**, where the two panels have nothing to do
515    /// with each other.
516    #[test]
517    fn in_free_mode_opening_one_leaves_the_other() {
518        let mut open = vec![1];
519        let list = three(ExpansionPanelList::new(&open, Msg::Toggle));
520        let asked = press(&list, "Three");
521        assert_eq!(asked, Msg::Toggle(2, true));
522        let Msg::Toggle(index, now) = asked else {
523            unreachable!()
524        };
525        if now {
526            open.push(index);
527        }
528
529        let after = three(ExpansionPanelList::new(&open, Msg::Toggle));
530        let drawn = texts(&ui(&after));
531        assert!(drawn.contains(&"body two".to_string()), "{drawn:?}");
532        assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
533    }
534
535    /// **Pressing the open one shuts it.** The reference's radio list cannot be shut at
536    /// all; a panel a reader has read is one they may want out of the way.
537    #[test]
538    fn pressing_the_open_panel_shuts_it() {
539        let list = three(ExpansionPanelList::radio(Some(1), Msg::Open));
540        assert_eq!(press(&list, "Two"), Msg::Open(None));
541    }
542
543    /// **Shut panels are one card and an open one is lifted out of it.** The gap is the
544    /// whole of what makes the set read as one object that splits, rather than as three
545    /// rows that happen to be stacked.
546    #[test]
547    fn the_card_splits_only_where_a_panel_is_open() {
548        let shut = three(ExpansionPanelList::new(&[], Msg::Toggle));
549        assert!(!shut.gap_before(1) && !shut.gap_before(2), "one card");
550
551        let second = three(ExpansionPanelList::radio(Some(1), Msg::Open));
552        assert!(second.gap_before(1), "parted from the one above it");
553        assert!(second.gap_before(2), "and from the one below");
554
555        let first = three(ExpansionPanelList::radio(Some(0), Msg::Open));
556        assert!(
557            first.gap_before(1),
558            "the open first panel parts from the second"
559        );
560        assert!(!first.gap_before(2), "and the two shut ones stay one card");
561    }
562
563    /// The gap is real room on the screen, not only a number: the second panel's card
564    /// starts lower once the first is open.
565    #[test]
566    fn the_gap_is_room_on_the_screen() {
567        let card_tops = |list: &ExpansionPanelList<Msg>| -> Vec<f32> {
568            let ui = ui(list);
569            let mut tops: Vec<f32> = flat(&ui)
570                .iter()
571                .filter_map(|p| match p {
572                    // Crisp ones only: a card's shadow is rounded too, and sits above it.
573                    Primitive::Rect {
574                        rect, radius, blur, ..
575                    } if radius.top_left > 0.0 && *blur == 0.0 => Some(rect.y),
576                    _ => None,
577                })
578                .collect();
579            tops.dedup();
580            tops
581        };
582        let shut = card_tops(&three(ExpansionPanelList::new(&[], Msg::Toggle)));
583        let open = card_tops(&three(ExpansionPanelList::radio(Some(0), Msg::Open)));
584        assert!(
585            open.len() > shut.len(),
586            "an open panel makes a second card: {shut:?} against {open:?}"
587        );
588    }
589
590    /// **Each card casts one shadow, and a card is a run.** The reference's list stands at
591    /// 2, and its mergeable material casts a shadow under each run of panels rather than
592    /// under each panel. Three shut panels are one card and one shadow; with the middle one
593    /// open they are three cards and three. None was cast here.
594    #[test]
595    fn each_card_casts_one_shadow() {
596        let shadows = |list: &ExpansionPanelList<Msg>, theme: &Theme| {
597            let ui = build_ui(list, Size::new(320.0, 400.0), &Runtime::default(), theme);
598            flat(&ui)
599                .iter()
600                .filter_map(|p| match p {
601                    Primitive::Rect { blur, .. } if *blur > 0.0 => Some((blur - 8.0) / 4.0),
602                    _ => None,
603                })
604                .collect::<Vec<_>>()
605        };
606        let theme = Theme::default();
607        assert_eq!(
608            shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &theme),
609            vec![2.0],
610            "one card, one shadow"
611        );
612        assert_eq!(
613            shadows(
614                &three(ExpansionPanelList::radio(Some(1), Msg::Open)),
615                &theme
616            ),
617            vec![2.0; 3],
618            "three cards, three"
619        );
620        assert!(shadows(
621            &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(0.0)),
622            &theme
623        )
624        .is_empty());
625
626        let mut level = Theme::default();
627        level.widgets.expansion_panel_list.elevation = Some(0.0);
628        assert!(
629            shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &level).is_empty(),
630            "the theme's word"
631        );
632        assert_eq!(
633            shadows(
634                &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(4.0)),
635                &level
636            ),
637            vec![4.0],
638            "and the list's over it"
639        );
640    }
641}