1use iced_widget::button::{Status, Style};
4use iced_widget::core::text as core_text;
5use iced_widget::core::time::{Duration, Instant};
6use iced_widget::core::widget::{self, Tree, tree};
7use iced_widget::core::{
8 Background, Border, Clipboard, Color, Element, Event, Layout, Length, Padding, Rectangle,
9 Shadow, Shell, Size, Vector, Widget, alignment, border, layout, mouse, overlay, renderer,
10};
11use iced_widget::graphics::geometry;
12use iced_widget::renderer::wgpu::primitive;
13use iced_widget::text;
14use iced_widget::{Container, Row, Stack, Text};
15
16use super::support::{alpha_color, duration_ms, lerp};
17use super::{absolute_line_height, button::Button};
18use crate::utils::{shadow_from_level, state_layer};
19use crate::{Theme, fonts, tokens};
20
21#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct Transition {
24 phase: TransitionPhase,
25 started_at: Option<Instant>,
26 shown_at: Option<Instant>,
27 duration: Duration,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum TransitionPhase {
33 Hidden,
34 Showing,
35 Shown,
36 Dismissing,
37}
38
39impl Default for Transition {
40 fn default() -> Self {
41 Self {
42 phase: TransitionPhase::Hidden,
43 started_at: None,
44 shown_at: None,
45 duration: duration_ms(tokens::component::snackbar::LONG_DURATION_MS),
46 }
47 }
48}
49
50impl Transition {
51 pub fn show(&mut self, now: Instant) {
53 self.show_for(
54 now,
55 duration_ms(tokens::component::snackbar::LONG_DURATION_MS),
56 );
57 }
58
59 pub fn show_for(&mut self, now: Instant, duration: Duration) {
61 match self.phase {
62 TransitionPhase::Showing | TransitionPhase::Shown => {
63 self.duration = duration;
64 self.shown_at = Some(now);
65 }
66 TransitionPhase::Hidden | TransitionPhase::Dismissing => {
67 self.phase = TransitionPhase::Showing;
68 self.started_at = Some(now);
69 self.shown_at = None;
70 self.duration = duration;
71 }
72 }
73 }
74
75 pub fn dismiss(&mut self, now: Instant) {
77 if self.is_active() && self.phase != TransitionPhase::Dismissing {
78 self.phase = TransitionPhase::Dismissing;
79 self.started_at = Some(now);
80 self.shown_at = None;
81 }
82 }
83
84 pub fn advance(&mut self, now: Instant) -> bool {
86 match self.phase {
87 TransitionPhase::Hidden => {}
88 TransitionPhase::Showing => {
89 if self.slide_progress(now) >= 1.0 {
90 self.phase = TransitionPhase::Shown;
91 self.started_at = None;
92 self.shown_at = Some(now);
93 }
94 }
95 TransitionPhase::Shown => {
96 if self.shown_at.is_some_and(|shown_at| {
97 now.saturating_duration_since(shown_at) >= self.duration
98 }) {
99 self.dismiss(now);
100 }
101 }
102 TransitionPhase::Dismissing => {
103 if self.slide_progress(now) >= 1.0 {
104 *self = Self::default();
105 }
106 }
107 }
108
109 self.is_active()
110 }
111
112 pub fn is_active(&self) -> bool {
114 self.phase != TransitionPhase::Hidden
115 }
116
117 pub fn is_animating(&self) -> bool {
119 matches!(
120 self.phase,
121 TransitionPhase::Showing | TransitionPhase::Dismissing
122 )
123 }
124
125 pub fn phase(&self) -> TransitionPhase {
127 self.phase
128 }
129
130 pub fn translation_y(&self, now: Instant, hidden_distance: f32) -> f32 {
132 let eased =
133 tokens::component::snackbar::SLIDE_ANIMATION_EASING.transform(self.slide_progress(now));
134
135 match self.phase {
136 TransitionPhase::Hidden => hidden_distance,
137 TransitionPhase::Showing => lerp(hidden_distance, 0.0, eased),
138 TransitionPhase::Shown => 0.0,
139 TransitionPhase::Dismissing => lerp(0.0, hidden_distance, eased),
140 }
141 }
142
143 pub fn content_alpha(&self, now: Instant) -> f32 {
145 match self.phase {
146 TransitionPhase::Hidden => 0.0,
147 TransitionPhase::Showing => {
148 let Some(started_at) = self.started_at else {
149 return 1.0;
150 };
151
152 let elapsed = now.saturating_duration_since(started_at);
153 let delay = duration_ms(
154 tokens::component::snackbar::SLIDE_ANIMATION_DURATION_MS
155 - tokens::component::snackbar::CONTENT_FADE_ANIMATION_DURATION_MS,
156 );
157
158 if elapsed <= delay {
159 return 0.0;
160 }
161
162 let fade_duration =
163 duration_ms(tokens::component::snackbar::CONTENT_FADE_ANIMATION_DURATION_MS);
164 let progress =
165 ((elapsed - delay).as_secs_f32() / fade_duration.as_secs_f32()).clamp(0.0, 1.0);
166
167 tokens::component::snackbar::CONTENT_FADE_ANIMATION_EASING.transform(progress)
168 }
169 TransitionPhase::Shown => 1.0,
170 TransitionPhase::Dismissing => {
171 let Some(started_at) = self.started_at else {
172 return 0.0;
173 };
174
175 let fade_duration =
176 duration_ms(tokens::component::snackbar::CONTENT_FADE_ANIMATION_DURATION_MS);
177 let progress = (now.saturating_duration_since(started_at).as_secs_f32()
178 / fade_duration.as_secs_f32())
179 .clamp(0.0, 1.0);
180
181 1.0 - tokens::component::snackbar::CONTENT_FADE_ANIMATION_EASING.transform(progress)
182 }
183 }
184 }
185
186 fn slide_progress(&self, now: Instant) -> f32 {
187 let Some(started_at) = self.started_at else {
188 return match self.phase {
189 TransitionPhase::Showing | TransitionPhase::Dismissing => 1.0,
190 TransitionPhase::Hidden | TransitionPhase::Shown => 0.0,
191 };
192 };
193
194 let duration = duration_ms(tokens::component::snackbar::SLIDE_ANIMATION_DURATION_MS);
195
196 (now.saturating_duration_since(started_at).as_secs_f32() / duration.as_secs_f32())
197 .clamp(0.0, 1.0)
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum Lines {
204 Single,
205 Two,
206}
207
208impl Lines {
209 const fn container_height(self) -> f32 {
210 match self {
211 Self::Single => tokens::component::snackbar::WITH_SINGLE_LINE_CONTAINER_HEIGHT,
212 Self::Two => tokens::component::snackbar::WITH_TWO_LINES_CONTAINER_HEIGHT,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct Options {
220 pub lines: Lines,
221 pub content_alpha: f32,
222}
223
224impl Default for Options {
225 fn default() -> Self {
226 Self {
227 lines: Lines::Single,
228 content_alpha: 1.0,
229 }
230 }
231}
232
233impl Options {
234 pub fn lines(mut self, lines: Lines) -> Self {
236 self.lines = lines;
237 self
238 }
239
240 pub fn content_alpha(mut self, content_alpha: f32) -> Self {
242 self.content_alpha = content_alpha;
243 self
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq)]
249pub struct ActionOptions {
250 pub content_alpha: f32,
251}
252
253impl Default for ActionOptions {
254 fn default() -> Self {
255 Self { content_alpha: 1.0 }
256 }
257}
258
259impl ActionOptions {
260 pub fn content_alpha(mut self, content_alpha: f32) -> Self {
262 self.content_alpha = content_alpha;
263 self
264 }
265}
266
267pub fn surface<'a, Message, Renderer>(
269 message: impl text::IntoFragment<'a>,
270 action: Option<Element<'a, Message, Theme, Renderer>>,
271 options: Options,
272) -> Container<'a, Message, Theme, Renderer>
273where
274 Message: 'a,
275 Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
276{
277 surface_container(
278 message,
279 action,
280 options.lines.container_height(),
281 options.content_alpha,
282 )
283}
284
285pub fn action<'a, Message, Renderer>(
287 label: impl text::IntoFragment<'a>,
288) -> Button<'a, Message, Renderer>
289where
290 Message: Clone + 'a,
291 Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
292{
293 action_with(label, ActionOptions::default())
294}
295
296pub fn action_button<'a, Message, Renderer>(
298 label: impl text::IntoFragment<'a>,
299 on_press: Message,
300) -> Element<'a, Message, Theme, Renderer>
301where
302 Message: Clone + 'a,
303 Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
304{
305 action_button_with(label, on_press, ActionOptions::default())
306}
307
308pub fn action_with<'a, Message, Renderer>(
310 label: impl text::IntoFragment<'a>,
311 options: ActionOptions,
312) -> Button<'a, Message, Renderer>
313where
314 Message: Clone + 'a,
315 Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
316{
317 let label_text = tokens::component::snackbar::ACTION_LABEL_TEXT;
318
319 Button::new(
320 Container::new(
321 Text::new(label)
322 .size(label_text.size)
323 .line_height(absolute_line_height(label_text.line_height)),
324 )
325 .height(Length::Fixed(tokens::component::button::CONTAINER_HEIGHT))
326 .padding(Padding {
327 top: 0.0,
328 right: tokens::component::button::TRAILING_SPACE,
329 bottom: 0.0,
330 left: tokens::component::button::LEADING_SPACE,
331 })
332 .align_y(alignment::Vertical::Center),
333 )
334 .height(Length::Fixed(tokens::component::button::CONTAINER_HEIGHT))
335 .padding(Padding::ZERO)
336 .style(move |theme, status| action_style_alpha(theme, status, options.content_alpha))
337}
338
339pub fn action_button_with<'a, Message, Renderer>(
341 label: impl text::IntoFragment<'a>,
342 on_press: Message,
343 options: ActionOptions,
344) -> Element<'a, Message, Theme, Renderer>
345where
346 Message: Clone + 'a,
347 Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
348{
349 action_with(label, options).on_press(on_press).into()
350}
351
352pub fn icon_action<'a, Message, Renderer>(
354 icon_name: impl text::IntoFragment<'a>,
355) -> Button<'a, Message, Renderer>
356where
357 Message: Clone + 'a,
358 Renderer: geometry::Renderer + core_text::Renderer + 'a,
359 iced_widget::core::Font: Into<Renderer::Font>,
360{
361 Button::new(
362 Container::new(fonts::icon(
363 icon_name,
364 tokens::component::snackbar::ICON_SIZE,
365 ))
366 .center_x(Length::Fixed(
367 tokens::component::icon_button::CONTAINER_WIDTH,
368 ))
369 .center_y(Length::Fixed(
370 tokens::component::icon_button::CONTAINER_HEIGHT,
371 )),
372 )
373 .width(Length::Fixed(
374 tokens::component::icon_button::CONTAINER_WIDTH,
375 ))
376 .height(Length::Fixed(
377 tokens::component::icon_button::CONTAINER_HEIGHT,
378 ))
379 .padding(Padding::ZERO)
380 .style(icon_action_style)
381}
382
383pub fn icon_action_button<'a, Message, Renderer>(
385 icon_name: impl text::IntoFragment<'a>,
386 on_press: Message,
387) -> Element<'a, Message, Theme, Renderer>
388where
389 Message: Clone + 'a,
390 Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
391 iced_widget::core::Font: Into<Renderer::Font>,
392{
393 icon_action(icon_name).on_press(on_press).into()
394}
395
396pub fn overlay<'a, Message, Renderer>(
398 content: impl Into<Element<'a, Message, Theme, Renderer>>,
399 snackbar: impl Into<Element<'a, Message, Theme, Renderer>>,
400 translation_y: f32,
401) -> Element<'a, Message, Theme, Renderer>
402where
403 Message: 'a,
404 Renderer: iced_widget::core::Renderer + 'a,
405{
406 Stack::with_children([
407 content.into(),
408 floating_layer(snackbar, translation_y).into(),
409 ])
410 .width(Length::Fill)
411 .height(Length::Fill)
412 .into()
413}
414
415pub fn host<'a, Message, Renderer>(
417 content: impl Into<Element<'a, Message, Theme, Renderer>>,
418 transition: &Transition,
419 now: Instant,
420 message: impl text::IntoFragment<'a>,
421 action_label: impl text::IntoFragment<'a>,
422 on_action: Message,
423) -> Element<'a, Message, Theme, Renderer>
424where
425 Message: Clone + 'a,
426 Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
427{
428 if !transition.is_active() {
429 return content.into();
430 }
431
432 let alpha = transition.content_alpha(now);
433 let hidden_distance = tokens::component::snackbar::WITH_SINGLE_LINE_CONTAINER_HEIGHT
434 + tokens::component::snackbar::BOTTOM_MARGIN;
435 let translation_y = transition.translation_y(now, hidden_distance);
436 let snackbar = surface(
437 message,
438 Some(action_button_with(
439 action_label,
440 on_action,
441 ActionOptions::default().content_alpha(alpha),
442 )),
443 Options::default().content_alpha(alpha),
444 );
445
446 overlay(content, snackbar, translation_y)
447}
448
449fn surface_container<'a, Message, Renderer>(
450 message: impl text::IntoFragment<'a>,
451 action: Option<Element<'a, Message, Theme, Renderer>>,
452 height: f32,
453 content_alpha: f32,
454) -> Container<'a, Message, Theme, Renderer>
455where
456 Message: 'a,
457 Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
458{
459 let supporting_text = tokens::component::snackbar::SUPPORTING_TEXT;
460 let mut content = Row::new()
461 .push(
462 Text::new(message)
463 .size(supporting_text.size)
464 .line_height(absolute_line_height(supporting_text.line_height))
465 .wrapping(text::Wrapping::Word)
466 .style(move |theme: &Theme| text::Style {
467 color: Some(alpha_color(
468 theme.colors().inverse.inverse_surface_text,
469 content_alpha,
470 )),
471 })
472 .width(Length::Fill),
473 )
474 .spacing(8)
475 .align_y(alignment::Vertical::Center);
476
477 if let Some(action) = action {
478 content = content.push(action);
479 }
480
481 Container::new(content)
482 .height(Length::Fixed(height))
483 .width(Length::Fill)
484 .padding(Padding {
485 top: 0.0,
486 right: 8.0,
487 bottom: 0.0,
488 left: 16.0,
489 })
490 .align_y(alignment::Vertical::Center)
491 .style(container_style)
492}
493
494fn floating_layer<'a, Message, Renderer>(
495 snackbar: impl Into<Element<'a, Message, Theme, Renderer>>,
496 translation_y: f32,
497) -> Container<'a, Message, Theme, Renderer>
498where
499 Message: 'a,
500 Renderer: iced_widget::core::Renderer + 'a,
501{
502 let snackbar = Container::new(snackbar)
503 .width(Length::Fill)
504 .max_width(tokens::component::snackbar::MAX_WIDTH);
505
506 Container::new(translated(snackbar, Vector::new(0.0, translation_y)))
507 .width(Length::Fill)
508 .height(Length::Fill)
509 .padding(Padding {
510 top: 0.0,
511 right: tokens::component::snackbar::HORIZONTAL_MARGIN,
512 bottom: tokens::component::snackbar::BOTTOM_MARGIN,
513 left: tokens::component::snackbar::HORIZONTAL_MARGIN,
514 })
515 .align_x(alignment::Horizontal::Center)
516 .align_y(alignment::Vertical::Bottom)
517}
518
519fn translated<'a, Message, Renderer>(
520 content: impl Into<Element<'a, Message, Theme, Renderer>>,
521 translation: Vector,
522) -> Element<'a, Message, Theme, Renderer>
523where
524 Message: 'a,
525 Renderer: iced_widget::core::Renderer + 'a,
526{
527 Element::new(Translated {
528 content: content.into(),
529 translation,
530 })
531}
532
533struct Translated<'a, Message, Renderer> {
534 content: Element<'a, Message, Theme, Renderer>,
535 translation: Vector,
536}
537
538impl<Message, Renderer> std::fmt::Debug for Translated<'_, Message, Renderer> {
539 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540 f.debug_struct("Translated")
541 .field("translation", &self.translation)
542 .finish_non_exhaustive()
543 }
544}
545
546impl<Message, Renderer> Widget<Message, Theme, Renderer> for Translated<'_, Message, Renderer>
547where
548 Renderer: iced_widget::core::Renderer,
549{
550 fn tag(&self) -> tree::Tag {
551 self.content.as_widget().tag()
552 }
553
554 fn state(&self) -> tree::State {
555 self.content.as_widget().state()
556 }
557
558 fn children(&self) -> Vec<Tree> {
559 self.content.as_widget().children()
560 }
561
562 fn diff(&self, tree: &mut Tree) {
563 self.content.as_widget().diff(tree);
564 }
565
566 fn size(&self) -> Size<Length> {
567 self.content.as_widget().size()
568 }
569
570 fn size_hint(&self) -> Size<Length> {
571 self.content.as_widget().size_hint()
572 }
573
574 fn layout(
575 &mut self,
576 tree: &mut Tree,
577 renderer: &Renderer,
578 limits: &layout::Limits,
579 ) -> layout::Node {
580 self.content.as_widget_mut().layout(tree, renderer, limits)
581 }
582
583 fn operate(
584 &mut self,
585 tree: &mut Tree,
586 layout: Layout<'_>,
587 renderer: &Renderer,
588 operation: &mut dyn widget::Operation,
589 ) {
590 self.content
591 .as_widget_mut()
592 .operate(tree, layout, renderer, operation);
593 }
594
595 fn update(
596 &mut self,
597 tree: &mut Tree,
598 event: &Event,
599 layout: Layout<'_>,
600 cursor: mouse::Cursor,
601 renderer: &Renderer,
602 clipboard: &mut dyn Clipboard,
603 shell: &mut Shell<'_, Message>,
604 viewport: &Rectangle,
605 ) {
606 let translation = self.translation;
607
608 self.content.as_widget_mut().update(
609 tree,
610 event,
611 layout,
612 cursor - translation,
613 renderer,
614 clipboard,
615 shell,
616 &(*viewport - translation),
617 );
618 }
619
620 fn mouse_interaction(
621 &self,
622 tree: &Tree,
623 layout: Layout<'_>,
624 cursor: mouse::Cursor,
625 viewport: &Rectangle,
626 renderer: &Renderer,
627 ) -> mouse::Interaction {
628 let translation = self.translation;
629
630 self.content.as_widget().mouse_interaction(
631 tree,
632 layout,
633 cursor - translation,
634 &(*viewport - translation),
635 renderer,
636 )
637 }
638
639 fn draw(
640 &self,
641 tree: &Tree,
642 renderer: &mut Renderer,
643 theme: &Theme,
644 style: &renderer::Style,
645 layout: Layout<'_>,
646 cursor: mouse::Cursor,
647 viewport: &Rectangle,
648 ) {
649 let Some(viewport) = layout.bounds().intersection(viewport) else {
650 return;
651 };
652 let translation = self.translation;
653
654 renderer.with_layer(viewport, |renderer| {
655 renderer.with_translation(translation, |renderer| {
656 self.content.as_widget().draw(
657 tree,
658 renderer,
659 theme,
660 style,
661 layout,
662 cursor - translation,
663 &(viewport - translation),
664 );
665 });
666 });
667 }
668
669 fn overlay<'b>(
670 &'b mut self,
671 tree: &'b mut Tree,
672 layout: Layout<'b>,
673 renderer: &Renderer,
674 viewport: &Rectangle,
675 translation: Vector,
676 ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
677 self.content.as_widget_mut().overlay(
678 tree,
679 layout,
680 renderer,
681 viewport,
682 translation + self.translation,
683 )
684 }
685}
686
687fn container_style(theme: &Theme) -> iced_widget::container::Style {
688 let colors = theme.colors();
689
690 iced_widget::container::Style {
693 background: Some(Background::Color(colors.inverse.inverse_surface)),
694 text_color: Some(colors.inverse.inverse_surface_text),
695 border: border::rounded(tokens::component::snackbar::CONTAINER_SHAPE),
696 shadow: shadow_from_level(
697 tokens::component::snackbar::CONTAINER_ELEVATION_LEVEL,
698 colors.shadow,
699 ),
700 snap: cfg!(feature = "crisp"),
701 }
702}
703
704fn action_style_alpha(theme: &Theme, status: Status, content_alpha: f32) -> Style {
705 let colors = theme.colors();
706 let foreground = alpha_color(colors.inverse.inverse_primary, content_alpha);
707 let active = Style {
708 background: None,
709 text_color: foreground,
710 border: border::rounded(tokens::component::button::CONTAINER_SHAPE),
711 shadow: Shadow::default(),
712 snap: cfg!(feature = "crisp"),
713 };
714
715 match status {
716 Status::Active => active,
717 Status::Hovered => Style {
718 background: Some(Background::Color(state_layer(
719 foreground,
720 tokens::state::HOVER_STATE_LAYER_OPACITY,
721 ))),
722 ..active
723 },
724 Status::Pressed => Style {
725 background: Some(Background::Color(state_layer(
726 foreground,
727 tokens::state::PRESSED_STATE_LAYER_OPACITY,
728 ))),
729 ..active
730 },
731 Status::Disabled => Style {
732 text_color: Color {
733 a: tokens::state::DISABLED_LABEL_TEXT_OPACITY,
734 ..foreground
735 },
736 ..active
737 },
738 }
739}
740
741fn icon_action_style(theme: &Theme, status: Status) -> Style {
742 let colors = theme.colors();
743 let foreground = colors.inverse.inverse_surface_text;
744 let active = Style {
745 background: None,
746 text_color: foreground,
747 border: Border {
748 color: Color::TRANSPARENT,
749 width: 0.0,
750 radius: tokens::component::icon_button::CONTAINER_SHAPE.into(),
751 },
752 shadow: Shadow::default(),
753 snap: cfg!(feature = "crisp"),
754 };
755
756 match status {
757 Status::Active => active,
758 Status::Hovered => Style {
759 background: Some(Background::Color(state_layer(
760 foreground,
761 tokens::state::HOVER_STATE_LAYER_OPACITY,
762 ))),
763 ..active
764 },
765 Status::Pressed => Style {
766 background: Some(Background::Color(state_layer(
767 foreground,
768 tokens::state::PRESSED_STATE_LAYER_OPACITY,
769 ))),
770 ..active
771 },
772 Status::Disabled => Style {
773 text_color: Color {
774 a: tokens::state::DISABLED_LABEL_TEXT_OPACITY,
775 ..foreground
776 },
777 ..active
778 },
779 }
780}
781
782#[cfg(test)]
783#[path = "../../../tests/widget/component/snackbar.rs"]
784mod tests;