Skip to main content

cranpose_ui/modifier/
scroll.rs

1//! Scroll modifier extensions for Modifier.
2//!
3//! # Overview
4//! This module implements scrollable containers with gesture-based interaction.
5//! It follows the pattern of separating:
6//! - **State management** (`ScrollGestureState`) - tracks pointer/drag state
7//! - **Event handling** (`ScrollGestureDetector`) - processes events and updates state
8//! - **Layout** (`ScrollElement`/`ScrollNode` in `scroll.rs`) - applies scroll offset
9//!
10//! # Gesture Flow
11//! 1. **Down**: Record initial position, reset drag state
12//! 2. **Move**: Check if movement along the scroll axis exceeds
13//!    `DRAG_THRESHOLD` (8dp of touch slop — pointer positions arrive in
14//!    logical, density-independent pixels on every platform)
15//!    - The drag is captured only when the scroll axis DOMINATES the total
16//!      movement (`|main| >= |cross|`, Compose-style axis locking). A drag
17//!      that decisively belongs to the other axis locks this detector out
18//!      for the rest of the gesture, so a horizontal scrollable nested in a
19//!      vertical one (chips row in a screen list) wins mostly-horizontal
20//!      drags and never steals mostly-vertical ones — and vice versa.
21//!    - Once captured: start consuming events, apply scroll delta. This
22//!      prevents child click handlers from firing during scrolls, and makes
23//!      enclosing scrollables abandon the gesture (they see consumed moves).
24//! 3. **Up/Cancel**: Clean up state, consume if was dragging
25
26use super::{inspector_metadata, Modifier, Point, PointerEventKind};
27use crate::current_density;
28use crate::fling_animation::{
29    fling_rest_position, FlingAnimation, SettleAnimation, MIN_FLING_VELOCITY,
30};
31use crate::render_state::schedule_modifier_slices_repass;
32use crate::scroll::{
33    scroll_motion_context_for_key, ScrollElement, ScrollMotionContext, ScrollMotionContextKey,
34    ScrollSettlePolicy, ScrollState,
35};
36use cranpose_core::internal::FrameCallbackRegistration;
37use cranpose_core::{current_runtime_handle, NodeId};
38use cranpose_foundation::{
39    velocity_tracker::ASSUME_STOPPED_MS, DelegatableNode, ModifierNode, ModifierNodeElement,
40    NodeCapabilities, NodeState, PointerButton, PointerButtons, VelocityTracker1D, DRAG_THRESHOLD,
41    MAX_FLING_VELOCITY,
42};
43use std::cell::{Cell, RefCell};
44use std::rc::Rc;
45use web_time::Instant;
46
47#[cfg(feature = "test-helpers")]
48pub fn last_fling_velocity() -> f32 {
49    crate::render_state::debug_last_fling_velocity()
50}
51
52#[cfg(feature = "test-helpers")]
53pub fn reset_last_fling_velocity() {
54    crate::render_state::debug_reset_last_fling_velocity();
55}
56
57#[inline]
58fn set_last_fling_velocity(velocity: f32) {
59    crate::render_state::record_last_fling_velocity(velocity);
60}
61
62/// Local gesture state for scroll drag handling.
63///
64/// This is NOT part of `ScrollState` to keep the scroll model pure.
65/// Each scroll modifier instance has its own gesture state, which enables
66/// multiple independent scroll regions without state interference.
67struct ScrollGestureState {
68    /// Position where pointer was pressed down.
69    /// Used to calculate total drag distance for threshold detection.
70    drag_down_position: Option<Point>,
71
72    /// Last known pointer position during drag.
73    /// Used to calculate incremental delta for each move event.
74    last_position: Option<Point>,
75
76    /// Whether we've crossed the drag threshold and are actively scrolling.
77    /// Once true, we consume all events until Up/Cancel to prevent child
78    /// handlers from receiving drag events.
79    is_dragging: bool,
80
81    /// Whether this gesture was decided to belong to the cross axis
82    /// (its cross-axis movement crossed the touch slop while dominating the
83    /// main axis). A locked-out detector never captures for the rest of the
84    /// gesture, so e.g. a horizontal chips row cannot steal a vertical
85    /// screen scroll that happens to drift sideways later on.
86    axis_locked_out: bool,
87
88    /// Velocity tracker for fling gesture detection.
89    velocity_tracker: VelocityTracker1D,
90
91    /// Time when gesture down started (for velocity calculation).
92    gesture_start_time: Option<Instant>,
93
94    /// Platform timestamp (ms) of the Down event, when the platform provides
95    /// input timestamps. Preferred over `gesture_start_time` because batched
96    /// input delivery (Android) makes delivery-time deltas meaningless.
97    gesture_start_event_time_ms: Option<i64>,
98
99    /// Last time a velocity sample was recorded (milliseconds since gesture start).
100    last_velocity_sample_ms: Option<i64>,
101
102    /// Current fling animation (if any).
103    fling_animation: Option<FlingAnimation>,
104
105    /// Current settle animation driving toward a policy target (if any).
106    settle_animation: Option<SettleAnimation>,
107
108    /// Frame loop watching for wheel-scroll idleness to run the settle policy
109    /// (wheel gestures have no end event).
110    wheel_settle_watcher: Option<WheelSettleWatcher>,
111}
112
113impl Default for ScrollGestureState {
114    fn default() -> Self {
115        Self {
116            drag_down_position: None,
117            last_position: None,
118            is_dragging: false,
119            axis_locked_out: false,
120            velocity_tracker: VelocityTracker1D::new(),
121            gesture_start_time: None,
122            gesture_start_event_time_ms: None,
123            last_velocity_sample_ms: None,
124            fling_animation: None,
125            settle_animation: None,
126            wheel_settle_watcher: None,
127        }
128    }
129}
130
131// ============================================================================
132// Helper Functions
133// ============================================================================
134
135/// Calculates the total movement distance from the original down position.
136///
137/// This is used to determine if we've crossed the drag threshold. Returns
138/// the distance along the requested axis (Y for `is_vertical`, X otherwise);
139/// callers pass `!is_vertical` to read the cross-axis component.
140#[inline]
141fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
142    if is_vertical {
143        to.y - from.y
144    } else {
145        to.x - from.x
146    }
147}
148
149/// Calculates the incremental movement delta from the previous position.
150///
151/// This is used to update the scroll offset incrementally during drag.
152/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
153#[inline]
154fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
155    if is_vertical {
156        to.y - from.y
157    } else {
158        to.x - from.x
159    }
160}
161
162// ============================================================================
163// Scroll Gesture Detector (Generic Implementation)
164// ============================================================================
165
166/// Trait for scroll targets that can receive scroll deltas.
167///
168/// Implemented by both `ScrollState` (regular scroll) and `LazyListState` (lazy lists).
169trait ScrollTarget: Clone {
170    /// Apply a gesture delta. Returns the consumed amount in gesture coordinates.
171    fn apply_delta(&self, delta: f32) -> f32;
172
173    /// Apply a wheel/trackpad event delta. Returns the consumed amount.
174    fn apply_wheel_delta(&self, delta: f32) -> f32 {
175        self.apply_delta(delta)
176    }
177
178    /// Apply a scroll delta during fling. Returns consumed delta in scroll coordinates.
179    fn apply_fling_delta(&self, delta: f32) -> f32;
180
181    /// Called after scroll to trigger any necessary invalidation.
182    fn invalidate(&self);
183
184    /// Get the current scroll offset.
185    fn current_offset(&self) -> f32;
186
187    /// Whether the target can currently scroll in either direction.
188    ///
189    /// When this is `false` the gesture detector must not capture drags:
190    /// a non-scrollable target (e.g. a lazy list realized in full inside an
191    /// unbounded parent, or a scroll container whose content fits its
192    /// viewport) would otherwise consume the move events that an enclosing
193    /// scrollable needs to receive.
194    fn can_scroll(&self) -> bool {
195        true
196    }
197
198    /// Settle policy remapping the post-interaction rest offset (see
199    /// [`ScrollSettlePolicy`]). `None` keeps natural rest positions.
200    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
201        None
202    }
203}
204
205impl ScrollTarget for ScrollState {
206    fn apply_delta(&self, delta: f32) -> f32 {
207        // Regular scroll uses negative delta (natural scrolling)
208        self.dispatch_raw_delta(-delta)
209    }
210
211    fn apply_fling_delta(&self, delta: f32) -> f32 {
212        self.dispatch_raw_delta(delta)
213    }
214
215    fn invalidate(&self) {
216        // ScrollState triggers invalidation internally
217    }
218
219    fn current_offset(&self) -> f32 {
220        self.value()
221    }
222
223    fn can_scroll(&self) -> bool {
224        self.max_value() > 0.0
225    }
226
227    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
228        ScrollState::settle_policy(self)
229    }
230}
231
232impl ScrollTarget for LazyListState {
233    fn apply_delta(&self, delta: f32) -> f32 {
234        // LazyListState uses positive delta directly
235        // dispatch_scroll_delta already calls self.invalidate() which triggers the
236        // layout invalidation callback registered in lazy_scroll_impl
237        self.dispatch_scroll_delta(delta)
238    }
239
240    fn apply_wheel_delta(&self, delta: f32) -> f32 {
241        if delta.abs() <= 0.001 {
242            0.0
243        } else {
244            self.dispatch_scroll_delta(delta)
245        }
246    }
247
248    fn apply_fling_delta(&self, delta: f32) -> f32 {
249        -self.dispatch_scroll_delta(-delta)
250    }
251
252    fn invalidate(&self) {
253        // dispatch_scroll_delta already handles invalidation internally via callback.
254        // The registered callback uses schedule_layout_repass for scoped layout work.
255    }
256
257    fn current_offset(&self) -> f32 {
258        // LazyListState doesn't have a simple offset - use first visible item offset
259        self.first_visible_item_scroll_offset()
260    }
261
262    fn can_scroll(&self) -> bool {
263        // Before the first measure pass no bounds are known; stay permissive
264        // so gestures that race the first layout are not dropped.
265        self.layout_info().total_items_count == 0
266            || self.can_scroll_forward_non_reactive()
267            || self.can_scroll_backward_non_reactive()
268    }
269}
270
271/// Generic scroll gesture detector that works with any ScrollTarget.
272///
273/// This struct provides a clean interface for processing pointer events
274/// and managing scroll interactions. The generic parameter S determines
275/// how scroll deltas are applied.
276/// Wheel/trackpad frame-time idleness after which the settle policy runs
277/// (wheel gestures have no end event to hook).
278const WHEEL_SETTLE_IDLE_NANOS: u64 = 180_000_000;
279
280/// Frame loop that waits for the scroll offset to sit still after wheel input
281/// and then runs the settle policy. Cancelled by any new gesture.
282struct WheelSettleWatcher {
283    is_running: Rc<Cell<bool>>,
284    registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
285}
286
287impl WheelSettleWatcher {
288    fn cancel(&self) {
289        self.is_running.set(false);
290        self.registration.borrow_mut().take();
291    }
292}
293
294struct ScrollGestureDetector<S: ScrollTarget> {
295    /// Shared gesture state (position tracking, drag status).
296    gesture_state: Rc<RefCell<ScrollGestureState>>,
297
298    /// The scroll target to update when drag is detected.
299    scroll_target: S,
300
301    /// Whether this is vertical or horizontal scroll.
302    is_vertical: bool,
303
304    /// Whether to reverse the scroll direction (flip delta).
305    reverse_scrolling: bool,
306
307    /// Active motion state for renderer policy selection.
308    motion_context: ScrollMotionContext,
309}
310
311impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
312    /// Creates a new detector for the given scroll configuration.
313    fn new(
314        gesture_state: Rc<RefCell<ScrollGestureState>>,
315        scroll_target: S,
316        is_vertical: bool,
317        reverse_scrolling: bool,
318        motion_context: ScrollMotionContext,
319    ) -> Self {
320        Self {
321            gesture_state,
322            scroll_target,
323            is_vertical,
324            reverse_scrolling,
325            motion_context,
326        }
327    }
328
329    /// Handles pointer down event.
330    ///
331    /// Records the initial position for threshold calculation and
332    /// resets drag state. We don't consume Down events because we
333    /// don't know yet if this will become a drag or a click.
334    ///
335    /// Returns `false` - Down events are never consumed to allow
336    /// potential child click handlers to receive the initial press.
337    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
338        let mut gs = self.gesture_state.borrow_mut();
339
340        // Cancel any running fling/settle animation and wheel-settle watcher
341        if let Some(fling) = gs.fling_animation.take() {
342            fling.cancel();
343        }
344        if let Some(settle) = gs.settle_animation.take() {
345            settle.cancel();
346        }
347        if let Some(watcher) = gs.wheel_settle_watcher.take() {
348            watcher.cancel();
349        }
350        self.motion_context.set_active(false);
351
352        gs.drag_down_position = Some(position);
353        gs.last_position = Some(position);
354        gs.is_dragging = false;
355        gs.axis_locked_out = false;
356        gs.velocity_tracker.reset();
357        gs.gesture_start_time = Some(Instant::now());
358        gs.gesture_start_event_time_ms = time_ms;
359
360        // Add initial position to velocity tracker
361        let pos = if self.is_vertical {
362            position.y
363        } else {
364            position.x
365        };
366        gs.velocity_tracker.add_data_point(0, pos);
367        gs.last_velocity_sample_ms = Some(0);
368
369        // Never consume Down - we don't know if this is a drag yet
370        false
371    }
372
373    /// Handles pointer move event.
374    ///
375    /// This is the core gesture detection logic:
376    /// 1. Safety check: if no primary button is pressed but we think we're
377    ///    tracking, we missed an Up event - reset state.
378    /// 2. Calculate total movement from down position on BOTH axes.
379    /// 3. Axis-locked slop: start dragging once the scroll-axis movement
380    ///    exceeds `DRAG_THRESHOLD` (8dp) AND dominates the cross-axis
381    ///    movement; a decisively cross-axis drag locks this detector out
382    ///    for the rest of the gesture.
383    /// 4. While dragging, apply scroll delta and consume events.
384    ///
385    /// Returns `true` if event should be consumed (we're actively dragging).
386    fn on_move(&self, position: Point, buttons: PointerButtons, time_ms: Option<i64>) -> bool {
387        let mut gs = self.gesture_state.borrow_mut();
388
389        // Safety: detect missed Up events (hit test delivered to wrong target)
390        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
391            gs.drag_down_position = None;
392            gs.last_position = None;
393            gs.is_dragging = false;
394            gs.axis_locked_out = false;
395            gs.gesture_start_time = None;
396            gs.gesture_start_event_time_ms = None;
397            gs.last_velocity_sample_ms = None;
398            gs.velocity_tracker.reset();
399            self.motion_context.set_active(false);
400            return false;
401        }
402
403        let Some(down_pos) = gs.drag_down_position else {
404            return false;
405        };
406
407        let Some(last_pos) = gs.last_position else {
408            gs.last_position = Some(position);
409            return false;
410        };
411
412        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
413
414        // Axis-locked touch slop (Compose-style): capture only when the
415        // movement along the scroll axis crosses the slop AND dominates the
416        // cross-axis movement, so of two nested scrollables the one whose
417        // axis matches the drag wins. Ties go to the innermost handler
418        // (children are dispatched before their ancestors). A drag that
419        // decisively belongs to the cross axis locks this detector out for
420        // the rest of the gesture. Targets that cannot scroll in either
421        // direction never capture, so enclosing scrollables receive the
422        // gesture instead.
423        if !gs.is_dragging && !gs.axis_locked_out {
424            let main_delta = calculate_total_delta(down_pos, position, self.is_vertical).abs();
425            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
426            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
427                if self.scroll_target.can_scroll() {
428                    gs.is_dragging = true;
429                    self.motion_context.set_active(true);
430                }
431            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
432                gs.axis_locked_out = true;
433            }
434        }
435
436        gs.last_position = Some(position);
437
438        // Track velocity for fling
439        let pos = if self.is_vertical {
440            position.y
441        } else {
442            position.x
443        };
444        let event_sample_ms = gs
445            .gesture_start_event_time_ms
446            .zip(time_ms)
447            .map(|(start_ms, now_ms)| now_ms - start_ms);
448        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
449            // The platform supplied real input timestamps: trust them.
450            // Android delivers touch samples batched/frame-aligned, so several
451            // moves are processed back-to-back here; only the event's own
452            // timestamp yields the real dt between finger positions. Real
453            // pauses must also stay real so a stop-then-release does not fling
454            // (the tracker treats gaps > ASSUME_STOPPED_MS as stopped).
455            Some(match gs.last_velocity_sample_ms {
456                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
457                None => event_sample_ms.max(0),
458            })
459        } else if let Some(start_time) = gs.gesture_start_time {
460            // Fallback: delivery-time stamping for platforms without input
461            // timestamps (desktop mouse, web).
462            let elapsed_ms = start_time.elapsed().as_millis() as i64;
463            // Keep sample times strictly increasing so velocity stays stable when
464            // multiple move events land in the same millisecond.
465            Some(match gs.last_velocity_sample_ms {
466                Some(last_sample_ms) => {
467                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
468                        last_sample_ms + 1
469                    } else {
470                        elapsed_ms
471                    };
472                    // Clamp large processing gaps so frame stalls don't erase fling velocity.
473                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
474                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
475                    }
476                    sample_ms
477                }
478                None => elapsed_ms,
479            })
480        } else {
481            None
482        };
483        if let Some(sample_ms) = sample_ms {
484            log::trace!(
485                target: "cranpose::velocity",
486                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
487            );
488            gs.velocity_tracker.add_data_point(sample_ms, pos);
489            gs.last_velocity_sample_ms = Some(sample_ms);
490        }
491
492        if gs.is_dragging {
493            drop(gs); // Release borrow before calling scroll target
494            let delta = if self.reverse_scrolling {
495                -incremental_delta
496            } else {
497                incremental_delta
498            };
499            let _ = self.scroll_target.apply_delta(delta);
500            self.scroll_target.invalidate();
501            true // Consume event while dragging
502        } else {
503            false
504        }
505    }
506
507    /// Handles pointer up event.
508    ///
509    /// Cleans up drag state. If we were actively dragging, calculates fling
510    /// velocity and starts fling animation if velocity is above threshold.
511    ///
512    /// Returns `true` if we were dragging (event should be consumed).
513    fn finish_gesture(&self, allow_fling: bool, release_time_ms: Option<i64>) -> bool {
514        let (was_dragging, velocity, start_fling, existing_fling) = {
515            let mut gs = self.gesture_state.borrow_mut();
516            let was_dragging = gs.is_dragging;
517            let mut velocity = 0.0;
518
519            if allow_fling && was_dragging && gs.gesture_start_time.is_some() {
520                // A finger that rested before lifting must not fling: the
521                // tracker only sees inter-SAMPLE gaps, so a release long
522                // after the last move would otherwise replay the stale
523                // pre-hold velocity (hold-then-release phantom fling).
524                let release_sample_ms = release_time_ms
525                    .zip(gs.gesture_start_event_time_ms)
526                    .map(|(release_ms, start_ms)| release_ms - start_ms)
527                    .or_else(|| {
528                        gs.gesture_start_time
529                            .map(|start| start.elapsed().as_millis() as i64)
530                    });
531                let rested_before_release = release_sample_ms
532                    .zip(gs.last_velocity_sample_ms)
533                    .is_some_and(|(release_ms, last_sample_ms)| {
534                        release_ms - last_sample_ms > ASSUME_STOPPED_MS
535                    });
536                if !rested_before_release {
537                    velocity = gs
538                        .velocity_tracker
539                        .calculate_velocity_with_max(MAX_FLING_VELOCITY);
540                }
541            }
542
543            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
544            let existing_fling = if start_fling {
545                gs.fling_animation.take()
546            } else {
547                None
548            };
549
550            gs.drag_down_position = None;
551            gs.last_position = None;
552            gs.is_dragging = false;
553            gs.axis_locked_out = false;
554            gs.gesture_start_time = None;
555            gs.gesture_start_event_time_ms = None;
556            gs.last_velocity_sample_ms = None;
557
558            (was_dragging, velocity, start_fling, existing_fling)
559        };
560
561        // Always record velocity for test accessibility (even if below fling threshold)
562        if allow_fling && was_dragging {
563            log::debug!(
564                target: "cranpose::velocity",
565                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
566            );
567            set_last_fling_velocity(velocity);
568        }
569
570        // Convert gesture velocity to scroll-offset velocity (offset units/s).
571        let adjusted_velocity = if self.reverse_scrolling {
572            -velocity
573        } else {
574            velocity
575        };
576        let fling_velocity = -adjusted_velocity;
577
578        // Settle policy: remap where this interaction comes to rest (the
579        // `targetContentOffset` analog). When it moves the rest position, a
580        // spring seeded with the release velocity replaces the decay so the
581        // adjustment still reads as one continuous deceleration.
582        let settle_target = if was_dragging {
583            self.scroll_target.settle_policy().and_then(|policy| {
584                let current = self.scroll_target.current_offset();
585                let proposed = if start_fling {
586                    fling_rest_position(current, fling_velocity, current_density())
587                } else {
588                    current
589                };
590                let target = policy(proposed, fling_velocity);
591                ((target - proposed).abs() > 0.5).then_some(target)
592            })
593        } else {
594            None
595        };
596
597        if let Some(target) = settle_target {
598            if let Some(old_fling) = existing_fling {
599                old_fling.cancel();
600            }
601            self.start_settle_animation(target, fling_velocity);
602        } else if start_fling {
603            if let Some(old_fling) = existing_fling {
604                old_fling.cancel();
605            }
606
607            // Get runtime handle for frame callbacks
608            if let Some(runtime) = current_runtime_handle() {
609                self.motion_context.set_active(true);
610                let scroll_target = self.scroll_target.clone();
611                let fling = FlingAnimation::new(runtime);
612                let motion_context = self.motion_context.clone();
613
614                // Get current scroll position for fling start
615                let initial_value = scroll_target.current_offset();
616
617                let scroll_target_for_fling = scroll_target.clone();
618                let scroll_target_for_end = scroll_target.clone();
619
620                fling.start_fling(
621                    initial_value,
622                    fling_velocity,
623                    current_density(),
624                    move |delta| {
625                        // Apply scroll delta during fling, return consumed amount
626                        let consumed = scroll_target_for_fling.apply_fling_delta(delta);
627                        scroll_target_for_fling.invalidate();
628                        consumed
629                    },
630                    move || {
631                        // Animation complete - invalidate to ensure final render
632                        scroll_target_for_end.invalidate();
633                        motion_context.set_active(false);
634                    },
635                );
636
637                let mut gs = self.gesture_state.borrow_mut();
638                gs.fling_animation = Some(fling);
639            }
640        } else {
641            self.motion_context.set_active(false);
642        }
643
644        was_dragging
645    }
646
647    /// Springs the scroll offset to `target` (offset units), seeded with the
648    /// release velocity so policy-adjusted rests feel like one deceleration.
649    fn start_settle_animation(&self, target: f32, initial_velocity: f32) {
650        let Some(runtime) = current_runtime_handle() else {
651            self.motion_context.set_active(false);
652            return;
653        };
654        self.motion_context.set_active(true);
655        let settle = SettleAnimation::new(runtime);
656        let scroll_target_for_settle = self.scroll_target.clone();
657        let scroll_target_for_end = self.scroll_target.clone();
658        let motion_context = self.motion_context.clone();
659        settle.start_settle(
660            self.scroll_target.current_offset(),
661            initial_velocity,
662            target,
663            move |delta| {
664                let consumed = scroll_target_for_settle.apply_fling_delta(delta);
665                scroll_target_for_settle.invalidate();
666                consumed
667            },
668            move || {
669                scroll_target_for_end.invalidate();
670                motion_context.set_active(false);
671            },
672        );
673        let mut gs = self.gesture_state.borrow_mut();
674        gs.settle_animation = Some(settle);
675    }
676
677    /// Handles pointer up event.
678    ///
679    /// Cleans up drag state. If we were actively dragging, calculates fling
680    /// velocity and starts fling animation if velocity is above threshold.
681    ///
682    /// Returns `true` if we were dragging (event should be consumed).
683    fn on_up(&self, time_ms: Option<i64>) -> bool {
684        self.finish_gesture(true, time_ms)
685    }
686
687    /// Handles pointer cancel event.
688    ///
689    /// Cleans up state without starting a fling. Returns `true` if we were dragging.
690    fn on_cancel(&self) -> bool {
691        self.finish_gesture(false, None)
692    }
693
694    /// Handles mouse wheel / trackpad scroll event.
695    ///
696    /// Returns `true` when the target consumed any delta.
697    fn on_scroll(&self, axis_delta: f32) -> bool {
698        if axis_delta.abs() <= f32::EPSILON {
699            return false;
700        }
701
702        {
703            // Wheel scroll should take over immediately and stop any active drag/fling state.
704            let mut gs = self.gesture_state.borrow_mut();
705            if let Some(fling) = gs.fling_animation.take() {
706                fling.cancel();
707            }
708            if let Some(settle) = gs.settle_animation.take() {
709                settle.cancel();
710            }
711            gs.drag_down_position = None;
712            gs.last_position = None;
713            gs.is_dragging = false;
714            gs.axis_locked_out = false;
715            gs.gesture_start_time = None;
716            gs.gesture_start_event_time_ms = None;
717            gs.last_velocity_sample_ms = None;
718            gs.velocity_tracker.reset();
719        }
720
721        self.motion_context.activate_for_current_frame();
722
723        let delta = if self.reverse_scrolling {
724            -axis_delta
725        } else {
726            axis_delta
727        };
728        let consumed = self.scroll_target.apply_wheel_delta(delta);
729        if consumed.abs() > 0.001 {
730            self.scroll_target.invalidate();
731            self.ensure_wheel_settle_watcher();
732            true
733        } else {
734            false
735        }
736    }
737
738    /// Arms (once) a frame loop that runs the settle policy after the wheel
739    /// goes idle. Wheel input has no end event, so idleness — the offset
740    /// sitting still for [`WHEEL_SETTLE_IDLE_NANOS`] of frame time — is the
741    /// gesture end.
742    fn ensure_wheel_settle_watcher(&self) {
743        if self.scroll_target.settle_policy().is_none() {
744            return;
745        }
746        {
747            let gs = self.gesture_state.borrow();
748            if gs
749                .wheel_settle_watcher
750                .as_ref()
751                .is_some_and(|watcher| watcher.is_running.get())
752            {
753                return;
754            }
755        }
756        let Some(runtime) = current_runtime_handle() else {
757            return;
758        };
759
760        let is_running = Rc::new(Cell::new(true));
761        let registration = Rc::new(RefCell::new(None));
762
763        struct WheelSettleLoop<S: ScrollTarget> {
764            detector: ScrollGestureDetector<S>,
765            gesture_state: Rc<RefCell<ScrollGestureState>>,
766            frame_clock: cranpose_core::internal::FrameClock,
767            is_running: Rc<Cell<bool>>,
768            registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
769            last_offset: Rc<Cell<f32>>,
770            idle_nanos: Rc<Cell<u64>>,
771            last_frame: Rc<Cell<Option<u64>>>,
772        }
773
774        impl<S: ScrollTarget + 'static> WheelSettleLoop<S> {
775            fn next(&self) -> Self {
776                Self {
777                    detector: self.detector.clone_for_watcher(),
778                    gesture_state: Rc::clone(&self.gesture_state),
779                    frame_clock: self.frame_clock.clone(),
780                    is_running: Rc::clone(&self.is_running),
781                    registration: Rc::clone(&self.registration),
782                    last_offset: Rc::clone(&self.last_offset),
783                    idle_nanos: Rc::clone(&self.idle_nanos),
784                    last_frame: Rc::clone(&self.last_frame),
785                }
786            }
787
788            fn schedule(self) {
789                let continuation = self.next();
790                let registration_slot = Rc::clone(&self.registration);
791                let new_registration = self.frame_clock.with_frame_nanos(move |frame_time_nanos| {
792                    let this = &continuation;
793                    if !this.is_running.get() {
794                        return;
795                    }
796                    // A live drag or fling owns the settle decision now.
797                    {
798                        let gs = this.gesture_state.borrow();
799                        let animating = gs.is_dragging
800                            || gs
801                                .fling_animation
802                                .as_ref()
803                                .is_some_and(FlingAnimation::is_running)
804                            || gs
805                                .settle_animation
806                                .as_ref()
807                                .is_some_and(SettleAnimation::is_running);
808                        if animating {
809                            this.is_running.set(false);
810                            return;
811                        }
812                    }
813
814                    let offset = this.detector.scroll_target.current_offset();
815                    let dt = this
816                        .last_frame
817                        .get()
818                        .map_or(0, |last| frame_time_nanos.saturating_sub(last));
819                    this.last_frame.set(Some(frame_time_nanos));
820                    if (offset - this.last_offset.get()).abs() > 0.01 {
821                        this.last_offset.set(offset);
822                        this.idle_nanos.set(0);
823                    } else {
824                        this.idle_nanos.set(this.idle_nanos.get() + dt);
825                    }
826
827                    if this.idle_nanos.get() >= WHEEL_SETTLE_IDLE_NANOS {
828                        this.is_running.set(false);
829                        if let Some(policy) = this.detector.scroll_target.settle_policy() {
830                            let target = policy(offset, 0.0);
831                            if (target - offset).abs() > 0.5 {
832                                this.detector.start_settle_animation(target, 0.0);
833                            }
834                        }
835                        return;
836                    }
837
838                    continuation.next().schedule();
839                });
840                *registration_slot.borrow_mut() = Some(new_registration);
841            }
842        }
843
844        WheelSettleLoop {
845            detector: self.clone_for_watcher(),
846            gesture_state: Rc::clone(&self.gesture_state),
847            frame_clock: runtime.frame_clock(),
848            is_running: Rc::clone(&is_running),
849            registration: Rc::clone(&registration),
850            last_offset: Rc::new(Cell::new(self.scroll_target.current_offset())),
851            idle_nanos: Rc::new(Cell::new(0u64)),
852            last_frame: Rc::new(Cell::new(None::<u64>)),
853        }
854        .schedule();
855
856        self.gesture_state.borrow_mut().wheel_settle_watcher = Some(WheelSettleWatcher {
857            is_running,
858            registration,
859        });
860    }
861
862    fn clone_for_watcher(&self) -> ScrollGestureDetector<S> {
863        ScrollGestureDetector {
864            gesture_state: Rc::clone(&self.gesture_state),
865            scroll_target: self.scroll_target.clone(),
866            is_vertical: self.is_vertical,
867            reverse_scrolling: self.reverse_scrolling,
868            motion_context: self.motion_context.clone(),
869        }
870    }
871}
872
873pub(crate) struct MotionContextAnimatedNode {
874    state: NodeState,
875    motion_context: ScrollMotionContext,
876    invalidation_callback_id: Option<u64>,
877    node_id: Option<NodeId>,
878}
879
880impl MotionContextAnimatedNode {
881    fn new(motion_context: ScrollMotionContext) -> Self {
882        Self {
883            state: NodeState::new(),
884            motion_context,
885            invalidation_callback_id: None,
886            node_id: None,
887        }
888    }
889
890    pub(crate) fn is_active(&self) -> bool {
891        self.motion_context.is_active()
892    }
893}
894
895pub(crate) struct TranslatedContentContextNode {
896    state: NodeState,
897    identity: usize,
898    offset_source: TranslatedContentOffsetSource,
899}
900
901impl TranslatedContentContextNode {
902    fn new(identity: usize, offset_source: TranslatedContentOffsetSource) -> Self {
903        Self {
904            state: NodeState::new(),
905            identity,
906            offset_source,
907        }
908    }
909
910    pub(crate) fn is_active(&self) -> bool {
911        true
912    }
913
914    pub(crate) fn identity(&self) -> usize {
915        self.identity
916    }
917
918    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
919        self.offset_source.content_offset_reader()
920    }
921}
922
923impl DelegatableNode for TranslatedContentContextNode {
924    fn node_state(&self) -> &NodeState {
925        &self.state
926    }
927}
928
929impl ModifierNode for TranslatedContentContextNode {}
930
931impl DelegatableNode for MotionContextAnimatedNode {
932    fn node_state(&self) -> &NodeState {
933        &self.state
934    }
935}
936
937impl ModifierNode for MotionContextAnimatedNode {
938    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
939        let node_id = context.node_id();
940        self.node_id = node_id;
941        if let Some(node_id) = node_id {
942            let callback_id = self
943                .motion_context
944                .add_invalidate_callback(Box::new(move || {
945                    schedule_modifier_slices_repass(node_id);
946                }));
947            self.invalidation_callback_id = Some(callback_id);
948        }
949    }
950
951    fn on_detach(&mut self) {
952        if let Some(id) = self.invalidation_callback_id.take() {
953            self.motion_context.remove_invalidate_callback(id);
954        }
955        self.node_id = None;
956    }
957}
958
959#[derive(Clone)]
960struct MotionContextAnimatedElement {
961    motion_context: ScrollMotionContext,
962}
963
964impl MotionContextAnimatedElement {
965    fn new(motion_context: ScrollMotionContext) -> Self {
966        Self { motion_context }
967    }
968}
969
970impl std::fmt::Debug for MotionContextAnimatedElement {
971    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
972        f.debug_struct("MotionContextAnimatedElement").finish()
973    }
974}
975
976impl PartialEq for MotionContextAnimatedElement {
977    fn eq(&self, other: &Self) -> bool {
978        self.motion_context.ptr_eq(&other.motion_context)
979    }
980}
981
982impl Eq for MotionContextAnimatedElement {}
983
984impl std::hash::Hash for MotionContextAnimatedElement {
985    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
986        self.motion_context.stable_key().hash(state);
987    }
988}
989
990impl ModifierNodeElement for MotionContextAnimatedElement {
991    type Node = MotionContextAnimatedNode;
992
993    fn create(&self) -> Self::Node {
994        MotionContextAnimatedNode::new(self.motion_context.clone())
995    }
996
997    fn update(&self, node: &mut Self::Node) {
998        if node.motion_context.ptr_eq(&self.motion_context) {
999            return;
1000        }
1001        if let Some(id) = node.invalidation_callback_id.take() {
1002            node.motion_context.remove_invalidate_callback(id);
1003        }
1004        node.motion_context = self.motion_context.clone();
1005        if let Some(node_id) = node.node_id {
1006            let callback_id = node
1007                .motion_context
1008                .add_invalidate_callback(Box::new(move || {
1009                    schedule_modifier_slices_repass(node_id);
1010                }));
1011            node.invalidation_callback_id = Some(callback_id);
1012        }
1013    }
1014
1015    fn capabilities(&self) -> NodeCapabilities {
1016        NodeCapabilities::LAYOUT
1017    }
1018}
1019
1020#[derive(Clone)]
1021enum TranslatedContentOffsetSource {
1022    LayoutContentOffset,
1023    LazyList {
1024        state: LazyListState,
1025        is_vertical: bool,
1026        reverse_scrolling: bool,
1027    },
1028}
1029
1030impl TranslatedContentOffsetSource {
1031    fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
1032        match self {
1033            Self::LayoutContentOffset => None,
1034            Self::LazyList {
1035                state, is_vertical, ..
1036            } => Some(Rc::new(lazy_list_content_offset_reader(
1037                *state,
1038                *is_vertical,
1039            ))),
1040        }
1041    }
1042
1043    fn is_vertical(&self) -> Option<bool> {
1044        match self {
1045            Self::LayoutContentOffset => None,
1046            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
1047        }
1048    }
1049
1050    fn reverse_scrolling(&self) -> Option<bool> {
1051        match self {
1052            Self::LayoutContentOffset => None,
1053            Self::LazyList {
1054                reverse_scrolling, ..
1055            } => Some(*reverse_scrolling),
1056        }
1057    }
1058}
1059
1060fn lazy_list_content_offset_reader(state: LazyListState, is_vertical: bool) -> impl Fn() -> Point {
1061    move || {
1062        let info = state.layout_info();
1063        if info.visible_items_info.is_empty() {
1064            return Point::default();
1065        };
1066        let main_offset = info.snap_anchor_offset;
1067        if is_vertical {
1068            Point::new(0.0, main_offset)
1069        } else {
1070            Point::new(main_offset, 0.0)
1071        }
1072    }
1073}
1074
1075#[derive(Clone)]
1076struct TranslatedContentContextElement {
1077    identity: usize,
1078    offset_source: TranslatedContentOffsetSource,
1079}
1080
1081impl TranslatedContentContextElement {
1082    fn new(identity: usize, offset_source: TranslatedContentOffsetSource) -> Self {
1083        Self {
1084            identity,
1085            offset_source,
1086        }
1087    }
1088}
1089
1090impl std::fmt::Debug for TranslatedContentContextElement {
1091    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1092        let offset_source = match &self.offset_source {
1093            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
1094            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
1095        };
1096        f.debug_struct("TranslatedContentContextElement")
1097            .field("identity", &self.identity)
1098            .field("offset_source", &offset_source)
1099            .finish()
1100    }
1101}
1102
1103impl PartialEq for TranslatedContentContextElement {
1104    fn eq(&self, other: &Self) -> bool {
1105        self.identity == other.identity
1106            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
1107            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
1108    }
1109}
1110
1111impl Eq for TranslatedContentContextElement {}
1112
1113impl std::hash::Hash for TranslatedContentContextElement {
1114    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1115        self.identity.hash(state);
1116        self.offset_source.is_vertical().hash(state);
1117        self.offset_source.reverse_scrolling().hash(state);
1118    }
1119}
1120
1121impl ModifierNodeElement for TranslatedContentContextElement {
1122    type Node = TranslatedContentContextNode;
1123
1124    fn create(&self) -> Self::Node {
1125        TranslatedContentContextNode::new(self.identity, self.offset_source.clone())
1126    }
1127
1128    fn update(&self, node: &mut Self::Node) {
1129        node.identity = self.identity;
1130        node.offset_source = self.offset_source.clone();
1131    }
1132
1133    fn capabilities(&self) -> NodeCapabilities {
1134        NodeCapabilities::LAYOUT
1135    }
1136}
1137
1138// ============================================================================
1139// Modifier Extensions
1140// ============================================================================
1141
1142impl Modifier {
1143    /// Creates a horizontally scrollable modifier.
1144    ///
1145    /// # Arguments
1146    /// * `state` - The ScrollState to control scroll position
1147    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1148    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1149    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1150    ///   drag right = scroll left (content moves right under finger).
1151    ///
1152    /// # Example
1153    /// ```text
1154    /// let scroll_state = ScrollState::new(0.0);
1155    /// Row(
1156    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
1157    ///     // ... content
1158    /// );
1159    /// ```
1160    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1161        self.then(scroll_impl(state, false, reverse_scrolling, None))
1162    }
1163
1164    /// Creates a vertically scrollable modifier.
1165    ///
1166    /// # Arguments
1167    /// * `state` - The ScrollState to control scroll position
1168    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1169    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1170    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1171    ///   drag down = scroll up (content moves down under finger).
1172    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1173        self.then(scroll_impl(state, true, reverse_scrolling, None))
1174    }
1175
1176    /// Creates a horizontally scrollable modifier with a guard that can disable scrolling.
1177    pub fn horizontal_scroll_guarded(
1178        self,
1179        state: ScrollState,
1180        reverse_scrolling: bool,
1181        guard: impl Fn() -> bool + 'static,
1182    ) -> Self {
1183        self.then(scroll_impl(
1184            state,
1185            false,
1186            reverse_scrolling,
1187            Some(Rc::new(guard)),
1188        ))
1189    }
1190
1191    /// Creates a vertically scrollable modifier with a guard that can disable scrolling.
1192    pub fn vertical_scroll_guarded(
1193        self,
1194        state: ScrollState,
1195        reverse_scrolling: bool,
1196        guard: impl Fn() -> bool + 'static,
1197    ) -> Self {
1198        self.then(scroll_impl(
1199            state,
1200            true,
1201            reverse_scrolling,
1202            Some(Rc::new(guard)),
1203        ))
1204    }
1205}
1206
1207/// Internal implementation for scroll modifiers.
1208///
1209/// Creates a combined modifier consisting of:
1210/// 1. Pointer input handler (for gesture detection)
1211/// 2. Layout modifier (for applying scroll offset)
1212///
1213/// The pointer input is added FIRST so it appears earlier in the modifier
1214/// chain, allowing it to intercept events before layout-related handlers.
1215fn scroll_impl(
1216    state: ScrollState,
1217    is_vertical: bool,
1218    reverse_scrolling: bool,
1219    guard: Option<Rc<dyn Fn() -> bool>>,
1220) -> Modifier {
1221    // Create local gesture state - each scroll modifier instance is independent
1222    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
1223    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1224        state_id: state.id(),
1225        is_vertical,
1226        reverse_scrolling,
1227    });
1228
1229    // Set up pointer input handler
1230    let scroll_state = state.clone();
1231    let pointer_motion_context = motion_context.clone();
1232    let key = (state.id(), is_vertical);
1233    let pointer_input = Modifier::empty().pointer_input(key, move |scope| {
1234        // Create detector inside the async closure to capture the cloned state
1235        let detector = ScrollGestureDetector::new(
1236            gesture_state.clone(),
1237            scroll_state.clone(),
1238            is_vertical,
1239            false, // ScrollState handles reversing in layout, not input
1240            pointer_motion_context.clone(),
1241        );
1242        let guard = guard.clone();
1243
1244        async move {
1245            scope
1246                .await_pointer_event_scope(|await_scope| async move {
1247                    // Main event loop - processes events until scope is cancelled
1248                    loop {
1249                        let event = await_scope.await_pointer_event().await;
1250
1251                        // Scroll drags track the primary pointer only;
1252                        // secondary pointers belong to multi-touch gestures
1253                        // (pinch/zoom) handled by other modifiers.
1254                        if event.id != 0 {
1255                            continue;
1256                        }
1257
1258                        if event.is_consumed() {
1259                            if matches!(
1260                                event.kind,
1261                                PointerEventKind::Down
1262                                    | PointerEventKind::Move
1263                                    | PointerEventKind::Up
1264                                    | PointerEventKind::Cancel
1265                            ) {
1266                                detector.on_cancel();
1267                            }
1268                            continue;
1269                        }
1270
1271                        if let Some(ref guard) = guard {
1272                            if !guard() {
1273                                if matches!(
1274                                    event.kind,
1275                                    PointerEventKind::Up | PointerEventKind::Cancel
1276                                ) {
1277                                    detector.on_cancel();
1278                                }
1279                                continue;
1280                            }
1281                        }
1282
1283                        // Delegate to detector's lifecycle methods
1284                        let should_consume = match event.kind {
1285                            PointerEventKind::Down => {
1286                                detector.on_down(event.position, event.time_ms)
1287                            }
1288                            PointerEventKind::Move => {
1289                                detector.on_move(event.position, event.buttons, event.time_ms)
1290                            }
1291                            PointerEventKind::Up => detector.on_up(event.time_ms),
1292                            PointerEventKind::Cancel => detector.on_cancel(),
1293                            PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
1294                                event.scroll_delta.y
1295                            } else {
1296                                event.scroll_delta.x
1297                            }),
1298                            PointerEventKind::Zoom
1299                            | PointerEventKind::Enter
1300                            | PointerEventKind::Exit => false,
1301                        };
1302
1303                        if should_consume {
1304                            event.consume();
1305                        }
1306                    }
1307                })
1308                .await;
1309        }
1310    });
1311
1312    // Create layout modifier for applying scroll offset to content
1313    let element = ScrollElement::new(state.clone(), is_vertical, reverse_scrolling);
1314    let layout_modifier =
1315        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1316            if is_vertical {
1317                "verticalScroll"
1318            } else {
1319                "horizontalScroll"
1320            },
1321            move |info| {
1322                info.add_property("isVertical", is_vertical.to_string());
1323                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1324            },
1325        ));
1326    let motion_modifier =
1327        Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()));
1328    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1329        state.id() as usize,
1330        TranslatedContentOffsetSource::LayoutContentOffset,
1331    ));
1332
1333    // Combine: pointer input THEN layout modifier, clip to bounds by default
1334    pointer_input
1335        .then(motion_modifier)
1336        .then(translated_content_modifier)
1337        .then(layout_modifier)
1338        .clip_to_bounds()
1339}
1340
1341// ============================================================================
1342// Lazy Scroll Support for LazyListState
1343// ============================================================================
1344
1345use cranpose_foundation::lazy::LazyListState;
1346
1347impl Modifier {
1348    /// Creates a vertically scrollable modifier for lazy lists.
1349    ///
1350    /// This connects pointer gestures to LazyListState for scroll handling.
1351    /// Unlike regular vertical_scroll, no layout offset is applied here
1352    /// since LazyListState manages item positioning internally.
1353    /// Creates a vertically scrollable modifier for lazy lists.
1354    ///
1355    /// This connects pointer gestures to LazyListState for scroll handling.
1356    /// Unlike regular vertical_scroll, no layout offset is applied here
1357    /// since LazyListState manages item positioning internally.
1358    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1359        self.then(lazy_scroll_impl(state, true, reverse_scrolling))
1360    }
1361
1362    /// Creates a horizontally scrollable modifier for lazy lists.
1363    pub fn lazy_horizontal_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1364        self.then(lazy_scroll_impl(state, false, reverse_scrolling))
1365    }
1366}
1367
1368/// Internal implementation for lazy scroll modifiers.
1369fn lazy_scroll_impl(state: LazyListState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
1370    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
1371    let list_state = state;
1372    let state_id = state.inner_ptr() as usize;
1373    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1374        state_identity: state_id,
1375        is_vertical,
1376        reverse_scrolling,
1377    });
1378    let key = (state_id, is_vertical, reverse_scrolling);
1379    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1380        state_id,
1381        TranslatedContentOffsetSource::LazyList {
1382            state,
1383            is_vertical,
1384            reverse_scrolling,
1385        },
1386    ));
1387
1388    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1389        .then(translated_content_modifier)
1390        .pointer_input(key, move |scope| {
1391            // Use the same generic detector with LazyListState
1392            let detector = ScrollGestureDetector::new(
1393                gesture_state.clone(),
1394                list_state,
1395                is_vertical,
1396                reverse_scrolling,
1397                motion_context.clone(),
1398            );
1399
1400            async move {
1401                scope
1402                    .await_pointer_event_scope(|await_scope| async move {
1403                        loop {
1404                            let event = await_scope.await_pointer_event().await;
1405
1406                            // Scroll drags track the primary pointer only;
1407                            // secondary pointers belong to multi-touch
1408                            // gestures handled by other modifiers.
1409                            if event.id != 0 {
1410                                continue;
1411                            }
1412
1413                            if event.is_consumed() {
1414                                if matches!(
1415                                    event.kind,
1416                                    PointerEventKind::Down
1417                                        | PointerEventKind::Move
1418                                        | PointerEventKind::Up
1419                                        | PointerEventKind::Cancel
1420                                ) {
1421                                    detector.on_cancel();
1422                                }
1423                                continue;
1424                            }
1425
1426                            // Delegate to detector's lifecycle methods
1427                            let should_consume = match event.kind {
1428                                PointerEventKind::Down => {
1429                                    detector.on_down(event.position, event.time_ms)
1430                                }
1431                                PointerEventKind::Move => {
1432                                    detector.on_move(event.position, event.buttons, event.time_ms)
1433                                }
1434                                PointerEventKind::Up => detector.on_up(event.time_ms),
1435                                PointerEventKind::Cancel => detector.on_cancel(),
1436                                PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
1437                                    event.scroll_delta.y
1438                                } else {
1439                                    event.scroll_delta.x
1440                                }),
1441                                PointerEventKind::Zoom
1442                                | PointerEventKind::Enter
1443                                | PointerEventKind::Exit => false,
1444                            };
1445
1446                            if should_consume {
1447                                event.consume();
1448                            }
1449                        }
1450                    })
1451                    .await;
1452            }
1453        })
1454}