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        let item = info
41            .visible_items_info
42            .iter()
43            .find(|item| item.index == index)?;
44        let leading = if index == 0 {
45            info.before_content_padding
46        } else {
47            0.0
48        };
49        Some(item.offset - leading)
50    });
51    if let Some(distance) = shown.flatten() {
52        return distance;
53    }
54    let first = state.first_visible_item_index_non_reactive();
55    let scrolled = state.first_visible_item_scroll_offset_non_reactive();
56    (index as f32 - first as f32) * state.average_item_size() - scrolled
57}
58
59/// Springs `state` through `distance`, then puts it at `land` when the
60/// animation ends on its own.
61fn animate(state: LazyListState, distance: f32, land: Option<(usize, f32)>) {
62    let Some(runtime) = cranpose_core::current_runtime_handle() else {
63        match land {
64            Some((index, offset)) => state.scroll_to_item(index, offset),
65            None => {
66                state.dispatch_scroll_delta(-distance);
67            }
68        }
69        return;
70    };
71    let settle = SettleAnimation::new(runtime, SpringParams::SETTLE_POLICY);
72    let stop = settle.clone();
73    let id = state.start_scroll_animation(Rc::new(move || stop.cancel()));
74    settle.start_settle(
75        0.0,
76        0.0,
77        distance,
78        move |delta| -state.dispatch_scroll_delta(-delta),
79        move |_| {
80            state.end_scroll_animation(id);
81            if let Some((index, offset)) = land {
82                state.scroll_to_item(index, offset);
83            }
84        },
85    );
86}