Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

1//! Lazy list state management.
2//!
3//! Provides [`LazyListState`] for controlling and observing lazy list scroll position.
4//!
5//! Design follows Jetpack Compose's LazyListState/LazyListScrollPosition pattern:
6//! - Reactive properties are backed by `MutableState<T>`:
7//!   - `first_visible_item_index`, `first_visible_item_scroll_offset`
8//!   - `can_scroll_forward`, `can_scroll_backward`
9//!   - `stats` (items_in_use, items_in_pool)
10//! - Non-reactive internals (caches, callbacks, scroll window, diagnostic counters) are in inner state
11
12use std::{cell::RefCell, rc::Rc};
13
14use cranpose_core::{MutableState, NodeId, RuntimeCell, StateId, collections::map::HashMap};
15use cranpose_macros::composable;
16
17use super::{diagnostics, nearest_range::NearestRangeState};
18
19const MAX_PENDING_SCROLL_DELTA: f32 = 2000.0;
20const ITEM_SIZE_CACHE_CAPACITY: usize = 8192;
21
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub(crate) struct LazyListMeasureStateSnapshot {
24    pub(crate) first_visible_item_index: usize,
25    pub(crate) first_visible_item_scroll_offset: f32,
26    pub(crate) pending_scroll_delta: f32,
27    pub(crate) window_scroll_delta: f32,
28    pub(crate) pending_scroll_to: Option<(usize, f32)>,
29    pub(crate) average_item_size: f32,
30}
31
32/// Statistics about lazy layout item lifecycle.
33///
34/// Used for testing and debugging virtualization behavior.
35#[derive(Clone, Debug, Default, PartialEq)]
36pub struct LazyLayoutStats {
37    /// Number of items currently composed and visible.
38    pub items_in_use: usize,
39
40    /// Number of items in the recycle pool (available for reuse).
41    pub items_in_pool: usize,
42
43    /// Total number of items that have been composed.
44    pub total_composed: usize,
45
46    /// Number of items that were reused instead of newly composed.
47    pub reuse_count: usize,
48}
49
50/// Contains the current scroll position represented by the first visible item
51/// index and the first visible item scroll offset.
52///
53/// This is a `Copy` type that holds reactive state. Reading `index` or `scroll_offset`
54/// during composition creates a snapshot dependency for automatic recomposition.
55///
56/// Matches Jetpack Compose's `LazyListScrollPosition` design.
57#[derive(Clone, Copy)]
58pub struct LazyListScrollPosition {
59    index: MutableState<usize>,
60    scroll_offset: MutableState<f32>,
61    inner: RuntimeCell<RefCell<ScrollPositionInner>>,
62}
63
64/// Non-reactive internal state for scroll position.
65struct ScrollPositionInner {
66    current_index: usize,
67    current_scroll_offset: f32,
68    last_known_first_item_key: Option<u64>,
69    nearest_range_state: NearestRangeState,
70}
71
72impl LazyListScrollPosition {
73    fn current_index(&self) -> usize {
74        self.inner
75            .try_with(|cell| cell.borrow().current_index)
76            .unwrap_or(0)
77    }
78
79    fn current_scroll_offset(&self) -> f32 {
80        self.inner
81            .try_with(|cell| cell.borrow().current_scroll_offset)
82            .unwrap_or(0.0)
83    }
84
85    /// Returns the index of the first visible item (reactive read).
86    pub fn index(&self) -> usize {
87        if !self.index.is_alive() {
88            return 0;
89        }
90        let Some(index) = self.inner.try_with(|cell| cell.borrow().current_index) else {
91            return 0;
92        };
93        self.index.subscribe_current_scope_only();
94        index
95    }
96
97    /// Returns the scroll offset of the first visible item (reactive read).
98    pub fn scroll_offset(&self) -> f32 {
99        if !self.scroll_offset.is_alive() {
100            return 0.0;
101        }
102        let Some(offset) = self
103            .inner
104            .try_with(|cell| cell.borrow().current_scroll_offset)
105        else {
106            return 0.0;
107        };
108        self.scroll_offset.subscribe_current_scope_only();
109        offset
110    }
111
112    pub(crate) fn update_from_measure_result(
113        &self,
114        first_visible_index: usize,
115        first_visible_scroll_offset: f32,
116        first_visible_item_key: Option<u64>,
117    ) {
118        if !self.index.is_alive() || !self.scroll_offset.is_alive() {
119            return;
120        }
121        let updated = self.inner.try_with(|cell| {
122            let mut inner = cell.borrow_mut();
123            inner.current_index = first_visible_index;
124            inner.current_scroll_offset = first_visible_scroll_offset;
125            inner.last_known_first_item_key = first_visible_item_key;
126            inner.nearest_range_state.update(first_visible_index);
127        });
128        if updated.is_none() {
129            return;
130        }
131
132        if self.index.get_non_reactive() != first_visible_index {
133            self.index.set(first_visible_index);
134        }
135        if (self.scroll_offset.get_non_reactive() - first_visible_scroll_offset).abs() > 0.001 {
136            self.scroll_offset.set(first_visible_scroll_offset);
137        }
138    }
139
140    pub(crate) fn request_position_and_forget_last_known_key(
141        &self,
142        index: usize,
143        scroll_offset: f32,
144    ) {
145        if !self.index.is_alive() || !self.scroll_offset.is_alive() {
146            return;
147        }
148        let updated = self.inner.try_with(|cell| {
149            let mut inner = cell.borrow_mut();
150            inner.current_index = index;
151            inner.current_scroll_offset = scroll_offset;
152            inner.last_known_first_item_key = None;
153            inner.nearest_range_state.update(index);
154        });
155        if updated.is_none() {
156            return;
157        }
158
159        if self.index.get_non_reactive() != index {
160            self.index.set(index);
161        }
162        if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
163            self.scroll_offset.set(scroll_offset);
164        }
165    }
166
167    pub(crate) fn update_if_first_item_moved<F>(
168        &self,
169        new_item_count: usize,
170        find_by_key: F,
171    ) -> usize
172    where
173        F: Fn(u64) -> Option<usize>,
174    {
175        if !self.index.is_alive() {
176            return 0;
177        }
178        let Some(cell) = self.inner.try_get() else {
179            return 0;
180        };
181        let (current_index, last_key) = {
182            let inner = cell.borrow();
183            (inner.current_index, inner.last_known_first_item_key)
184        };
185
186        let new_index = match last_key {
187            None => current_index.min(new_item_count.saturating_sub(1)),
188            Some(key) => find_by_key(key)
189                .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
190        };
191
192        if current_index != new_index {
193            {
194                let mut inner = cell.borrow_mut();
195                inner.current_index = new_index;
196                inner.nearest_range_state.update(new_index);
197            }
198            self.index.set(new_index);
199        }
200        new_index
201    }
202
203    /// Returns the nearest range for optimized key lookups.
204    pub fn nearest_range(&self) -> std::ops::Range<usize> {
205        self.inner
206            .try_with(|cell| cell.borrow().nearest_range_state.range())
207            .unwrap_or(0..0)
208    }
209}
210
211/// State object for lazy list scroll position tracking.
212///
213/// Holds the current scroll position and provides methods to programmatically
214/// control scrolling. Create with [`rememberLazyListState()`] in composition.
215///
216/// This type is `Copy`, so it can be passed to multiple closures without explicit `.clone()` calls.
217///
218/// # Reactive Properties (read during composition triggers recomposition)
219/// - `first_visible_item_index()` - index of first visible item
220/// - `first_visible_item_scroll_offset()` - scroll offset within first item
221/// - `can_scroll_forward()` - whether more items exist below/right
222/// - `can_scroll_backward()` - whether more items exist above/left
223/// - `stats()` - lifecycle statistics (`items_in_use`, `items_in_pool`)
224///
225/// # Non-Reactive Properties
226/// - `stats().total_composed` - total items composed (diagnostic)
227/// - `stats().reuse_count` - items reused from pool (diagnostic)
228/// - `layout_info()` - detailed layout information
229///
230/// # Example
231///
232/// ```rust,ignore
233/// let state = rememberLazyListState();
234///
235/// // Scroll to item 50
236/// state.scroll_to_item(50, 0.0);
237///
238/// // Get current visible item (reactive read)
239/// println!("First visible: {}", state.first_visible_item_index());
240/// ```
241#[derive(Clone, Copy)]
242pub struct LazyListState {
243    scroll_position: LazyListScrollPosition,
244    can_scroll_forward_state: MutableState<bool>,
245    can_scroll_backward_state: MutableState<bool>,
246    stats_state: MutableState<LazyLayoutStats>,
247    scroll_in_progress_state: MutableState<bool>,
248    dragged_state: MutableState<bool>,
249    inner: RuntimeCell<RefCell<LazyListStateInner>>,
250}
251
252impl PartialEq for LazyListState {
253    fn eq(&self, other: &Self) -> bool {
254        self.inner == other.inner
255    }
256}
257
258#[derive(Clone, Copy)]
259struct CachedItemSize {
260    size: f32,
261    last_used: u64,
262}
263
264/// Non-reactive internal state for LazyListState.
265/// Where a scroll that the user let go of comes to rest: given the rest
266/// offset the release would reach, within the first visible item, and the
267/// release velocity, the offset to settle at instead.
268pub type LazySettlePolicy = Rc<dyn Fn(f32, f32) -> f32>;
269
270struct LazyListStateInner {
271    scroll_to_be_consumed: f32,
272    settle_policy: Option<LazySettlePolicy>,
273    /// The id of the running scroll animation and how to stop it.
274    scroll_animation: Option<(u64, Rc<dyn Fn()>)>,
275    next_scroll_animation: u64,
276    /// Whether the scroll modifier's drag, fling or settle moves the list.
277    gesture_scrolling: bool,
278    /// The first visible item and its offset where the scroll in progress
279    /// began.
280    scroll_start: Option<(usize, f32)>,
281
282    pending_scroll_to_index: Option<(usize, f32)>,
283
284    layout_info: LazyListLayoutInfo,
285    /// The visible items of the layout info before the last, cleared, which
286    /// the next measure pass fills.
287    spare_items_info: Vec<LazyListItemInfo>,
288    current_can_scroll_forward: bool,
289    current_can_scroll_backward: bool,
290
291    invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
292    next_callback_id: u64,
293
294    layout_invalidation_callback_id: Option<u64>,
295    layout_invalidation_node_id: Option<NodeId>,
296
297    total_composed: usize,
298    reuse_count: usize,
299
300    item_size_cache: HashMap<usize, CachedItemSize>,
301    item_size_clock: u64,
302
303    average_item_size: f32,
304    total_measured_items: usize,
305    next_measure_cycle_id: u64,
306    next_item_measure_pass_id: u64,
307
308    last_scroll_delta: f32,
309
310    hold_scroll_window: bool,
311}
312
313/// Creates a remembered [`LazyListState`] with default initial position.
314///
315/// This is the recommended way to create a `LazyListState` in composition.
316/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
317///
318/// # Example
319///
320/// ```rust,ignore
321/// let list_state = rememberLazyListState();
322///
323/// // Pass to multiple closures - no .clone() needed!
324/// LazyColumn(modifier, list_state, spec, content);
325/// Button(move || list_state.scroll_to_item(0, 0.0));
326/// ```
327#[composable]
328#[track_caller]
329pub fn rememberLazyListState() -> LazyListState {
330    rememberLazyListStateWithPosition(0, 0.0)
331}
332
333/// Creates a remembered [`LazyListState`] with the specified initial position.
334///
335/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
336#[composable]
337pub fn rememberLazyListStateWithPosition(
338    initial_first_visible_item_index: usize,
339    initial_first_visible_item_scroll_offset: f32,
340) -> LazyListState {
341    cranpose_core::remember(move || {
342        LazyListState::new(
343            initial_first_visible_item_index,
344            initial_first_visible_item_scroll_offset,
345        )
346    })
347    .with(|state| *state)
348}
349
350impl LazyListState {
351    /// A list's scroll states, for code that remembers the list state itself.
352    ///
353    /// Call it inside a `remember` -- [`rememberLazyListState`] is this plus
354    /// the slot -- so that the states belong to that slot and are released
355    /// with it.
356    pub fn new(
357        initial_first_visible_item_index: usize,
358        initial_first_visible_item_scroll_offset: f32,
359    ) -> Self {
360        LazyListState {
361            scroll_position: LazyListScrollPosition {
362                index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
363                scroll_offset: cranpose_core::mutableStateOf(
364                    initial_first_visible_item_scroll_offset,
365                ),
366                inner: RuntimeCell::new(RefCell::new(ScrollPositionInner {
367                    current_index: initial_first_visible_item_index,
368                    current_scroll_offset: initial_first_visible_item_scroll_offset,
369                    last_known_first_item_key: None,
370                    nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
371                })),
372            },
373            can_scroll_forward_state: cranpose_core::mutableStateOf(false),
374            can_scroll_backward_state: cranpose_core::mutableStateOf(false),
375            stats_state: cranpose_core::mutableStateOf(LazyLayoutStats::default()),
376            scroll_in_progress_state: cranpose_core::mutableStateOf(false),
377            dragged_state: cranpose_core::mutableStateOf(false),
378            inner: RuntimeCell::new(RefCell::new(LazyListStateInner {
379                scroll_to_be_consumed: 0.0,
380                settle_policy: None,
381                scroll_animation: None,
382                next_scroll_animation: 1,
383                gesture_scrolling: false,
384                scroll_start: None,
385                pending_scroll_to_index: None,
386                layout_info: LazyListLayoutInfo::default(),
387                spare_items_info: Vec::new(),
388                current_can_scroll_forward: false,
389                current_can_scroll_backward: false,
390                invalidate_callbacks: Vec::new(),
391                next_callback_id: 1,
392                layout_invalidation_callback_id: None,
393                layout_invalidation_node_id: None,
394                total_composed: 0,
395                reuse_count: 0,
396                item_size_cache: HashMap::default(),
397                item_size_clock: 0,
398                average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
399                total_measured_items: 0,
400                next_measure_cycle_id: 1,
401                next_item_measure_pass_id: 1,
402                last_scroll_delta: 0.0,
403                hold_scroll_window: false,
404            })),
405        }
406    }
407
408    /// The first visible item and how far it is scrolled, or `None` once the
409    /// slot that held this state was released. A saver reads it when the
410    /// list leaves the composition.
411    pub fn try_position(&self) -> Option<(usize, f32)> {
412        self.scroll_position.inner.try_with(|inner| {
413            let inner = inner.borrow();
414            (inner.current_index, inner.current_scroll_offset)
415        })
416    }
417
418    /// Returns a stable identity pointer for the live inner state allocation.
419    ///
420    /// The pointer is the address of the value `inner` keeps in the runtime, so it remains
421    /// stable for the lifetime of a live `LazyListState` and can be used as a composition
422    /// identity key.
423    pub fn inner_ptr(&self) -> *const () {
424        self.inner
425            .try_with(|inner| std::ptr::from_ref(inner).cast::<()>())
426            .unwrap_or(std::ptr::null())
427    }
428
429    /// Returns the index of the first visible item.
430    ///
431    /// When called during composition, this creates a reactive subscription
432    /// so that changes to the index will trigger recomposition.
433    pub fn first_visible_item_index(&self) -> usize {
434        self.scroll_position.index()
435    }
436
437    /// How many items the list holds, as of its last measure.
438    pub fn total_items_count(&self) -> usize {
439        self.inner
440            .with(|cell| cell.borrow().layout_info.total_items_count)
441    }
442
443    /// Returns the first visible item index without subscribing the current composition scope.
444    ///
445    /// Use this from draw/input/diagnostic code that needs the latest position but must not
446    /// recompose when the scroll position changes.
447    pub fn first_visible_item_index_non_reactive(&self) -> usize {
448        self.scroll_position.current_index()
449    }
450
451    /// Returns the scroll offset of the first visible item.
452    ///
453    /// This is the amount the first item is scrolled off-screen (positive = scrolled up/left).
454    /// When called during composition, this creates a reactive subscription
455    /// so that changes to the offset will trigger recomposition.
456    pub fn first_visible_item_scroll_offset(&self) -> f32 {
457        self.scroll_position.scroll_offset()
458    }
459
460    /// Returns the first visible item scroll offset without subscribing the current composition scope.
461    ///
462    /// Use this from draw/input/diagnostic code that needs the latest position but must not
463    /// recompose when the scroll position changes.
464    pub fn first_visible_item_scroll_offset_non_reactive(&self) -> f32 {
465        self.scroll_position.current_scroll_offset()
466    }
467
468    #[doc(hidden)]
469    pub fn reactive_state_ids(&self) -> [StateId; 5] {
470        [
471            self.scroll_position.index.runtime_state_id(),
472            self.scroll_position.scroll_offset.runtime_state_id(),
473            self.can_scroll_forward_state.runtime_state_id(),
474            self.can_scroll_backward_state.runtime_state_id(),
475            self.stats_state.runtime_state_id(),
476        ]
477    }
478
479    /// Returns the layout info from the last measure pass.
480    pub fn layout_info(&self) -> LazyListLayoutInfo {
481        self.with_layout_info(LazyListLayoutInfo::clone)
482            .unwrap_or_default()
483    }
484
485    /// Reads the layout info from the last measure pass in place, without
486    /// the copy of the visible items that [`Self::layout_info`] makes.
487    /// `None` once the state is gone.
488    pub fn with_layout_info<R>(&self, read: impl FnOnce(&LazyListLayoutInfo) -> R) -> Option<R> {
489        self.inner.try_with(|cell| read(&cell.borrow().layout_info))
490    }
491
492    /// Returns the current item lifecycle statistics.
493    ///
494    /// When called during composition, this creates a reactive subscription
495    /// so that changes to `items_in_use` or `items_in_pool` will trigger recomposition.
496    /// The `total_composed` and `reuse_count` fields are diagnostic and non-reactive.
497    pub fn stats(&self) -> LazyLayoutStats {
498        if !self.stats_state.is_alive() || !self.inner.is_alive() {
499            return LazyLayoutStats::default();
500        }
501        let reactive = self.stats_state.get();
502        let (total_composed, reuse_count) = self.inner.with(|cell| {
503            let inner = cell.borrow();
504            (inner.total_composed, inner.reuse_count)
505        });
506        LazyLayoutStats {
507            items_in_use: reactive.items_in_use,
508            items_in_pool: reactive.items_in_pool,
509            total_composed,
510            reuse_count,
511        }
512    }
513
514    /// Updates the item lifecycle statistics.
515    ///
516    /// Called by the layout measurement after updating slot pools.
517    /// Triggers recomposition if `items_in_use` or `items_in_pool` changed.
518    pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
519        if !self.stats_state.is_alive() || !self.inner.is_alive() {
520            return;
521        }
522
523        let current = self.stats_state.get_non_reactive();
524
525        let should_update_reactive = if items_in_use > current.items_in_use {
526            true
527        } else if items_in_use < current.items_in_use {
528            current.items_in_use - items_in_use > 1
529        } else {
530            false
531        };
532
533        if should_update_reactive {
534            self.stats_state.set(LazyLayoutStats {
535                items_in_use,
536                items_in_pool,
537                ..current
538            });
539        }
540    }
541
542    /// Records that an item was composed (either new or reused).
543    ///
544    /// This updates diagnostic counters in non-reactive state.
545    /// Does NOT trigger recomposition.
546    pub fn record_composition(&self, was_reused: bool) {
547        self.inner.try_with(|cell| {
548            let mut inner = cell.borrow_mut();
549            inner.total_composed += 1;
550            if was_reused {
551                inner.reuse_count += 1;
552            }
553        });
554    }
555
556    /// Makes the next measure pass size its beyond-bounds window by the last
557    /// scroll delta a pass consumed when it has none of its own, so a pass
558    /// between frames keeps the window the scroll gave the frame before it.
559    pub fn hold_scroll_window(&self) {
560        self.inner
561            .try_with(|cell| cell.borrow_mut().hold_scroll_window = true);
562    }
563
564    /// Installs, or clears, where a drag or a fling the user let go of comes
565    /// to rest: a pager settles on its pages.
566    pub fn set_settle_policy(&self, policy: Option<LazySettlePolicy>) {
567        self.inner
568            .try_with(|cell| cell.borrow_mut().settle_policy = policy);
569    }
570
571    /// The installed settle policy, if any.
572    pub fn settle_policy(&self) -> Option<LazySettlePolicy> {
573        self.inner
574            .try_with(|cell| cell.borrow().settle_policy.clone())
575            .flatten()
576    }
577
578    /// Registers a scroll animation, which `stop` stops, in place of the one
579    /// running, which it stops, and returns the new one's id. A press on the
580    /// list stops it too.
581    pub fn start_scroll_animation(&self, stop: Rc<dyn Fn()>) -> u64 {
582        let Some((id, previous)) = self.inner.try_with(|cell| {
583            let mut inner = cell.borrow_mut();
584            let id = inner.next_scroll_animation;
585            inner.next_scroll_animation += 1;
586            (id, inner.scroll_animation.replace((id, stop)))
587        }) else {
588            return 0;
589        };
590        if let Some((_, stop)) = previous {
591            stop();
592        }
593        self.refresh_scroll_in_progress();
594        id
595    }
596
597    /// Forgets the scroll animation `id` once it ended on its own.
598    pub fn end_scroll_animation(&self, id: u64) {
599        let _ = self.inner.try_with(|cell| {
600            let mut inner = cell.borrow_mut();
601            if inner
602                .scroll_animation
603                .as_ref()
604                .is_some_and(|(running, _)| *running == id)
605            {
606                inner.scroll_animation = None;
607            }
608        });
609        self.refresh_scroll_in_progress();
610    }
611
612    /// Stops the running scroll animation, and returns whether one ran.
613    pub fn stop_scroll_animation(&self) -> bool {
614        let running = self
615            .inner
616            .try_with(|cell| cell.borrow_mut().scroll_animation.take())
617            .flatten();
618        let stopped = running.map(|(_, stop)| stop()).is_some();
619        self.refresh_scroll_in_progress();
620        stopped
621    }
622
623    /// Whether a drag, a fling, a settle or a scroll animation moves the list
624    /// now: Compose's `isScrollInProgress`. A composable that reads it
625    /// recomposes when a scroll starts and when it ends.
626    pub fn is_scroll_in_progress(&self) -> bool {
627        self.scroll_in_progress_state.is_alive() && self.scroll_in_progress_state.get()
628    }
629
630    /// Whether a finger or a pointer drags the list now: Compose's
631    /// `interactionSource.collectIsDraggedAsState()`. A composable that reads
632    /// it recomposes when a drag starts and when it ends.
633    pub fn is_dragged(&self) -> bool {
634        self.dragged_state.is_alive() && self.dragged_state.get()
635    }
636
637    /// Marks whether the scroll modifier's drag moves the list, for
638    /// [`Self::is_dragged`]. The scroll modifier calls it.
639    pub fn set_dragged(&self, dragged: bool) {
640        if self.dragged_state.is_alive() && self.dragged_state.get_non_reactive() != dragged {
641            self.dragged_state.set(dragged);
642        }
643    }
644
645    /// Marks whether a drag, a fling or a settle of the scroll modifier moves
646    /// the list, for [`Self::is_scroll_in_progress`]. The scroll modifier
647    /// calls it; scroll animations mark themselves.
648    pub fn set_gesture_scroll_active(&self, active: bool) {
649        let changed = self.inner.try_with(|cell| {
650            std::mem::replace(&mut cell.borrow_mut().gesture_scrolling, active) != active
651        });
652        if changed == Some(true) {
653            self.refresh_scroll_in_progress();
654        }
655    }
656
657    /// The first visible item and its offset where the scroll in progress
658    /// began, or `None` while no scroll is in progress. A pager keeps its
659    /// settled page from it.
660    pub fn position_before_scroll(&self) -> Option<(usize, f32)> {
661        self.inner
662            .try_with(|cell| cell.borrow().scroll_start)
663            .flatten()
664    }
665
666    fn refresh_scroll_in_progress(&self) {
667        if !self.scroll_in_progress_state.is_alive() {
668            return;
669        }
670        let Some(moving) = self.inner.try_with(|cell| {
671            let inner = cell.borrow();
672            inner.gesture_scrolling || inner.scroll_animation.is_some()
673        }) else {
674            return;
675        };
676        if self.scroll_in_progress_state.get_non_reactive() == moving {
677            return;
678        }
679        let start = if moving { self.try_position() } else { None };
680        let _ = self
681            .inner
682            .try_with(|cell| cell.borrow_mut().scroll_start = start);
683        self.scroll_in_progress_state.set(moving);
684    }
685
686    /// Scrolls to the specified item index.
687    ///
688    /// # Arguments
689    /// * `index` - The index of the item to scroll to
690    /// * `scroll_offset` - Additional offset within the item (default 0)
691    pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
692        if !self.inner.is_alive() {
693            return;
694        }
695        if diagnostics::telemetry_enabled() {
696            log::warn!(
697                "[lazy-measure-telemetry] scroll_to_item request index={index} offset={scroll_offset:.2}"
698            );
699        }
700        self.inner.with(|cell| {
701            let mut inner = cell.borrow_mut();
702            inner.pending_scroll_to_index = Some((index, scroll_offset));
703            // The jump replaces the scroll deltas that have not been applied.
704            inner.scroll_to_be_consumed = 0.0;
705        });
706
707        self.scroll_position
708            .request_position_and_forget_last_known_key(index, scroll_offset);
709
710        self.invalidate();
711    }
712
713    /// Dispatches a raw scroll delta.
714    ///
715    /// Returns the amount of scroll actually consumed.
716    ///
717    /// This triggers layout invalidation via registered callbacks. The callbacks
718    /// are registered by LazyColumnImpl/LazyRowImpl with
719    /// `schedule_measure_repass(node_id)` — the list's own item sizes are what
720    /// changes, so the repass has to bubble measure dirtiness, not just
721    /// placement. The node id carries through to the scene phase, which scopes
722    /// its graph update to that subtree: O(subtree) instead of O(entire app).
723    pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
724        let Some(cell) = self.inner.try_get() else {
725            return 0.0;
726        };
727        let has_scroll_bounds = cell.borrow().layout_info.total_items_count > 0;
728        let pushing_forward = delta < -0.001;
729        let pushing_backward = delta > 0.001;
730        let can_scroll_forward =
731            self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
732        let can_scroll_backward =
733            self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
734        let blocked_by_bounds = has_scroll_bounds
735            && ((pushing_forward && !can_scroll_forward)
736                || (pushing_backward && !can_scroll_backward));
737
738        if blocked_by_bounds {
739            let should_invalidate = {
740                let mut inner = cell.borrow_mut();
741                let pending_before = inner.scroll_to_be_consumed;
742                if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
743                    inner.scroll_to_be_consumed = 0.0;
744                }
745                if diagnostics::telemetry_enabled() {
746                    log::warn!(
747                        "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
748                        delta,
749                        pending_before,
750                        inner.scroll_to_be_consumed
751                    );
752                }
753                (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
754            };
755            if should_invalidate {
756                Self::run_invalidate_callbacks(&cell);
757            }
758            return 0.0;
759        }
760
761        let should_invalidate = {
762            let mut inner = cell.borrow_mut();
763            let pending_before = inner.scroll_to_be_consumed;
764            let pending = inner.scroll_to_be_consumed;
765            let reverse_input =
766                pending.abs() > 0.001 && delta.abs() > 0.001 && pending.signum() != delta.signum();
767            if reverse_input {
768                if diagnostics::telemetry_enabled() {
769                    log::warn!(
770                        "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={pending:.2} new_delta={delta:.2}"
771                    );
772                }
773                inner.scroll_to_be_consumed = delta;
774            } else {
775                inner.scroll_to_be_consumed += delta;
776            }
777            inner.scroll_to_be_consumed = inner
778                .scroll_to_be_consumed
779                .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
780            if diagnostics::telemetry_enabled() {
781                log::warn!(
782                    "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
783                    delta,
784                    inner.scroll_to_be_consumed
785                );
786            }
787            (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
788        };
789        if should_invalidate {
790            Self::run_invalidate_callbacks(&cell);
791        }
792        delta
793    }
794
795    /// Peeks at the pending scroll delta without consuming it.
796    ///
797    /// Used for direction inference before measurement consumes the delta.
798    /// This is more accurate than comparing first visible index, especially for:
799    /// - Scrolling within the same item (partial scroll)
800    /// - Variable height items where scroll offset changes without index change
801    pub fn peek_scroll_delta(&self) -> f32 {
802        self.inner
803            .try_with(|cell| cell.borrow().scroll_to_be_consumed)
804            .unwrap_or(0.0)
805    }
806
807    pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
808        let (pending_scroll_delta, window_scroll_delta, pending_scroll_to, average_item_size) =
809            self.inner
810                .try_with(|cell| {
811                    let mut inner = cell.borrow_mut();
812                    let pending_scroll_to = inner.pending_scroll_to_index.take();
813                    let pending_scroll_delta = inner.scroll_to_be_consumed;
814                    inner.scroll_to_be_consumed = 0.0;
815                    let held = std::mem::take(&mut inner.hold_scroll_window);
816                    let window_scroll_delta = if pending_scroll_delta.abs() > 0.001 {
817                        inner.last_scroll_delta = pending_scroll_delta;
818                        pending_scroll_delta
819                    } else if held {
820                        inner.last_scroll_delta
821                    } else {
822                        0.0
823                    };
824                    (
825                        pending_scroll_delta,
826                        window_scroll_delta,
827                        pending_scroll_to,
828                        inner.average_item_size,
829                    )
830                })
831                .unwrap_or((0.0, 0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
832
833        let (first_visible_item_index, first_visible_item_scroll_offset) =
834            self.try_position().unwrap_or((0, 0.0));
835        LazyListMeasureStateSnapshot {
836            first_visible_item_index,
837            first_visible_item_scroll_offset,
838            pending_scroll_delta,
839            window_scroll_delta,
840            pending_scroll_to,
841            average_item_size,
842        }
843    }
844
845    pub(crate) fn next_measure_cycle_id(&self) -> u64 {
846        self.inner
847            .try_with(|cell| {
848                let mut inner = cell.borrow_mut();
849                let id = inner.next_measure_cycle_id;
850                inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
851                id
852            })
853            .unwrap_or(0)
854    }
855
856    pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
857        self.inner
858            .try_with(|cell| {
859                let mut inner = cell.borrow_mut();
860                let id = inner.next_item_measure_pass_id;
861                inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
862                id
863            })
864            .unwrap_or(0)
865    }
866
867    fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
868        inner.total_measured_items += 1;
869        let n = inner.total_measured_items as f32;
870        inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
871    }
872
873    fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
874        inner.item_size_clock = inner.item_size_clock.saturating_add(1);
875        inner.item_size_clock
876    }
877
878    fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
879        let tick = Self::next_item_size_cache_tick(inner);
880        let item = CachedItemSize {
881            size,
882            last_used: tick,
883        };
884        if let Some(cached) = inner.item_size_cache.get_mut(&index) {
885            *cached = item;
886            return false;
887        }
888        if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
889            Self::evict_stale_item_sizes(inner, tick);
890        }
891        inner.item_size_cache.insert(index, item);
892        true
893    }
894
895    /// Forgets the sizes that no read or write used in the last half of the
896    /// cache's capacity of uses. Every use takes the next tick, so at most
897    /// half the entries were used that recently: a full cache frees half its
898    /// room in one pass, and the oldest sizes go first.
899    fn evict_stale_item_sizes(inner: &mut LazyListStateInner, tick: u64) {
900        let oldest_kept = tick.saturating_sub((ITEM_SIZE_CACHE_CAPACITY / 2) as u64);
901        inner
902            .item_size_cache
903            .retain(|_, item| item.last_used > oldest_kept);
904    }
905
906    /// Caches the measured size of an item for scroll estimation.
907    pub fn cache_item_size(&self, index: usize, size: f32) {
908        self.inner.try_with(|cell| {
909            let mut inner = cell.borrow_mut();
910            if Self::insert_item_size(&mut inner, index, size) {
911                Self::record_item_size_sample(&mut inner, size);
912            }
913        });
914    }
915
916    /// Caches multiple measured item sizes in one pass and returns the updated average.
917    pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
918    where
919        I: IntoIterator<Item = (usize, f32)>,
920    {
921        self.inner
922            .try_with(|cell| {
923                let mut inner = cell.borrow_mut();
924                for (index, size) in sizes {
925                    if Self::insert_item_size(&mut inner, index, size) {
926                        Self::record_item_size_sample(&mut inner, size);
927                    }
928                }
929                inner.average_item_size
930            })
931            .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
932    }
933
934    /// Gets a cached item size if available.
935    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
936        self.inner
937            .try_with(|cell| {
938                let mut inner = cell.borrow_mut();
939                let tick = Self::next_item_size_cache_tick(&mut inner);
940                let item = inner.item_size_cache.get_mut(&index)?;
941                item.last_used = tick;
942                Some(item.size)
943            })
944            .flatten()
945    }
946
947    /// Returns the running average of measured item sizes.
948    pub fn average_item_size(&self) -> f32 {
949        self.inner
950            .try_with(|cell| cell.borrow().average_item_size)
951            .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
952    }
953
954    /// Returns the current nearest range for optimized key lookup.
955    pub fn nearest_range(&self) -> std::ops::Range<usize> {
956        self.scroll_position.nearest_range()
957    }
958
959    pub(crate) fn update_scroll_position(
960        &self,
961        first_visible_item_index: usize,
962        first_visible_item_scroll_offset: f32,
963    ) {
964        self.scroll_position.update_from_measure_result(
965            first_visible_item_index,
966            first_visible_item_scroll_offset,
967            None,
968        );
969    }
970
971    pub(crate) fn update_scroll_position_with_key(
972        &self,
973        first_visible_item_index: usize,
974        first_visible_item_scroll_offset: f32,
975        first_visible_item_key: u64,
976    ) {
977        self.scroll_position.update_from_measure_result(
978            first_visible_item_index,
979            first_visible_item_scroll_offset,
980            Some(first_visible_item_key),
981        );
982    }
983
984    /// Adjusts scroll position if the first visible item was moved due to data changes.
985    ///
986    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
987    /// If items were inserted/removed before the current scroll position,
988    /// this finds the item by its key and updates the index accordingly.
989    ///
990    /// Returns the adjusted first visible item index.
991    pub fn update_scroll_position_if_item_moved<F>(
992        &self,
993        new_item_count: usize,
994        get_index_by_key: F,
995    ) -> usize
996    where
997        F: Fn(u64) -> Option<usize>,
998    {
999        self.scroll_position
1000            .update_if_first_item_moved(new_item_count, get_index_by_key)
1001    }
1002
1003    pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
1004        self.inner.try_with(|cell| {
1005            let mut inner = cell.borrow_mut();
1006            info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
1007            let mut spare = std::mem::replace(&mut inner.layout_info, info).visible_items_info;
1008            spare.clear();
1009            inner.spare_items_info = spare;
1010        });
1011    }
1012
1013    /// An empty list for the next layout info's visible items, which keeps
1014    /// the storage of the one before.
1015    pub(crate) fn spare_items_info(&self) -> Vec<LazyListItemInfo> {
1016        self.inner
1017            .try_with(|cell| std::mem::take(&mut cell.borrow_mut().spare_items_info))
1018            .unwrap_or_default()
1019    }
1020
1021    /// Returns whether we can scroll forward (more items below/right).
1022    ///
1023    /// When called during composition, this creates a reactive subscription
1024    /// so that changes will trigger recomposition.
1025    pub fn can_scroll_forward(&self) -> bool {
1026        if !self.can_scroll_forward_state.is_alive() {
1027            return false;
1028        }
1029        self.can_scroll_forward_state.subscribe_current_scope_only();
1030        self.can_scroll_forward_non_reactive()
1031    }
1032
1033    /// Returns whether the list can scroll forward without subscribing the current composition scope.
1034    pub fn can_scroll_forward_non_reactive(&self) -> bool {
1035        if !self.can_scroll_forward_state.is_alive() {
1036            return false;
1037        }
1038        self.inner
1039            .try_with(|cell| cell.borrow().current_can_scroll_forward)
1040            .unwrap_or(false)
1041    }
1042
1043    /// Returns whether we can scroll backward (more items above/left).
1044    ///
1045    /// When called during composition, this creates a reactive subscription
1046    /// so that changes will trigger recomposition.
1047    pub fn can_scroll_backward(&self) -> bool {
1048        if !self.can_scroll_backward_state.is_alive() {
1049            return false;
1050        }
1051        self.can_scroll_backward_state
1052            .subscribe_current_scope_only();
1053        self.can_scroll_backward_non_reactive()
1054    }
1055
1056    /// Returns whether the list can scroll backward without subscribing the current composition scope.
1057    pub fn can_scroll_backward_non_reactive(&self) -> bool {
1058        if !self.can_scroll_backward_state.is_alive() {
1059            return false;
1060        }
1061        self.inner
1062            .try_with(|cell| cell.borrow().current_can_scroll_backward)
1063            .unwrap_or(false)
1064    }
1065
1066    pub(crate) fn update_scroll_bounds(&self) {
1067        if !self.can_scroll_forward_state.is_alive() || !self.can_scroll_backward_state.is_alive() {
1068            return;
1069        }
1070        let Some(cell) = self.inner.try_get() else {
1071            return;
1072        };
1073        let can_forward = {
1074            let inner = cell.borrow();
1075            let info = &inner.layout_info;
1076            let viewport_end = info.viewport_size - info.after_content_padding;
1077            // A staggered grid's last item by index need not be the one
1078            // that reaches furthest.
1079            if let Some(last_visible) = info.visible_items_info.last() {
1080                last_visible.index < info.total_items_count.saturating_sub(1)
1081                    || info
1082                        .visible_items_info
1083                        .iter()
1084                        .rev()
1085                        .any(|item| item.offset + item.size > viewport_end)
1086            } else {
1087                false
1088            }
1089        };
1090
1091        let (first_index, first_offset) = self.try_position().unwrap_or((0, 0.0));
1092        let can_backward = first_index > 0 || first_offset > 0.0;
1093
1094        {
1095            let mut inner = cell.borrow_mut();
1096            inner.current_can_scroll_forward = can_forward;
1097            inner.current_can_scroll_backward = can_backward;
1098        }
1099
1100        if self.can_scroll_forward_state.get_non_reactive() != can_forward {
1101            self.can_scroll_forward_state.set(can_forward);
1102        }
1103        if self.can_scroll_backward_state.get_non_reactive() != can_backward {
1104            self.can_scroll_backward_state.set(can_backward);
1105        }
1106    }
1107
1108    /// Adds an invalidation callback.
1109    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
1110        self.inner
1111            .try_with(|cell| {
1112                let mut inner = cell.borrow_mut();
1113                let id = inner.next_callback_id;
1114                inner.next_callback_id += 1;
1115                inner.invalidate_callbacks.push((id, callback));
1116                id
1117            })
1118            .unwrap_or(0)
1119    }
1120
1121    /// Tries to register a layout invalidation callback for the specified node.
1122    ///
1123    /// Returns the callback id for the active layout callback.
1124    ///
1125    /// Registering again always replaces the previous active layout callback, even when
1126    /// the node id stays the same. This keeps ownership tied to the latest effect
1127    /// instance so disposing an older scope cannot unregister the live callback.
1128    pub fn try_register_layout_callback(
1129        &self,
1130        node_id: NodeId,
1131        callback: Rc<dyn Fn()>,
1132    ) -> Option<u64> {
1133        if !self.inner.is_alive() {
1134            return None;
1135        }
1136        self.inner.with(|cell| {
1137            let mut inner = cell.borrow_mut();
1138            if let Some(existing_id) = inner.layout_invalidation_callback_id {
1139                inner
1140                    .invalidate_callbacks
1141                    .retain(|(cb_id, _)| *cb_id != existing_id);
1142            }
1143            let id = inner.next_callback_id;
1144            inner.next_callback_id += 1;
1145            inner.invalidate_callbacks.push((id, callback));
1146            inner.layout_invalidation_callback_id = Some(id);
1147            inner.layout_invalidation_node_id = Some(node_id);
1148            Some(id)
1149        })
1150    }
1151
1152    /// Removes an invalidation callback.
1153    pub fn remove_invalidate_callback(&self, id: u64) {
1154        self.inner.try_with(|cell| {
1155            let mut inner = cell.borrow_mut();
1156            inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1157            if inner.layout_invalidation_callback_id == Some(id) {
1158                inner.layout_invalidation_callback_id = None;
1159                inner.layout_invalidation_node_id = None;
1160            }
1161        });
1162    }
1163
1164    fn invalidate(&self) {
1165        if let Some(cell) = self.inner.try_get() {
1166            Self::run_invalidate_callbacks(&cell);
1167        }
1168    }
1169
1170    /// Runs the invalidation callbacks `cell` holds, from a copy of the
1171    /// list: a callback may register or remove callbacks.
1172    fn run_invalidate_callbacks(cell: &RefCell<LazyListStateInner>) {
1173        let callbacks: Vec<_> = cell
1174            .borrow()
1175            .invalidate_callbacks
1176            .iter()
1177            .map(|(_, cb)| Rc::clone(cb))
1178            .collect();
1179
1180        for callback in callbacks {
1181            callback();
1182        }
1183    }
1184}
1185
1186/// Information about the currently visible items in a lazy list.
1187#[derive(Clone, Default, Debug)]
1188pub struct LazyListLayoutInfo {
1189    /// Information about each visible item.
1190    pub visible_items_info: Vec<LazyListItemInfo>,
1191
1192    /// Total number of items in the list.
1193    pub total_items_count: usize,
1194
1195    /// Raw viewport size reported by parent constraints (before infinite fallback).
1196    pub raw_viewport_size: f32,
1197
1198    /// Whether the viewport was treated as infinite/unbounded.
1199    pub is_infinite_viewport: bool,
1200
1201    /// Size of the viewport in the main axis.
1202    pub viewport_size: f32,
1203
1204    /// Start offset of the viewport in layout coordinates.
1205    pub viewport_start_offset: f32,
1206
1207    /// End offset of the viewport in layout coordinates.
1208    pub viewport_end_offset: f32,
1209
1210    /// Content padding before the first item.
1211    pub before_content_padding: f32,
1212
1213    /// Content padding after the last item.
1214    pub after_content_padding: f32,
1215
1216    /// The space between two items — Compose's `mainAxisItemSpacing`.
1217    pub main_axis_item_spacing: f32,
1218
1219    /// Continuous main-axis visual offset used to snap translated lazy-list content.
1220    pub snap_anchor_offset: f32,
1221
1222    /// Whether item offsets are placed from the end edge of the viewport.
1223    pub reverse_layout: bool,
1224}
1225
1226/// Information about a single visible item in a lazy list.
1227#[derive(Clone, Debug)]
1228pub struct LazyListItemInfo {
1229    /// Index of the item in the data source.
1230    pub index: usize,
1231
1232    /// Key of the item.
1233    pub key: u64,
1234
1235    /// Offset of the item from the start of the list content.
1236    pub offset: f32,
1237
1238    /// Size of the item in the main axis.
1239    pub size: f32,
1240}
1241
1242fn continuous_snap_anchor_offset(
1243    previous: &LazyListLayoutInfo,
1244    current: &LazyListLayoutInfo,
1245) -> f32 {
1246    let Some(first_current) = current.visible_items_info.first() else {
1247        return 0.0;
1248    };
1249
1250    for current_item in &current.visible_items_info {
1251        if let Some(previous_item) = previous
1252            .visible_items_info
1253            .iter()
1254            .find(|item| item.key == current_item.key)
1255        {
1256            let previous_offset = snap_anchor_item_offset(previous, previous_item);
1257            let current_offset = snap_anchor_item_offset(current, current_item);
1258            return previous.snap_anchor_offset + current_offset - previous_offset;
1259        }
1260    }
1261
1262    snap_anchor_item_offset(current, first_current)
1263}
1264
1265fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1266    if info.reverse_layout {
1267        info.viewport_size - item.offset - item.size
1268    } else {
1269        item.offset
1270    }
1271}
1272
1273#[cfg(test)]
1274#[path = "tests/lazy_list_state_test_helpers.rs"]
1275pub(crate) mod test_helpers;
1276
1277#[cfg(test)]
1278#[path = "tests/lazy_list_state_tests.rs"]
1279mod tests;