Skip to main content

frus_widgets/
dismiss.rs

1//! [`Dismissible`] — swipe an item aside to remove it.
2//!
3//! ```ignore
4//! Dismissible::new(row)
5//!     .height(56.0)
6//!     .on_dismiss(Msg::Delete(id))
7//!     .background(Container::new().color(theme.error))
8//! ```
9//!
10//! ## The gesture is shared, not stolen
11//!
12//! A dismissible list item covers the whole row, and a list scrolls. The two gestures
13//! start identically — a finger goes down and moves — so the question is not *who is on
14//! top* but *which way the finger went*. The first movement past the drag threshold
15//! decides: mostly sideways is a dismissal, mostly up or down is a scroll, and the loser
16//! never sees the gesture at all. That decision lives in the shell (`app.rs`), because
17//! it is the only place that knows about both.
18//!
19//! Without it, wrapping a row in a `Dismissible` would silently break scrolling for the
20//! whole list — the failure being that the list simply stops moving, with nothing to
21//! suggest why.
22//!
23//! ## The three acts
24//!
25//! | | |
26//! |---|---|
27//! | **drag** | the item follows the finger, revealing a background behind it |
28//! | **settle** | released: it flies out the way it was going, or slides back |
29//! | **collapse** | once out, its height shrinks to nothing, and *then* the message goes |
30//!
31//! The collapse is what keeps a list from jumping: the neighbours close the gap over
32//! 300 ms instead of teleporting into it. The message is dispatched at the end, so the
33//! application removes the item only once the hole it leaves has already closed.
34//!
35//! ## Sizing
36//!
37//! A `Dismissible` overlays its backgrounds under its child, which makes it a layout
38//! **leaf** like [`crate::Stack`]: give it a height (or a width, on a vertical swipe).
39//! A row in a `ListView`, which already has a fixed item height, is the usual case.
40
41use frus_core::{Rect, Scene};
42use frus_layout::{Dimension, Style};
43
44use crate::interaction::{Status, WidgetId};
45use crate::theme::Theme;
46use crate::widget::Widget;
47
48/// Fraction of the item's extent a drag must cover to dismiss on release.
49pub const DISMISS_THRESHOLD: f32 = 0.4;
50
51/// Least speed, in px/s, at which a release counts as a fling rather than a drop.
52pub const MIN_FLING_VELOCITY: f32 = 700.0;
53
54/// By how much the swipe axis must beat the other one for a fling to count. A fast
55/// diagonal is not a swipe; this is what keeps a hurried scroll from throwing rows out.
56pub const MIN_FLING_VELOCITY_DELTA: f32 = 400.0;
57
58/// Seconds for a released item to fly out or slide back.
59pub const MOVEMENT_TIME: f32 = 0.200;
60
61/// Seconds for a dismissed item's box to shrink to nothing.
62pub const RESIZE_TIME: f32 = 0.300;
63
64/// Which way an item may be swiped, and which way it went.
65#[derive(Copy, Clone, Debug, PartialEq, Eq)]
66pub enum DismissDirection {
67    /// Towards the reading start — leftwards in LTR.
68    ToStart,
69    /// Towards the reading end — rightwards in LTR.
70    ToEnd,
71    /// Upwards.
72    Up,
73    /// Downwards.
74    Down,
75}
76
77impl DismissDirection {
78    /// `true` when this direction moves along the horizontal axis.
79    pub fn is_horizontal(&self) -> bool {
80        matches!(self, DismissDirection::ToStart | DismissDirection::ToEnd)
81    }
82
83    /// The sign this direction gives the offset: negative towards the start or up.
84    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/// Which directions an item accepts.
93#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
94pub enum DismissAxis {
95    /// Both ways along the horizontal axis — the default.
96    #[default]
97    Horizontal,
98    /// Both ways along the vertical axis.
99    Vertical,
100    /// Towards the reading start only.
101    ToStart,
102    /// Towards the reading end only.
103    ToEnd,
104    /// Upwards only.
105    Up,
106    /// Downwards only.
107    Down,
108}
109
110impl DismissAxis {
111    /// `true` when the accepted directions lie along the horizontal axis. This is what
112    /// the shell compares the finger's movement against.
113    pub fn is_horizontal(&self) -> bool {
114        matches!(
115            self,
116            DismissAxis::Horizontal | DismissAxis::ToStart | DismissAxis::ToEnd
117        )
118    }
119
120    /// The direction a movement of `sign` would be, or `None` when that way is not
121    /// accepted — which is how a one-way item refuses the wrong swipe outright, rather
122    /// than letting it move and then snapping back.
123    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/// Where an item is in its cycle.
142#[derive(Copy, Clone, Debug, PartialEq, Eq)]
143pub enum DismissPhase {
144    /// A finger is moving it.
145    Drag,
146    /// Released: sliding back to rest.
147    Restore,
148    /// Released past the point of no return: flying out.
149    Fly,
150    /// Out of sight: the box is shrinking, and the message follows.
151    Collapse,
152}
153
154/// The retained state of one dismissible item.
155#[derive(Copy, Clone, Debug)]
156pub struct DismissState {
157    phase: DismissPhase,
158    /// How far along its axis the item has moved, as a fraction of its extent.
159    /// Negative towards the start or up.
160    progress: f32,
161    /// How much of the item's box is left, `1` down to `0`.
162    extent_factor: f32,
163    /// Where an animated phase started, and how far through it is.
164    from: f32,
165    elapsed: f32,
166    /// The way it went, once that is settled.
167    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    /// Where the item is in its cycle.
185    pub fn phase(&self) -> DismissPhase {
186        self.phase
187    }
188
189    /// How far it has moved, as a signed fraction of its extent.
190    pub fn progress(&self) -> f32 {
191        self.progress
192    }
193
194    /// How much of its box is left, `1` down to `0`.
195    pub fn extent_factor(&self) -> f32 {
196        self.extent_factor
197    }
198
199    /// The way it went, once settled.
200    pub fn direction(&self) -> Option<DismissDirection> {
201        self.direction
202    }
203
204    /// `true` while a finger owns the item.
205    pub fn is_dragging(&self) -> bool {
206        self.phase == DismissPhase::Drag
207    }
208
209    /// Moves the item by `delta` px along its axis, over an item of `extent` px.
210    /// A direction the item does not accept is refused outright rather than moved and
211    /// snapped back.
212    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            // Not an accepted direction: it may return to rest, and no further.
220            None => 0.0,
221        };
222        self.progress = allowed.clamp(-1.0, 1.0);
223        self.direction = axis.direction(self.progress.signum());
224    }
225
226    /// The finger lets go, at `velocity` px/s along the swipe axis and `cross` px/s
227    /// across it. Returns the direction the item is being dismissed in, or `None` when
228    /// it slides back.
229    ///
230    /// A fling counts only if it is fast enough **and** clearly along the axis: a
231    /// hurried diagonal is not a swipe, and without that test a fast scroll would throw
232    /// rows out of the list.
233    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    /// Advances by `dt` seconds. Returns `Some(direction)` on the single frame the
264    /// collapse finishes — the moment the application should be told.
265    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                    // The gap has finished closing; only now is the item's absence
291                    // something the application should act on.
292                    return (false, self.direction);
293                }
294                (true, None)
295            }
296        }
297    }
298
299    /// `true` once there is nothing left to show or animate.
300    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/// What a [`Dismissible`] tells the frame about itself.
307#[derive(Copy, Clone, Debug, PartialEq)]
308pub struct DismissSpec {
309    /// Which ways the item may be swiped.
310    pub axis: DismissAxis,
311    /// Fraction of the extent a drag must cover to dismiss on release.
312    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/// One dismissible item of the frame: where it is, and how it was configured.
325#[derive(Copy, Clone, Debug, PartialEq)]
326pub struct Dismissable {
327    /// The item's identity, and the key of its retained state.
328    pub id: WidgetId,
329    /// Its box on screen.
330    pub rect: Rect,
331    /// Its configuration this frame.
332    pub spec: DismissSpec,
333}
334
335impl Dismissable {
336    /// The extent a drag is measured against: the width for a sideways swipe, the
337    /// height for an up-and-down one.
338    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
347/// Swipe an item aside to remove it.
348///
349/// ```ignore
350/// Dismissible::new(row)
351///     .height(56.0)
352///     .on_dismiss(Msg::Delete(id))
353///     .background(Container::new().color(theme.error))
354/// ```
355///
356/// The background is revealed as the item slides off it. Give a `secondary_background`
357/// to show something different in each direction — the usual "archive one way, delete
358/// the other".
359///
360/// Wrap each item in a [`crate::Keyed`] when the list can reorder: the retained
361/// dismissal state is keyed by identity, and positional identity would hand a
362/// half-swiped state to whichever row moved into that slot.
363pub 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    /// `[background?, secondary_background?, child]` — the child is always last, so it
372    /// paints over them.
373    children: Vec<Box<dyn Widget<Msg>>>,
374    /// How many of `children` are backgrounds.
375    backgrounds: usize,
376}
377
378impl<Msg: Clone> Dismissible<Msg> {
379    /// Makes `child` swipeable. Nothing is removed until
380    /// [`on_dismiss`](Self::on_dismiss) gives it a message to send.
381    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    /// The message dispatched once the item has flown out **and** its gap has closed,
396    /// whichever way it went.
397    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
398        self.message = Some(message.into());
399        self
400    }
401
402    /// The message for a swipe towards the reading start, overriding
403    /// [`on_dismiss`](Self::on_dismiss) for that direction.
404    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    /// The message for a swipe towards the reading end.
410    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    /// Which ways the item may be swiped; horizontal by default.
416    pub fn axis(mut self, axis: DismissAxis) -> Self {
417        self.spec.axis = axis;
418        self
419    }
420
421    /// Fraction of the extent a drag must cover to dismiss on release.
422    pub fn threshold(mut self, threshold: f32) -> Self {
423        self.spec.threshold = threshold;
424        self
425    }
426
427    /// What shows behind the item as it slides away.
428    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    /// What shows behind the item when it goes the **other** way. Requires a
435    /// [`background`](Self::background) first, which covers the first direction.
436    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    /// Sets the width, in logical pixels.
443    pub fn width(mut self, width: f32) -> Self {
444        self.width = Dimension::Length(width);
445        self
446    }
447
448    /// Sets the height, in logical pixels.
449    pub fn height(mut self, height: f32) -> Self {
450        self.height = Dimension::Length(height);
451        self
452    }
453
454    /// Flex growth factor along the parent's main axis.
455    pub fn flex(mut self, grow: f32) -> Self {
456        self.flex_grow = grow;
457        self
458    }
459
460    /// How many of the children are backgrounds — the child itself is the last one.
461    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    // The backgrounds sit under the child at the same box, which is exactly a stack —
487    // and a stack is a layout leaf whose children are laid out separately.
488    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
505/// The colour of the strip a background shows through, for the tests and for a caller
506/// painting its own.
507pub fn revealed_strip(rect: Rect, progress: f32) -> Rect {
508    let travelled = (rect.width * progress).abs().min(rect.width);
509    if progress < 0.0 {
510        // Slid towards the start: the strip is uncovered on the end side.
511        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
522/// Advances every dismissible of the frame by `dt`, dropping those that have finished.
523/// Returns `(still animating, the items whose gap has just closed)`.
524pub(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        // An item that has left the tree keeps no claim on the frame — except while it
536        // is collapsing, which is the one phase whose whole point is that the widget is
537        // on its way out.
538        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
551/// Moves the item `id` by `delta` px, creating its state on the first move.
552pub(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
562/// Releases the item `id`, returning the direction it is being dismissed in.
563pub(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        // Flying out: nothing announced yet.
677        assert!(run(&mut states, MOVEMENT_TIME + 0.01).is_empty());
678        assert_eq!(state(&states).phase(), DismissPhase::Collapse);
679        // The collapse has only just begun — the frame that ended the flight started it,
680        // so a frame or two of it has already run.
681        assert!(state(&states).extent_factor() > 0.9);
682
683        // Half way through the collapse the box is half gone, still nothing announced.
684        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        // Fast enough sideways, but going down almost as fast: a hurried scroll.
728        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        // Dragged past the threshold one way, then flicked back the other.
750        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        // The application removed the row early; the collapse still has to finish, or
828        // the message that tells it to remove the row would never arrive.
829        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}