Skip to main content

rosace_widgets/tree/
dismissible.rs

1//! `Dismissible` — swipe a list item left/right to reveal an action or
2//! dismiss it (Mail/Messages-style), the other very common mobile list
3//! pattern missing from the widget set. Single-child wrapper, typically
4//! around a [`super::ListTile`].
5//!
6//! Drag model mirrors [`super::Carousel`]'s (same `ctx.on_press_at` +
7//! per-node [`rosace_scroll::ScrollController`] idiom, D101/D108): the
8//! controller's otherwise-unused `offset[0]` slot stores the live drag
9//! distance (this widget never scrolls anything, so there's no real
10//! offset meaning to conflict with). On release past `threshold` (a
11//! fraction of the width), the content eases fully off-screen and
12//! `on_dismissed` fires once; short of that, it springs back to rest.
13
14use std::sync::Arc;
15use rosace_core::types::{Point, Rect, Size};
16use rosace_render::Color;
17
18use super::{avail_w, BoxedWidget, Children, LayoutCtx, PaintCtx, Widget};
19
20/// Which swipe directions dismiss.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DismissDirection {
23    /// Swipe either way.
24    Horizontal,
25    /// Only swiping left (content moves left, revealing the background on
26    /// the right) dismisses.
27    EndToStart,
28    /// Only swiping right dismisses.
29    StartToEnd,
30}
31
32/// Drag distance, as a fraction of width, past which a release commits to
33/// dismissing instead of springing back.
34const DEFAULT_THRESHOLD: f32 = 0.35;
35
36pub struct Dismissible {
37    child: BoxedWidget,
38    background: Option<BoxedWidget>,
39    direction: DismissDirection,
40    threshold: f32,
41    on_dismissed: Option<Arc<dyn Fn() + Send + Sync>>,
42}
43
44impl Dismissible {
45    pub fn new(child: impl Widget + 'static) -> Self {
46        Self {
47            child: Box::new(child),
48            background: None,
49            direction: DismissDirection::Horizontal,
50            threshold: DEFAULT_THRESHOLD,
51            on_dismissed: None,
52        }
53    }
54
55    /// Custom content shown behind, revealed as the item is dragged
56    /// (defaults to a red panel with a trash icon on the revealed side).
57    pub fn background(mut self, w: impl Widget + 'static) -> Self {
58        self.background = Some(Box::new(w));
59        self
60    }
61    pub fn direction(mut self, d: DismissDirection) -> Self {
62        self.direction = d;
63        self
64    }
65    /// Drag distance (fraction of width, default 0.35) past which release
66    /// commits to dismissing instead of springing back.
67    pub fn threshold(mut self, t: f32) -> Self {
68        self.threshold = t.clamp(0.05, 0.95);
69        self
70    }
71    /// Fired once when the dismiss commits. The app is expected to remove
72    /// this item from its list in response — this widget doesn't know
73    /// about the list it's in, it only reports the gesture.
74    pub fn on_dismissed(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
75        self.on_dismissed = Some(Arc::new(f));
76        self
77    }
78
79    fn allows(&self, dx: f32) -> bool {
80        match self.direction {
81            DismissDirection::Horizontal => true,
82            DismissDirection::EndToStart => dx < 0.0,
83            DismissDirection::StartToEnd => dx > 0.0,
84        }
85    }
86}
87
88impl Widget for Dismissible {
89    fn children(&self) -> Children<'_> {
90        Children::One(&*self.child)
91    }
92
93    fn layout(&self, ctx: &LayoutCtx) -> Size {
94        let child_size = self.child.layout(ctx);
95        Size { width: avail_w(ctx.constraints), height: child_size.height }
96    }
97
98    fn paint(&self, ctx: &mut PaintCtx) {
99        let r = ctx.rect;
100        let ctrl = ctx.scroll_controller();
101
102        let drag_ctrl = ctrl.clone();
103        ctx.on_press_at(move |x, y| {
104            let (dx, _) = drag_ctrl.drag_delta(x, y);
105            if dx != 0.0 {
106                let o = drag_ctrl.offset.get();
107                drag_ctrl.offset.set([o[0] + dx, o[1]]);
108            }
109        });
110
111        let is_pressed = ctx.pressed();
112        let was_pressed = ctrl.was_pressed();
113        let mut dx = ctrl.offset.get()[0];
114
115        if !is_pressed && was_pressed {
116            let commit = dx.abs() >= r.size.width * self.threshold && self.allows(dx);
117            if commit {
118                let target = if dx < 0.0 { -r.size.width } else { r.size.width };
119                ctrl.offset.set([target, 0.0]);
120                dx = target;
121                if let Some(cb) = &self.on_dismissed {
122                    cb();
123                }
124            } else {
125                ctrl.offset.set([0.0, 0.0]);
126                dx = 0.0;
127            }
128            ctrl.end_drag();
129        }
130        ctrl.set_was_pressed(is_pressed);
131
132        if dx.abs() > 0.001 {
133            ctx.request_animation();
134        }
135
136        // Background revealed behind the sliding content.
137        if dx.abs() > 0.001 {
138            match &self.background {
139                Some(bg) => bg.paint(&mut ctx.child(r)),
140                None => draw_default_background(ctx, r, dx),
141            }
142        }
143
144        let child_rect = Rect { origin: Point { x: r.origin.x + dx, y: r.origin.y }, size: r.size };
145        ctx.record(rosace_render::DrawCommand::PushClip { rect: r });
146        self.child.paint(&mut ctx.child(child_rect));
147        ctx.record(rosace_render::DrawCommand::PopClip);
148    }
149}
150
151/// Default background: a red panel with a trash icon anchored to the side
152/// being revealed (the side opposite the drag direction).
153fn draw_default_background(ctx: &mut PaintCtx, r: Rect, dx: f32) {
154    let red = Color::rgb(220, 62, 54);
155    ctx.fill_rect(r, red);
156    const ICON: f32 = 22.0;
157    let cy = r.origin.y + (r.size.height - ICON) / 2.0;
158    let cx = if dx < 0.0 {
159        r.origin.x + r.size.width - ICON - 18.0 // revealed on the right
160    } else {
161        r.origin.x + 18.0 // revealed on the left
162    };
163    let icon = super::Icon::new(super::IconKind::Trash).size(ICON).color(Color::rgb(255, 255, 255));
164    icon.paint(&mut ctx.child(Rect { origin: Point { x: cx, y: cy }, size: Size { width: ICON, height: ICON } }));
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use rosace_layout::Constraints;
171
172    struct Row;
173    impl Widget for Row {
174        fn layout(&self, _ctx: &LayoutCtx) -> Size {
175            Size { width: 300.0, height: 56.0 }
176        }
177        fn paint(&self, _ctx: &mut PaintCtx) {}
178    }
179
180    fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
181        (rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
182    }
183
184    #[test]
185    fn height_matches_child_width_fills_parent() {
186        let d = Dismissible::new(Row);
187        let (font, theme) = test_env();
188        let ctx = LayoutCtx::new(Constraints::loose(390.0, 800.0), &font, &theme);
189        let size = d.layout(&ctx);
190        assert_eq!((size.width, size.height), (390.0, 56.0));
191    }
192
193    #[test]
194    fn allows_respects_direction() {
195        let both = Dismissible::new(Row);
196        assert!(both.allows(-50.0) && both.allows(50.0));
197
198        let end_to_start = Dismissible::new(Row).direction(DismissDirection::EndToStart);
199        assert!(end_to_start.allows(-50.0) && !end_to_start.allows(50.0));
200
201        let start_to_end = Dismissible::new(Row).direction(DismissDirection::StartToEnd);
202        assert!(!start_to_end.allows(-50.0) && start_to_end.allows(50.0));
203    }
204
205    #[test]
206    fn threshold_clamps_to_sane_range() {
207        let d = Dismissible::new(Row).threshold(5.0);
208        assert!(d.threshold <= 0.95);
209        let d2 = Dismissible::new(Row).threshold(-1.0);
210        assert!(d2.threshold >= 0.05);
211    }
212}