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