use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use cranpose_animation::{
Animatable, AnimationSpec, Easing, RepeatMode, StartOffset, infiniteRepeatable,
rememberInfiniteTransition,
};
use cranpose_core::{RuntimeHandle, remember, with_current_composer};
use cranpose_foundation::SemanticsCustomAction;
use cranpose_macros::composable;
use cranpose_ui::{
Alignment, Modifier, NestedScroll, NestedScrollConnection, NestedScrollSource, Point,
ScrollAxis, Size,
widgets::{Box, BoxSpec},
};
use cranpose_ui_graphics::{Brush, Stroke, StrokeCap};
use crate::{motion::LiquidMotion, theme::liquid_colors};
const THRESHOLD: f32 = 72.0;
const MAX_PULL: f32 = THRESHOLD * 1.6;
const RESISTANCE: f32 = 0.5;
const INDICATOR_SIZE: f32 = 36.0;
const RING_RADIUS: f32 = 10.0;
const RING_WIDTH: f32 = 2.5;
struct Pull {
travel: RefCell<Animatable<f32>>,
pulled: Cell<f32>,
refreshing: Cell<bool>,
on_refresh: RefCell<Rc<dyn Fn()>>,
}
impl Pull {
fn new(runtime: RuntimeHandle) -> Self {
Self {
travel: RefCell::new(Animatable::new(0.0, runtime)),
pulled: Cell::new(0.0),
refreshing: Cell::new(false),
on_refresh: RefCell::new(Rc::new(|| {})),
}
}
fn pull_to(&self, pulled: f32) {
self.pulled.set(pulled);
self.travel.borrow_mut().snapTo(pulled);
}
fn settle(&self) {
let rest = if self.refreshing.get() {
THRESHOLD
} else {
0.0
};
self.travel
.borrow_mut()
.animateTo(rest, LiquidMotion::snappy());
}
fn travel(&self) -> f32 {
self.travel.borrow().state().value()
}
}
struct PullConnection(Rc<Pull>);
impl NestedScrollConnection for PullConnection {
fn on_pre_scroll(&self, axis: ScrollAxis, available: f32, source: NestedScrollSource) -> f32 {
let pull = &self.0;
let pulled = pull.pulled.get();
if axis != ScrollAxis::Vertical || source != NestedScrollSource::Drag || pulled <= 0.0 {
return 0.0;
}
if available >= 0.0 {
return 0.0;
}
let taken = (available * RESISTANCE).max(-pulled);
pull.pull_to(pulled + taken);
taken / RESISTANCE
}
fn on_post_scroll(
&self,
axis: ScrollAxis,
_consumed: f32,
available: f32,
source: NestedScrollSource,
) -> f32 {
let pull = &self.0;
if axis != ScrollAxis::Vertical
|| source != NestedScrollSource::Drag
|| available <= 0.0
|| pull.refreshing.get()
{
return 0.0;
}
pull.pull_to((pull.pulled.get() + available * RESISTANCE).min(MAX_PULL));
available
}
fn on_pre_fling(&self, axis: ScrollAxis, velocity: f32) -> f32 {
let pull = &self.0;
let pulled = pull.pulled.get();
if axis != ScrollAxis::Vertical || pulled <= 0.0 {
return 0.0;
}
pull.pulled.set(0.0);
if pulled >= THRESHOLD && !pull.refreshing.get() {
pull.refreshing.set(true);
let on_refresh = Rc::clone(&pull.on_refresh.borrow());
on_refresh();
}
pull.settle();
velocity
}
}
#[composable]
pub fn LiquidPullToRefreshBox(
modifier: Modifier,
refreshing: bool,
refresh_label: impl Into<String>,
on_refresh: impl Fn() + 'static,
content: impl FnMut() + 'static,
) {
let pull = with_current_composer(|composer| {
let runtime = composer.runtime_handle();
composer
.remember(move || Rc::new(Pull::new(runtime)))
.with(Rc::clone)
});
let on_refresh: Rc<dyn Fn()> = Rc::new(on_refresh);
*pull.on_refresh.borrow_mut() = Rc::clone(&on_refresh);
if pull.refreshing.replace(refreshing) != refreshing && pull.pulled.get() <= 0.0 {
pull.settle();
}
let connection = remember({
let pull = Rc::clone(&pull);
move || Rc::new(PullConnection(pull)) as Rc<dyn NestedScrollConnection>
})
.with(Rc::clone);
let action = SemanticsCustomAction::new(refresh_label.into(), move || on_refresh());
let container = modifier
.clip_to_bounds()
.semantics(move |config| config.custom_actions.push(action.clone()));
let mut content = content;
Box(container, BoxSpec::default(), move || {
NestedScroll(connection.clone(), &mut content);
PullIndicator(PullHandle(Rc::clone(&pull)), refreshing);
});
}
#[derive(Clone)]
struct PullHandle(Rc<Pull>);
impl PartialEq for PullHandle {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.0, &other.0)
}
}
#[composable]
fn PullIndicator(pull: PullHandle, refreshing: bool) {
let colors = liquid_colors();
let (surface, accent) = (colors.surface, colors.accent);
let spin = rememberInfiniteTransition("pull-refresh").animateFloat(
0.0,
360.0,
infiniteRepeatable(
AnimationSpec::tween(900, Easing::LinearEasing),
RepeatMode::Restart,
StartOffset::default(),
),
"pull-refresh-spin",
);
let modifier = Modifier::empty()
.size(Size::new(INDICATOR_SIZE, INDICATOR_SIZE))
.draw_behind(move |scope| {
let travel = pull.0.travel();
if travel <= 0.5 {
return;
}
let top = travel - INDICATOR_SIZE;
let center = Point::new(INDICATOR_SIZE * 0.5, top + INDICATOR_SIZE * 0.5);
scope.draw_circle(Brush::solid(surface), center, INDICATOR_SIZE * 0.5);
let (start, sweep) = if refreshing {
(spin.value(), 270.0)
} else {
(-90.0, 300.0 * (travel / THRESHOLD).min(1.0))
};
scope.draw_arc(
Brush::solid(accent),
center,
RING_RADIUS,
f32::to_radians(start),
f32::to_radians(sweep),
Stroke::new(RING_WIDTH).with_cap(StrokeCap::Round),
);
});
Box(
Modifier::empty().fill_max_width(),
BoxSpec::default().content_alignment(Alignment::TOP_CENTER),
move || {
Box(modifier.clone(), BoxSpec::default(), || {});
},
);
}