use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::authoring::{
Affine, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult,
InputEvent, LayoutCtx, PaintCtx, PaintScene, Point, PointerPhase, SemanticsCtx, Size, View,
Widget, any, build_child, rebuild_child, route_event_single, teardown_child, visit_children,
};
use frust::{AnimationController, ScrollInfo, scroll_view};
use crate::loading_indicator::loading_indicator;
use crate::tokens::MaterialMotion;
const DEFAULT_DISPLACEMENT: f64 = 40.0;
const SNAP_DURATION: Duration = MaterialMotion::SHORT_3;
const RETRACT_DURATION: Duration = MaterialMotion::SHORT_4;
const INDICATOR_FALLBACK_DIAMETER: f64 = 48.0;
fn visual_pull(overscroll: f64, max_pull: f64) -> f64 {
(-overscroll).clamp(0.0, max_pull.max(0.0))
}
fn snap_pull(from: f64, max_pull: f64, t: f64) -> f64 {
from + (max_pull - from) * t.clamp(0.0, 1.0)
}
fn cancel_retract_pull(from: f64, t: f64) -> f64 {
from * (1.0 - t.clamp(0.0, 1.0))
}
fn done_scale(t: f64) -> f64 {
1.0 - t.clamp(0.0, 1.0)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Status {
Idle,
Pulling,
Refreshing,
Retracting(RetractKind),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RetractKind {
Canceled,
Done,
}
pub struct RefreshIndicatorView<State: 'static> {
scroll: AnyView<State>,
indicator: Option<AnyView<State>>,
pull: Rc<Cell<f64>>,
released: Rc<Cell<bool>>,
in_flight: Rc<Cell<bool>>,
displacement: f64,
refreshing: bool,
}
pub fn refresh_indicator<State, V, R>(child: V, on_refresh: R) -> RefreshIndicatorView<State>
where
State: 'static,
V: View<State>,
R: Fn(&mut State) + 'static,
{
let pull = Rc::new(Cell::new(0.0_f64));
let released = Rc::new(Cell::new(false));
let in_flight = Rc::new(Cell::new(false));
let pull_for_scroll = pull.clone();
let released_for_scroll = released.clone();
let in_flight_for_scroll = in_flight.clone();
let on_refresh: Rc<dyn Fn(&mut State)> = Rc::new(on_refresh);
let scroll = any(scroll_view(child)
.on_scroll(move |_state: &mut State, info: ScrollInfo| {
pull_for_scroll.set(info.overscroll);
})
.on_refresh_release(move |state: &mut State| {
if in_flight_for_scroll.replace(true) {
return;
}
released_for_scroll.set(true);
on_refresh(state);
}));
RefreshIndicatorView {
scroll,
indicator: Some(any(loading_indicator())),
pull,
released,
in_flight,
displacement: DEFAULT_DISPLACEMENT,
refreshing: false,
}
}
impl<State: 'static> RefreshIndicatorView<State> {
pub fn contained(mut self) -> Self {
self.indicator = Some(any(loading_indicator().contained()));
self
}
pub fn no_spinner(mut self) -> Self {
self.indicator = None;
self
}
pub fn displacement(mut self, displacement: f64) -> Self {
self.displacement = displacement.max(0.0);
self
}
pub fn refreshing(mut self, refreshing: bool) -> Self {
self.refreshing = refreshing;
self
}
}
impl<State: 'static> View<State> for RefreshIndicatorView<State> {
type Element = RefreshIndicatorWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> RefreshIndicatorWidget {
RefreshIndicatorWidget {
scroll: build_child(&self.scroll, ctx),
indicator: self.indicator.as_ref().map(|v| build_child(v, ctx)),
pull: self.pull.clone(),
released: self.released.clone(),
in_flight: self.in_flight.clone(),
displacement: self.displacement,
refreshing: self.refreshing,
status: Status::Idle,
pull_at_transition: 0.0,
snap: AnimationController::new(SNAP_DURATION),
retract: AnimationController::new(RETRACT_DURATION),
indicator_size: Size::new(INDICATOR_FALLBACK_DIAMETER, INDICATOR_FALLBACK_DIAMETER),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut RefreshIndicatorWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = rebuild_child(&prev.scroll, &self.scroll, &mut element.scroll, ctx);
element.pull = self.pull.clone();
element.released = self.released.clone();
element.in_flight = self.in_flight.clone();
element
.in_flight
.set(matches!(element.status, Status::Refreshing));
if (element.displacement - self.displacement).abs() > f64::EPSILON {
element.displacement = self.displacement;
flags |= ChangeFlags::PAINT;
}
match (&prev.indicator, &self.indicator) {
(None, None) => {}
(Some(p), Some(n)) => {
let pod = element.indicator.as_mut().expect("indicator pod present");
flags |= rebuild_child(p, n, pod, ctx);
}
(None, Some(n)) => {
element.indicator = Some(build_child(n, ctx));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(Some(p), None) => {
let mut pod = element.indicator.take().expect("indicator pod present");
teardown_child(p, &mut pod, ctx);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
if !self.refreshing && element.refreshing && matches!(element.status, Status::Refreshing) {
element.begin_retract(RetractKind::Done);
flags |= ChangeFlags::PAINT;
}
element.refreshing = self.refreshing;
flags
}
fn teardown(&self, element: &mut RefreshIndicatorWidget, ctx: &mut BuildCtx<'_>) {
teardown_child(&self.scroll, &mut element.scroll, ctx);
if let (Some(view), Some(pod)) = (&self.indicator, element.indicator.as_mut()) {
teardown_child(view, pod, ctx);
}
}
}
pub struct RefreshIndicatorWidget {
scroll: ChildPod,
indicator: Option<ChildPod>,
pull: Rc<Cell<f64>>,
released: Rc<Cell<bool>>,
in_flight: Rc<Cell<bool>>,
displacement: f64,
refreshing: bool,
status: Status,
pull_at_transition: f64,
snap: AnimationController,
retract: AnimationController,
indicator_size: Size,
}
impl RefreshIndicatorWidget {
fn max_pull(&self) -> f64 {
self.displacement + self.indicator_size.height
}
fn begin_refresh(&mut self) {
self.pull_at_transition = visual_pull(self.pull.get(), self.max_pull());
self.snap = AnimationController::new(SNAP_DURATION);
self.snap.forward();
self.status = Status::Refreshing;
self.in_flight.set(true);
}
fn begin_cancel(&mut self) {
self.pull_at_transition = visual_pull(self.pull.get(), self.max_pull());
self.retract = AnimationController::new(RETRACT_DURATION);
self.retract.forward();
self.status = Status::Retracting(RetractKind::Canceled);
}
fn begin_retract(&mut self, kind: RetractKind) {
self.retract = AnimationController::new(RETRACT_DURATION);
self.retract.forward();
self.status = Status::Retracting(kind);
self.in_flight.set(false);
}
}
impl Widget for RefreshIndicatorWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.scroll.layout_child(ctx, bc);
self.scroll.set_origin(Point::ORIGIN);
if let Some(indicator) = self.indicator.as_mut() {
let d = indicator.layout_child(ctx, &BoxConstraints::loose(size));
self.indicator_size = d;
indicator.set_origin(Point::new((size.width - d.width) / 2.0, 0.0));
}
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let (origin, size) = (ctx.origin(), ctx.size());
self.scroll.paint_child(ctx, scene);
let max_pull = self.max_pull();
let (pull_px, scale) = match self.status {
Status::Idle => return,
Status::Pulling => (visual_pull(self.pull.get(), max_pull), 1.0),
Status::Refreshing => {
if self.snap.advance(ctx.frame_time()) {
ctx.request_frame();
}
(
snap_pull(self.pull_at_transition, max_pull, self.snap.value_clamped()),
1.0,
)
}
Status::Retracting(kind) => {
if self.retract.advance(ctx.frame_time()) {
ctx.request_frame();
} else {
self.status = Status::Idle;
self.pull.set(0.0);
}
let t = self.retract.value_clamped();
match kind {
RetractKind::Canceled => (cancel_retract_pull(self.pull_at_transition, t), 1.0),
RetractKind::Done => (max_pull, done_scale(t)),
}
}
};
let Some(indicator) = self.indicator.as_mut() else {
return;
};
let indicator_h = self.indicator_size.height;
let inset = pull_px - indicator_h;
scene.push_clip(origin, size);
if scale < 1.0 {
let cx = origin.x + indicator.origin().x + indicator.size().width / 2.0;
let cy = origin.y + inset + indicator_h / 2.0;
scene.push_transform(
Affine::translate((cx, cy))
* Affine::scale(scale)
* Affine::translate((-cx, -cy))
* Affine::translate((0.0, inset)),
);
} else {
scene.push_transform(Affine::translate((0.0, inset)));
}
indicator.paint_child(ctx, scene);
scene.pop_transform();
scene.pop_clip();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let result = route_event_single(&mut self.scroll, ctx, event);
if self.released.replace(false) && !matches!(self.status, Status::Refreshing) {
self.begin_refresh();
} else {
match self.status {
Status::Idle => {
if self.pull.get() < 0.0 {
self.status = Status::Pulling;
}
}
Status::Pulling => {
let released_gesture = matches!(
event,
InputEvent::Pointer(p)
if matches!(p.phase, PointerPhase::Up | PointerPhase::Cancel)
);
if released_gesture {
self.begin_cancel();
}
}
Status::Refreshing | Status::Retracting(_) => {}
}
}
result
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.scroll.semantics_child(ctx);
}
visit_children!(scroll, indicator);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use frust::authoring::{
BezPath, Brush, Color, PaintCtx as TestPaintCtx, PointerButton, PointerEvent,
};
use std::any::Any;
#[test]
fn visual_pull_tracks_overscroll_and_clamps_to_max_pull() {
assert_eq!(visual_pull(0.0, 88.0), 0.0, "in range: no reveal");
assert_eq!(visual_pull(-10.0, 88.0), 10.0);
assert_eq!(visual_pull(-64.0, 88.0), 64.0, "exactly at the trigger");
assert_eq!(
visual_pull(-500.0, 88.0),
88.0,
"clamps to the resting-position cap"
);
assert_eq!(
visual_pull(5.0, 88.0),
0.0,
"past-bottom overscroll is not a pull"
);
}
#[test]
fn snap_pull_lerps_from_the_captured_pull_to_the_resting_position() {
assert_eq!(snap_pull(20.0, 88.0, 0.0), 20.0, "t=0: still at the pull");
assert_eq!(snap_pull(20.0, 88.0, 1.0), 88.0, "t=1: fully at rest");
assert_eq!(snap_pull(20.0, 88.0, 0.5), 54.0, "t=0.5: halfway");
assert_eq!(snap_pull(0.0, 88.0, 2.0), 88.0, "t is clamped past 1");
}
#[test]
fn cancel_retract_pull_lerps_the_captured_pull_back_to_zero() {
assert_eq!(cancel_retract_pull(40.0, 0.0), 40.0);
assert_eq!(cancel_retract_pull(40.0, 1.0), 0.0);
assert_eq!(cancel_retract_pull(40.0, 0.5), 20.0);
assert_eq!(
cancel_retract_pull(40.0, -1.0),
40.0,
"t is clamped below 0"
);
}
#[test]
fn done_scale_shrinks_from_one_to_zero() {
assert_eq!(done_scale(0.0), 1.0);
assert_eq!(done_scale(1.0), 0.0);
assert_eq!(done_scale(0.25), 0.75);
}
#[derive(Default)]
struct AppState {
refreshes: u32,
refreshing: bool,
}
#[derive(Clone, Copy)]
struct Content(Size);
struct ContentWidget(Size);
impl<S: 'static> View<S> for Content {
type Element = ContentWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> ContentWidget {
ContentWidget(self.0)
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut ContentWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for ContentWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.0)
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
}
const VIEWPORT: Size = Size::new(200.0, 400.0);
const CONTENT: Size = Size::new(200.0, 1000.0);
fn build_view(on_refresh: impl Fn(&mut AppState) + 'static) -> RefreshIndicatorView<AppState> {
refresh_indicator(Content(CONTENT), on_refresh)
}
fn build(view: &RefreshIndicatorView<AppState>) -> RefreshIndicatorWidget {
let mut counter = 0u64;
let mut w = View::<AppState>::build(view, &mut BuildCtx::new(&mut counter));
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(VIEWPORT));
w
}
fn ptr(phase: PointerPhase, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(50.0, y),
button: PointerButton::Primary,
})
}
fn dispatch(w: &mut RefreshIndicatorWidget, state: &mut AppState, event: &InputEvent) {
let sa: &mut dyn Any = state;
let mut ctx = EventCtx::new(sa, Point::ZERO, VIEWPORT);
w.event(&mut ctx, event);
}
fn drag_past_trigger(w: &mut RefreshIndicatorWidget, state: &mut AppState) {
dispatch(w, state, &ptr(PointerPhase::Down, 50.0));
dispatch(w, state, &ptr(PointerPhase::Move, 90.0)); dispatch(w, state, &ptr(PointerPhase::Move, 290.0)); dispatch(w, state, &ptr(PointerPhase::Up, 290.0));
}
#[derive(Default)]
struct Recorder {
fills: usize,
rrects: usize,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {
self.fills += 1;
}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {
self.rrects += 1;
}
}
fn paint_at(w: &mut RefreshIndicatorWidget, t_ms: u64) -> Recorder {
let mut rec = Recorder::default();
let mut ctx = TestPaintCtx::for_test(
Point::ZERO,
VIEWPORT,
FrameTime::from_nanos(t_ms * 1_000_000),
);
w.paint(&mut ctx, &mut rec);
rec
}
#[test]
fn a_pull_under_the_trigger_does_not_refresh_and_retracts() {
let view = build_view(|_| {});
let mut w = build(&view);
let mut state = AppState::default();
dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0)); dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 180.0)); assert_eq!(w.status, Status::Pulling);
dispatch(&mut w, &mut state, &ptr(PointerPhase::Up, 180.0));
assert_eq!(state.refreshes, 0, "released under the trigger");
assert_eq!(w.status, Status::Retracting(RetractKind::Canceled));
paint_at(&mut w, 0); paint_at(&mut w, 300); assert_eq!(w.status, Status::Idle, "settles back to idle");
}
#[test]
fn a_pull_past_the_trigger_fires_on_refresh_exactly_once_and_holds_while_refreshing() {
let view = build_view(|s: &mut AppState| {
s.refreshes += 1;
s.refreshing = true;
});
let mut w = build(&view);
let mut state = AppState::default();
drag_past_trigger(&mut w, &mut state);
assert_eq!(state.refreshes, 1);
assert!(state.refreshing, "on_refresh flipped the app's own flag");
assert_eq!(w.status, Status::Refreshing);
paint_at(&mut w, 0);
paint_at(&mut w, 1000);
assert_eq!(w.status, Status::Refreshing, "still holding");
}
#[test]
fn cancel_mid_pull_abandons_without_firing_refresh() {
let view = build_view(|s: &mut AppState| s.refreshes += 1);
let mut w = build(&view);
let mut state = AppState::default();
dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 290.0)); assert_eq!(w.status, Status::Pulling, "not yet released");
dispatch(&mut w, &mut state, &ptr(PointerPhase::Cancel, 290.0));
assert_eq!(
state.refreshes, 0,
"a Cancel never fires the refresh callback"
);
assert_eq!(w.status, Status::Retracting(RetractKind::Canceled));
}
#[test]
fn a_second_pull_while_refreshing_does_not_refire_on_refresh() {
let view = build_view(|s: &mut AppState| {
s.refreshes += 1;
s.refreshing = true;
});
let mut w = build(&view);
let mut state = AppState::default();
drag_past_trigger(&mut w, &mut state);
assert_eq!(state.refreshes, 1);
assert_eq!(w.status, Status::Refreshing);
drag_past_trigger(&mut w, &mut state);
assert_eq!(
state.refreshes, 1,
"a refresh already in flight is not re-triggered"
);
}
#[test]
fn refreshing_confirmed_false_ends_the_spinner_and_settles_to_idle() {
let make = || {
build_view(|s: &mut AppState| {
s.refreshes += 1;
s.refreshing = true;
})
};
let view1 = make();
let mut w = build(&view1);
let mut state = AppState::default();
drag_past_trigger(&mut w, &mut state);
assert_eq!(w.status, Status::Refreshing);
let view2 = make().refreshing(true);
let mut counter = 1u64;
View::<AppState>::rebuild(&view2, &view1, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.status, Status::Refreshing, "still confirmed in-flight");
let view3 = make().refreshing(false);
View::<AppState>::rebuild(&view3, &view2, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.status, Status::Retracting(RetractKind::Done));
paint_at(&mut w, 0);
paint_at(&mut w, 300); assert_eq!(w.status, Status::Idle);
drag_past_trigger(&mut w, &mut state);
assert_eq!(state.refreshes, 2);
}
#[test]
fn idle_paints_only_the_wrapped_content() {
let view = build_view(|_| {});
let mut w = build(&view);
let rec = paint_at(&mut w, 0);
assert_eq!(rec.fills, 0);
assert_eq!(rec.rrects, 0);
}
#[test]
fn pulling_reveals_the_default_expressive_spinner_with_no_container() {
let view = build_view(|_| {});
let mut w = build(&view);
let mut state = AppState::default();
dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 150.0));
assert_eq!(w.status, Status::Pulling);
let rec = paint_at(&mut w, 0);
assert_eq!(rec.fills, 1, "the morphing shape paints");
assert_eq!(rec.rrects, 0, "the default variant paints no container");
}
#[test]
fn contained_variant_paints_a_container_behind_the_shape() {
let view = build_view(|_| {}).contained();
let mut w = build(&view);
let mut state = AppState::default();
dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 150.0));
let rec = paint_at(&mut w, 0);
assert_eq!(rec.fills, 1);
assert_eq!(rec.rrects, 1, "the contained variant paints its container");
}
#[test]
fn no_spinner_paints_nothing_extra_through_the_whole_lifecycle() {
let view = build_view(|s: &mut AppState| s.refreshing = true).no_spinner();
let mut w = build(&view);
let mut state = AppState::default();
dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 150.0));
assert_eq!(w.status, Status::Pulling, "the lifecycle still tracks");
let rec = paint_at(&mut w, 0);
assert_eq!(rec.fills, 0);
assert_eq!(rec.rrects, 0);
dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 290.0));
dispatch(&mut w, &mut state, &ptr(PointerPhase::Up, 290.0));
assert_eq!(
w.status,
Status::Refreshing,
"the trigger contract still fires"
);
assert!(state.refreshing);
let rec = paint_at(&mut w, 0);
assert_eq!(rec.fills, 0, "still nothing painted");
assert_eq!(rec.rrects, 0);
}
#[test]
fn displacement_and_indicator_height_set_the_resting_pull_cap() {
let view = build_view(|_| {}).displacement(100.0);
let w = build(&view);
assert_eq!(w.max_pull(), 100.0 + INDICATOR_FALLBACK_DIAMETER_FOR_TEST);
}
const INDICATOR_FALLBACK_DIAMETER_FOR_TEST: f64 = 48.0;
}