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