1use frus_core::{Rect, Scene};
42use frus_layout::{Dimension, Style};
43
44use crate::interaction::{Status, WidgetId};
45use crate::theme::Theme;
46use crate::widget::Widget;
47
48pub const DISMISS_THRESHOLD: f32 = 0.4;
50
51pub const MIN_FLING_VELOCITY: f32 = 700.0;
53
54pub const MIN_FLING_VELOCITY_DELTA: f32 = 400.0;
57
58pub const MOVEMENT_TIME: f32 = 0.200;
60
61pub const RESIZE_TIME: f32 = 0.300;
63
64#[derive(Copy, Clone, Debug, PartialEq, Eq)]
66pub enum DismissDirection {
67 ToStart,
69 ToEnd,
71 Up,
73 Down,
75}
76
77impl DismissDirection {
78 pub fn is_horizontal(&self) -> bool {
80 matches!(self, DismissDirection::ToStart | DismissDirection::ToEnd)
81 }
82
83 pub fn sign(&self) -> f32 {
85 match self {
86 DismissDirection::ToStart | DismissDirection::Up => -1.0,
87 DismissDirection::ToEnd | DismissDirection::Down => 1.0,
88 }
89 }
90}
91
92#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
94pub enum DismissAxis {
95 #[default]
97 Horizontal,
98 Vertical,
100 ToStart,
102 ToEnd,
104 Up,
106 Down,
108}
109
110impl DismissAxis {
111 pub fn is_horizontal(&self) -> bool {
114 matches!(
115 self,
116 DismissAxis::Horizontal | DismissAxis::ToStart | DismissAxis::ToEnd
117 )
118 }
119
120 pub fn direction(&self, sign: f32) -> Option<DismissDirection> {
124 let negative = sign < 0.0;
125 match (self, negative) {
126 (DismissAxis::Horizontal, true) | (DismissAxis::ToStart, true) => {
127 Some(DismissDirection::ToStart)
128 }
129 (DismissAxis::Horizontal, false) | (DismissAxis::ToEnd, false) => {
130 Some(DismissDirection::ToEnd)
131 }
132 (DismissAxis::Vertical, true) | (DismissAxis::Up, true) => Some(DismissDirection::Up),
133 (DismissAxis::Vertical, false) | (DismissAxis::Down, false) => {
134 Some(DismissDirection::Down)
135 }
136 _ => None,
137 }
138 }
139}
140
141#[derive(Copy, Clone, Debug, PartialEq, Eq)]
143pub enum DismissPhase {
144 Drag,
146 Restore,
148 Fly,
150 Collapse,
152}
153
154#[derive(Copy, Clone, Debug)]
156pub struct DismissState {
157 phase: DismissPhase,
158 progress: f32,
161 extent_factor: f32,
163 from: f32,
165 elapsed: f32,
166 direction: Option<DismissDirection>,
168}
169
170impl Default for DismissState {
171 fn default() -> Self {
172 Self {
173 phase: DismissPhase::Drag,
174 progress: 0.0,
175 extent_factor: 1.0,
176 from: 0.0,
177 elapsed: 0.0,
178 direction: None,
179 }
180 }
181}
182
183impl DismissState {
184 pub fn phase(&self) -> DismissPhase {
186 self.phase
187 }
188
189 pub fn progress(&self) -> f32 {
191 self.progress
192 }
193
194 pub fn extent_factor(&self) -> f32 {
196 self.extent_factor
197 }
198
199 pub fn direction(&self) -> Option<DismissDirection> {
201 self.direction
202 }
203
204 pub fn is_dragging(&self) -> bool {
206 self.phase == DismissPhase::Drag
207 }
208
209 fn drag(&mut self, delta: f32, extent: f32, axis: DismissAxis) {
213 if self.phase != DismissPhase::Drag || extent <= 0.0 {
214 return;
215 }
216 let next = self.progress + delta / extent;
217 let allowed = match axis.direction(next.signum()) {
218 Some(_) => next,
219 None => 0.0,
221 };
222 self.progress = allowed.clamp(-1.0, 1.0);
223 self.direction = axis.direction(self.progress.signum());
224 }
225
226 fn release(
234 &mut self,
235 velocity: f32,
236 cross: f32,
237 axis: DismissAxis,
238 threshold: f32,
239 ) -> Option<DismissDirection> {
240 if self.phase != DismissPhase::Drag {
241 return None;
242 }
243 let flung = self.progress != 0.0
244 && velocity.abs() - cross.abs() >= MIN_FLING_VELOCITY_DELTA
245 && velocity.abs() >= MIN_FLING_VELOCITY;
246 let outcome = if flung {
247 axis.direction(velocity.signum())
248 } else if self.progress.abs() >= threshold {
249 axis.direction(self.progress.signum())
250 } else {
251 None
252 };
253 self.direction = outcome;
254 self.from = self.progress;
255 self.elapsed = 0.0;
256 self.phase = match outcome {
257 Some(_) => DismissPhase::Fly,
258 None => DismissPhase::Restore,
259 };
260 outcome
261 }
262
263 fn advance(&mut self, dt: f32) -> (bool, Option<DismissDirection>) {
266 match self.phase {
267 DismissPhase::Drag => (false, None),
268 DismissPhase::Restore => {
269 self.elapsed += dt;
270 let t = (self.elapsed / MOVEMENT_TIME).clamp(0.0, 1.0);
271 self.progress = self.from * (1.0 - t);
272 (t < 1.0, None)
273 }
274 DismissPhase::Fly => {
275 self.elapsed += dt;
276 let t = (self.elapsed / MOVEMENT_TIME).clamp(0.0, 1.0);
277 let target = self.direction.map(|d| d.sign()).unwrap_or(0.0);
278 self.progress = self.from + (target - self.from) * t;
279 if t >= 1.0 {
280 self.phase = DismissPhase::Collapse;
281 self.elapsed = 0.0;
282 }
283 (true, None)
284 }
285 DismissPhase::Collapse => {
286 self.elapsed += dt;
287 let t = (self.elapsed / RESIZE_TIME).clamp(0.0, 1.0);
288 self.extent_factor = 1.0 - t;
289 if t >= 1.0 {
290 return (false, self.direction);
293 }
294 (true, None)
295 }
296 }
297 }
298
299 fn is_spent(&self) -> bool {
301 (self.phase == DismissPhase::Restore && self.progress == 0.0)
302 || (self.phase == DismissPhase::Collapse && self.extent_factor <= 0.0)
303 }
304}
305
306#[derive(Copy, Clone, Debug, PartialEq)]
308pub struct DismissSpec {
309 pub axis: DismissAxis,
311 pub threshold: f32,
313}
314
315impl Default for DismissSpec {
316 fn default() -> Self {
317 Self {
318 axis: DismissAxis::Horizontal,
319 threshold: DISMISS_THRESHOLD,
320 }
321 }
322}
323
324#[derive(Copy, Clone, Debug, PartialEq)]
326pub struct Dismissable {
327 pub id: WidgetId,
329 pub rect: Rect,
331 pub spec: DismissSpec,
333}
334
335impl Dismissable {
336 pub fn extent(&self) -> f32 {
339 if self.spec.axis.is_horizontal() {
340 self.rect.width
341 } else {
342 self.rect.height
343 }
344 }
345}
346
347pub struct Dismissible<Msg = crate::callback::Callback> {
364 spec: DismissSpec,
365 width: Dimension,
366 height: Dimension,
367 flex_grow: f32,
368 message: Option<Msg>,
369 to_start: Option<Msg>,
370 to_end: Option<Msg>,
371 children: Vec<Box<dyn Widget<Msg>>>,
374 backgrounds: usize,
376}
377
378impl<Msg: Clone> Dismissible<Msg> {
379 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
382 Self {
383 spec: DismissSpec::default(),
384 width: Dimension::Auto,
385 height: Dimension::Auto,
386 flex_grow: 0.0,
387 message: None,
388 to_start: None,
389 to_end: None,
390 children: vec![Box::new(child)],
391 backgrounds: 0,
392 }
393 }
394
395 pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
398 self.message = Some(message.into());
399 self
400 }
401
402 pub fn on_dismiss_to_start(mut self, message: impl Into<Msg>) -> Self {
405 self.to_start = Some(message.into());
406 self
407 }
408
409 pub fn on_dismiss_to_end(mut self, message: impl Into<Msg>) -> Self {
411 self.to_end = Some(message.into());
412 self
413 }
414
415 pub fn axis(mut self, axis: DismissAxis) -> Self {
417 self.spec.axis = axis;
418 self
419 }
420
421 pub fn threshold(mut self, threshold: f32) -> Self {
423 self.spec.threshold = threshold;
424 self
425 }
426
427 pub fn background(mut self, background: impl Widget<Msg> + 'static) -> Self {
429 self.children.insert(self.backgrounds, Box::new(background));
430 self.backgrounds += 1;
431 self
432 }
433
434 pub fn secondary_background(mut self, background: impl Widget<Msg> + 'static) -> Self {
437 self.children.insert(self.backgrounds, Box::new(background));
438 self.backgrounds += 1;
439 self
440 }
441
442 pub fn width(mut self, width: f32) -> Self {
444 self.width = Dimension::Length(width);
445 self
446 }
447
448 pub fn height(mut self, height: f32) -> Self {
450 self.height = Dimension::Length(height);
451 self
452 }
453
454 pub fn flex(mut self, grow: f32) -> Self {
456 self.flex_grow = grow;
457 self
458 }
459
460 pub fn background_count(&self) -> usize {
462 self.backgrounds
463 }
464}
465
466impl<Msg: Clone> Widget<Msg> for Dismissible<Msg> {
467 fn style(&self) -> Style {
468 Style {
469 width: self.width,
470 height: self.height,
471 flex_grow: self.flex_grow,
472 ..Default::default()
473 }
474 }
475
476 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
477 &self.children
478 }
479
480 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
481
482 fn on_click(&self) -> Option<Msg> {
483 None
484 }
485
486 fn stack(&self) -> bool {
489 true
490 }
491
492 fn dismissible(&self) -> Option<DismissSpec> {
493 Some(self.spec)
494 }
495
496 fn on_dismissed(&self, direction: DismissDirection) -> Option<Msg> {
497 let specific = match direction {
498 DismissDirection::ToStart | DismissDirection::Up => self.to_start.as_ref(),
499 DismissDirection::ToEnd | DismissDirection::Down => self.to_end.as_ref(),
500 };
501 specific.or(self.message.as_ref()).cloned()
502 }
503}
504
505pub fn revealed_strip(rect: Rect, progress: f32) -> Rect {
508 let travelled = (rect.width * progress).abs().min(rect.width);
509 if progress < 0.0 {
510 Rect::new(
512 rect.x + rect.width - travelled,
513 rect.y,
514 travelled,
515 rect.height,
516 )
517 } else {
518 Rect::new(rect.x, rect.y, travelled, rect.height)
519 }
520}
521
522pub(crate) fn advance_all(
525 states: &mut std::collections::HashMap<WidgetId, DismissState>,
526 items: &[Dismissable],
527 dt: f32,
528) -> (bool, Vec<(WidgetId, DismissDirection)>) {
529 if states.is_empty() {
530 return (false, Vec::new());
531 }
532 let mut animating = false;
533 let mut finished = Vec::new();
534 states.retain(|id, state| {
535 if !items.iter().any(|i| i.id == *id) && state.phase != DismissPhase::Collapse {
539 return false;
540 }
541 let (moving, done) = state.advance(dt);
542 animating |= moving;
543 if let Some(direction) = done {
544 finished.push((*id, direction));
545 }
546 !state.is_spent()
547 });
548 (animating, finished)
549}
550
551pub(crate) fn drag_into(
553 states: &mut std::collections::HashMap<WidgetId, DismissState>,
554 id: WidgetId,
555 delta: f32,
556 extent: f32,
557 axis: DismissAxis,
558) {
559 states.entry(id).or_default().drag(delta, extent, axis);
560}
561
562pub(crate) fn release_of(
564 states: &mut std::collections::HashMap<WidgetId, DismissState>,
565 id: WidgetId,
566 velocity: f32,
567 cross: f32,
568 axis: DismissAxis,
569 threshold: f32,
570) -> Option<DismissDirection> {
571 states
572 .get_mut(&id)
573 .and_then(|s| s.release(velocity, cross, axis, threshold))
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use std::collections::HashMap;
580
581 const EXTENT: f32 = 400.0;
582
583 fn item(id: WidgetId) -> Dismissable {
584 Dismissable {
585 id,
586 rect: Rect::new(0.0, 0.0, EXTENT, 56.0),
587 spec: DismissSpec::default(),
588 }
589 }
590
591 fn run(states: &mut HashMap<WidgetId, DismissState>, seconds: f32) -> Vec<DismissDirection> {
592 let items = [item(WidgetId::ROOT)];
593 let mut out = Vec::new();
594 let mut left = seconds;
595 while left > 0.0 {
596 let dt = left.min(1.0 / 60.0);
597 let (_, done) = advance_all(states, &items, dt);
598 out.extend(done.into_iter().map(|(_, d)| d));
599 left -= dt;
600 }
601 out
602 }
603
604 fn state(states: &HashMap<WidgetId, DismissState>) -> DismissState {
605 *states.get(&WidgetId::ROOT).expect("a state")
606 }
607
608 #[test]
609 fn a_short_swipe_slides_back() {
610 let mut states = HashMap::new();
611 drag_into(
612 &mut states,
613 WidgetId::ROOT,
614 EXTENT * 0.2,
615 EXTENT,
616 DismissAxis::Horizontal,
617 );
618 assert!(release_of(
619 &mut states,
620 WidgetId::ROOT,
621 0.0,
622 0.0,
623 DismissAxis::Horizontal,
624 DISMISS_THRESHOLD
625 )
626 .is_none());
627 assert_eq!(state(&states).phase(), DismissPhase::Restore);
628 assert!(run(&mut states, MOVEMENT_TIME + 0.05).is_empty());
629 assert!(
630 states.is_empty(),
631 "nothing retained once it is back at rest"
632 );
633 }
634
635 #[test]
636 fn a_long_enough_swipe_dismisses() {
637 let mut states = HashMap::new();
638 drag_into(
639 &mut states,
640 WidgetId::ROOT,
641 EXTENT * 0.5,
642 EXTENT,
643 DismissAxis::Horizontal,
644 );
645 let direction = release_of(
646 &mut states,
647 WidgetId::ROOT,
648 0.0,
649 0.0,
650 DismissAxis::Horizontal,
651 DISMISS_THRESHOLD,
652 );
653 assert_eq!(direction, Some(DismissDirection::ToEnd));
654 assert_eq!(state(&states).phase(), DismissPhase::Fly);
655 }
656
657 #[test]
658 fn the_message_waits_for_the_gap_to_close() {
659 let mut states = HashMap::new();
660 drag_into(
661 &mut states,
662 WidgetId::ROOT,
663 EXTENT * 0.5,
664 EXTENT,
665 DismissAxis::Horizontal,
666 );
667 release_of(
668 &mut states,
669 WidgetId::ROOT,
670 0.0,
671 0.0,
672 DismissAxis::Horizontal,
673 DISMISS_THRESHOLD,
674 );
675
676 assert!(run(&mut states, MOVEMENT_TIME + 0.01).is_empty());
678 assert_eq!(state(&states).phase(), DismissPhase::Collapse);
679 assert!(state(&states).extent_factor() > 0.9);
682
683 assert!(run(&mut states, RESIZE_TIME * 0.5).is_empty());
685 let half = state(&states).extent_factor();
686 assert!(
687 (0.3..0.7).contains(&half),
688 "the neighbours are closing the gap, got {half}"
689 );
690
691 let done = run(&mut states, RESIZE_TIME);
692 assert_eq!(done, vec![DismissDirection::ToEnd]);
693 assert!(states.is_empty());
694 }
695
696 #[test]
697 fn a_fast_flick_dismisses_from_a_short_drag() {
698 let mut states = HashMap::new();
699 drag_into(
700 &mut states,
701 WidgetId::ROOT,
702 EXTENT * 0.05,
703 EXTENT,
704 DismissAxis::Horizontal,
705 );
706 let direction = release_of(
707 &mut states,
708 WidgetId::ROOT,
709 1500.0,
710 0.0,
711 DismissAxis::Horizontal,
712 DISMISS_THRESHOLD,
713 );
714 assert_eq!(direction, Some(DismissDirection::ToEnd));
715 }
716
717 #[test]
718 fn a_fast_diagonal_is_not_a_swipe() {
719 let mut states = HashMap::new();
720 drag_into(
721 &mut states,
722 WidgetId::ROOT,
723 EXTENT * 0.05,
724 EXTENT,
725 DismissAxis::Horizontal,
726 );
727 let direction = release_of(
729 &mut states,
730 WidgetId::ROOT,
731 1500.0,
732 1400.0,
733 DismissAxis::Horizontal,
734 DISMISS_THRESHOLD,
735 );
736 assert_eq!(direction, None, "this must not throw the row out");
737 }
738
739 #[test]
740 fn a_flick_the_other_way_wins_over_the_drag_so_far() {
741 let mut states = HashMap::new();
742 drag_into(
743 &mut states,
744 WidgetId::ROOT,
745 EXTENT * 0.45,
746 EXTENT,
747 DismissAxis::Horizontal,
748 );
749 let direction = release_of(
751 &mut states,
752 WidgetId::ROOT,
753 -1500.0,
754 0.0,
755 DismissAxis::Horizontal,
756 DISMISS_THRESHOLD,
757 );
758 assert_eq!(direction, Some(DismissDirection::ToStart));
759 }
760
761 #[test]
762 fn a_one_way_item_refuses_the_wrong_direction_outright() {
763 let mut states = HashMap::new();
764 drag_into(
765 &mut states,
766 WidgetId::ROOT,
767 -EXTENT * 0.5,
768 EXTENT,
769 DismissAxis::ToEnd,
770 );
771 assert_eq!(
772 state(&states).progress(),
773 0.0,
774 "it does not move, rather than moving and snapping back"
775 );
776 }
777
778 #[test]
779 fn an_item_cannot_be_dragged_past_its_own_width() {
780 let mut states = HashMap::new();
781 drag_into(
782 &mut states,
783 WidgetId::ROOT,
784 EXTENT * 5.0,
785 EXTENT,
786 DismissAxis::Horizontal,
787 );
788 assert_eq!(state(&states).progress(), 1.0);
789 }
790
791 #[test]
792 fn a_held_item_moves_on_its_own_not_at_all() {
793 let mut states = HashMap::new();
794 drag_into(
795 &mut states,
796 WidgetId::ROOT,
797 EXTENT * 0.3,
798 EXTENT,
799 DismissAxis::Horizontal,
800 );
801 let held = state(&states).progress();
802 run(&mut states, 0.5);
803 assert_eq!(state(&states).progress(), held);
804 }
805
806 #[test]
807 fn a_collapsing_item_finishes_even_after_it_leaves_the_tree() {
808 let mut states = HashMap::new();
809 drag_into(
810 &mut states,
811 WidgetId::ROOT,
812 EXTENT * 0.5,
813 EXTENT,
814 DismissAxis::Horizontal,
815 );
816 release_of(
817 &mut states,
818 WidgetId::ROOT,
819 0.0,
820 0.0,
821 DismissAxis::Horizontal,
822 DISMISS_THRESHOLD,
823 );
824 run(&mut states, MOVEMENT_TIME + 0.01);
825 assert_eq!(state(&states).phase(), DismissPhase::Collapse);
826
827 let mut done = Vec::new();
830 for _ in 0..40 {
831 let (_, d) = advance_all(&mut states, &[], 1.0 / 60.0);
832 done.extend(d);
833 }
834 assert_eq!(done.len(), 1);
835 }
836
837 #[test]
838 fn the_revealed_strip_follows_the_item_off() {
839 let rect = Rect::new(10.0, 0.0, 100.0, 50.0);
840 let to_end = revealed_strip(rect, 0.25);
841 assert_eq!((to_end.x, to_end.width), (10.0, 25.0));
842 let to_start = revealed_strip(rect, -0.25);
843 assert_eq!((to_start.x, to_start.width), (85.0, 25.0));
844 }
845
846 #[test]
847 fn the_widget_reports_the_message_for_the_direction_it_went() {
848 let widget = Dismissible::<i32>::new(crate::Container::new())
849 .on_dismiss(1)
850 .on_dismiss_to_start(2);
851 assert_eq!(
852 Widget::<i32>::on_dismissed(&widget, DismissDirection::ToStart),
853 Some(2)
854 );
855 assert_eq!(
856 Widget::<i32>::on_dismissed(&widget, DismissDirection::ToEnd),
857 Some(1),
858 "the general message covers the direction with none of its own"
859 );
860 }
861
862 #[test]
863 fn the_child_is_the_last_of_the_children() {
864 let widget = Dismissible::<i32>::new(crate::Text::new("row"))
865 .background(crate::Container::new())
866 .secondary_background(crate::Container::new());
867 assert_eq!(widget.background_count(), 2);
868 assert_eq!(Widget::<i32>::children(&widget).len(), 3);
869 assert_eq!(
870 Widget::<i32>::children(&widget)[2].debug_name(),
871 "Text",
872 "the child paints over its backgrounds"
873 );
874 }
875}