Skip to main content

cranpose_ui/widgets/
pager.rs

1//! Pagers: Compose's `HorizontalPager` and `VerticalPager`. The pages fill
2//! the pager or take a fixed length, compose lazily as a lazy row or
3//! column's items do, and a drag or a fling the user lets go of comes to
4//! rest at the start of a page.
5
6use std::rc::Rc;
7
8use cranpose_foundation::lazy::{
9    LazyItems, LazyListIntervalContent, LazyListScope, LazyListState, LazySettlePolicy,
10    rememberLazyListStateWithPosition,
11};
12use cranpose_ui_layout::LinearArrangement;
13
14use crate::{
15    AnimateScroll, composable,
16    modifier::Modifier,
17    widgets::{
18        Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, LazyColumn, LazyColumnSpec,
19        LazyRow, LazyRowSpec, PaddingValues,
20    },
21};
22
23/// A release faster than this, in logical pixels per second, turns the page
24/// toward the fling however little the drag moved it.
25const PAGE_FLING_VELOCITY: f32 = 400.0;
26
27/// How long a pager's pages are along its axis — Compose's `PageSize`.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum PageSize {
30    /// Each page is as long as the pager.
31    Fill,
32    /// Each page is this many logical pixels long, so a shorter page shows
33    /// the next one beside it.
34    Fixed(f32),
35}
36
37/// Options for a pager, Compose's pager arguments beside its state and its
38/// page count.
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct PagerSpec {
41    /// How long each page is along the pager's axis.
42    pub page_size: PageSize,
43    /// Logical pixels between two pages.
44    pub page_spacing: f32,
45    /// Compose's `contentPadding`. A page comes to rest after the start
46    /// padding, a `PageSize::Fill` page is as long as the pager minus the
47    /// padding at both ends, and the neighbouring pages show in the padding.
48    pub content_padding: PaddingValues,
49    /// Pages composed and laid out beyond the ones in view on each side:
50    /// Compose's `beyondViewportPageCount`.
51    pub beyond_viewport_page_count: usize,
52}
53
54impl Default for PagerSpec {
55    fn default() -> Self {
56        Self {
57            page_size: PageSize::Fill,
58            page_spacing: 0.0,
59            content_padding: PaddingValues::default(),
60            beyond_viewport_page_count: 2,
61        }
62    }
63}
64
65/// Where a pager is — Compose's `PagerState`. It is `Copy`, so it moves into
66/// any number of closures.
67#[derive(Clone, Copy, PartialEq)]
68pub struct PagerState {
69    list: LazyListState,
70}
71
72/// A [`PagerState`] that starts on `initial_page`.
73#[composable]
74pub fn rememberPagerState(initial_page: usize) -> PagerState {
75    PagerState {
76        list: rememberLazyListStateWithPosition(initial_page, 0.0),
77    }
78}
79
80impl PagerState {
81    /// A pager's state on `initial_page`, for code that remembers the state
82    /// itself. Call it inside a `remember`, as [`LazyListState::new`].
83    pub fn new(initial_page: usize) -> Self {
84        Self {
85            list: LazyListState::new(initial_page, 0.0),
86        }
87    }
88
89    /// The first page in view, or `None` once the slot that held this state
90    /// was released.
91    pub fn try_first_page(&self) -> Option<usize> {
92        self.list.try_position().map(|(page, _)| page)
93    }
94
95    /// The page that fills most of the pager. A composable that reads it
96    /// recomposes while the pager scrolls.
97    pub fn current_page(&self) -> usize {
98        self.page_at(
99            self.list.first_visible_item_index(),
100            self.list.first_visible_item_scroll_offset(),
101        )
102    }
103
104    /// The page the pager rests on: Compose's `settledPage`. While a drag,
105    /// a fling or an animation moves the pager, it stays the page that
106    /// scroll began on, so a composable that reads it recomposes when a
107    /// scroll starts and when it ends, not on the frames between.
108    pub fn settled_page(&self) -> usize {
109        let (first, offset) = match self.list.position_before_scroll() {
110            Some(start) if self.list.is_scroll_in_progress() => start,
111            // At rest the offset changes only with the page, so the page
112            // alone subscribes.
113            _ => (
114                self.list.first_visible_item_index(),
115                self.list.first_visible_item_scroll_offset_non_reactive(),
116            ),
117        };
118        self.page_at(first, offset)
119    }
120
121    /// Whether a drag, a fling or an animation moves the pager now:
122    /// Compose's `isScrollInProgress`.
123    pub fn is_scroll_in_progress(&self) -> bool {
124        self.list.is_scroll_in_progress()
125    }
126
127    /// How far the pager stands from [`Self::current_page`], as a fraction of
128    /// a page from -0.5 to 0.5 — Compose's `currentPageOffsetFraction`.
129    pub fn current_page_offset_fraction(&self) -> f32 {
130        let page = self.page_extent();
131        if page <= 0.0 {
132            return 0.0;
133        }
134        let fraction = self.list.first_visible_item_scroll_offset() / page;
135        if fraction > 0.5 {
136            fraction - 1.0
137        } else {
138            fraction
139        }
140    }
141
142    /// Shows `page` at once.
143    pub fn scroll_to_page(&self, page: usize) {
144        self.list.scroll_to_item(page, 0.0);
145    }
146
147    /// Scrolls to `page` with a spring — Compose's `animateScrollToPage`. A
148    /// press on the pager stops it, and letting go settles on a page.
149    pub fn animate_scroll_to_page(&self, page: usize) {
150        self.list.animate_scroll_to_item(page, 0.0);
151    }
152
153    /// The state of the lazy row or column the pages sit in, for a scroll
154    /// indicator.
155    pub fn lazy_list_state(&self) -> LazyListState {
156        self.list
157    }
158
159    /// The page that fills most of the pager when page `first` is the first
160    /// in view, scrolled by `offset`.
161    fn page_at(&self, first: usize, offset: f32) -> usize {
162        if offset > self.page_extent() / 2.0 {
163            first + 1
164        } else {
165            first
166        }
167    }
168
169    /// How far the start of a page lies from the start of the next one.
170    fn page_extent(&self) -> f32 {
171        self.list
172            .with_layout_info(|info| {
173                info.visible_items_info
174                    .first()
175                    .map_or(info.viewport_size, |page| {
176                        page.size + info.main_axis_item_spacing
177                    })
178            })
179            .unwrap_or_default()
180    }
181}
182
183/// Where a scroll the user lets go of comes to rest: the start of the first
184/// page it shows or of the page after, the way the release goes when it is a
185/// fling, else the one closer to where it would stop.
186fn page_settle(state: PagerState) -> LazySettlePolicy {
187    Rc::new(move |proposed, velocity| {
188        let page = state.page_extent();
189        if page <= 0.0 {
190            return proposed;
191        }
192        let forward = if velocity.abs() >= PAGE_FLING_VELOCITY {
193            velocity > 0.0
194        } else {
195            proposed > page / 2.0
196        };
197        if forward { page } else { 0.0 }
198    })
199}
200
201/// Pages side by side, `page_count` of them, each as high as the pager
202/// and as wide as `spec` says — Compose's `HorizontalPager`. The pager
203/// needs a bounded size.
204#[composable(no_skip)]
205pub fn HorizontalPager<F>(
206    modifier: Modifier,
207    state: PagerState,
208    spec: PagerSpec,
209    page_count: usize,
210    page_content: F,
211) where
212    F: Fn(usize) + 'static,
213{
214    Pager(
215        modifier,
216        state,
217        spec,
218        page_count,
219        true,
220        Rc::new(page_content),
221    );
222}
223
224/// Pages stacked from top to bottom — Compose's `VerticalPager`.
225#[composable(no_skip)]
226pub fn VerticalPager<F>(
227    modifier: Modifier,
228    state: PagerState,
229    spec: PagerSpec,
230    page_count: usize,
231    page_content: F,
232) where
233    F: Fn(usize) + 'static,
234{
235    Pager(
236        modifier,
237        state,
238        spec,
239        page_count,
240        false,
241        Rc::new(page_content),
242    );
243}
244
245#[composable(no_skip)]
246fn Pager(
247    modifier: Modifier,
248    state: PagerState,
249    spec: PagerSpec,
250    page_count: usize,
251    horizontal: bool,
252    page_content: Rc<dyn Fn(usize)>,
253) {
254    cranpose_core::DisposableEffect(state, move |scope| {
255        state.list.set_settle_policy(Some(page_settle(state)));
256        scope.on_dispose(move || state.list.set_settle_policy(None))
257    });
258    BoxWithConstraints(modifier, move |scope| {
259        let constraints = scope.constraints();
260        let padding = spec.content_padding;
261        let width = constraints.max_width - padding.start - padding.end;
262        let height = constraints.max_height - padding.top - padding.bottom;
263        let (width, height) = match (spec.page_size, horizontal) {
264            (PageSize::Fill, _) => (width, height),
265            (PageSize::Fixed(length), true) => (length, height),
266            (PageSize::Fixed(length), false) => (width, length),
267        };
268        let content = Rc::clone(&page_content);
269        let pages = move |pages: &mut LazyListIntervalContent| {
270            pages.items(
271                LazyItems::new(page_count).key(|page| page as u64),
272                move |page| {
273                    let content = Rc::clone(&content);
274                    Box(
275                        Modifier::empty().size_points(width, height),
276                        BoxSpec::default(),
277                        move || content(page),
278                    );
279                },
280            );
281        };
282        let fill = Modifier::empty().fill_max_size();
283        let spacing = LinearArrangement::SpacedBy(spec.page_spacing);
284        if horizontal {
285            let row = LazyRowSpec {
286                horizontal_arrangement: spacing,
287                content_padding: padding,
288                beyond_bounds_item_count: spec.beyond_viewport_page_count,
289                ..LazyRowSpec::default()
290            };
291            LazyRow(fill, state.list, row, pages);
292        } else {
293            let column = LazyColumnSpec {
294                vertical_arrangement: spacing,
295                content_padding: padding,
296                beyond_bounds_item_count: spec.beyond_viewport_page_count,
297                ..LazyColumnSpec::default()
298            };
299            LazyColumn(fill, state.list, column, pages);
300        }
301    });
302}