Skip to main content

tessera_components/
pull_refresh.rs

1//! Pull-to-refresh container and indicator for scrollable content.
2//!
3//! ## Usage
4//!
5//! Trigger data reloads when users pull down at the top of a scrollable view.
6use tessera_ui::{
7    Callback, CallbackWith, Color, Dp, FocusModifierExt, Modifier, Px, RenderSlot, State,
8    current_frame_nanos, layout::layout, receive_frame_nanos, remember, tessera, use_context,
9};
10
11use crate::{
12    alignment::Alignment,
13    boxed::boxed,
14    modifier::ModifierExt,
15    nested_scroll::{
16        NestedScrollConnection, PostScrollInput, PreFlingInput, PreScrollInput, ScrollDelta,
17        ScrollVelocity,
18    },
19    progress::circular_progress_indicator,
20    shape_def::Shape,
21    surface::surface,
22    theme::{MaterialTheme, content_color_for},
23};
24
25/// Material defaults for pull-to-refresh.
26pub struct PullRefreshDefaults;
27
28impl PullRefreshDefaults {
29    /// Pull distance required to trigger a refresh.
30    pub const REFRESH_THRESHOLD: Dp = Dp(80.0);
31    /// Offset where the indicator rests while refreshing.
32    pub const REFRESHING_OFFSET: Dp = Dp(56.0);
33    /// Indicator container size.
34    pub const INDICATOR_SIZE: Dp = Dp(40.0);
35    /// Indicator stroke width.
36    pub const INDICATOR_STROKE_WIDTH: Dp = Dp(2.5);
37    /// Indicator elevation.
38    pub const INDICATOR_ELEVATION: Dp = Dp(6.0);
39}
40
41const DRAG_MULTIPLIER: f32 = 0.5;
42const INDICATOR_CONTENT_SCALE: f32 = 0.6;
43const INDICATOR_SMOOTHING: f32 = 0.2;
44const INDICATOR_FADE_START_PROGRESS: f32 = 0.05;
45const INDICATOR_FADE_END_PROGRESS: f32 = 0.25;
46
47/// Tracks pull-to-refresh state and indicator position.
48pub struct PullRefreshController {
49    refreshing: bool,
50    position: f32,
51    target_position: f32,
52    distance_pulled: f32,
53    threshold: f32,
54    refreshing_offset: f32,
55    last_frame_nanos: Option<u64>,
56}
57
58impl Default for PullRefreshController {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl PullRefreshController {
65    /// Creates a new `PullRefreshController` with default thresholds.
66    pub fn new() -> Self {
67        Self {
68            refreshing: false,
69            position: 0.0,
70            target_position: 0.0,
71            distance_pulled: 0.0,
72            threshold: PullRefreshDefaults::REFRESH_THRESHOLD.to_pixels_f32(),
73            refreshing_offset: PullRefreshDefaults::REFRESHING_OFFSET.to_pixels_f32(),
74            last_frame_nanos: None,
75        }
76    }
77
78    /// Returns the pull progress as a ratio of the refresh threshold.
79    pub fn progress(&self) -> f32 {
80        if self.threshold <= 0.0 {
81            return 0.0;
82        }
83        self.adjusted_distance_pulled() / self.threshold
84    }
85
86    /// Returns whether a refresh is currently in progress.
87    pub fn refreshing(&self) -> bool {
88        self.refreshing
89    }
90
91    /// Returns the current indicator position in pixels.
92    pub fn position(&self) -> Px {
93        Px::saturating_from_f32(self.position)
94    }
95
96    fn is_pulling(&self) -> bool {
97        self.distance_pulled > 0.0
98    }
99
100    fn set_refreshing(&mut self, refreshing: bool) {
101        if self.refreshing == refreshing {
102            return;
103        }
104        self.refreshing = refreshing;
105        self.distance_pulled = 0.0;
106        let target = if refreshing {
107            self.refreshing_offset
108        } else {
109            0.0
110        };
111        self.set_target_position(target);
112    }
113
114    fn set_threshold(&mut self, threshold: f32) {
115        if threshold.is_finite() && threshold > 0.0 {
116            self.threshold = threshold;
117        }
118    }
119
120    fn set_refreshing_offset(&mut self, offset: f32) {
121        if offset.is_finite() && offset >= 0.0 {
122            self.refreshing_offset = offset;
123            if self.refreshing {
124                self.set_target_position(offset);
125            }
126        }
127    }
128
129    fn on_pull(&mut self, pull_delta: f32) -> f32 {
130        if self.refreshing {
131            return 0.0;
132        }
133
134        let new_offset = (self.distance_pulled + pull_delta).max(0.0);
135        let consumed = new_offset - self.distance_pulled;
136        self.distance_pulled = new_offset;
137        let position = self.calculate_indicator_position();
138        self.position = position;
139        self.target_position = position;
140        consumed
141    }
142
143    fn on_release(&mut self) -> bool {
144        if self.refreshing {
145            return false;
146        }
147
148        let should_refresh = self.adjusted_distance_pulled() > self.threshold;
149        self.distance_pulled = 0.0;
150        self.set_target_position(0.0);
151        should_refresh
152    }
153
154    fn cancel_pull(&mut self) {
155        if !self.refreshing && self.is_pulling() {
156            self.distance_pulled = 0.0;
157            self.set_target_position(0.0);
158        }
159    }
160
161    fn update_position(&mut self, frame_nanos: u64, smoothing: f32) -> bool {
162        let delta_time = if let Some(last_frame_nanos) = self.last_frame_nanos {
163            frame_nanos.saturating_sub(last_frame_nanos) as f32 / 1_000_000_000.0
164        } else {
165            0.016
166        };
167        self.last_frame_nanos = Some(frame_nanos);
168
169        let diff = self.target_position - self.position;
170        if diff.abs() < 0.5 {
171            if (self.position - self.target_position).abs() > f32::EPSILON {
172                self.position = self.target_position;
173                return true;
174            }
175            return false;
176        }
177
178        let mut movement_factor = (1.0 - smoothing).mul_add(delta_time * 60.0, 0.0);
179        if movement_factor > 1.0 {
180            movement_factor = 1.0;
181        }
182
183        let old_position = self.position;
184        self.position += diff * movement_factor;
185        (self.position - old_position).abs() > f32::EPSILON
186    }
187
188    fn adjusted_distance_pulled(&self) -> f32 {
189        self.distance_pulled * DRAG_MULTIPLIER
190    }
191
192    fn calculate_indicator_position(&self) -> f32 {
193        let adjusted = self.adjusted_distance_pulled();
194        if adjusted <= self.threshold {
195            return adjusted;
196        }
197
198        let overshoot_percent = (self.progress().abs() - 1.0).max(0.0);
199        let linear_tension = overshoot_percent.clamp(0.0, 2.0);
200        let tension_percent = linear_tension - (linear_tension * linear_tension / 4.0);
201        self.threshold + self.threshold * tension_percent
202    }
203
204    fn set_target_position(&mut self, target: f32) {
205        self.target_position = target.max(0.0);
206    }
207
208    fn has_pending_animation_frame(&self) -> bool {
209        (self.target_position - self.position).abs() > f32::EPSILON
210    }
211}
212
213/// # pull_refresh_indicator
214///
215/// Draws the default pull-to-refresh indicator for refreshable lists and feeds.
216///
217/// ## Usage
218///
219/// Use inside a pull-to-refresh container to visualize pull progress or
220/// refreshing state.
221///
222/// ## Parameters
223///
224/// - `modifier` — optional modifier chain applied to the indicator.
225/// - `size` — optional indicator container size.
226/// - `background_color` — optional indicator background color.
227/// - `content_color` — optional indicator content color.
228/// - `track_color` — optional indicator track color.
229/// - `stroke_width` — optional indicator stroke width.
230/// - `elevation` — optional indicator elevation.
231/// - `controller` — optional controller that drives progress and refreshing.
232///
233/// ## Examples
234///
235/// ```
236/// use tessera_components::pull_refresh::{PullRefreshController, pull_refresh_indicator};
237/// use tessera_components::theme::{MaterialTheme, material_theme};
238/// use tessera_ui::{remember, tessera};
239///
240/// #[tessera]
241/// fn demo() {
242///     material_theme()
243///         .theme(|| MaterialTheme::default())
244///         .child(|| {
245///             let controller = remember(PullRefreshController::new);
246///             pull_refresh_indicator().controller(controller);
247///         });
248/// }
249/// ```
250#[tessera]
251pub fn pull_refresh_indicator(
252    modifier: Option<Modifier>,
253    size: Option<Dp>,
254    background_color: Option<Color>,
255    content_color: Option<Color>,
256    track_color: Option<Color>,
257    stroke_width: Option<Dp>,
258    elevation: Option<Dp>,
259    controller: Option<State<PullRefreshController>>,
260) {
261    let scheme = use_context::<MaterialTheme>()
262        .expect("MaterialTheme must be provided")
263        .get()
264        .color_scheme;
265    let background_color = background_color.unwrap_or(scheme.surface);
266    let content_color = content_color.unwrap_or_else(|| {
267        content_color_for(background_color, &scheme).unwrap_or(scheme.on_surface)
268    });
269    let controller = match controller {
270        Some(value) => value,
271        None => remember(PullRefreshController::new),
272    };
273    let refreshing = controller.with(|s| s.refreshing());
274    let progress = controller.with(|s| s.progress()).clamp(0.0, 1.0);
275    let indicator_alpha = if refreshing {
276        1.0
277    } else if progress <= INDICATOR_FADE_START_PROGRESS {
278        0.0
279    } else if progress >= INDICATOR_FADE_END_PROGRESS {
280        1.0
281    } else {
282        (progress - INDICATOR_FADE_START_PROGRESS)
283            / (INDICATOR_FADE_END_PROGRESS - INDICATOR_FADE_START_PROGRESS)
284    };
285
286    if indicator_alpha <= 0.0 {
287        return;
288    }
289
290    let indicator_size = size.unwrap_or(PullRefreshDefaults::INDICATOR_SIZE);
291    let indicator_modifier = modifier
292        .unwrap_or_default()
293        .size(indicator_size, indicator_size)
294        .alpha(indicator_alpha);
295    let content_size = Dp(indicator_size.0 * INDICATOR_CONTENT_SCALE as f64);
296
297    surface()
298        .modifier(indicator_modifier)
299        .shape(Shape::Ellipse)
300        .style(background_color.into())
301        .elevation(elevation.unwrap_or(PullRefreshDefaults::INDICATOR_ELEVATION))
302        .content_alignment(Alignment::Center)
303        .child(move || {
304            if !refreshing {
305                circular_progress_indicator()
306                    .diameter(content_size)
307                    .stroke_width(
308                        stroke_width.unwrap_or(PullRefreshDefaults::INDICATOR_STROKE_WIDTH),
309                    )
310                    .color(content_color)
311                    .track_color(track_color.unwrap_or(Color::TRANSPARENT))
312                    .progress(progress);
313            } else {
314                circular_progress_indicator()
315                    .diameter(content_size)
316                    .stroke_width(
317                        stroke_width.unwrap_or(PullRefreshDefaults::INDICATOR_STROKE_WIDTH),
318                    )
319                    .color(content_color)
320                    .track_color(track_color.unwrap_or(Color::TRANSPARENT));
321            }
322        });
323}
324
325/// # pull_refresh
326///
327/// Wraps a scrollable child and adds pull-to-refresh interaction for lists and
328/// feeds.
329///
330/// ## Usage
331///
332/// Use for feeds or lists that need to reload data when pulled from the top.
333///
334/// ## Parameters
335///
336/// - `modifier` — optional modifier chain applied to the pull-refresh
337///   container.
338/// - `on_refresh` — optional callback invoked when a refresh is triggered.
339/// - `refreshing` — whether a refresh is currently in progress.
340/// - `enabled` — whether pull-to-refresh interactions are enabled.
341/// - `refresh_threshold` — optional pull distance required to trigger a
342///   refresh.
343/// - `refreshing_offset` — optional resting offset while refreshing.
344/// - `indicator_size` — optional indicator container size.
345/// - `indicator_background_color` — optional indicator background color.
346/// - `indicator_content_color` — optional indicator content color.
347/// - `indicator_track_color` — optional indicator track color.
348/// - `indicator_stroke_width` — optional indicator stroke width.
349/// - `indicator_elevation` — optional indicator elevation.
350/// - `controller` — optional external refresh controller.
351/// - `indicator` — optional custom indicator content; uses `indicator_size` and
352///   follows the controller position.
353/// - `child` — optional content rendered inside the pull-refresh container.
354///
355/// ## Examples
356///
357/// ```
358/// use tessera_components::theme::{MaterialTheme, material_theme};
359/// use tessera_components::{
360///     column::column,
361///     modifier::ModifierExt as _,
362///     pull_refresh::{PullRefreshController, pull_refresh},
363///     scrollable::ScrollableController,
364///     text::text,
365/// };
366/// use tessera_ui::{Modifier, layout::layout, remember, tessera};
367///
368/// #[tessera]
369/// fn demo() {
370///     material_theme()
371///         .theme(|| MaterialTheme::default())
372///         .child(|| {
373///             let refresh_controller = remember(PullRefreshController::new);
374///             let scroll = remember(ScrollableController::new);
375///             pull_refresh()
376///                 .on_refresh(|| {})
377///                 .refreshing(false)
378///                 .controller(refresh_controller)
379///                 .child(move || {
380///                     layout()
381///                         .modifier(Modifier::new().vertical_scroll(scroll))
382///                         .child(move || {
383///                             column().children(|| {
384///                                 text("Pull down to refresh");
385///                             });
386///                         });
387///                 });
388///         });
389/// }
390///
391/// # tessera_ui::testing::layout_test(demo).run();
392/// ```
393#[tessera]
394pub fn pull_refresh(
395    modifier: Option<Modifier>,
396    on_refresh: Option<Callback>,
397    refreshing: Option<bool>,
398    enabled: Option<bool>,
399    refresh_threshold: Option<Dp>,
400    refreshing_offset: Option<Dp>,
401    indicator_size: Option<Dp>,
402    indicator_background_color: Option<Color>,
403    indicator_content_color: Option<Color>,
404    indicator_track_color: Option<Color>,
405    indicator_stroke_width: Option<Dp>,
406    indicator_elevation: Option<Dp>,
407    controller: Option<State<PullRefreshController>>,
408    indicator: Option<RenderSlot>,
409    child: Option<RenderSlot>,
410) {
411    let refreshing = refreshing.unwrap_or(false);
412    let enabled = enabled.unwrap_or(true);
413    let scheme = use_context::<MaterialTheme>()
414        .expect("MaterialTheme must be provided")
415        .get()
416        .color_scheme;
417    let indicator_background_color = indicator_background_color.unwrap_or(scheme.surface);
418    let indicator_content_color = indicator_content_color.unwrap_or_else(|| {
419        content_color_for(indicator_background_color, &scheme).unwrap_or(scheme.on_surface)
420    });
421    let controller = match controller {
422        Some(value) => value,
423        None => remember(PullRefreshController::new),
424    };
425    let child = child.unwrap_or_else(RenderSlot::empty);
426    let modifier = modifier
427        .unwrap_or_else(|| Modifier::new().fill_max_size())
428        .clip_to_bounds();
429
430    controller.with_mut(|state| {
431        state.set_threshold(
432            refresh_threshold
433                .unwrap_or(PullRefreshDefaults::REFRESH_THRESHOLD)
434                .to_pixels_f32(),
435        );
436        state.set_refreshing_offset(
437            refreshing_offset
438                .unwrap_or(PullRefreshDefaults::REFRESHING_OFFSET)
439                .to_pixels_f32(),
440        );
441        state.set_refreshing(refreshing);
442    });
443    let frame_nanos = current_frame_nanos();
444    controller.with_mut(|s| {
445        s.update_position(frame_nanos, INDICATOR_SMOOTHING);
446    });
447    if controller.with(|s| s.has_pending_animation_frame()) {
448        receive_frame_nanos(move |frame_nanos| {
449            let has_pending_animation_frame = controller.with_mut(|s| {
450                s.update_position(frame_nanos, INDICATOR_SMOOTHING);
451                s.has_pending_animation_frame()
452            });
453            if has_pending_animation_frame {
454                tessera_ui::FrameNanosControl::Continue
455            } else {
456                tessera_ui::FrameNanosControl::Stop
457            }
458        });
459    }
460
461    let parent_nested_scroll = NestedScrollConnection::nearest();
462    let on_refresh = on_refresh.unwrap_or_default();
463    let coordinate_scale = remember(|| 1.0_f32);
464    let nested_scroll_connection = NestedScrollConnection::new()
465        .with_coordinate_scale(coordinate_scale)
466        .with_pre_scroll_handler(CallbackWith::new({
467            move |input: PreScrollInput| {
468                if !enabled || input.source != tessera_ui::ScrollEventSource::Touch {
469                    return ScrollDelta::ZERO;
470                }
471
472                let consumed_y = if input.available.y < 0.0 && controller.with(|s| s.is_pulling()) {
473                    controller.with_mut(|s| s.on_pull(input.available.y))
474                } else {
475                    0.0
476                };
477                ScrollDelta::new(0.0, consumed_y)
478            }
479        }))
480        .with_post_scroll_handler(CallbackWith::new({
481            move |input: PostScrollInput| {
482                if !enabled || input.source != tessera_ui::ScrollEventSource::Touch {
483                    return ScrollDelta::ZERO;
484                }
485                let _ = input.consumed_by_child;
486
487                let consumed_y = if input.available.y > 0.0 {
488                    controller.with_mut(|s| s.on_pull(input.available.y))
489                } else {
490                    0.0
491                };
492                ScrollDelta::new(0.0, consumed_y)
493            }
494        }))
495        .with_pre_fling_handler(CallbackWith::new({
496            move |input: PreFlingInput| {
497                if !enabled || !controller.with(|s| s.is_pulling()) {
498                    return ScrollVelocity::ZERO;
499                }
500
501                let should_refresh = controller.with_mut(|s| s.on_release());
502                if should_refresh {
503                    on_refresh.call();
504                }
505
506                ScrollVelocity::new(0.0, input.available.y.max(0.0))
507            }
508        }))
509        .with_post_fling_handler(CallbackWith::new(move |_| {
510            controller.with_mut(PullRefreshController::cancel_pull);
511            ScrollVelocity::ZERO
512        }))
513        .with_parent(parent_nested_scroll);
514
515    layout()
516        .modifier(
517            modifier.on_local_to_window(move |transform: tessera_ui::LocalToWindow| {
518                if coordinate_scale.get() != transform.scale() {
519                    coordinate_scale.set(transform.scale());
520                }
521            }),
522        )
523        .child(move || {
524            let child = child;
525            let nested_scroll_connection = nested_scroll_connection.clone();
526            boxed()
527                .modifier(
528                    Modifier::new()
529                        .fill_max_size()
530                        .nested_scroll(nested_scroll_connection.clone()),
531                )
532                .alignment(Alignment::TopCenter)
533                .children(move || {
534                    child.render();
535                    layout()
536                        .modifier(Modifier::new().align(Alignment::TopCenter))
537                        .child(move || {
538                            let offset = indicator_offset_dp(
539                                controller,
540                                indicator_size.unwrap_or(PullRefreshDefaults::INDICATOR_SIZE),
541                            );
542                            layout()
543                                .modifier(Modifier::new().offset(Dp(0.0), offset))
544                                .child(move || {
545                                    if let Some(indicator) = indicator {
546                                        boxed()
547                                            .modifier(
548                                                Modifier::new().size(
549                                                    indicator_size.unwrap_or(
550                                                        PullRefreshDefaults::INDICATOR_SIZE,
551                                                    ),
552                                                    indicator_size.unwrap_or(
553                                                        PullRefreshDefaults::INDICATOR_SIZE,
554                                                    ),
555                                                ),
556                                            )
557                                            .alignment(Alignment::Center)
558                                            .children(move || {
559                                                indicator.render();
560                                            });
561                                    } else {
562                                        pull_refresh_indicator()
563                                            .size(
564                                                indicator_size
565                                                    .unwrap_or(PullRefreshDefaults::INDICATOR_SIZE),
566                                            )
567                                            .background_color(indicator_background_color)
568                                            .content_color(indicator_content_color)
569                                            .track_color(
570                                                indicator_track_color.unwrap_or(Color::TRANSPARENT),
571                                            )
572                                            .stroke_width(indicator_stroke_width.unwrap_or(
573                                                PullRefreshDefaults::INDICATOR_STROKE_WIDTH,
574                                            ))
575                                            .elevation(indicator_elevation.unwrap_or(
576                                                PullRefreshDefaults::INDICATOR_ELEVATION,
577                                            ))
578                                            .controller(controller);
579                                    }
580                                });
581                        });
582                });
583        });
584}
585
586fn indicator_offset_dp(controller: State<PullRefreshController>, indicator_size: Dp) -> Dp {
587    let indicator_offset_px =
588        controller.with(|s| s.position().to_f32()) - indicator_size.to_pixels_f32();
589    Dp::from_pixels_f32(indicator_offset_px)
590}
591
592#[cfg(all(test, not(target_family = "wasm")))]
593#[path = "pull_refresh_tests.rs"]
594mod tests;