Skip to main content

repose_material/material3/
pull_to_refresh.rs

1#![allow(non_snake_case)]
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5use web_time::Duration;
6
7use crate::{Icon, Symbol};
8use repose_core::animation::{AnimationSpec, Easing, RepeatableSpec};
9use repose_core::*;
10use repose_ui::{
11    Box, Column, TextStyle,
12    ViewExt,
13    anim::animate_f32_from,
14};
15
16use super::*;
17
18/// Configuration for pull-to-refresh.
19#[derive(Clone, Debug)]
20pub struct PullToRefreshConfig {
21    pub modifier: Modifier,
22    pub indicator_color: Color,
23    pub threshold: f32,
24    pub content_alignment: AlignItems,
25}
26
27impl Default for PullToRefreshConfig {
28    fn default() -> Self {
29        Self {
30            modifier: Modifier::new(),
31            indicator_color: PullToRefreshDefaults::indicator_color(),
32            threshold: PullToRefreshDefaults::THRESHOLD,
33            content_alignment: AlignItems::FLEX_START,
34        }
35    }
36}
37
38/// State for `PullToRefresh` - tracks pull progress and refresh trigger.
39///
40/// Connect to a [`ScrollState`](repose_ui::scroll::ScrollState) via
41/// [`set_scroll_state`](PullToRefreshState::set_scroll_state) so that the
42/// pull offset is automatically driven by scroll overscroll.
43pub struct PullToRefreshState {
44    refreshing: Signal<bool>,
45    scroll_state: RefCell<Option<Rc<repose_ui::scroll::ScrollState>>>,
46    threshold: f32,
47    triggered: Cell<bool>,
48}
49
50impl Default for PullToRefreshState {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl PullToRefreshState {
57    pub fn new() -> Self {
58        Self {
59            refreshing: signal(false),
60            scroll_state: RefCell::new(None),
61            threshold: 64.0,
62            triggered: Cell::new(false),
63        }
64    }
65
66    /// Connect this PullToRefresh state to a scroll state.
67    /// The pull offset is then derived from the scroll state's overscroll.
68    pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
69        *self.scroll_state.borrow_mut() = Some(state);
70    }
71
72    /// Set the overscroll threshold that triggers a refresh (default 64px).
73    pub fn set_threshold(&mut self, px: f32) {
74        self.threshold = px;
75    }
76
77    pub fn is_refreshing(&self) -> bool {
78        self.refreshing.get()
79    }
80
81    pub fn set_refreshing(&self, v: bool) {
82        self.refreshing.set(v);
83        if !v && let Some(sc) = self.scroll_state.borrow().as_ref() {
84            sc.set_overscroll(0.0);
85        }
86    }
87
88    /// Read the current pull offset from the connected scroll state's overscroll.
89    pub fn pull_offset(&self) -> f32 {
90        if let Some(sc) = self.scroll_state.borrow().as_ref() {
91            let os = sc.overscroll_offset();
92            if os < 0.0 { -os } else { 0.0 }
93        } else {
94            0.0
95        }
96    }
97}
98
99/// Wraps scrollable content with a pull-to-refresh indicator.
100///
101/// Renders a small spinner at the top when the user pulls down past a threshold,
102/// or shows the current pull offset as a visual indicator.
103///
104/// The `state` must be connected to a [`ScrollState`](repose_ui::scroll::ScrollState)
105/// via [`set_scroll_state`](PullToRefreshState::set_scroll_state) for the pull
106/// offset to be derived from the scroll overscroll automatically.
107pub fn PullToRefresh(
108    state: Rc<PullToRefreshState>,
109    modifier: Modifier,
110    on_refresh: Rc<dyn Fn()>,
111    content: View,
112    config: PullToRefreshConfig,
113) -> View {
114    let pull = state.pull_offset();
115    let refreshing = state.is_refreshing();
116    let threshold = config.threshold;
117
118    if state.triggered.get() && !refreshing && pull < threshold {
119        state.triggered.set(false);
120    }
121
122    if !refreshing && !state.triggered.get() && pull >= threshold {
123        state.triggered.set(true);
124        state.refreshing.set(true);
125        (on_refresh)();
126    }
127
128    let frac_key = format!("ptr_frac_{}", Rc::as_ptr(&state) as u64);
129    let raw_frac = if refreshing {
130        1.0
131    } else if pull > 0.0 {
132        pull / threshold
133    } else {
134        0.0
135    };
136    let distance_fraction = animate_f32_from(frac_key, 0.0, raw_frac, theme().motion.color);
137
138    let adjusted_percent = (distance_fraction.min(1.0) - 0.4).max(0.0) * 5.0 / 3.0;
139    let overshoot_percent = (distance_fraction - 1.0).max(0.0);
140    let linear_tension = overshoot_percent.min(2.0);
141    let tension_percent = linear_tension - linear_tension.powi(2) / 4.0;
142    let rotation_turns = (-0.25 + 0.4 * adjusted_percent + tension_percent) * 0.5;
143    // rotate by 360° to convert turns → degrees, then to radians for the modifier
144    let spinner_rotation_rad = rotation_turns * std::f32::consts::TAU;
145
146    // Indicator at top (pushed into view by overscroll) + content below.
147    let indicator_h = distance_fraction * threshold;
148    let comp_scale = adjusted_percent.min(1.0);
149    let icon_size = if refreshing {
150        24.0
151    } else {
152        (16.0 + comp_scale * 8.0).min(24.0)
153    };
154    let rotation = if refreshing {
155        animate_f32_from(
156            "ptr_spin",
157            0.0,
158            std::f32::consts::TAU,
159            AnimationSpec::tween(Duration::from_millis(1000), Easing::Linear)
160                .repeated(RepeatableSpec::infinite()),
161        )
162    } else {
163        spinner_rotation_rad
164    };
165    let alpha = if refreshing {
166        1.0
167    } else if distance_fraction >= 1.0 {
168        1.0
169    } else {
170        0.3
171    };
172    Column(modifier.align_items(config.content_alignment)).child((
173        if distance_fraction > 0.01 {
174            Box(Modifier::new()
175                .fill_max_width()
176                .height(indicator_h)
177                .align_items(AlignItems::CENTER)
178                .justify_content(JustifyContent::CENTER))
179            .child(
180                Box(Modifier::new()
181                    .size(icon_size, icon_size)
182                    .translate(icon_size * 0.5, icon_size * 0.5)
183                    .rotate(rotation)
184                    .translate(-icon_size * 0.5, -icon_size * 0.5))
185                .child(if refreshing {
186                    Icon(Symbol::new("refresh", '\u{E5D5}'))
187                        .size(24.0)
188                        .color(config.indicator_color)
189                } else {
190                    Icon(Symbol::new("arrow_downward", '\u{E5DB}'))
191                        .size(icon_size)
192                        .color(config.indicator_color.with_alpha_f32(alpha))
193                }),
194            )
195        } else {
196            Box(Modifier::new())
197        },
198        content,
199    ))
200}