Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

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