repose_material/material3/
pull_to_refresh.rs1#![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#[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
34pub 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 pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
65 *self.scroll_state.borrow_mut() = Some(state);
66 }
67
68 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 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
95pub 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 let spinner_rotation_rad = rotation_turns * std::f32::consts::TAU;
141
142 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}