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