Skip to main content

frus_widgets/
toasthost.rs

1//! [`ScaffoldMessenger`]: the **notification layer** — places and stacks [`crate::SnackBar`]s
2//! in a corner of the screen, with an optional **entry transition**.
3//!
4//! Put it as the last layer of a [`crate::Stack`], above the interface. The widget fills
5//! the available surface and aligns its toasts in the chosen corner ([`SnackBarPosition`]);
6//! several toasts **stack** in a column. `fade_in` wraps each toast in an animated
7//! opacity (the existing animation layer, [`crate::AnimatedOpacity`]) for a fade-in.
8//!
9//! The **content** (which toast(s) to show, their queue and auto-dismiss) stays driven
10//! by the application through [`crate::SnackBarQueue`]: `ScaffoldMessenger` only places them.
11
12use frus_core::{Curve, Insets, Rect, Scene};
13use frus_layout::{Align, FlexDirection, Justify, Style};
14
15use crate::animated::AnimatedOpacity;
16use crate::interaction::Status;
17use crate::theme::Theme;
18use crate::widget::Widget;
19
20/// Default margin between the toasts and the edges: **none**.
21///
22/// What a notification keeps clear of the page belongs to the notification, which is
23/// where the reference keeps it (`snack_bar.dart:823`, `:989`) — a fixed bar keeps
24/// nothing clear and a floating one keeps 15 and 10, and neither number is the host's to
25/// decide. This used to impose 16 on everything it held, which a floating bar then added
26/// its own margin to. [`ScaffoldMessenger::padding`] is still there for a caller who
27/// wants a layer of their own arrangement.
28const HOST_PAD: f32 = 0.0;
29/// Vertical gap between stacked toasts.
30const STACK_GAP: f32 = 8.0;
31
32/// The corner the notifications are anchored to.
33#[derive(Copy, Clone, Debug, PartialEq, Eq)]
34pub enum SnackBarPosition {
35    /// The top corner at the start of the line: top left in a left-to-right layout.
36    TopStart,
37    /// The middle of the top edge.
38    TopCenter,
39    /// The top corner at the end of the line: top right in a left-to-right layout.
40    TopEnd,
41    /// The bottom corner at the start of the line: bottom left in a left-to-right layout.
42    BottomStart,
43    /// The middle of the bottom edge.
44    BottomCenter,
45    /// The bottom corner at the end of the line: bottom right in a left-to-right layout.
46    BottomEnd,
47}
48
49impl SnackBarPosition {
50    /// Vertical alignment (the column's main axis): top vs bottom.
51    fn justify(self) -> Justify {
52        match self {
53            SnackBarPosition::TopStart | SnackBarPosition::TopCenter | SnackBarPosition::TopEnd => {
54                Justify::Start
55            }
56            _ => Justify::End,
57        }
58    }
59
60    /// Horizontal alignment (the cross axis): left / centre / right.
61    fn align(self) -> Align {
62        match self {
63            SnackBarPosition::TopStart | SnackBarPosition::BottomStart => Align::Start,
64            SnackBarPosition::TopCenter | SnackBarPosition::BottomCenter => Align::Center,
65            SnackBarPosition::TopEnd | SnackBarPosition::BottomEnd => Align::End,
66        }
67    }
68}
69
70/// A notification layer anchored in a corner.
71pub struct ScaffoldMessenger<Msg = crate::callback::Callback> {
72    position: SnackBarPosition,
73    padding: f32,
74    children: Vec<Box<dyn Widget<Msg>>>,
75}
76
77impl<Msg: Clone + 'static> ScaffoldMessenger<Msg> {
78    /// An empty layer anchored at `position`.
79    pub fn new(position: SnackBarPosition) -> Self {
80        Self {
81            position,
82            padding: HOST_PAD,
83            children: Vec::new(),
84        }
85    }
86
87    /// Margin between the toasts and the edges. **None by default**: what a notification
88    /// keeps clear of the page is the notification's own business — see
89    /// [`SnackBar::margin`](crate::SnackBar::margin).
90    pub fn padding(mut self, padding: f32) -> Self {
91        self.padding = padding;
92        self
93    }
94
95    /// Adds a toast to the stack (call it several times to stack several).
96    pub fn toast(mut self, widget: impl Widget<Msg> + 'static) -> Self {
97        self.children.push(Box::new(widget));
98        self
99    }
100
101    /// Wraps **every** toast in an animated opacity (`duration` seconds): a fade-in
102    /// built on the existing animation layer. Call this after the `toast`s.
103    pub fn fade_in(self, duration: f32) -> Self {
104        self.wrap_opacity(1.0, duration)
105    }
106
107    /// The mirror of [`fade_in`](Self::fade_in): animates the opacity toward **0** — the
108    /// **exit** transition (the toast fades out before it is removed, Material style).
109    /// The application plays it when the notification is leaving (see
110    /// [`crate::SnackBarQueue::is_leaving`]).
111    pub fn fade_out(self, duration: f32) -> Self {
112        self.wrap_opacity(0.0, duration)
113    }
114
115    /// Wraps each toast in an opacity animated toward `target`.
116    fn wrap_opacity(mut self, target: f32, duration: f32) -> Self {
117        self.children = self
118            .children
119            .into_iter()
120            .map(|child| {
121                Box::new(AnimatedOpacity::new(
122                    target,
123                    duration,
124                    Curve::ease_in_out(),
125                    child,
126                )) as Box<dyn Widget<Msg>>
127            })
128            .collect();
129        self
130    }
131}
132
133impl<Msg: Clone> Widget<Msg> for ScaffoldMessenger<Msg> {
134    /// It asks to fill **both** axes rather than declaring `100%` on either — see
135    /// [`FillAxes`](crate::widget::FillAxes). A percentage resolves against the parent's
136    /// *resolved* size, which a parent that shrink-wraps has not got yet, so a shell
137    /// nested in one vanished entirely (milestone 404).
138    fn style(&self) -> Style {
139        Style {
140            flex_direction: FlexDirection::Column,
141            justify: self.position.justify(),
142            align: self.position.align(),
143            gap: STACK_GAP,
144            padding: Insets::new(self.padding, self.padding, self.padding, self.padding),
145            ..Default::default()
146        }
147    }
148
149    /// A shell takes everything it is offered, on both axes.
150    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
151        crate::widget::FillAxes::BOTH
152    }
153
154    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
155        &self.children
156    }
157
158    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
159
160    fn on_click(&self) -> Option<Msg> {
161        None
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::text::Text;
169
170    #[test]
171    fn empty_host_has_no_children() {
172        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::TopEnd);
173        assert!(Widget::<()>::children(&host).is_empty());
174    }
175
176    #[test]
177    fn position_maps_to_justify_and_align() {
178        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::BottomEnd).toast(Text::new("x"));
179        let style = Widget::<()>::style(&host);
180        assert!(
181            matches!(style.justify, Justify::End),
182            "bottom → justify End"
183        );
184        assert!(matches!(style.align, Align::End), "right → align End");
185        assert_eq!(Widget::<()>::children(&host).len(), 1);
186
187        let top_center =
188            Widget::<()>::style(&ScaffoldMessenger::<()>::new(SnackBarPosition::TopCenter));
189        assert!(matches!(top_center.justify, Justify::Start));
190        assert!(matches!(top_center.align, Align::Center));
191    }
192
193    #[test]
194    fn stacks_multiple_and_fade_in_preserves_count() {
195        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::BottomCenter)
196            .toast(Text::new("a"))
197            .toast(Text::new("b"))
198            .fade_in(0.2);
199        assert_eq!(
200            Widget::<()>::children(&host).len(),
201            2,
202            "two toasts, wrapped in a fade"
203        );
204    }
205
206    #[test]
207    fn fade_out_wraps_children() {
208        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::BottomCenter)
209            .toast(Text::new("bye"))
210            .fade_out(0.3);
211        assert_eq!(
212            Widget::<()>::children(&host).len(),
213            1,
214            "toast wrapped in a fade-out"
215        );
216    }
217}