Skip to main content

cranpose_ui/widgets/
animate_scroll.rs

1//! Animated scrolling of lazy lists, and of the grids and pagers built on
2//! them: Compose's `animateScrollBy` and `animateScrollToItem`.
3
4use std::rc::Rc;
5
6use cranpose_foundation::lazy::LazyListState;
7
8use crate::fling_animation::{SettleAnimation, SpringParams};
9
10/// Scrolls a lazy list with a spring. A press on the list, or another
11/// animation, stops it where it is.
12pub trait AnimateScroll {
13    /// Scrolls `distance` logical pixels, positive toward the end.
14    fn animate_scroll_by(&self, distance: f32);
15
16    /// Scrolls until item `index` starts `offset` past the start of the
17    /// viewport, as [`LazyListState::scroll_to_item`] would place it.
18    fn animate_scroll_to_item(&self, index: usize, offset: f32);
19}
20
21impl AnimateScroll for LazyListState {
22    fn animate_scroll_by(&self, distance: f32) {
23        animate(*self, distance, None);
24    }
25
26    fn animate_scroll_to_item(&self, index: usize, offset: f32) {
27        animate(
28            *self,
29            distance_to_item(self, index) + offset,
30            Some((index, offset)),
31        );
32    }
33}
34
35/// How far item `index` starts past where `scroll_to_item(index, 0)` puts
36/// it: measured when the list shows it, estimated from the average item size
37/// when not. The animation lands on the item exactly either way.
38fn distance_to_item(state: &LazyListState, index: usize) -> f32 {
39    let shown = state.with_layout_info(|info| {
40        info.visible_items_info
41            .iter()
42            .find(|item| item.index == index)
43            .map(|item| item.offset)
44    });
45    if let Some(distance) = shown.flatten() {
46        return distance;
47    }
48    let first = state.first_visible_item_index_non_reactive();
49    let scrolled = state.first_visible_item_scroll_offset_non_reactive();
50    (index as f32 - first as f32) * state.average_item_size() - scrolled
51}
52
53/// Springs `state` through `distance`, then puts it at `land` when the
54/// animation ends on its own.
55fn animate(state: LazyListState, distance: f32, land: Option<(usize, f32)>) {
56    let Some(runtime) = cranpose_core::current_runtime_handle() else {
57        match land {
58            Some((index, offset)) => state.scroll_to_item(index, offset),
59            None => {
60                state.dispatch_scroll_delta(-distance);
61            }
62        }
63        return;
64    };
65    let settle = SettleAnimation::new(runtime, SpringParams::SETTLE_POLICY);
66    let stop = settle.clone();
67    let id = state.start_scroll_animation(Rc::new(move || stop.cancel()));
68    settle.start_settle(
69        0.0,
70        0.0,
71        distance,
72        move |delta| -state.dispatch_scroll_delta(-delta),
73        move |_| {
74            state.end_scroll_animation(id);
75            if let Some((index, offset)) = land {
76                state.scroll_to_item(index, offset);
77            }
78        },
79    );
80}