use super::{inspector_metadata, Modifier, Point, PointerEventKind};
use crate::current_density;
use crate::fling_animation::FlingAnimation;
use crate::fling_animation::MIN_FLING_VELOCITY;
use crate::schedule_draw_repass;
use crate::scroll::{ScrollElement, ScrollState};
use cranpose_core::{current_runtime_handle, NodeId};
use cranpose_foundation::{
velocity_tracker::ASSUME_STOPPED_MS, DelegatableNode, ModifierNode, ModifierNodeElement,
NodeCapabilities, NodeState, PointerButton, PointerButtons, VelocityTracker1D, DRAG_THRESHOLD,
MAX_FLING_VELOCITY,
};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use web_time::Instant;
#[cfg(feature = "test-helpers")]
mod test_velocity_tracking {
use std::sync::atomic::{AtomicU32, Ordering};
static LAST_FLING_VELOCITY: AtomicU32 = AtomicU32::new(0);
pub fn last_fling_velocity() -> f32 {
f32::from_bits(LAST_FLING_VELOCITY.load(Ordering::SeqCst))
}
pub fn reset_last_fling_velocity() {
LAST_FLING_VELOCITY.store(0.0f32.to_bits(), Ordering::SeqCst);
}
pub(super) fn set_last_fling_velocity(velocity: f32) {
LAST_FLING_VELOCITY.store(velocity.to_bits(), Ordering::SeqCst);
}
}
#[cfg(feature = "test-helpers")]
pub use test_velocity_tracking::{last_fling_velocity, reset_last_fling_velocity};
#[inline]
fn set_last_fling_velocity(velocity: f32) {
#[cfg(feature = "test-helpers")]
test_velocity_tracking::set_last_fling_velocity(velocity);
#[cfg(not(feature = "test-helpers"))]
let _ = velocity; }
struct ScrollGestureState {
drag_down_position: Option<Point>,
last_position: Option<Point>,
is_dragging: bool,
velocity_tracker: VelocityTracker1D,
gesture_start_time: Option<Instant>,
last_velocity_sample_ms: Option<i64>,
fling_animation: Option<FlingAnimation>,
}
impl Default for ScrollGestureState {
fn default() -> Self {
Self {
drag_down_position: None,
last_position: None,
is_dragging: false,
velocity_tracker: VelocityTracker1D::new(),
gesture_start_time: None,
last_velocity_sample_ms: None,
fling_animation: None,
}
}
}
#[derive(Clone)]
struct MotionContextState {
inner: Rc<MotionContextStateInner>,
}
struct MotionContextStateInner {
active: Cell<bool>,
generation: Cell<u64>,
invalidate_callbacks: RefCell<std::collections::HashMap<u64, Box<dyn Fn()>>>,
pending_invalidation: Cell<bool>,
}
impl MotionContextState {
fn new() -> Self {
Self {
inner: Rc::new(MotionContextStateInner {
active: Cell::new(false),
generation: Cell::new(0),
invalidate_callbacks: RefCell::new(std::collections::HashMap::new()),
pending_invalidation: Cell::new(false),
}),
}
}
fn is_active(&self) -> bool {
self.inner.active.get()
}
fn set_active(&self, active: bool) {
if self.inner.active.replace(active) != active {
self.bump_generation();
self.invalidate();
}
}
fn activate_for_next_frame(&self) {
let was_active = self.inner.active.replace(true);
let generation = self.bump_generation();
if !was_active {
self.invalidate();
}
if let Some(runtime) = current_runtime_handle() {
let state = self.clone();
let _ = runtime.register_frame_callback(move |_| {
state.clear_if_generation(generation);
});
runtime.schedule();
} else {
self.clear_if_generation(generation);
}
}
fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
static NEXT_CALLBACK_ID: AtomicU64 = AtomicU64::new(1);
let id = NEXT_CALLBACK_ID.fetch_add(1, Ordering::Relaxed);
self.inner
.invalidate_callbacks
.borrow_mut()
.insert(id, callback);
if self.inner.pending_invalidation.replace(false) {
if let Some(callback) = self.inner.invalidate_callbacks.borrow().get(&id) {
callback();
}
}
id
}
fn remove_invalidate_callback(&self, id: u64) {
self.inner.invalidate_callbacks.borrow_mut().remove(&id);
}
fn bump_generation(&self) -> u64 {
let next = self.inner.generation.get().wrapping_add(1);
self.inner.generation.set(next);
next
}
fn clear_if_generation(&self, generation: u64) {
if self.inner.generation.get() == generation {
self.set_active(false);
}
}
fn invalidate(&self) {
let callbacks = self.inner.invalidate_callbacks.borrow();
if callbacks.is_empty() {
self.inner.pending_invalidation.set(true);
return;
}
for callback in callbacks.values() {
callback();
}
}
}
#[inline]
fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
if is_vertical {
to.y - from.y
} else {
to.x - from.x
}
}
#[inline]
fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
if is_vertical {
to.y - from.y
} else {
to.x - from.x
}
}
trait ScrollTarget: Clone {
fn apply_delta(&self, delta: f32) -> f32;
fn apply_fling_delta(&self, delta: f32) -> f32;
fn invalidate(&self);
fn current_offset(&self) -> f32;
}
impl ScrollTarget for ScrollState {
fn apply_delta(&self, delta: f32) -> f32 {
self.dispatch_raw_delta(-delta)
}
fn apply_fling_delta(&self, delta: f32) -> f32 {
self.dispatch_raw_delta(delta)
}
fn invalidate(&self) {
}
fn current_offset(&self) -> f32 {
self.value()
}
}
impl ScrollTarget for LazyListState {
fn apply_delta(&self, delta: f32) -> f32 {
self.dispatch_scroll_delta(delta)
}
fn apply_fling_delta(&self, delta: f32) -> f32 {
-self.dispatch_scroll_delta(-delta)
}
fn invalidate(&self) {
}
fn current_offset(&self) -> f32 {
self.first_visible_item_scroll_offset()
}
}
struct ScrollGestureDetector<S: ScrollTarget> {
gesture_state: Rc<RefCell<ScrollGestureState>>,
scroll_target: S,
is_vertical: bool,
reverse_scrolling: bool,
motion_context: MotionContextState,
}
impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
fn new(
gesture_state: Rc<RefCell<ScrollGestureState>>,
scroll_target: S,
is_vertical: bool,
reverse_scrolling: bool,
motion_context: MotionContextState,
) -> Self {
Self {
gesture_state,
scroll_target,
is_vertical,
reverse_scrolling,
motion_context,
}
}
fn on_down(&self, position: Point) -> bool {
let mut gs = self.gesture_state.borrow_mut();
if let Some(fling) = gs.fling_animation.take() {
fling.cancel();
}
self.motion_context.set_active(false);
gs.drag_down_position = Some(position);
gs.last_position = Some(position);
gs.is_dragging = false;
gs.velocity_tracker.reset();
gs.gesture_start_time = Some(Instant::now());
let pos = if self.is_vertical {
position.y
} else {
position.x
};
gs.velocity_tracker.add_data_point(0, pos);
gs.last_velocity_sample_ms = Some(0);
false
}
fn on_move(&self, position: Point, buttons: PointerButtons) -> bool {
let mut gs = self.gesture_state.borrow_mut();
if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
gs.drag_down_position = None;
gs.last_position = None;
gs.is_dragging = false;
gs.gesture_start_time = None;
gs.last_velocity_sample_ms = None;
gs.velocity_tracker.reset();
self.motion_context.set_active(false);
return false;
}
let Some(down_pos) = gs.drag_down_position else {
return false;
};
let Some(last_pos) = gs.last_position else {
gs.last_position = Some(position);
return false;
};
let total_delta = calculate_total_delta(down_pos, position, self.is_vertical);
let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
if !gs.is_dragging && total_delta.abs() > DRAG_THRESHOLD {
gs.is_dragging = true;
self.motion_context.set_active(true);
}
gs.last_position = Some(position);
if let Some(start_time) = gs.gesture_start_time {
let elapsed_ms = start_time.elapsed().as_millis() as i64;
let pos = if self.is_vertical {
position.y
} else {
position.x
};
let sample_ms = match gs.last_velocity_sample_ms {
Some(last_sample_ms) => {
let mut sample_ms = if elapsed_ms <= last_sample_ms {
last_sample_ms + 1
} else {
elapsed_ms
};
if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
}
sample_ms
}
None => elapsed_ms,
};
gs.velocity_tracker.add_data_point(sample_ms, pos);
gs.last_velocity_sample_ms = Some(sample_ms);
}
if gs.is_dragging {
drop(gs); let delta = if self.reverse_scrolling {
-incremental_delta
} else {
incremental_delta
};
let _ = self.scroll_target.apply_delta(delta);
self.scroll_target.invalidate();
true } else {
false
}
}
fn finish_gesture(&self, allow_fling: bool) -> bool {
let (was_dragging, velocity, start_fling, existing_fling) = {
let mut gs = self.gesture_state.borrow_mut();
let was_dragging = gs.is_dragging;
let mut velocity = 0.0;
if allow_fling && was_dragging && gs.gesture_start_time.is_some() {
velocity = gs
.velocity_tracker
.calculate_velocity_with_max(MAX_FLING_VELOCITY);
}
let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
let existing_fling = if start_fling {
gs.fling_animation.take()
} else {
None
};
gs.drag_down_position = None;
gs.last_position = None;
gs.is_dragging = false;
gs.gesture_start_time = None;
gs.last_velocity_sample_ms = None;
(was_dragging, velocity, start_fling, existing_fling)
};
if allow_fling && was_dragging {
set_last_fling_velocity(velocity);
}
if start_fling {
if let Some(old_fling) = existing_fling {
old_fling.cancel();
}
if let Some(runtime) = current_runtime_handle() {
self.motion_context.set_active(true);
let scroll_target = self.scroll_target.clone();
let reverse = self.reverse_scrolling;
let fling = FlingAnimation::new(runtime);
let motion_context = self.motion_context.clone();
let initial_value = scroll_target.current_offset();
let adjusted_velocity = if reverse { -velocity } else { velocity };
let fling_velocity = -adjusted_velocity;
let scroll_target_for_fling = scroll_target.clone();
let scroll_target_for_end = scroll_target.clone();
fling.start_fling(
initial_value,
fling_velocity,
current_density(),
move |delta| {
let consumed = scroll_target_for_fling.apply_fling_delta(delta);
scroll_target_for_fling.invalidate();
consumed
},
move || {
scroll_target_for_end.invalidate();
motion_context.set_active(false);
},
);
let mut gs = self.gesture_state.borrow_mut();
gs.fling_animation = Some(fling);
}
} else {
self.motion_context.set_active(false);
}
was_dragging
}
fn on_up(&self) -> bool {
self.finish_gesture(true)
}
fn on_cancel(&self) -> bool {
self.finish_gesture(false)
}
fn on_scroll(&self, axis_delta: f32) -> bool {
if axis_delta.abs() <= f32::EPSILON {
return false;
}
{
let mut gs = self.gesture_state.borrow_mut();
if let Some(fling) = gs.fling_animation.take() {
fling.cancel();
}
gs.drag_down_position = None;
gs.last_position = None;
gs.is_dragging = false;
gs.gesture_start_time = None;
gs.last_velocity_sample_ms = None;
gs.velocity_tracker.reset();
}
self.motion_context.activate_for_next_frame();
let delta = if self.reverse_scrolling {
-axis_delta
} else {
axis_delta
};
let consumed = self.scroll_target.apply_delta(delta);
if consumed.abs() > 0.001 {
self.scroll_target.invalidate();
true
} else {
false
}
}
}
pub(crate) struct MotionContextAnimatedNode {
state: NodeState,
motion_context: MotionContextState,
invalidation_callback_id: Option<u64>,
node_id: Option<NodeId>,
}
impl MotionContextAnimatedNode {
fn new(motion_context: MotionContextState) -> Self {
Self {
state: NodeState::new(),
motion_context,
invalidation_callback_id: None,
node_id: None,
}
}
pub(crate) fn is_active(&self) -> bool {
self.motion_context.is_active()
}
}
pub(crate) struct TranslatedContentContextNode {
state: NodeState,
identity: usize,
}
impl TranslatedContentContextNode {
fn new(identity: usize) -> Self {
Self {
state: NodeState::new(),
identity,
}
}
pub(crate) fn is_active(&self) -> bool {
true
}
pub(crate) fn identity(&self) -> usize {
self.identity
}
}
impl DelegatableNode for TranslatedContentContextNode {
fn node_state(&self) -> &NodeState {
&self.state
}
}
impl ModifierNode for TranslatedContentContextNode {}
impl DelegatableNode for MotionContextAnimatedNode {
fn node_state(&self) -> &NodeState {
&self.state
}
}
impl ModifierNode for MotionContextAnimatedNode {
fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
let node_id = context.node_id();
self.node_id = node_id;
if let Some(node_id) = node_id {
let callback_id = self
.motion_context
.add_invalidate_callback(Box::new(move || {
schedule_draw_repass(node_id);
}));
self.invalidation_callback_id = Some(callback_id);
}
}
fn on_detach(&mut self) {
if let Some(id) = self.invalidation_callback_id.take() {
self.motion_context.remove_invalidate_callback(id);
}
self.node_id = None;
}
}
#[derive(Clone)]
struct MotionContextAnimatedElement {
motion_context: MotionContextState,
}
impl MotionContextAnimatedElement {
fn new(motion_context: MotionContextState) -> Self {
Self { motion_context }
}
}
impl std::fmt::Debug for MotionContextAnimatedElement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MotionContextAnimatedElement").finish()
}
}
impl PartialEq for MotionContextAnimatedElement {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.motion_context.inner, &other.motion_context.inner)
}
}
impl Eq for MotionContextAnimatedElement {}
impl std::hash::Hash for MotionContextAnimatedElement {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
(Rc::as_ptr(&self.motion_context.inner) as usize).hash(state);
}
}
impl ModifierNodeElement for MotionContextAnimatedElement {
type Node = MotionContextAnimatedNode;
fn create(&self) -> Self::Node {
MotionContextAnimatedNode::new(self.motion_context.clone())
}
fn update(&self, node: &mut Self::Node) {
if Rc::ptr_eq(&node.motion_context.inner, &self.motion_context.inner) {
return;
}
if let Some(id) = node.invalidation_callback_id.take() {
node.motion_context.remove_invalidate_callback(id);
}
node.motion_context = self.motion_context.clone();
if let Some(node_id) = node.node_id {
let callback_id = node
.motion_context
.add_invalidate_callback(Box::new(move || {
schedule_draw_repass(node_id);
}));
node.invalidation_callback_id = Some(callback_id);
}
}
fn capabilities(&self) -> NodeCapabilities {
NodeCapabilities::LAYOUT
}
}
#[derive(Clone)]
struct TranslatedContentContextElement {
identity: usize,
}
impl TranslatedContentContextElement {
fn new(identity: usize) -> Self {
Self { identity }
}
}
impl std::fmt::Debug for TranslatedContentContextElement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TranslatedContentContextElement")
.field("identity", &self.identity)
.finish()
}
}
impl PartialEq for TranslatedContentContextElement {
fn eq(&self, other: &Self) -> bool {
self.identity == other.identity
}
}
impl Eq for TranslatedContentContextElement {}
impl std::hash::Hash for TranslatedContentContextElement {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.identity.hash(state);
}
}
impl ModifierNodeElement for TranslatedContentContextElement {
type Node = TranslatedContentContextNode;
fn create(&self) -> Self::Node {
TranslatedContentContextNode::new(self.identity)
}
fn update(&self, node: &mut Self::Node) {
node.identity = self.identity;
}
fn capabilities(&self) -> NodeCapabilities {
NodeCapabilities::LAYOUT
}
}
impl Modifier {
pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
self.then(scroll_impl(state, false, reverse_scrolling, None))
}
pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
self.then(scroll_impl(state, true, reverse_scrolling, None))
}
pub fn horizontal_scroll_guarded(
self,
state: ScrollState,
reverse_scrolling: bool,
guard: impl Fn() -> bool + 'static,
) -> Self {
self.then(scroll_impl(
state,
false,
reverse_scrolling,
Some(Rc::new(guard)),
))
}
pub fn vertical_scroll_guarded(
self,
state: ScrollState,
reverse_scrolling: bool,
guard: impl Fn() -> bool + 'static,
) -> Self {
self.then(scroll_impl(
state,
true,
reverse_scrolling,
Some(Rc::new(guard)),
))
}
}
fn scroll_impl(
state: ScrollState,
is_vertical: bool,
reverse_scrolling: bool,
guard: Option<Rc<dyn Fn() -> bool>>,
) -> Modifier {
let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
let motion_context = MotionContextState::new();
let scroll_state = state.clone();
let pointer_motion_context = motion_context.clone();
let key = (state.id(), is_vertical);
let pointer_input = Modifier::empty().pointer_input(key, move |scope| {
let detector = ScrollGestureDetector::new(
gesture_state.clone(),
scroll_state.clone(),
is_vertical,
false, pointer_motion_context.clone(),
);
let guard = guard.clone();
async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event = await_scope.await_pointer_event().await;
if let Some(ref guard) = guard {
if !guard() {
if matches!(
event.kind,
PointerEventKind::Up | PointerEventKind::Cancel
) {
detector.on_cancel();
}
continue;
}
}
let should_consume = match event.kind {
PointerEventKind::Down => detector.on_down(event.position),
PointerEventKind::Move => {
detector.on_move(event.position, event.buttons)
}
PointerEventKind::Up => detector.on_up(),
PointerEventKind::Cancel => detector.on_cancel(),
PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
event.scroll_delta.y
} else {
event.scroll_delta.x
}),
PointerEventKind::Enter | PointerEventKind::Exit => false,
};
if should_consume {
event.consume();
}
}
})
.await;
}
});
let element = ScrollElement::new(state.clone(), is_vertical, reverse_scrolling);
let layout_modifier =
Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
if is_vertical {
"verticalScroll"
} else {
"horizontalScroll"
},
move |info| {
info.add_property("isVertical", is_vertical.to_string());
info.add_property("reverseScrolling", reverse_scrolling.to_string());
},
));
let motion_modifier =
Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()));
let translated_content_modifier =
Modifier::with_element(TranslatedContentContextElement::new(state.id() as usize));
pointer_input
.then(motion_modifier)
.then(translated_content_modifier)
.then(layout_modifier)
.clip_to_bounds()
}
use cranpose_foundation::lazy::LazyListState;
impl Modifier {
pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
self.then(lazy_scroll_impl(state, true, reverse_scrolling))
}
pub fn lazy_horizontal_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
self.then(lazy_scroll_impl(state, false, reverse_scrolling))
}
}
fn lazy_scroll_impl(state: LazyListState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
let list_state = state;
let motion_context = MotionContextState::new();
let state_id = std::ptr::addr_of!(*state.inner_ptr()) as usize;
let key = (state_id, is_vertical, reverse_scrolling);
let translated_content_modifier =
Modifier::with_element(TranslatedContentContextElement::new(state_id));
Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
.then(translated_content_modifier)
.pointer_input(key, move |scope| {
let detector = ScrollGestureDetector::new(
gesture_state.clone(),
list_state,
is_vertical,
reverse_scrolling,
motion_context.clone(),
);
async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event = await_scope.await_pointer_event().await;
let should_consume = match event.kind {
PointerEventKind::Down => detector.on_down(event.position),
PointerEventKind::Move => {
detector.on_move(event.position, event.buttons)
}
PointerEventKind::Up => detector.on_up(),
PointerEventKind::Cancel => detector.on_cancel(),
PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
event.scroll_delta.y
} else {
event.scroll_delta.x
}),
PointerEventKind::Enter | PointerEventKind::Exit => false,
};
if should_consume {
event.consume();
}
}
})
.await;
}
})
}