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 total movement exceeds `DRAG_THRESHOLD` (8px)
13//!    - If threshold crossed: start consuming events, apply scroll delta
14//!    - This prevents child click handlers from firing during scrolls
15//! 3. **Up/Cancel**: Clean up state, consume if was dragging
16
17use super::{inspector_metadata, Modifier, Point, PointerEventKind};
18use crate::current_density;
19use crate::fling_animation::FlingAnimation;
20use crate::fling_animation::MIN_FLING_VELOCITY;
21use crate::scroll::{ScrollElement, ScrollState};
22use cranpose_core::current_runtime_handle;
23use cranpose_foundation::{
24    velocity_tracker::ASSUME_STOPPED_MS, PointerButton, PointerButtons, VelocityTracker1D,
25    DRAG_THRESHOLD, MAX_FLING_VELOCITY,
26};
27use std::cell::RefCell;
28use std::rc::Rc;
29use web_time::Instant;
30
31// ============================================================================
32// Test Accessibility: Last Fling Velocity (only in test-helpers builds)
33// ============================================================================
34
35#[cfg(feature = "test-helpers")]
36mod test_velocity_tracking {
37    use std::sync::atomic::{AtomicU32, Ordering};
38
39    /// Stores the last fling velocity calculated for test verification.
40    ///
41    /// Uses `AtomicU32` (not thread_local) because the test driver runs on a separate
42    /// thread from the UI thread, and the test needs to read values written by the UI.
43    ///
44    /// # Parallel Test Safety
45    /// This global state means parallel tests could interfere with each other.
46    /// For test isolation, run robot tests sequentially (cargo test -- --test-threads=1)
47    /// or use test harnesses that reset state between runs.
48    static LAST_FLING_VELOCITY: AtomicU32 = AtomicU32::new(0);
49
50    /// Returns the last fling velocity calculated (in px/sec).
51    ///
52    /// This is primarily for testing - allows robot tests to verify that
53    /// velocity detection is working correctly instead of relying on log output.
54    pub fn last_fling_velocity() -> f32 {
55        f32::from_bits(LAST_FLING_VELOCITY.load(Ordering::SeqCst))
56    }
57
58    /// Resets the last fling velocity to 0.0.
59    ///
60    /// Call this at the start of a test to ensure clean state.
61    pub fn reset_last_fling_velocity() {
62        LAST_FLING_VELOCITY.store(0.0f32.to_bits(), Ordering::SeqCst);
63    }
64
65    /// Internal: Set the last fling velocity (called from gesture detection).
66    pub(super) fn set_last_fling_velocity(velocity: f32) {
67        LAST_FLING_VELOCITY.store(velocity.to_bits(), Ordering::SeqCst);
68    }
69}
70
71#[cfg(feature = "test-helpers")]
72pub use test_velocity_tracking::{last_fling_velocity, reset_last_fling_velocity};
73
74/// Internal: Set the last fling velocity (called from gesture detection).
75/// No-op in production builds without test-helpers feature.
76#[inline]
77fn set_last_fling_velocity(velocity: f32) {
78    #[cfg(feature = "test-helpers")]
79    test_velocity_tracking::set_last_fling_velocity(velocity);
80    #[cfg(not(feature = "test-helpers"))]
81    let _ = velocity; // Silence unused variable warning
82}
83
84/// Local gesture state for scroll drag handling.
85///
86/// This is NOT part of `ScrollState` to keep the scroll model pure.
87/// Each scroll modifier instance has its own gesture state, which enables
88/// multiple independent scroll regions without state interference.
89struct ScrollGestureState {
90    /// Position where pointer was pressed down.
91    /// Used to calculate total drag distance for threshold detection.
92    drag_down_position: Option<Point>,
93
94    /// Last known pointer position during drag.
95    /// Used to calculate incremental delta for each move event.
96    last_position: Option<Point>,
97
98    /// Whether we've crossed the drag threshold and are actively scrolling.
99    /// Once true, we consume all events until Up/Cancel to prevent child
100    /// handlers from receiving drag events.
101    is_dragging: bool,
102
103    /// Velocity tracker for fling gesture detection.
104    velocity_tracker: VelocityTracker1D,
105
106    /// Time when gesture down started (for velocity calculation).
107    gesture_start_time: Option<Instant>,
108
109    /// Last time a velocity sample was recorded (milliseconds since gesture start).
110    last_velocity_sample_ms: Option<i64>,
111
112    /// Current fling animation (if any).
113    fling_animation: Option<FlingAnimation>,
114}
115
116impl Default for ScrollGestureState {
117    fn default() -> Self {
118        Self {
119            drag_down_position: None,
120            last_position: None,
121            is_dragging: false,
122            velocity_tracker: VelocityTracker1D::new(),
123            gesture_start_time: None,
124            last_velocity_sample_ms: None,
125            fling_animation: None,
126        }
127    }
128}
129
130// ============================================================================
131// Helper Functions
132// ============================================================================
133
134/// Calculates the total movement distance from the original down position.
135///
136/// This is used to determine if we've crossed the drag threshold.
137/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
138#[inline]
139fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
140    if is_vertical {
141        to.y - from.y
142    } else {
143        to.x - from.x
144    }
145}
146
147/// Calculates the incremental movement delta from the previous position.
148///
149/// This is used to update the scroll offset incrementally during drag.
150/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
151#[inline]
152fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
153    if is_vertical {
154        to.y - from.y
155    } else {
156        to.x - from.x
157    }
158}
159
160// ============================================================================
161// Scroll Gesture Detector (Generic Implementation)
162// ============================================================================
163
164/// Trait for scroll targets that can receive scroll deltas.
165///
166/// Implemented by both `ScrollState` (regular scroll) and `LazyListState` (lazy lists).
167trait ScrollTarget: Clone {
168    /// Apply a gesture delta. Returns the consumed amount in gesture coordinates.
169    fn apply_delta(&self, delta: f32) -> f32;
170
171    /// Apply a scroll delta during fling. Returns consumed delta in scroll coordinates.
172    fn apply_fling_delta(&self, delta: f32) -> f32;
173
174    /// Called after scroll to trigger any necessary invalidation.
175    fn invalidate(&self);
176
177    /// Get the current scroll offset.
178    fn current_offset(&self) -> f32;
179}
180
181impl ScrollTarget for ScrollState {
182    fn apply_delta(&self, delta: f32) -> f32 {
183        // Regular scroll uses negative delta (natural scrolling)
184        self.dispatch_raw_delta(-delta)
185    }
186
187    fn apply_fling_delta(&self, delta: f32) -> f32 {
188        self.dispatch_raw_delta(delta)
189    }
190
191    fn invalidate(&self) {
192        // ScrollState triggers invalidation internally
193    }
194
195    fn current_offset(&self) -> f32 {
196        self.value()
197    }
198}
199
200impl ScrollTarget for LazyListState {
201    fn apply_delta(&self, delta: f32) -> f32 {
202        // LazyListState uses positive delta directly
203        // dispatch_scroll_delta already calls self.invalidate() which triggers the
204        // layout invalidation callback registered in lazy_scroll_impl
205        self.dispatch_scroll_delta(delta)
206    }
207
208    fn apply_fling_delta(&self, delta: f32) -> f32 {
209        -self.dispatch_scroll_delta(-delta)
210    }
211
212    fn invalidate(&self) {
213        // dispatch_scroll_delta already handles invalidation internally via callback.
214        // We do NOT call request_layout_invalidation() here - that's the global
215        // nuclear option that invalidates ALL layout caches app-wide.
216        // The registered callback uses schedule_layout_repass for scoped invalidation.
217    }
218
219    fn current_offset(&self) -> f32 {
220        // LazyListState doesn't have a simple offset - use first visible item offset
221        self.first_visible_item_scroll_offset()
222    }
223}
224
225/// Generic scroll gesture detector that works with any ScrollTarget.
226///
227/// This struct provides a clean interface for processing pointer events
228/// and managing scroll interactions. The generic parameter S determines
229/// how scroll deltas are applied.
230struct ScrollGestureDetector<S: ScrollTarget> {
231    /// Shared gesture state (position tracking, drag status).
232    gesture_state: Rc<RefCell<ScrollGestureState>>,
233
234    /// The scroll target to update when drag is detected.
235    scroll_target: S,
236
237    /// Whether this is vertical or horizontal scroll.
238    is_vertical: bool,
239
240    /// Whether to reverse the scroll direction (flip delta).
241    reverse_scrolling: bool,
242}
243
244impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
245    /// Creates a new detector for the given scroll configuration.
246    fn new(
247        gesture_state: Rc<RefCell<ScrollGestureState>>,
248        scroll_target: S,
249        is_vertical: bool,
250        reverse_scrolling: bool,
251    ) -> Self {
252        Self {
253            gesture_state,
254            scroll_target,
255            is_vertical,
256            reverse_scrolling,
257        }
258    }
259
260    /// Handles pointer down event.
261    ///
262    /// Records the initial position for threshold calculation and
263    /// resets drag state. We don't consume Down events because we
264    /// don't know yet if this will become a drag or a click.
265    ///
266    /// Returns `false` - Down events are never consumed to allow
267    /// potential child click handlers to receive the initial press.
268    fn on_down(&self, position: Point) -> bool {
269        let mut gs = self.gesture_state.borrow_mut();
270
271        // Cancel any running fling animation
272        if let Some(fling) = gs.fling_animation.take() {
273            fling.cancel();
274        }
275
276        gs.drag_down_position = Some(position);
277        gs.last_position = Some(position);
278        gs.is_dragging = false;
279        gs.velocity_tracker.reset();
280        gs.gesture_start_time = Some(Instant::now());
281
282        // Add initial position to velocity tracker
283        let pos = if self.is_vertical {
284            position.y
285        } else {
286            position.x
287        };
288        gs.velocity_tracker.add_data_point(0, pos);
289        gs.last_velocity_sample_ms = Some(0);
290
291        // Never consume Down - we don't know if this is a drag yet
292        false
293    }
294
295    /// Handles pointer move event.
296    ///
297    /// This is the core gesture detection logic:
298    /// 1. Safety check: if no primary button is pressed but we think we're
299    ///    tracking, we missed an Up event - reset state.
300    /// 2. Calculate total movement from down position.
301    /// 3. If total movement exceeds `DRAG_THRESHOLD` (8px), start dragging.
302    /// 4. While dragging, apply scroll delta and consume events.
303    ///
304    /// Returns `true` if event should be consumed (we're actively dragging).
305    fn on_move(&self, position: Point, buttons: PointerButtons) -> bool {
306        let mut gs = self.gesture_state.borrow_mut();
307
308        // Safety: detect missed Up events (hit test delivered to wrong target)
309        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
310            gs.drag_down_position = None;
311            gs.last_position = None;
312            gs.is_dragging = false;
313            gs.gesture_start_time = None;
314            gs.last_velocity_sample_ms = None;
315            gs.velocity_tracker.reset();
316            return false;
317        }
318
319        let Some(down_pos) = gs.drag_down_position else {
320            return false;
321        };
322
323        let Some(last_pos) = gs.last_position else {
324            gs.last_position = Some(position);
325            return false;
326        };
327
328        let total_delta = calculate_total_delta(down_pos, position, self.is_vertical);
329        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
330
331        // Threshold check: start dragging only after moving 8px from down position.
332        if !gs.is_dragging && total_delta.abs() > DRAG_THRESHOLD {
333            gs.is_dragging = true;
334        }
335
336        gs.last_position = Some(position);
337
338        // Track velocity for fling
339        if let Some(start_time) = gs.gesture_start_time {
340            let elapsed_ms = start_time.elapsed().as_millis() as i64;
341            let pos = if self.is_vertical {
342                position.y
343            } else {
344                position.x
345            };
346            // Keep sample times strictly increasing so velocity stays stable when
347            // multiple move events land in the same millisecond.
348            let sample_ms = match gs.last_velocity_sample_ms {
349                Some(last_sample_ms) => {
350                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
351                        last_sample_ms + 1
352                    } else {
353                        elapsed_ms
354                    };
355                    // Clamp large processing gaps so frame stalls don't erase fling velocity.
356                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
357                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
358                    }
359                    sample_ms
360                }
361                None => elapsed_ms,
362            };
363            gs.velocity_tracker.add_data_point(sample_ms, pos);
364            gs.last_velocity_sample_ms = Some(sample_ms);
365        }
366
367        if gs.is_dragging {
368            drop(gs); // Release borrow before calling scroll target
369            let delta = if self.reverse_scrolling {
370                -incremental_delta
371            } else {
372                incremental_delta
373            };
374            let _ = self.scroll_target.apply_delta(delta);
375            self.scroll_target.invalidate();
376            true // Consume event while dragging
377        } else {
378            false
379        }
380    }
381
382    /// Handles pointer up event.
383    ///
384    /// Cleans up drag state. If we were actively dragging, calculates fling
385    /// velocity and starts fling animation if velocity is above threshold.
386    ///
387    /// Returns `true` if we were dragging (event should be consumed).
388    fn finish_gesture(&self, allow_fling: bool) -> bool {
389        let (was_dragging, velocity, start_fling, existing_fling) = {
390            let mut gs = self.gesture_state.borrow_mut();
391            let was_dragging = gs.is_dragging;
392            let mut velocity = 0.0;
393
394            if allow_fling && was_dragging && gs.gesture_start_time.is_some() {
395                velocity = gs
396                    .velocity_tracker
397                    .calculate_velocity_with_max(MAX_FLING_VELOCITY);
398            }
399
400            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
401            let existing_fling = if start_fling {
402                gs.fling_animation.take()
403            } else {
404                None
405            };
406
407            gs.drag_down_position = None;
408            gs.last_position = None;
409            gs.is_dragging = false;
410            gs.gesture_start_time = None;
411            gs.last_velocity_sample_ms = None;
412
413            (was_dragging, velocity, start_fling, existing_fling)
414        };
415
416        // Always record velocity for test accessibility (even if below fling threshold)
417        if allow_fling && was_dragging {
418            set_last_fling_velocity(velocity);
419        }
420
421        // Start fling animation if velocity is significant
422        if start_fling {
423            if let Some(old_fling) = existing_fling {
424                old_fling.cancel();
425            }
426
427            // Get runtime handle for frame callbacks
428            if let Some(runtime) = current_runtime_handle() {
429                let scroll_target = self.scroll_target.clone();
430                let reverse = self.reverse_scrolling;
431                let fling = FlingAnimation::new(runtime);
432
433                // Get current scroll position for fling start
434                let initial_value = scroll_target.current_offset();
435
436                // Convert gesture velocity to scroll velocity.
437                let adjusted_velocity = if reverse { -velocity } else { velocity };
438                let fling_velocity = -adjusted_velocity;
439
440                let scroll_target_for_fling = scroll_target.clone();
441                let scroll_target_for_end = scroll_target.clone();
442
443                fling.start_fling(
444                    initial_value,
445                    fling_velocity,
446                    current_density(),
447                    move |delta| {
448                        // Apply scroll delta during fling, return consumed amount
449                        let consumed = scroll_target_for_fling.apply_fling_delta(delta);
450                        scroll_target_for_fling.invalidate();
451                        consumed
452                    },
453                    move || {
454                        // Animation complete - invalidate to ensure final render
455                        scroll_target_for_end.invalidate();
456                    },
457                );
458
459                let mut gs = self.gesture_state.borrow_mut();
460                gs.fling_animation = Some(fling);
461            }
462        }
463
464        was_dragging
465    }
466
467    /// Handles pointer up event.
468    ///
469    /// Cleans up drag state. If we were actively dragging, calculates fling
470    /// velocity and starts fling animation if velocity is above threshold.
471    ///
472    /// Returns `true` if we were dragging (event should be consumed).
473    fn on_up(&self) -> bool {
474        self.finish_gesture(true)
475    }
476
477    /// Handles pointer cancel event.
478    ///
479    /// Cleans up state without starting a fling. Returns `true` if we were dragging.
480    fn on_cancel(&self) -> bool {
481        self.finish_gesture(false)
482    }
483
484    /// Handles mouse wheel / trackpad scroll event.
485    ///
486    /// Returns `true` when the target consumed any delta.
487    fn on_scroll(&self, axis_delta: f32) -> bool {
488        if axis_delta.abs() <= f32::EPSILON {
489            return false;
490        }
491
492        {
493            // Wheel scroll should take over immediately and stop any active drag/fling state.
494            let mut gs = self.gesture_state.borrow_mut();
495            if let Some(fling) = gs.fling_animation.take() {
496                fling.cancel();
497            }
498            gs.drag_down_position = None;
499            gs.last_position = None;
500            gs.is_dragging = false;
501            gs.gesture_start_time = None;
502            gs.last_velocity_sample_ms = None;
503            gs.velocity_tracker.reset();
504        }
505
506        let delta = if self.reverse_scrolling {
507            -axis_delta
508        } else {
509            axis_delta
510        };
511        let consumed = self.scroll_target.apply_delta(delta);
512        if consumed.abs() > 0.001 {
513            self.scroll_target.invalidate();
514            true
515        } else {
516            false
517        }
518    }
519}
520
521// ============================================================================
522// Modifier Extensions
523// ============================================================================
524
525impl Modifier {
526    /// Creates a horizontally scrollable modifier.
527    ///
528    /// # Arguments
529    /// * `state` - The ScrollState to control scroll position
530    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
531    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
532    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
533    ///   drag right = scroll left (content moves right under finger).
534    ///
535    /// # Example
536    /// ```text
537    /// let scroll_state = ScrollState::new(0.0);
538    /// Row(
539    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
540    ///     // ... content
541    /// );
542    /// ```
543    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
544        self.then(scroll_impl(state, false, reverse_scrolling, None))
545    }
546
547    /// Creates a vertically scrollable modifier.
548    ///
549    /// # Arguments
550    /// * `state` - The ScrollState to control scroll position
551    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
552    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
553    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
554    ///   drag down = scroll up (content moves down under finger).
555    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
556        self.then(scroll_impl(state, true, reverse_scrolling, None))
557    }
558
559    /// Creates a horizontally scrollable modifier with a guard that can disable scrolling.
560    pub fn horizontal_scroll_guarded(
561        self,
562        state: ScrollState,
563        reverse_scrolling: bool,
564        guard: impl Fn() -> bool + 'static,
565    ) -> Self {
566        self.then(scroll_impl(
567            state,
568            false,
569            reverse_scrolling,
570            Some(Rc::new(guard)),
571        ))
572    }
573
574    /// Creates a vertically scrollable modifier with a guard that can disable scrolling.
575    pub fn vertical_scroll_guarded(
576        self,
577        state: ScrollState,
578        reverse_scrolling: bool,
579        guard: impl Fn() -> bool + 'static,
580    ) -> Self {
581        self.then(scroll_impl(
582            state,
583            true,
584            reverse_scrolling,
585            Some(Rc::new(guard)),
586        ))
587    }
588}
589
590/// Internal implementation for scroll modifiers.
591///
592/// Creates a combined modifier consisting of:
593/// 1. Pointer input handler (for gesture detection)
594/// 2. Layout modifier (for applying scroll offset)
595///
596/// The pointer input is added FIRST so it appears earlier in the modifier
597/// chain, allowing it to intercept events before layout-related handlers.
598fn scroll_impl(
599    state: ScrollState,
600    is_vertical: bool,
601    reverse_scrolling: bool,
602    guard: Option<Rc<dyn Fn() -> bool>>,
603) -> Modifier {
604    // Create local gesture state - each scroll modifier instance is independent
605    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
606
607    // Set up pointer input handler
608    let scroll_state = state.clone();
609    let key = (state.id(), is_vertical);
610    let pointer_input = Modifier::empty().pointer_input(key, move |scope| {
611        // Create detector inside the async closure to capture the cloned state
612        let detector = ScrollGestureDetector::new(
613            gesture_state.clone(),
614            scroll_state.clone(),
615            is_vertical,
616            false, // ScrollState handles reversing in layout, not input
617        );
618        let guard = guard.clone();
619
620        async move {
621            scope
622                .await_pointer_event_scope(|await_scope| async move {
623                    // Main event loop - processes events until scope is cancelled
624                    loop {
625                        let event = await_scope.await_pointer_event().await;
626
627                        if let Some(ref guard) = guard {
628                            if !guard() {
629                                if matches!(
630                                    event.kind,
631                                    PointerEventKind::Up | PointerEventKind::Cancel
632                                ) {
633                                    detector.on_cancel();
634                                }
635                                continue;
636                            }
637                        }
638
639                        // Delegate to detector's lifecycle methods
640                        let should_consume = match event.kind {
641                            PointerEventKind::Down => detector.on_down(event.position),
642                            PointerEventKind::Move => {
643                                detector.on_move(event.position, event.buttons)
644                            }
645                            PointerEventKind::Up => detector.on_up(),
646                            PointerEventKind::Cancel => detector.on_cancel(),
647                            PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
648                                event.scroll_delta.y
649                            } else {
650                                event.scroll_delta.x
651                            }),
652                        };
653
654                        if should_consume {
655                            event.consume();
656                        }
657                    }
658                })
659                .await;
660        }
661    });
662
663    // Create layout modifier for applying scroll offset to content
664    let element = ScrollElement::new(state.clone(), is_vertical, reverse_scrolling);
665    let layout_modifier =
666        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
667            if is_vertical {
668                "verticalScroll"
669            } else {
670                "horizontalScroll"
671            },
672            move |info| {
673                info.add_property("isVertical", is_vertical.to_string());
674                info.add_property("reverseScrolling", reverse_scrolling.to_string());
675            },
676        ));
677
678    // Combine: pointer input THEN layout modifier, clip to bounds by default
679    pointer_input.then(layout_modifier).clip_to_bounds()
680}
681
682// ============================================================================
683// Lazy Scroll Support for LazyListState
684// ============================================================================
685
686use cranpose_foundation::lazy::LazyListState;
687
688impl Modifier {
689    /// Creates a vertically scrollable modifier for lazy lists.
690    ///
691    /// This connects pointer gestures to LazyListState for scroll handling.
692    /// Unlike regular vertical_scroll, no layout offset is applied here
693    /// since LazyListState manages item positioning internally.
694    /// Creates a vertically scrollable modifier for lazy lists.
695    ///
696    /// This connects pointer gestures to LazyListState for scroll handling.
697    /// Unlike regular vertical_scroll, no layout offset is applied here
698    /// since LazyListState manages item positioning internally.
699    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
700        self.then(lazy_scroll_impl(state, true, reverse_scrolling))
701    }
702
703    /// Creates a horizontally scrollable modifier for lazy lists.
704    pub fn lazy_horizontal_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
705        self.then(lazy_scroll_impl(state, false, reverse_scrolling))
706    }
707}
708
709/// Internal implementation for lazy scroll modifiers.
710fn lazy_scroll_impl(state: LazyListState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
711    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
712    let list_state = state;
713
714    // Note: Layout invalidation callback is registered in LazyColumnImpl/LazyRowImpl
715    // after the node is created, using schedule_layout_repass(node_id) for O(subtree)
716    // performance instead of request_layout_invalidation() which is O(entire app).
717
718    // Use a unique key per LazyListState
719    let state_id = std::ptr::addr_of!(*state.inner_ptr()) as usize;
720    let key = (state_id, is_vertical, reverse_scrolling);
721
722    Modifier::empty().pointer_input(key, move |scope| {
723        // Use the same generic detector with LazyListState
724        let detector = ScrollGestureDetector::new(
725            gesture_state.clone(),
726            list_state,
727            is_vertical,
728            reverse_scrolling,
729        );
730
731        async move {
732            scope
733                .await_pointer_event_scope(|await_scope| async move {
734                    loop {
735                        let event = await_scope.await_pointer_event().await;
736
737                        // Delegate to detector's lifecycle methods
738                        let should_consume = match event.kind {
739                            PointerEventKind::Down => detector.on_down(event.position),
740                            PointerEventKind::Move => {
741                                detector.on_move(event.position, event.buttons)
742                            }
743                            PointerEventKind::Up => detector.on_up(),
744                            PointerEventKind::Cancel => detector.on_cancel(),
745                            PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
746                                event.scroll_delta.y
747                            } else {
748                                event.scroll_delta.x
749                            }),
750                        };
751
752                        if should_consume {
753                            event.consume();
754                        }
755                    }
756                })
757                .await;
758        }
759    })
760}