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