rosace_widgets/tree/
pull_to_refresh.rs1use 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
25const TRIGGER_DISTANCE: f32 = 70.0;
27const INDICATOR_SIZE: f32 = 32.0;
29const INDICATOR_TOP_MARGIN: f32 = 16.0;
31const PHYSICS: ScrollPhysics = ScrollPhysics::Bounce { friction: 0.88, spring_stiffness: 12.0 };
33
34pub 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 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 pub fn refreshing(mut self, v: bool) -> Self {
58 self.refreshing = v;
59 self
60 }
61
62 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 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
136fn 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}