Skip to main content

cranpose_ui/modifier/
scroll.rs

1use std::{
2    cell::{Cell, RefCell},
3    rc::Rc,
4};
5
6use cranpose_core::{NodeId, current_runtime_handle, internal::FrameCallbackRegistration};
7use cranpose_foundation::{
8    DRAG_THRESHOLD, DelegatableNode, MAX_FLING_VELOCITY, ModifierNode, ModifierNodeElement,
9    NodeCapabilities, NodeState, PointerButton, PointerButtons, VelocityTracker1D,
10    velocity_tracker::ASSUME_STOPPED_MS,
11};
12use cranpose_ui_layout::Axis;
13use web_time::Instant;
14
15use super::{Modifier, Point, PointerEvent, PointerEventKind, inspector_metadata};
16use crate::{
17    draggable::{DraggableState, ScrollableState},
18    fling_animation::{
19        FlingAnimation, MIN_FLING_VELOCITY, SettleAnimation, SpringParams, fling_rest_position,
20    },
21    render_state::schedule_modifier_slices_repass,
22    scroll::{
23        ScrollElement, ScrollMotionContext, ScrollMotionContextKey, ScrollSettlePolicy,
24        ScrollState, scroll_motion_context_for_key,
25    },
26};
27
28#[cfg(feature = "test-helpers")]
29pub fn last_fling_velocity() -> f32 {
30    crate::render_state::debug_last_fling_velocity()
31}
32
33#[cfg(feature = "test-helpers")]
34pub fn reset_last_fling_velocity() {
35    crate::render_state::debug_reset_last_fling_velocity();
36}
37
38#[inline]
39fn set_last_fling_velocity(velocity: f32) {
40    crate::render_state::record_last_fling_velocity(velocity);
41}
42
43struct ScrollGestureState {
44    disposed: bool,
45    drag_down_position: Option<Point>,
46
47    last_position: Option<Point>,
48
49    is_dragging: bool,
50
51    /// Whether the press stopped a scroll animation the app started, so
52    /// letting go settles even without a drag.
53    interrupted_animation: bool,
54
55    axis_locked_out: bool,
56
57    velocity_tracker: VelocityTracker1D,
58
59    gesture_start_time: Option<Instant>,
60
61    gesture_start_event_time_ms: Option<i64>,
62
63    last_velocity_sample_ms: Option<i64>,
64
65    fling_animation: Option<FlingAnimation>,
66
67    is_overscrolling: bool,
68
69    settle_animation: Option<SettleAnimation>,
70
71    wheel_settle_watcher: Option<WheelSettleWatcher>,
72}
73
74impl Default for ScrollGestureState {
75    fn default() -> Self {
76        Self {
77            disposed: false,
78            drag_down_position: None,
79            last_position: None,
80            is_dragging: false,
81            interrupted_animation: false,
82            axis_locked_out: false,
83            velocity_tracker: VelocityTracker1D::new(),
84            gesture_start_time: None,
85            gesture_start_event_time_ms: None,
86            last_velocity_sample_ms: None,
87            fling_animation: None,
88            is_overscrolling: false,
89            settle_animation: None,
90            wheel_settle_watcher: None,
91        }
92    }
93}
94
95impl ScrollGestureState {
96    fn is_active(&self) -> bool {
97        self.is_dragging
98            || self.is_overscrolling
99            || self
100                .fling_animation
101                .as_ref()
102                .is_some_and(FlingAnimation::is_running)
103            || self
104                .settle_animation
105                .as_ref()
106                .is_some_and(SettleAnimation::is_running)
107            || self
108                .wheel_settle_watcher
109                .as_ref()
110                .is_some_and(|watcher| watcher.is_running.get())
111    }
112}
113
114#[inline]
115fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
116    if is_vertical {
117        to.y - from.y
118    } else {
119        to.x - from.x
120    }
121}
122
123#[inline]
124fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
125    if is_vertical {
126        to.y - from.y
127    } else {
128        to.x - from.x
129    }
130}
131
132trait ScrollTarget: Clone {
133    fn apply_delta(&self, delta: f32) -> f32;
134
135    fn apply_wheel_delta(&self, delta: f32) -> f32 {
136        self.apply_delta(delta)
137    }
138
139    fn apply_fling_delta(&self, delta: f32) -> f32;
140
141    fn invalidate(&self);
142
143    fn current_offset(&self) -> f32;
144
145    fn can_scroll(&self) -> bool {
146        true
147    }
148
149    fn can_consume(&self, gesture_delta: f32) -> bool {
150        let _ = gesture_delta;
151        self.can_scroll()
152    }
153
154    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
155        None
156    }
157
158    fn allows_fling(&self) -> bool {
159        true
160    }
161
162    fn set_dragging(&self, dragging: bool) {
163        let _ = dragging;
164    }
165
166    /// Stops a scroll animation the app started, which a press takes over,
167    /// and returns whether one ran.
168    fn stop_animation(&self) -> bool {
169        false
170    }
171}
172
173impl ScrollTarget for ScrollState {
174    fn apply_delta(&self, delta: f32) -> f32 {
175        -self.dispatch_raw_delta(-delta)
176    }
177
178    fn apply_fling_delta(&self, delta: f32) -> f32 {
179        self.dispatch_raw_delta(delta)
180    }
181
182    fn invalidate(&self) {}
183
184    fn current_offset(&self) -> f32 {
185        self.value()
186    }
187
188    fn can_scroll(&self) -> bool {
189        self.max_value() > 0.0
190    }
191
192    fn can_consume(&self, gesture_delta: f32) -> bool {
193        let raw = -gesture_delta;
194        if raw > 0.0 {
195            self.value_non_reactive() < self.max_value()
196        } else {
197            self.value_non_reactive() > 0.0
198        }
199    }
200
201    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
202        ScrollState::settle_policy(self)
203    }
204}
205
206impl ScrollTarget for LazyListState {
207    fn apply_delta(&self, delta: f32) -> f32 {
208        self.dispatch_scroll_delta(delta)
209    }
210
211    fn apply_wheel_delta(&self, delta: f32) -> f32 {
212        if delta.abs() <= 0.001 {
213            0.0
214        } else {
215            self.dispatch_scroll_delta(delta)
216        }
217    }
218
219    fn apply_fling_delta(&self, delta: f32) -> f32 {
220        -self.dispatch_scroll_delta(-delta)
221    }
222
223    fn invalidate(&self) {}
224
225    fn current_offset(&self) -> f32 {
226        self.first_visible_item_scroll_offset()
227    }
228
229    fn can_scroll(&self) -> bool {
230        self.total_items_count() == 0
231            || self.can_scroll_forward_non_reactive()
232            || self.can_scroll_backward_non_reactive()
233    }
234
235    fn can_consume(&self, gesture_delta: f32) -> bool {
236        if self.total_items_count() == 0 {
237            return true;
238        }
239        if gesture_delta < 0.0 {
240            self.can_scroll_forward_non_reactive()
241        } else {
242            self.can_scroll_backward_non_reactive()
243        }
244    }
245
246    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
247        LazyListState::settle_policy(self)
248    }
249
250    fn stop_animation(&self) -> bool {
251        self.stop_scroll_animation()
252    }
253}
254
255struct DragGesture<S: ScrollTarget> {
256    target: S,
257    is_vertical: bool,
258    reverse_input: bool,
259    motion_context: ScrollMotionContext,
260    guard: Option<Rc<dyn Fn() -> bool>>,
261}
262
263struct ScrollGestureLifetime {
264    gesture_state: Rc<RefCell<ScrollGestureState>>,
265    motion_context: ScrollMotionContext,
266}
267
268impl Drop for ScrollGestureLifetime {
269    fn drop(&mut self) {
270        let state = std::mem::replace(
271            &mut *self.gesture_state.borrow_mut(),
272            ScrollGestureState {
273                disposed: true,
274                ..ScrollGestureState::default()
275            },
276        );
277        let was_active = state.is_active();
278        if let Some(fling) = state.fling_animation {
279            fling.cancel();
280        }
281        if let Some(settle) = state.settle_animation {
282            settle.cancel();
283        }
284        if let Some(watcher) = state.wheel_settle_watcher {
285            watcher.cancel();
286        }
287        if was_active {
288            self.motion_context.set_active(false);
289        }
290    }
291}
292
293fn drag_gesture_input<K, S>(key: K, gesture: DragGesture<S>) -> Modifier
294where
295    K: std::hash::Hash + 'static,
296    S: ScrollTarget + 'static,
297{
298    let DragGesture {
299        target,
300        is_vertical,
301        reverse_input,
302        motion_context,
303        guard,
304    } = gesture;
305
306    Modifier::empty().pointer_input(key, move |scope| {
307        let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
308        let detector = ScrollGestureDetector::new(
309            gesture_state.clone(),
310            target.clone(),
311            is_vertical,
312            reverse_input,
313            motion_context.overscroll(),
314            motion_context.clone(),
315        );
316        let guard = guard.clone();
317        let lifetime = ScrollGestureLifetime {
318            gesture_state: Rc::clone(&gesture_state),
319            motion_context: motion_context.clone(),
320        };
321
322        async move {
323            let _lifetime = lifetime;
324            scope
325                .await_pointer_event_scope(|await_scope| async move {
326                    loop {
327                        let event = await_scope.await_pointer_event().await;
328
329                        if event.id != 0 {
330                            continue;
331                        }
332
333                        if event.is_consumed() {
334                            if matches!(
335                                event.kind,
336                                PointerEventKind::Down
337                                    | PointerEventKind::Move
338                                    | PointerEventKind::Up
339                                    | PointerEventKind::Cancel
340                            ) {
341                                detector.on_cancel();
342                            }
343                            continue;
344                        }
345
346                        if let Some(ref guard) = guard
347                            && !guard()
348                        {
349                            if matches!(event.kind, PointerEventKind::Up | PointerEventKind::Cancel)
350                            {
351                                detector.on_cancel();
352                            }
353                            continue;
354                        }
355
356                        let should_consume = match event.kind {
357                            PointerEventKind::Down => {
358                                detector.on_down(event.position, event.time_ms)
359                            }
360                            PointerEventKind::Move => detector.on_move(
361                                event.position,
362                                event.buttons,
363                                event.time_ms,
364                                &event,
365                            ),
366                            PointerEventKind::Up => detector.on_up(event.time_ms),
367                            PointerEventKind::Cancel => detector.on_cancel(),
368                            PointerEventKind::Scroll => detector.on_scroll(
369                                if is_vertical {
370                                    event.scroll_delta.y
371                                } else {
372                                    event.scroll_delta.x
373                                },
374                                &event,
375                            ),
376                            PointerEventKind::Zoom
377                            | PointerEventKind::RotaryScrollPre
378                            | PointerEventKind::RotaryScroll
379                            | PointerEventKind::Enter
380                            | PointerEventKind::Exit => false,
381                        };
382
383                        if should_consume {
384                            event.consume();
385                        }
386                    }
387                })
388                .await;
389        }
390    })
391}
392
393impl ScrollTarget for DraggableState {
394    fn apply_delta(&self, delta: f32) -> f32 {
395        self.drag_by(delta);
396        delta
397    }
398
399    fn apply_fling_delta(&self, delta: f32) -> f32 {
400        self.drag_by(delta);
401        delta
402    }
403
404    fn invalidate(&self) {}
405
406    fn current_offset(&self) -> f32 {
407        self.offset()
408    }
409
410    fn allows_fling(&self) -> bool {
411        false
412    }
413
414    fn set_dragging(&self, dragging: bool) {
415        DraggableState::set_dragging(self, dragging);
416    }
417}
418
419impl ScrollTarget for ScrollableState {
420    fn apply_delta(&self, delta: f32) -> f32 {
421        self.dispatch_raw_delta(delta)
422    }
423
424    fn apply_fling_delta(&self, delta: f32) -> f32 {
425        -self.dispatch_raw_delta(-delta)
426    }
427
428    fn invalidate(&self) {}
429
430    fn current_offset(&self) -> f32 {
431        -self.offset()
432    }
433
434    fn set_dragging(&self, dragging: bool) {
435        ScrollableState::set_dragging(self, dragging);
436    }
437}
438
439const WHEEL_SETTLE_IDLE_NANOS: u64 = 180_000_000;
440
441struct WheelSettleWatcher {
442    is_running: Rc<Cell<bool>>,
443    registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
444}
445
446impl WheelSettleWatcher {
447    fn cancel(&self) {
448        self.is_running.set(false);
449        self.registration.borrow_mut().take();
450    }
451}
452
453struct ScrollGestureDetector<S: ScrollTarget> {
454    gesture_state: Rc<RefCell<ScrollGestureState>>,
455
456    scroll_target: S,
457
458    is_vertical: bool,
459
460    reverse_scrolling: bool,
461
462    overscroll: crate::scroll::OverscrollEffect,
463
464    motion_context: ScrollMotionContext,
465}
466
467impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
468    fn new(
469        gesture_state: Rc<RefCell<ScrollGestureState>>,
470        scroll_target: S,
471        is_vertical: bool,
472        reverse_scrolling: bool,
473        overscroll: crate::scroll::OverscrollEffect,
474        motion_context: ScrollMotionContext,
475    ) -> Self {
476        Self {
477            gesture_state,
478            scroll_target,
479            is_vertical,
480            reverse_scrolling,
481            overscroll,
482            motion_context,
483        }
484    }
485
486    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
487        let interrupted = self.scroll_target.stop_animation();
488        let mut gs = self.gesture_state.borrow_mut();
489        gs.interrupted_animation = interrupted;
490
491        if let Some(fling) = gs.fling_animation.take() {
492            fling.cancel();
493        }
494        if let Some(settle) = gs.settle_animation.take() {
495            settle.cancel();
496        }
497        if let Some(watcher) = gs.wheel_settle_watcher.take() {
498            watcher.cancel();
499        }
500        self.motion_context.set_active(false);
501
502        gs.drag_down_position = Some(position);
503        gs.last_position = Some(position);
504        gs.is_dragging = false;
505        gs.axis_locked_out = false;
506        gs.velocity_tracker.reset();
507        gs.gesture_start_time = Some(Instant::now());
508        gs.gesture_start_event_time_ms = time_ms;
509        gs.is_overscrolling = self.overscroll.offset().abs() > 0.001;
510
511        let pos = if self.is_vertical {
512            position.y
513        } else {
514            position.x
515        };
516        gs.velocity_tracker.add_data_point(0, pos);
517        gs.last_velocity_sample_ms = Some(0);
518
519        false
520    }
521
522    fn on_move(
523        &self,
524        position: Point,
525        buttons: PointerButtons,
526        time_ms: Option<i64>,
527        event: &PointerEvent,
528    ) -> bool {
529        let mut gs = self.gesture_state.borrow_mut();
530
531        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
532            if gs.is_dragging {
533                self.scroll_target.set_dragging(false);
534            }
535            gs.drag_down_position = None;
536            gs.last_position = None;
537            gs.is_dragging = false;
538            gs.axis_locked_out = false;
539            gs.gesture_start_time = None;
540            gs.gesture_start_event_time_ms = None;
541            gs.last_velocity_sample_ms = None;
542            gs.velocity_tracker.reset();
543            self.motion_context.set_active(false);
544            return false;
545        }
546
547        let Some(down_pos) = gs.drag_down_position else {
548            return false;
549        };
550
551        let Some(last_pos) = gs.last_position else {
552            gs.last_position = Some(position);
553            return false;
554        };
555
556        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
557
558        let mut edge_candidate = false;
559        if !gs.is_dragging && !gs.axis_locked_out {
560            let signed_main_delta = calculate_total_delta(down_pos, position, self.is_vertical);
561            let main_delta = signed_main_delta.abs();
562            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
563            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
564                if self.scroll_target.can_consume(signed_main_delta) {
565                    gs.is_dragging = true;
566                    self.scroll_target.set_dragging(true);
567                    self.motion_context.set_active(true);
568                } else if self.scroll_target.can_scroll() {
569                    edge_candidate = true;
570                }
571            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
572                gs.axis_locked_out = true;
573            }
574        }
575
576        gs.last_position = Some(position);
577
578        let pos = if self.is_vertical {
579            position.y
580        } else {
581            position.x
582        };
583        let event_sample_ms = gs
584            .gesture_start_event_time_ms
585            .zip(time_ms)
586            .map(|(start_ms, now_ms)| now_ms - start_ms);
587        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
588            Some(match gs.last_velocity_sample_ms {
589                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
590                None => event_sample_ms.max(0),
591            })
592        } else if let Some(start_time) = gs.gesture_start_time {
593            let elapsed_ms = start_time.elapsed().as_millis() as i64;
594            Some(match gs.last_velocity_sample_ms {
595                Some(last_sample_ms) => {
596                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
597                        last_sample_ms + 1
598                    } else {
599                        elapsed_ms
600                    };
601                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
602                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
603                    }
604                    sample_ms
605                }
606                None => elapsed_ms,
607            })
608        } else {
609            None
610        };
611        if let Some(sample_ms) = sample_ms {
612            log::trace!(
613                target: "cranpose::velocity",
614                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
615            );
616            gs.velocity_tracker.add_data_point(sample_ms, pos);
617            gs.last_velocity_sample_ms = Some(sample_ms);
618        }
619
620        if gs.is_dragging {
621            drop(gs);
622            let delta = if self.reverse_scrolling {
623                -incremental_delta
624            } else {
625                incremental_delta
626            };
627            let overscroll = self.overscroll.clone();
628            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_delta(delta));
629            self.scroll_target.invalidate();
630            true
631        } else if gs.is_overscrolling {
632            drop(gs);
633            let delta = if self.reverse_scrolling {
634                -incremental_delta
635            } else {
636                incremental_delta
637            };
638            self.apply_overscroll_delta(delta)
639        } else if edge_candidate {
640            drop(gs);
641            let delta = if self.reverse_scrolling {
642                -incremental_delta
643            } else {
644                incremental_delta
645            };
646            let detector = self.clone_for_watcher();
647            event.defer_post_dispatch_action(move || detector.apply_overscroll_candidate(delta));
648            false
649        } else {
650            false
651        }
652    }
653
654    fn apply_overscroll_delta(&self, delta: f32) -> bool {
655        self.motion_context.set_active(true);
656        let overscroll = self.overscroll.clone();
657        let target_consumed = Cell::new(0.0);
658        let before_overscroll = overscroll.offset();
659        overscroll.apply_to_scroll(delta, |delta| {
660            let consumed = self.scroll_target.apply_delta(delta);
661            target_consumed.set(consumed);
662            consumed
663        });
664        self.scroll_target.invalidate();
665        let consumed = target_consumed.get().abs() > 0.001
666            || (overscroll.offset() - before_overscroll).abs() > 0.001;
667        if !consumed {
668            self.motion_context.set_active(false);
669        }
670        consumed
671    }
672
673    fn apply_overscroll_candidate(&self, delta: f32) -> bool {
674        if self.gesture_state.borrow().disposed {
675            return false;
676        }
677        let consumed = self.apply_overscroll_delta(delta);
678        if consumed {
679            self.gesture_state.borrow_mut().is_overscrolling = true;
680        }
681        consumed
682    }
683
684    fn finish_gesture(&self, allow_fling: bool, release_time_ms: Option<i64>) -> bool {
685        let (was_dragging, interrupted, gesture_owned, velocity, start_fling, existing_fling) = {
686            let mut gs = self.gesture_state.borrow_mut();
687            let was_dragging = gs.is_dragging;
688            let interrupted = std::mem::take(&mut gs.interrupted_animation);
689            let gesture_owned = was_dragging || gs.is_overscrolling;
690            let mut velocity = 0.0;
691
692            if allow_fling && gesture_owned && gs.gesture_start_time.is_some() {
693                let release_sample_ms = release_time_ms
694                    .zip(gs.gesture_start_event_time_ms)
695                    .map(|(release_ms, start_ms)| release_ms - start_ms)
696                    .or_else(|| {
697                        gs.gesture_start_time
698                            .map(|start| start.elapsed().as_millis() as i64)
699                    });
700                let rested_before_release = release_sample_ms
701                    .zip(gs.last_velocity_sample_ms)
702                    .is_some_and(|(release_ms, last_sample_ms)| {
703                        release_ms - last_sample_ms > ASSUME_STOPPED_MS
704                    });
705                if !rested_before_release {
706                    velocity = gs
707                        .velocity_tracker
708                        .calculate_velocity_with_max(MAX_FLING_VELOCITY);
709                }
710            }
711
712            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
713            let existing_fling = if start_fling {
714                gs.fling_animation.take()
715            } else {
716                None
717            };
718
719            if was_dragging {
720                self.scroll_target.set_dragging(false);
721            }
722            gs.drag_down_position = None;
723            gs.last_position = None;
724            gs.is_dragging = false;
725            gs.is_overscrolling = false;
726            gs.axis_locked_out = false;
727            gs.gesture_start_time = None;
728            gs.gesture_start_event_time_ms = None;
729            gs.last_velocity_sample_ms = None;
730
731            (
732                was_dragging,
733                interrupted,
734                gesture_owned,
735                velocity,
736                start_fling,
737                existing_fling,
738            )
739        };
740
741        if allow_fling && gesture_owned {
742            log::debug!(
743                target: "cranpose::velocity",
744                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
745            );
746            set_last_fling_velocity(velocity);
747        }
748
749        let adjusted_velocity = if self.reverse_scrolling {
750            -velocity
751        } else {
752            velocity
753        };
754        let fling_velocity = -adjusted_velocity;
755        let has_overscroll = self.overscroll.offset().abs() > 0.001;
756
757        let settle_target = (was_dragging || interrupted)
758            .then(|| self.settle_target(start_fling, fling_velocity))
759            .flatten();
760
761        if has_overscroll {
762            if let Some(old_fling) = existing_fling {
763                old_fling.cancel();
764            }
765            self.start_overscroll_settle(-fling_velocity);
766        } else if let Some(target) = settle_target {
767            if let Some(old_fling) = existing_fling {
768                old_fling.cancel();
769            }
770            self.start_settle_animation(target, fling_velocity);
771        } else if start_fling {
772            if let Some(old_fling) = existing_fling {
773                old_fling.cancel();
774            }
775            self.start_fling_animation(fling_velocity);
776        } else {
777            self.motion_context.set_active(false);
778        }
779
780        gesture_owned
781    }
782
783    /// Where the target's settle policy moves the rest of a release, if it
784    /// moves it at all.
785    fn settle_target(&self, start_fling: bool, fling_velocity: f32) -> Option<f32> {
786        let policy = self.scroll_target.settle_policy()?;
787        let current = self.scroll_target.current_offset();
788        let proposed = if start_fling {
789            fling_rest_position(current, fling_velocity)
790        } else {
791            current
792        };
793        let target = policy(proposed, fling_velocity);
794        ((target - proposed).abs() > 0.5).then_some(target)
795    }
796
797    fn start_fling_animation(&self, fling_velocity: f32) {
798        let Some(runtime) = current_runtime_handle() else {
799            self.motion_context.set_active(false);
800            return;
801        };
802        self.motion_context.set_active(true);
803        let scroll_target = self.scroll_target.clone();
804        let fling = FlingAnimation::new(runtime);
805        let motion_context = self.motion_context.clone();
806        let initial_value = scroll_target.current_offset();
807        let scroll_target_for_fling = scroll_target.clone();
808        let detector_for_end = self.clone_for_watcher();
809        let overscroll_for_fling = self.overscroll.clone();
810
811        fling.start_fling(
812            initial_value,
813            fling_velocity,
814            move |delta| {
815                let consumed = overscroll_for_fling.apply_to_fling(delta, |delta| {
816                    scroll_target_for_fling.apply_fling_delta(delta)
817                });
818                scroll_target_for_fling.invalidate();
819                consumed
820            },
821            move || {
822                scroll_target.invalidate();
823                let settle_running = detector_for_end
824                    .gesture_state
825                    .borrow()
826                    .settle_animation
827                    .as_ref()
828                    .is_some_and(SettleAnimation::is_running);
829                if detector_for_end.overscroll.offset().abs() > 0.001 && !settle_running {
830                    detector_for_end.start_overscroll_settle(0.0);
831                }
832                detector_for_end.update_motion_active(&motion_context);
833            },
834        );
835
836        let mut gs = self.gesture_state.borrow_mut();
837        gs.fling_animation = Some(fling);
838    }
839
840    fn start_settle_animation(&self, target: f32, initial_velocity: f32) {
841        let Some(runtime) = current_runtime_handle() else {
842            self.motion_context.set_active(false);
843            return;
844        };
845        self.motion_context.set_active(true);
846        let settle = SettleAnimation::new(runtime, SpringParams::SETTLE_POLICY);
847        let scroll_target_for_settle = self.scroll_target.clone();
848        let scroll_target_for_end = self.scroll_target.clone();
849        let detector_for_end = self.clone_for_watcher();
850        let motion_context = self.motion_context.clone();
851        settle.start_settle(
852            self.scroll_target.current_offset(),
853            initial_velocity,
854            target,
855            move |delta| {
856                let consumed = scroll_target_for_settle.apply_fling_delta(delta);
857                scroll_target_for_settle.invalidate();
858                consumed
859            },
860            move |_| {
861                scroll_target_for_end.invalidate();
862                detector_for_end.update_motion_active(&motion_context);
863            },
864        );
865        let mut gs = self.gesture_state.borrow_mut();
866        gs.settle_animation = Some(settle);
867    }
868
869    fn start_overscroll_settle(&self, initial_velocity: f32) {
870        let Some(runtime) = current_runtime_handle() else {
871            let current = self.overscroll.offset();
872            self.overscroll.apply_settle_delta(-current);
873            self.motion_context.set_active(false);
874            return;
875        };
876        let settle = SettleAnimation::new(runtime, SpringParams::OVERSCROLL_BOUNCE);
877        let overscroll_for_settle = self.overscroll.clone();
878        let overscroll_for_end = self.overscroll.clone();
879        let detector_for_end = self.clone_for_watcher();
880        let motion_context = self.motion_context.clone();
881        let initial = self.overscroll.offset();
882        settle.start_settle(
883            initial,
884            initial_velocity,
885            0.0,
886            move |delta| overscroll_for_settle.apply_settle_delta(delta),
887            move |end| {
888                let current = overscroll_for_end.offset();
889                overscroll_for_end.apply_settle_delta(-current);
890                if end.hit_boundary && end.velocity.abs() > MIN_FLING_VELOCITY {
891                    detector_for_end.start_fling_animation(-end.velocity);
892                } else {
893                    detector_for_end.update_motion_active(&motion_context);
894                }
895            },
896        );
897        let mut gs = self.gesture_state.borrow_mut();
898        gs.is_overscrolling = false;
899        gs.settle_animation = Some(settle);
900    }
901
902    fn update_motion_active(&self, motion_context: &ScrollMotionContext) {
903        let running = self.gesture_state.borrow().is_active();
904        if !running {
905            motion_context.set_active(false);
906        }
907    }
908
909    fn on_up(&self, time_ms: Option<i64>) -> bool {
910        self.finish_gesture(self.scroll_target.allows_fling(), time_ms)
911    }
912
913    fn on_cancel(&self) -> bool {
914        self.finish_gesture(false, None)
915    }
916
917    fn on_scroll(&self, axis_delta: f32, event: &PointerEvent) -> bool {
918        if axis_delta.abs() <= f32::EPSILON {
919            return false;
920        }
921
922        let delta = if self.reverse_scrolling {
923            -axis_delta
924        } else {
925            axis_delta
926        };
927        if !self.scroll_target.can_consume(delta) && self.scroll_target.can_scroll() {
928            let detector = self.clone_for_watcher();
929            event.defer_post_dispatch_action(move || detector.apply_wheel_delta(delta));
930            return false;
931        }
932        self.apply_wheel_delta(delta)
933    }
934
935    fn apply_wheel_delta(&self, delta: f32) -> bool {
936        {
937            let mut gs = self.gesture_state.borrow_mut();
938            if gs.disposed {
939                return false;
940            }
941            if let Some(fling) = gs.fling_animation.take() {
942                fling.cancel();
943            }
944            if let Some(settle) = gs.settle_animation.take() {
945                settle.cancel();
946            }
947            gs.drag_down_position = None;
948            gs.last_position = None;
949            gs.is_dragging = false;
950            gs.axis_locked_out = false;
951            gs.gesture_start_time = None;
952            gs.gesture_start_event_time_ms = None;
953            gs.last_velocity_sample_ms = None;
954            gs.velocity_tracker.reset();
955        }
956
957        self.motion_context.activate_for_current_frame();
958        let overscroll = self.overscroll.clone();
959        let consumed =
960            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_wheel_delta(delta));
961        let overscroll_active = self.overscroll.offset().abs() > 0.001;
962        if consumed.abs() > 0.001 {
963            self.scroll_target.invalidate();
964        }
965        if consumed.abs() > 0.001 || overscroll_active {
966            self.ensure_wheel_settle_watcher();
967            true
968        } else {
969            false
970        }
971    }
972
973    fn ensure_wheel_settle_watcher(&self) {
974        if self.scroll_target.settle_policy().is_none() && self.overscroll.offset().abs() <= 0.001 {
975            return;
976        }
977        {
978            let gs = self.gesture_state.borrow();
979            if gs
980                .wheel_settle_watcher
981                .as_ref()
982                .is_some_and(|watcher| watcher.is_running.get())
983            {
984                return;
985            }
986        }
987        let Some(runtime) = current_runtime_handle() else {
988            return;
989        };
990
991        let is_running = Rc::new(Cell::new(true));
992        let registration = Rc::new(RefCell::new(None));
993
994        struct WheelSettleLoop<S: ScrollTarget> {
995            detector: ScrollGestureDetector<S>,
996            gesture_state: Rc<RefCell<ScrollGestureState>>,
997            frame_clock: cranpose_core::internal::FrameClock,
998            is_running: Rc<Cell<bool>>,
999            registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
1000            last_offset: Rc<Cell<f32>>,
1001            idle_nanos: Rc<Cell<u64>>,
1002            last_frame: Rc<Cell<Option<u64>>>,
1003        }
1004
1005        impl<S: ScrollTarget + 'static> WheelSettleLoop<S> {
1006            fn next(&self) -> Self {
1007                Self {
1008                    detector: self.detector.clone_for_watcher(),
1009                    gesture_state: Rc::clone(&self.gesture_state),
1010                    frame_clock: self.frame_clock.clone(),
1011                    is_running: Rc::clone(&self.is_running),
1012                    registration: Rc::clone(&self.registration),
1013                    last_offset: Rc::clone(&self.last_offset),
1014                    idle_nanos: Rc::clone(&self.idle_nanos),
1015                    last_frame: Rc::clone(&self.last_frame),
1016                }
1017            }
1018
1019            fn schedule(self) {
1020                let continuation = self.next();
1021                let registration_slot = Rc::clone(&self.registration);
1022                let new_registration = self.frame_clock.with_frame_nanos(move |frame_time_nanos| {
1023                    let this = &continuation;
1024                    if !this.is_running.get() {
1025                        return;
1026                    }
1027                    {
1028                        let gs = this.gesture_state.borrow();
1029                        let animating = gs.is_dragging
1030                            || gs
1031                                .fling_animation
1032                                .as_ref()
1033                                .is_some_and(FlingAnimation::is_running)
1034                            || gs
1035                                .settle_animation
1036                                .as_ref()
1037                                .is_some_and(SettleAnimation::is_running);
1038                        if animating {
1039                            this.is_running.set(false);
1040                            return;
1041                        }
1042                    }
1043
1044                    let offset = this.detector.scroll_target.current_offset();
1045                    let dt = this
1046                        .last_frame
1047                        .get()
1048                        .map_or(0, |last| frame_time_nanos.saturating_sub(last));
1049                    this.last_frame.set(Some(frame_time_nanos));
1050                    if (offset - this.last_offset.get()).abs() > 0.01 {
1051                        this.last_offset.set(offset);
1052                        this.idle_nanos.set(0);
1053                    } else {
1054                        this.idle_nanos.set(this.idle_nanos.get() + dt);
1055                    }
1056
1057                    if this.idle_nanos.get() >= WHEEL_SETTLE_IDLE_NANOS {
1058                        this.is_running.set(false);
1059                        if this.detector.overscroll.offset().abs() > 0.001 {
1060                            this.detector.start_overscroll_settle(0.0);
1061                            return;
1062                        }
1063                        if let Some(policy) = this.detector.scroll_target.settle_policy() {
1064                            let target = policy(offset, 0.0);
1065                            if (target - offset).abs() > 0.5 {
1066                                this.detector.start_settle_animation(target, 0.0);
1067                            }
1068                        }
1069                        return;
1070                    }
1071
1072                    continuation.next().schedule();
1073                });
1074                *registration_slot.borrow_mut() = Some(new_registration);
1075            }
1076        }
1077
1078        WheelSettleLoop {
1079            detector: self.clone_for_watcher(),
1080            gesture_state: Rc::clone(&self.gesture_state),
1081            frame_clock: runtime.frame_clock(),
1082            is_running: Rc::clone(&is_running),
1083            registration: Rc::clone(&registration),
1084            last_offset: Rc::new(Cell::new(self.scroll_target.current_offset())),
1085            idle_nanos: Rc::new(Cell::new(0u64)),
1086            last_frame: Rc::new(Cell::new(None::<u64>)),
1087        }
1088        .schedule();
1089
1090        self.gesture_state.borrow_mut().wheel_settle_watcher = Some(WheelSettleWatcher {
1091            is_running,
1092            registration,
1093        });
1094    }
1095
1096    fn clone_for_watcher(&self) -> ScrollGestureDetector<S> {
1097        ScrollGestureDetector {
1098            gesture_state: Rc::clone(&self.gesture_state),
1099            scroll_target: self.scroll_target.clone(),
1100            is_vertical: self.is_vertical,
1101            reverse_scrolling: self.reverse_scrolling,
1102            overscroll: self.overscroll.clone(),
1103            motion_context: self.motion_context.clone(),
1104        }
1105    }
1106}
1107
1108pub(crate) struct MotionContextAnimatedNode {
1109    state: NodeState,
1110    motion_context: ScrollMotionContext,
1111    invalidation_callback_id: Option<u64>,
1112    overscroll_callback_id: Option<u64>,
1113    node_id: Option<NodeId>,
1114}
1115
1116impl MotionContextAnimatedNode {
1117    fn new(motion_context: ScrollMotionContext) -> Self {
1118        Self {
1119            state: NodeState::new(),
1120            motion_context,
1121            invalidation_callback_id: None,
1122            overscroll_callback_id: None,
1123            node_id: None,
1124        }
1125    }
1126
1127    pub(crate) fn is_active(&self) -> bool {
1128        self.motion_context.is_active()
1129    }
1130
1131    fn attach_callbacks(&mut self) {
1132        if let Some(node_id) = self.node_id {
1133            self.invalidation_callback_id = Some(self.motion_context.add_invalidate_callback(
1134                Box::new(move || schedule_modifier_slices_repass(node_id)),
1135            ));
1136            self.overscroll_callback_id = Some(
1137                self.motion_context
1138                    .overscroll()
1139                    .add_invalidate_callback(Box::new(move || {
1140                        crate::schedule_measure_repass(node_id);
1141                        schedule_modifier_slices_repass(node_id);
1142                    })),
1143            );
1144        }
1145    }
1146
1147    fn detach_callbacks(&mut self) {
1148        if let Some(id) = self.invalidation_callback_id.take() {
1149            self.motion_context.remove_invalidate_callback(id);
1150        }
1151        if let Some(id) = self.overscroll_callback_id.take() {
1152            self.motion_context
1153                .overscroll()
1154                .remove_invalidate_callback(id);
1155        }
1156    }
1157}
1158
1159pub(crate) struct TranslatedContentContextNode {
1160    state: NodeState,
1161    identity: usize,
1162    offset_source: TranslatedContentOffsetSource,
1163    overscroll: crate::scroll::OverscrollEffect,
1164    overscroll_callback_id: Option<u64>,
1165    node_id: Option<NodeId>,
1166}
1167
1168impl TranslatedContentContextNode {
1169    fn new(
1170        identity: usize,
1171        offset_source: TranslatedContentOffsetSource,
1172        overscroll: crate::scroll::OverscrollEffect,
1173    ) -> Self {
1174        Self {
1175            state: NodeState::new(),
1176            identity,
1177            offset_source,
1178            overscroll,
1179            overscroll_callback_id: None,
1180            node_id: None,
1181        }
1182    }
1183
1184    pub(crate) fn is_active(&self) -> bool {
1185        true
1186    }
1187
1188    pub(crate) fn identity(&self) -> usize {
1189        self.identity
1190    }
1191
1192    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
1193        self.offset_source
1194            .content_offset_reader(self.overscroll.clone())
1195    }
1196
1197    fn attach_callback(&mut self) {
1198        if let Some(node_id) = self.node_id {
1199            self.overscroll_callback_id =
1200                Some(self.overscroll.add_invalidate_callback(Box::new(move || {
1201                    schedule_modifier_slices_repass(node_id);
1202                })));
1203        }
1204    }
1205
1206    fn detach_callback(&mut self) {
1207        if let Some(id) = self.overscroll_callback_id.take() {
1208            self.overscroll.remove_invalidate_callback(id);
1209        }
1210    }
1211}
1212
1213impl DelegatableNode for TranslatedContentContextNode {
1214    fn node_state(&self) -> &NodeState {
1215        &self.state
1216    }
1217}
1218
1219impl ModifierNode for TranslatedContentContextNode {
1220    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1221        self.node_id = context.node_id();
1222        self.attach_callback();
1223    }
1224
1225    fn on_detach(&mut self) {
1226        self.detach_callback();
1227        self.node_id = None;
1228    }
1229}
1230
1231impl DelegatableNode for MotionContextAnimatedNode {
1232    fn node_state(&self) -> &NodeState {
1233        &self.state
1234    }
1235}
1236
1237impl ModifierNode for MotionContextAnimatedNode {
1238    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1239        self.node_id = context.node_id();
1240        self.attach_callbacks();
1241    }
1242
1243    fn on_detach(&mut self) {
1244        self.detach_callbacks();
1245        self.node_id = None;
1246    }
1247}
1248
1249#[derive(Clone)]
1250struct MotionContextAnimatedElement {
1251    motion_context: ScrollMotionContext,
1252}
1253
1254impl MotionContextAnimatedElement {
1255    fn new(motion_context: ScrollMotionContext) -> Self {
1256        Self { motion_context }
1257    }
1258}
1259
1260impl std::fmt::Debug for MotionContextAnimatedElement {
1261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1262        f.debug_struct("MotionContextAnimatedElement").finish()
1263    }
1264}
1265
1266impl PartialEq for MotionContextAnimatedElement {
1267    fn eq(&self, other: &Self) -> bool {
1268        self.motion_context.ptr_eq(&other.motion_context)
1269    }
1270}
1271
1272impl Eq for MotionContextAnimatedElement {}
1273
1274impl std::hash::Hash for MotionContextAnimatedElement {
1275    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1276        self.motion_context.stable_key().hash(state);
1277    }
1278}
1279
1280impl ModifierNodeElement for MotionContextAnimatedElement {
1281    type Node = MotionContextAnimatedNode;
1282
1283    fn create(&self) -> Self::Node {
1284        MotionContextAnimatedNode::new(self.motion_context.clone())
1285    }
1286
1287    fn update(&self, node: &mut Self::Node) {
1288        if node.motion_context.ptr_eq(&self.motion_context) {
1289            return;
1290        }
1291        node.detach_callbacks();
1292        node.motion_context = self.motion_context.clone();
1293        node.attach_callbacks();
1294    }
1295
1296    fn capabilities(&self) -> NodeCapabilities {
1297        NodeCapabilities::LAYOUT
1298    }
1299}
1300
1301#[derive(Clone)]
1302enum TranslatedContentOffsetSource {
1303    LayoutContentOffset,
1304    LazyList {
1305        state: LazyListState,
1306        is_vertical: bool,
1307        reverse_scrolling: bool,
1308    },
1309}
1310
1311impl TranslatedContentOffsetSource {
1312    fn content_offset_reader(
1313        &self,
1314        overscroll: crate::scroll::OverscrollEffect,
1315    ) -> Option<Rc<dyn Fn() -> Point>> {
1316        match self {
1317            Self::LayoutContentOffset => None,
1318            Self::LazyList {
1319                state, is_vertical, ..
1320            } => Some(Rc::new(lazy_list_content_offset_reader(
1321                *state,
1322                *is_vertical,
1323                overscroll,
1324            ))),
1325        }
1326    }
1327
1328    fn is_vertical(&self) -> Option<bool> {
1329        match self {
1330            Self::LayoutContentOffset => None,
1331            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
1332        }
1333    }
1334
1335    fn reverse_scrolling(&self) -> Option<bool> {
1336        match self {
1337            Self::LayoutContentOffset => None,
1338            Self::LazyList {
1339                reverse_scrolling, ..
1340            } => Some(*reverse_scrolling),
1341        }
1342    }
1343}
1344
1345fn lazy_list_content_offset_reader(
1346    state: LazyListState,
1347    is_vertical: bool,
1348    overscroll: crate::scroll::OverscrollEffect,
1349) -> impl Fn() -> Point {
1350    move || {
1351        let Some((viewport, main_offset)) = state
1352            .with_layout_info(|info| {
1353                (!info.visible_items_info.is_empty())
1354                    .then_some((info.viewport_size, info.snap_anchor_offset))
1355            })
1356            .flatten()
1357        else {
1358            return Point::default();
1359        };
1360        overscroll.set_dimension(viewport);
1361        let bounce = overscroll.offset();
1362        if is_vertical {
1363            Point::new(0.0, main_offset + bounce)
1364        } else {
1365            Point::new(main_offset + bounce, 0.0)
1366        }
1367    }
1368}
1369
1370#[derive(Clone)]
1371struct TranslatedContentContextElement {
1372    identity: usize,
1373    offset_source: TranslatedContentOffsetSource,
1374    overscroll: crate::scroll::OverscrollEffect,
1375}
1376
1377impl TranslatedContentContextElement {
1378    fn new(
1379        identity: usize,
1380        offset_source: TranslatedContentOffsetSource,
1381        overscroll: crate::scroll::OverscrollEffect,
1382    ) -> Self {
1383        Self {
1384            identity,
1385            offset_source,
1386            overscroll,
1387        }
1388    }
1389}
1390
1391impl std::fmt::Debug for TranslatedContentContextElement {
1392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1393        let offset_source = match &self.offset_source {
1394            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
1395            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
1396        };
1397        f.debug_struct("TranslatedContentContextElement")
1398            .field("identity", &self.identity)
1399            .field("offset_source", &offset_source)
1400            .finish()
1401    }
1402}
1403
1404impl PartialEq for TranslatedContentContextElement {
1405    fn eq(&self, other: &Self) -> bool {
1406        self.identity == other.identity
1407            && self.overscroll.ptr_eq(&other.overscroll)
1408            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
1409            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
1410    }
1411}
1412
1413impl Eq for TranslatedContentContextElement {}
1414
1415impl std::hash::Hash for TranslatedContentContextElement {
1416    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1417        self.identity.hash(state);
1418        self.offset_source.is_vertical().hash(state);
1419        self.offset_source.reverse_scrolling().hash(state);
1420    }
1421}
1422
1423impl ModifierNodeElement for TranslatedContentContextElement {
1424    type Node = TranslatedContentContextNode;
1425
1426    fn create(&self) -> Self::Node {
1427        TranslatedContentContextNode::new(
1428            self.identity,
1429            self.offset_source.clone(),
1430            self.overscroll.clone(),
1431        )
1432    }
1433
1434    fn update(&self, node: &mut Self::Node) {
1435        node.identity = self.identity;
1436        node.offset_source = self.offset_source.clone();
1437        if !node.overscroll.ptr_eq(&self.overscroll) {
1438            node.detach_callback();
1439            node.overscroll = self.overscroll.clone();
1440            node.attach_callback();
1441        }
1442    }
1443
1444    fn capabilities(&self) -> NodeCapabilities {
1445        NodeCapabilities::LAYOUT
1446    }
1447}
1448
1449impl Modifier {
1450    /// Creates a horizontally scrollable modifier.
1451    ///
1452    /// # Arguments
1453    /// * `state` - The ScrollState to control scroll position
1454    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1455    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1456    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1457    ///   drag right = scroll left (content moves right under finger).
1458    ///
1459    /// # Example
1460    /// ```text
1461    /// let scroll_state = ScrollState::new(0.0);
1462    /// Row(
1463    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
1464    ///     // ... content
1465    /// );
1466    /// ```
1467    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1468        self.then(scroll_impl(
1469            state,
1470            false,
1471            reverse_scrolling,
1472            None,
1473            (0.0, 0.0),
1474        ))
1475    }
1476
1477    /// Creates a vertically scrollable modifier.
1478    ///
1479    /// # Arguments
1480    /// * `state` - The ScrollState to control scroll position
1481    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1482    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1483    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1484    ///   drag down = scroll up (content moves down under finger).
1485    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1486        self.then(scroll_impl(
1487            state,
1488            true,
1489            reverse_scrolling,
1490            None,
1491            (0.0, 0.0),
1492        ))
1493    }
1494
1495    /// Adds a vertical scroll container with content padding in logical pixels.
1496    ///
1497    /// The padding scrolls with the content. Keyboard and accessibility focus
1498    /// keep controls inside the padded viewport, clear of overlaid system UI.
1499    pub fn vertical_scroll_with_content_padding(
1500        self,
1501        state: ScrollState,
1502        reverse_scrolling: bool,
1503        padding: cranpose_ui_graphics::EdgeInsets,
1504    ) -> Self {
1505        let density = crate::density();
1506        self.then(scroll_impl(
1507            state,
1508            true,
1509            reverse_scrolling,
1510            None,
1511            (density.dp(padding.top), density.dp(padding.bottom)),
1512        ))
1513        .window_insets_padding(padding)
1514    }
1515
1516    /// Adds a horizontal scroll container with content padding in logical pixels.
1517    ///
1518    /// The padding scrolls with the content. Keyboard and accessibility focus
1519    /// keep controls inside the padded viewport. Insets use physical edges.
1520    pub fn horizontal_scroll_with_content_padding(
1521        self,
1522        state: ScrollState,
1523        reverse_scrolling: bool,
1524        padding: cranpose_ui_graphics::EdgeInsets,
1525    ) -> Self {
1526        let density = crate::density();
1527        self.then(scroll_impl(
1528            state,
1529            false,
1530            reverse_scrolling,
1531            None,
1532            (density.dp(padding.left), density.dp(padding.right)),
1533        ))
1534        .window_insets_padding(padding)
1535    }
1536
1537    /// A horizontal scroll that never claims a pointer gesture.
1538    ///
1539    /// The position still moves through [`ScrollState`], but drags and wheel
1540    /// events pass straight through, so an owner can run its own
1541    /// `pointer_input` on the same row -- a drag-to-reorder list, say --
1542    /// without the two competing for the gesture.
1543    pub fn horizontal_scroll_without_gestures(
1544        self,
1545        state: ScrollState,
1546        reverse_scrolling: bool,
1547    ) -> Self {
1548        self.then(scroll_impl(
1549            state,
1550            false,
1551            reverse_scrolling,
1552            Some(Rc::new(|| false)),
1553            (0.0, 0.0),
1554        ))
1555    }
1556
1557    /// A vertical scroll that never claims a pointer gesture.
1558    ///
1559    /// The vertical counterpart of [`Modifier::horizontal_scroll_without_gestures`].
1560    pub fn vertical_scroll_without_gestures(
1561        self,
1562        state: ScrollState,
1563        reverse_scrolling: bool,
1564    ) -> Self {
1565        self.then(scroll_impl(
1566            state,
1567            true,
1568            reverse_scrolling,
1569            Some(Rc::new(|| false)),
1570            (0.0, 0.0),
1571        ))
1572    }
1573}
1574
1575fn scroll_impl(
1576    state: ScrollState,
1577    is_vertical: bool,
1578    reverse_scrolling: bool,
1579    guard: Option<Rc<dyn Fn() -> bool>>,
1580    content_padding: (f32, f32),
1581) -> Modifier {
1582    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1583        state_id: state.id(),
1584        is_vertical,
1585        reverse_scrolling,
1586    });
1587
1588    let pointer_input = drag_gesture_input(
1589        (state.id(), is_vertical),
1590        DragGesture {
1591            target: state,
1592            is_vertical,
1593            reverse_input: false,
1594            motion_context: motion_context.clone(),
1595            guard,
1596        },
1597    );
1598
1599    let overscroll = motion_context.overscroll();
1600    let element = ScrollElement::new(state, overscroll.clone(), is_vertical, reverse_scrolling);
1601    let layout_modifier =
1602        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1603            if is_vertical {
1604                "verticalScroll"
1605            } else {
1606                "horizontalScroll"
1607            },
1608            move |info| {
1609                info.add_property("isVertical", is_vertical.to_string());
1610                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1611            },
1612        ));
1613    let motion_modifier = Modifier::with_element(MotionContextAnimatedElement::new(motion_context));
1614    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1615        state.id() as usize,
1616        TranslatedContentOffsetSource::LayoutContentOffset,
1617        overscroll,
1618    ));
1619
1620    // The clip wraps the scroll layout, as Compose's `scrollingContainer`
1621    // clips before it scrolls: it cuts at the viewport, not at the content
1622    // the layout moves.
1623    pointer_input
1624        .clip_to_bounds()
1625        .then(motion_modifier)
1626        .then(translated_content_modifier)
1627        .then(layout_modifier)
1628        .semantics(scroll_semantics(
1629            state,
1630            is_vertical,
1631            reverse_scrolling,
1632            content_padding,
1633        ))
1634}
1635
1636/// What a screen reader learns about a scroll container: how far it has
1637/// scrolled, whether it can go on, and how to page it. Compose's
1638/// `verticalScrollAxisRange` / `horizontalScrollAxisRange` and `scrollBy`.
1639/// Without this a reader's cursor stops at the last row it can see.
1640fn scroll_semantics(
1641    state: ScrollState,
1642    is_vertical: bool,
1643    reverse_scrolling: bool,
1644    content_padding: (f32, f32),
1645) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1646    move |config| {
1647        let range = cranpose_foundation::ScrollAxisRange::new(
1648            state.value(),
1649            state.max_value(),
1650            reverse_scrolling,
1651        )
1652        .with_content_padding(content_padding.0, content_padding.1);
1653        if is_vertical {
1654            config.vertical_scroll = Some(range);
1655        } else {
1656            config.horizontal_scroll = Some(range);
1657        }
1658        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1659            move |dx, dy| {
1660                let delta = if is_vertical { dy } else { dx };
1661                state.dispatch_raw_delta(delta).abs() > f32::EPSILON
1662            },
1663        ));
1664    }
1665}
1666
1667use cranpose_foundation::lazy::{LazyListState, LazySettlePolicy};
1668
1669impl Modifier {
1670    /// Creates a vertically scrollable modifier for lazy lists.
1671    ///
1672    /// This connects pointer gestures to LazyListState for scroll handling.
1673    /// Unlike regular vertical_scroll, no layout offset is applied here
1674    /// since LazyListState manages item positioning internally.
1675    /// Creates a vertically scrollable modifier for lazy lists.
1676    ///
1677    /// This connects pointer gestures to LazyListState for scroll handling.
1678    /// Unlike regular vertical_scroll, no layout offset is applied here
1679    /// since LazyListState manages item positioning internally.
1680    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1681        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1682            state_identity: state.inner_ptr() as usize,
1683            is_vertical: true,
1684            reverse_scrolling,
1685        });
1686        self.lazy_vertical_scroll_with_context(state, reverse_scrolling, (0.0, 0.0), motion_context)
1687    }
1688
1689    pub(crate) fn lazy_vertical_scroll_with_context(
1690        self,
1691        state: LazyListState,
1692        reverse_scrolling: bool,
1693        content_padding: (f32, f32),
1694        motion_context: ScrollMotionContext,
1695    ) -> Self {
1696        self.then(lazy_scroll_impl(
1697            state,
1698            true,
1699            reverse_scrolling,
1700            content_padding,
1701            motion_context,
1702        ))
1703    }
1704
1705    pub(crate) fn lazy_horizontal_scroll_with_context(
1706        self,
1707        state: LazyListState,
1708        reverse_scrolling: bool,
1709        content_padding: (f32, f32),
1710        motion_context: ScrollMotionContext,
1711    ) -> Self {
1712        self.then(lazy_scroll_impl(
1713            state,
1714            false,
1715            reverse_scrolling,
1716            content_padding,
1717            motion_context,
1718        ))
1719    }
1720}
1721
1722fn lazy_scroll_impl(
1723    state: LazyListState,
1724    is_vertical: bool,
1725    reverse_scrolling: bool,
1726    content_padding: (f32, f32),
1727    motion_context: ScrollMotionContext,
1728) -> Modifier {
1729    let list_state = state;
1730    let state_id = state.inner_ptr() as usize;
1731    let key = (state_id, is_vertical, reverse_scrolling);
1732    let overscroll = motion_context.overscroll();
1733    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1734        state_id,
1735        TranslatedContentOffsetSource::LazyList {
1736            state,
1737            is_vertical,
1738            reverse_scrolling,
1739        },
1740        overscroll,
1741    ));
1742
1743    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1744        .then(translated_content_modifier)
1745        .then(drag_gesture_input(
1746            key,
1747            DragGesture {
1748                target: list_state,
1749                is_vertical,
1750                reverse_input: reverse_scrolling,
1751                motion_context,
1752                guard: None,
1753            },
1754        ))
1755        .semantics(lazy_scroll_semantics(
1756            list_state,
1757            is_vertical,
1758            reverse_scrolling,
1759            content_padding,
1760        ))
1761}
1762
1763/// What a screen reader learns about a lazy list. The list has no whole
1764/// extent to give, so the first visible item stands in for the position, a
1765/// half step marks a list scrolled part way into that item, and one more step
1766/// stays ahead while the list can still scroll forward. A reader needs only
1767/// the two answers: can it page back, can it page on.
1768pub(crate) fn lazy_scroll_semantics(
1769    state: LazyListState,
1770    is_vertical: bool,
1771    reverse_scrolling: bool,
1772    content_padding: (f32, f32),
1773) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1774    move |config| {
1775        let mut value = state.first_visible_item_index() as f32;
1776        if state.first_visible_item_scroll_offset() > 0.0 {
1777            value += 0.5;
1778        }
1779        let max_value = if state.can_scroll_forward() {
1780            value + 1.0
1781        } else {
1782            value
1783        };
1784        let range = cranpose_foundation::ScrollAxisRange::new(value, max_value, reverse_scrolling)
1785            .with_content_padding(content_padding.0, content_padding.1);
1786        let count = state.total_items_count();
1787        config.collection = Some(cranpose_foundation::CollectionInfo {
1788            rows: if is_vertical { count } else { 1 },
1789            columns: if is_vertical { 1 } else { count },
1790        });
1791        config
1792            .role
1793            .get_or_insert(cranpose_foundation::SemanticsWidgetRole::List);
1794        if is_vertical {
1795            config.vertical_scroll = Some(range);
1796        } else {
1797            config.horizontal_scroll = Some(range);
1798        }
1799        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1800            move |dx, dy| {
1801                let delta = if is_vertical { dy } else { dx };
1802                match state.settle_policy() {
1803                    Some(policy) => settle_reader_scroll(&state, &policy, delta),
1804                    None => state.dispatch_scroll_delta(-delta).abs() > f32::EPSILON,
1805                }
1806            },
1807        ));
1808        config.scroll_to_index = Some(cranpose_foundation::SemanticsScrollToIndex::new(
1809            move |index| jump_to_row(&state, index),
1810        ));
1811    }
1812}
1813
1814/// Moves a list whose scroll comes to rest by a settle policy `delta` along
1815/// its items, and on to where the policy rests the item that reaches the
1816/// start, as a release there would. A reader's page of a pager turns one
1817/// page.
1818fn settle_reader_scroll(state: &LazyListState, policy: &LazySettlePolicy, delta: f32) -> bool {
1819    let can_move = if delta > 0.0 {
1820        state.can_scroll_forward_non_reactive()
1821    } else {
1822        state.can_scroll_backward_non_reactive()
1823    };
1824    let size = state.average_item_size();
1825    if !can_move || size <= 0.0 {
1826        return false;
1827    }
1828    let first = state.first_visible_item_index_non_reactive() as f32;
1829    let reached =
1830        (first * size + state.first_visible_item_scroll_offset_non_reactive() + delta).max(0.0);
1831    let last = state.total_items_count().saturating_sub(1) as f32;
1832    let index = (reached / size).floor().min(last);
1833    let target = policy(reached - index * size, 0.0);
1834    crate::AnimateScroll::animate_scroll_to_item(state, index as usize, target);
1835    true
1836}
1837
1838/// Puts the row at `index` at the top of a lazy list for a screen reader that
1839/// asked for it. A list with no rows, or an index past the last row, takes
1840/// nothing and says so.
1841fn jump_to_row(state: &LazyListState, index: usize) -> bool {
1842    if index >= state.total_items_count() {
1843        return false;
1844    }
1845    state.scroll_to_item(index, 0.0);
1846    true
1847}
1848
1849impl Modifier {
1850    /// Drags along `axis`, reporting each delta to `state`.
1851    ///
1852    /// The gesture is the one the scroll containers run — touch slop before a
1853    /// drag begins, axis locking so a mostly-vertical drag never steals a
1854    /// horizontal one, the primary pointer only, and yielding to whoever
1855    /// consumed the event first — so a dragged control and a scrolled list feel
1856    /// the same under a finger. Unlike a scroll, a release does not fling: a
1857    /// control the user placed stays where it was let go.
1858    ///
1859    /// Deltas are logical pixels, positive to the right for [`Axis::Horizontal`]
1860    /// and downwards for [`Axis::Vertical`].
1861    pub fn draggable(self, axis: Axis, state: DraggableState) -> Self {
1862        self.then(draggable_impl(axis, state, None))
1863    }
1864
1865    /// Scrolls content with custom placement along `axis`.
1866    ///
1867    /// Supplies drag, wheel and inertial scrolling without translating or
1868    /// clipping layout. A new pointer press interrupts the fling. The state's
1869    /// consumption handler stops it when the content reaches a boundary.
1870    pub fn scrollable(self, axis: Axis, state: ScrollableState) -> Self {
1871        let is_vertical = axis.is_vertical();
1872        let identity = state.identity();
1873        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
1874            state_identity: identity,
1875            is_vertical,
1876        });
1877        self.then(Modifier::with_element(MotionContextAnimatedElement::new(
1878            motion_context.clone(),
1879        )))
1880        .then(drag_gesture_input(
1881            (identity, is_vertical),
1882            DragGesture {
1883                target: state,
1884                is_vertical,
1885                reverse_input: false,
1886                motion_context,
1887                guard: None,
1888            },
1889        ))
1890    }
1891
1892    /// Drags along `axis` only while `guard` says so.
1893    ///
1894    /// The guard is asked per event, so a control can stop accepting a drag
1895    /// half way through one and the gesture ends rather than being applied to
1896    /// a control that has since been disabled.
1897    pub fn draggable_guarded(
1898        self,
1899        axis: Axis,
1900        state: DraggableState,
1901        guard: impl Fn() -> bool + 'static,
1902    ) -> Self {
1903        self.then(draggable_impl(axis, state, Some(Rc::new(guard))))
1904    }
1905}
1906
1907fn draggable_impl(
1908    axis: Axis,
1909    state: DraggableState,
1910    guard: Option<Rc<dyn Fn() -> bool>>,
1911) -> Modifier {
1912    let is_vertical = axis.is_vertical();
1913    let identity = state.identity();
1914    drag_gesture_input(
1915        (identity, is_vertical),
1916        DragGesture {
1917            target: state,
1918            is_vertical,
1919            reverse_input: false,
1920            motion_context: scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
1921                state_identity: identity,
1922                is_vertical,
1923            }),
1924            guard,
1925        },
1926    )
1927}