Skip to main content

rosace_widgets/tree/
pull_to_refresh.rs

1//! `PullToRefresh` — wraps content with the pull-down-to-refresh gesture,
2//! the standard mobile pattern for reloading a list/feed.
3//!
4//! Doesn't reimplement a scrollable itself: it's a
5//! [`super::register_nested_scroll`] LINK (D-NESTED-SCROLL, the same chain
6//! mechanism `ScrollView` composes with) around whatever `child` is. If
7//! `child` is itself scrollable (a `ListView`/`ScrollView`), it only hands
8//! this node the leftover drag once it's exhausted at its own top; if
9//! `child` is plain content, this node gets the whole gesture directly —
10//! either way `PullToRefresh` only ever owns a one-sided "pulled past the
11//! top" offset, using the exact same `ScrollController` physics
12//! (`try_apply_delta`/`coast`/`settle_bounce`) `ScrollView` itself runs.
13//! `viewport_size`/`content_size` are deliberately left unpublished (their
14//! default `[0, 0]`), which makes that bound math always resolve to
15//! "spring back to exactly 0" — there is no real scroll extent here, just
16//! a pull distance.
17
18use std::sync::Arc;
19use rosace_core::types::{Point, Rect, Size};
20use rosace_render::{Color, DrawCommand};
21use rosace_scroll::ScrollPhysics;
22
23use super::{avail_h, avail_w, intersect_rect, BoxedWidget, Children, LayoutCtx, PaintCtx, Widget};
24
25/// Pull distance (logical px) past which a release triggers `on_refresh`.
26const TRIGGER_DISTANCE: f32 = 70.0;
27/// Indicator diameter (logical px).
28const INDICATOR_SIZE: f32 = 32.0;
29/// Gap between the indicator and the top edge once it's fully revealed.
30const INDICATOR_TOP_MARGIN: f32 = 16.0;
31/// Same rubber-band shape `ScrollView` uses under `Bounce`.
32const PHYSICS: ScrollPhysics = ScrollPhysics::Bounce { friction: 0.88, spring_stiffness: 12.0 };
33
34/// Wraps `child` (typically a `ListView`/`Column`) with pull-to-refresh.
35pub struct PullToRefresh {
36    child: BoxedWidget,
37    on_refresh: Option<Arc<dyn Fn() + Send + Sync>>,
38    refreshing: bool,
39    color: Option<Color>,
40}
41
42impl PullToRefresh {
43    pub fn new(child: impl Widget + 'static) -> Self {
44        Self { child: Box::new(child), on_refresh: None, refreshing: false, color: None }
45    }
46
47    /// Fired once when the user releases past the trigger distance. Typical
48    /// use: flip an `Atom<bool>` (fed back via `.refreshing(..)`) and kick
49    /// off async work that flips it back when done.
50    pub fn on_refresh(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
51        self.on_refresh = Some(Arc::new(f));
52        self
53    }
54
55    /// Whether a refresh is in flight — shows a spinning (indeterminate)
56    /// indicator instead of the pull-progress ring while `true`.
57    pub fn refreshing(mut self, v: bool) -> Self {
58        self.refreshing = v;
59        self
60    }
61
62    /// Indicator tint — defaults to the theme's `primary`.
63    pub fn color(mut self, c: Color) -> Self {
64        self.color = Some(c);
65        self
66    }
67}
68
69impl Widget for PullToRefresh {
70    fn children(&self) -> Children<'_> {
71        Children::One(&*self.child)
72    }
73
74    fn layout(&self, ctx: &LayoutCtx) -> Size {
75        Size { width: avail_w(ctx.constraints), height: avail_h(ctx.constraints) }
76    }
77
78    fn paint(&self, ctx: &mut PaintCtx) {
79        let r = ctx.rect;
80        let color = self.color.unwrap_or_else(|| ctx.tc(ctx.theme.colors.primary));
81        let ctrl = ctx.scroll_controller();
82
83        let drag_ctrl = ctrl.clone();
84        ctx.register_nested_scroll(move |_dx, dy| drag_ctrl.try_apply_delta(0.0, -dy, PHYSICS));
85
86        let dt = rosace_animate::frame_dt().max(0.0001);
87        let is_pressed = ctx.pressed();
88        let was_pressed = ctrl.was_pressed();
89        if is_pressed {
90            ctrl.track_velocity(dt);
91        } else {
92            if was_pressed { ctrl.end_drag(); }
93            if ctrl.coast(PHYSICS, dt) {
94                ctx.request_animation();
95            }
96        }
97        let released_this_frame = was_pressed && !is_pressed;
98        ctrl.set_was_pressed(is_pressed);
99
100        let pull = (-ctrl.offset.get()[1]).max(0.0);
101
102        if released_this_frame && !self.refreshing && pull >= TRIGGER_DISTANCE {
103            if let Some(cb) = &self.on_refresh {
104                cb();
105            }
106        }
107
108        // Content translates down by the pull distance, revealing the
109        // indicator above it — the standard mobile pull-to-refresh visual.
110        let child_rect = Rect {
111            origin: Point { x: r.origin.x, y: r.origin.y + pull },
112            size: r.size,
113        };
114        ctx.record(DrawCommand::PushClip { rect: r });
115        let effective_clip = ctx.clip_rect.and_then(|p| intersect_rect(p, r)).unwrap_or(r);
116        let mut child_ctx = ctx.child(child_rect);
117        child_ctx.clip_rect = Some(effective_clip);
118        self.child.paint(&mut child_ctx);
119        ctx.record(DrawCommand::PopClip);
120
121        if self.refreshing {
122            let cx = r.origin.x + r.size.width / 2.0;
123            let cy = r.origin.y + INDICATOR_TOP_MARGIN + INDICATOR_SIZE / 2.0;
124            draw_indicator(ctx, Point { x: cx, y: cy }, None, color);
125            ctx.request_animation();
126        } else if pull > 0.0 {
127            let progress = (pull / TRIGGER_DISTANCE).min(1.0);
128            let travel = pull.min(TRIGGER_DISTANCE + INDICATOR_TOP_MARGIN);
129            let cx = r.origin.x + r.size.width / 2.0;
130            let cy = r.origin.y - INDICATOR_SIZE / 2.0 + travel;
131            draw_indicator(ctx, Point { x: cx, y: cy }, Some(progress), color);
132        }
133    }
134}
135
136/// Draws the indicator ring directly (rather than delegating to a real
137/// `CircularProgress` child widget) since its center is computed from the
138/// live pull distance every frame, not a layout slot.
139fn draw_indicator(ctx: &mut PaintCtx, center: Point, progress: Option<f32>, color: Color) {
140    const THICKNESS: f32 = 3.0;
141    let radius = (INDICATOR_SIZE - THICKNESS) / 2.0;
142    let track = Color::rgba(color.r, color.g, color.b, 40);
143    ctx.fill_arc(center, radius, THICKNESS, 0.0, 360.0, track);
144    match progress {
145        Some(p) if p > 0.0 => {
146            ctx.fill_arc(center, radius, THICKNESS, -90.0, 360.0 * p, color);
147        }
148        Some(_) => {}
149        None => {
150            let t = super::anim_clock();
151            let start = (t * 360.0) % 360.0;
152            ctx.fill_arc(center, radius, THICKNESS, start, 270.0, color);
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use rosace_layout::Constraints;
161
162    struct Filler;
163    impl Widget for Filler {
164        fn layout(&self, ctx: &LayoutCtx) -> Size {
165            Size { width: avail_w(ctx.constraints), height: 2000.0 }
166        }
167        fn paint(&self, _ctx: &mut PaintCtx) {}
168    }
169
170    fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
171        (rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
172    }
173
174    #[test]
175    fn fills_available_space() {
176        let w = PullToRefresh::new(Filler);
177        let (font, theme) = test_env();
178        let ctx = LayoutCtx::new(Constraints::tight(390.0, 800.0), &font, &theme);
179        let size = w.layout(&ctx);
180        assert_eq!((size.width, size.height), (390.0, 800.0));
181    }
182
183    #[test]
184    fn builders_set_state() {
185        let w = PullToRefresh::new(Filler).refreshing(true).color(Color::rgb(1, 2, 3));
186        assert!(w.refreshing);
187        assert_eq!(w.color, Some(Color::rgb(1, 2, 3)));
188    }
189}