1use 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
25pub struct PullRefreshDefaults;
27
28impl PullRefreshDefaults {
29 pub const REFRESH_THRESHOLD: Dp = Dp(80.0);
31 pub const REFRESHING_OFFSET: Dp = Dp(56.0);
33 pub const INDICATOR_SIZE: Dp = Dp(40.0);
35 pub const INDICATOR_STROKE_WIDTH: Dp = Dp(2.5);
37 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
47pub 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 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 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 pub fn refreshing(&self) -> bool {
88 self.refreshing
89 }
90
91 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#[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#[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;