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, as far as [`PagerFling`] lets it.
5
6use std::rc::Rc;
7
8use cranpose_core::{MutableState, mutableStateOf, remember};
9use cranpose_foundation::lazy::{
10    LazyItems, LazyListIntervalContent, LazyListScope, LazyListState, LazySettlePolicy,
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/// Where in the pager a page comes to rest — Compose's `SnapPosition`. It
38/// matters for a page shorter than the space between the content paddings.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum SnapPosition {
41    /// At the start, after the start padding.
42    #[default]
43    Start,
44    /// In the middle of the space between the paddings, with room for the
45    /// neighbouring pages on both sides.
46    Center,
47    /// At the end, before the end padding.
48    End,
49}
50
51impl SnapPosition {
52    /// How much of the room beside a resting page lies before it.
53    fn fraction(self) -> f32 {
54        match self {
55            Self::Start => 0.0,
56            Self::Center => 0.5,
57            Self::End => 1.0,
58        }
59    }
60}
61
62/// How a pager comes to rest after the user lets go — the options of
63/// Compose's `PagerDefaults.flingBehavior`.
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct PagerFling {
66    /// The most pages one fling turns, at least one — Compose's
67    /// `PagerSnapDistance.atMost(pages)`. A fling turns as many pages as its
68    /// speed carries the pager, up to this many.
69    pub max_pages: usize,
70    /// How far into the next page a slow release must have carried the
71    /// pager to turn to it, as a fraction of a page — Compose's
72    /// `snapPositionalThreshold`.
73    pub positional_threshold: f32,
74}
75
76impl Default for PagerFling {
77    fn default() -> Self {
78        Self {
79            max_pages: 1,
80            positional_threshold: 0.5,
81        }
82    }
83}
84
85/// Options for a pager, Compose's pager arguments beside its state and its
86/// page count.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct PagerSpec {
89    /// How long each page is along the pager's axis.
90    pub page_size: PageSize,
91    /// Logical pixels between two pages.
92    pub page_spacing: f32,
93    /// Compose's `contentPadding`. A page comes to rest after the start
94    /// padding, a `PageSize::Fill` page is as long as the pager minus the
95    /// padding at both ends, and the neighbouring pages show in the padding.
96    pub content_padding: PaddingValues,
97    /// Pages composed and laid out beyond the ones in view on each side:
98    /// Compose's `beyondViewportPageCount`.
99    pub beyond_viewport_page_count: usize,
100    /// How a release comes to rest: Compose's `flingBehavior`.
101    pub fling: PagerFling,
102    /// Where a page comes to rest, the first and the last page too:
103    /// Compose's `snapPosition`.
104    pub snap_position: SnapPosition,
105}
106
107impl Default for PagerSpec {
108    fn default() -> Self {
109        Self {
110            page_size: PageSize::Fill,
111            page_spacing: 0.0,
112            content_padding: PaddingValues::default(),
113            beyond_viewport_page_count: 2,
114            fling: PagerFling::default(),
115            snap_position: SnapPosition::Start,
116        }
117    }
118}
119
120/// Where a pager is — Compose's `PagerState`. It is `Copy`, so it moves into
121/// any number of closures.
122#[derive(Clone, Copy, PartialEq)]
123pub struct PagerState {
124    list: LazyListState,
125    /// The page the running animation or settle goes to.
126    target: MutableState<Option<usize>>,
127}
128
129/// A [`PagerState`] that starts on `initial_page`.
130#[composable]
131pub fn rememberPagerState(initial_page: usize) -> PagerState {
132    remember(|| PagerState::new(initial_page)).with(|state| *state)
133}
134
135impl PagerState {
136    /// A pager's state on `initial_page`, for code that remembers the state
137    /// itself. Call it inside a `remember`, as [`LazyListState::new`].
138    pub fn new(initial_page: usize) -> Self {
139        Self {
140            list: LazyListState::new(initial_page, 0.0),
141            target: mutableStateOf(None),
142        }
143    }
144
145    /// The first page in view, or `None` once the slot that held this state
146    /// was released.
147    pub fn try_first_page(&self) -> Option<usize> {
148        self.list.try_position().map(|(page, _)| page)
149    }
150
151    /// The page that fills most of the pager. A composable that reads it
152    /// recomposes while the pager scrolls.
153    pub fn current_page(&self) -> usize {
154        self.page_at(
155            self.list.first_visible_item_index(),
156            self.list.first_visible_item_scroll_offset(),
157        )
158    }
159
160    /// The page the pager rests on: Compose's `settledPage`. While a drag,
161    /// a fling or an animation moves the pager, it stays the page that
162    /// scroll began on, so a composable that reads it recomposes when a
163    /// scroll starts and when it ends, not on the frames between.
164    pub fn settled_page(&self) -> usize {
165        let (first, offset) = match self.list.position_before_scroll() {
166            Some(start) if self.list.is_scroll_in_progress() => start,
167            // At rest the offset changes only with the page, so the page
168            // alone subscribes.
169            _ => (
170                self.list.first_visible_item_index(),
171                self.list.first_visible_item_scroll_offset_non_reactive(),
172            ),
173        };
174        self.page_at(first, offset)
175    }
176
177    /// Whether a drag, a fling or an animation moves the pager now:
178    /// Compose's `isScrollInProgress`.
179    pub fn is_scroll_in_progress(&self) -> bool {
180        self.list.is_scroll_in_progress()
181    }
182
183    /// The page the pager is on its way to: Compose's `targetPage`. While an
184    /// animation or the settle after a release moves the pager, it is the
185    /// page that motion ends on; during a drag and at rest it is
186    /// [`Self::current_page`].
187    pub fn target_page(&self) -> usize {
188        if self.list.is_scroll_in_progress()
189            && !self.list.is_dragged()
190            && let Some(page) = self.target.get()
191        {
192            return page;
193        }
194        self.current_page()
195    }
196
197    /// How far the pager stands from [`Self::current_page`], as a fraction of
198    /// a page from -0.5 to 0.5 — Compose's `currentPageOffsetFraction`.
199    pub fn current_page_offset_fraction(&self) -> f32 {
200        let pages = self.pages_at(
201            self.list.first_visible_item_index(),
202            self.list.first_visible_item_scroll_offset(),
203        );
204        pages - pages.round()
205    }
206
207    /// Shows `page` at once.
208    pub fn scroll_to_page(&self, page: usize) {
209        self.list.scroll_to_item(page, self.rest_offset(page));
210    }
211
212    /// Scrolls to `page` with a spring — Compose's `animateScrollToPage`. A
213    /// press on the pager stops it, and letting go settles on a page.
214    pub fn animate_scroll_to_page(&self, page: usize) {
215        self.target.set(Some(page));
216        self.list
217            .animate_scroll_to_item(page, self.rest_offset(page));
218    }
219
220    /// The scroll offset within `page` at which it rests: a page after the
221    /// first rests after the start padding, which the list counts as the
222    /// first page's.
223    fn rest_offset(&self, page: usize) -> f32 {
224        match self.geometry() {
225            Some(geometry) if page > 0 => -geometry.before,
226            _ => 0.0,
227        }
228    }
229
230    /// Places the pager's first page, which it was created on, after the
231    /// `before` padding of its first layout, as a later page rests.
232    fn place_initial_page(&self, before: f32) {
233        let untouched = self
234            .list
235            .with_layout_info(|info| info.total_items_count == 0)
236            .unwrap_or(false);
237        let Some((page, offset)) = self.list.try_position() else {
238            return;
239        };
240        if untouched && page > 0 && offset == 0.0 && before > 0.0 {
241            self.list.scroll_to_item(page, -before);
242        }
243    }
244
245    /// The state of the lazy row or column the pages sit in, for a scroll
246    /// indicator.
247    pub fn lazy_list_state(&self) -> LazyListState {
248        self.list
249    }
250
251    /// The page nearest to where it rests when page `first` is the first in
252    /// view, scrolled by `offset`.
253    fn page_at(&self, first: usize, offset: f32) -> usize {
254        self.pages_at(first, offset).round().max(0.0) as usize
255    }
256
257    /// Where the pager stands in pages: page `p` rests at `p`, and 2.25 is
258    /// a quarter of the way from page 2 to page 3.
259    fn pages_at(&self, first: usize, offset: f32) -> f32 {
260        self.geometry()
261            .map_or(first as f32, |geometry| geometry.pages_at(first, offset))
262    }
263
264    /// The pages' geometry from the pager's last layout.
265    fn geometry(&self) -> Option<PageGeometry> {
266        self.list
267            .with_layout_info(|info| {
268                let page = info.visible_items_info.first()?;
269                let extent = page.size + info.main_axis_item_spacing;
270                (extent > 0.0).then_some(PageGeometry {
271                    extent,
272                    before: info.before_content_padding,
273                })
274            })
275            .flatten()
276    }
277}
278
279/// Where a pager's pages lie along its axis.
280#[derive(Clone, Copy)]
281struct PageGeometry {
282    /// How far the start of a page lies from the start of the next one.
283    extent: f32,
284    /// The padding before the first page, which the list counts as part of
285    /// the first page.
286    before: f32,
287}
288
289impl PageGeometry {
290    /// How far the list has scrolled when page `first` is the first in view
291    /// at `offset`. Page `p` rests at `p` extents.
292    fn scrolled(self, first: usize, offset: f32) -> f32 {
293        let start = if first == 0 {
294            0.0
295        } else {
296            self.before + first as f32 * self.extent
297        };
298        start + offset
299    }
300
301    fn pages_at(self, first: usize, offset: f32) -> f32 {
302        self.scrolled(first, offset) / self.extent
303    }
304}
305
306/// Where a scroll the user lets go of comes to rest: a fling turns toward
307/// its direction as many pages as it would carry the pager, from one to
308/// `fling.max_pages`; a slow release goes to the page it would stop in, or
309/// the next one past `fling.positional_threshold` of it.
310fn page_settle(state: PagerState, fling: PagerFling) -> LazySettlePolicy {
311    Rc::new(move |proposed, velocity| {
312        let Some(geometry) = state.geometry() else {
313            return proposed;
314        };
315        // In pages: where the pager stands and where the release would stop.
316        let first = state.list.first_visible_item_index_non_reactive();
317        let now = geometry.pages_at(
318            first,
319            state.list.first_visible_item_scroll_offset_non_reactive(),
320        );
321        let rest = geometry.pages_at(first, proposed);
322        let target = if velocity.abs() >= PAGE_FLING_VELOCITY {
323            let most = fling.max_pages.max(1) as f32;
324            if velocity > 0.0 {
325                rest.ceil().clamp(now.floor() + 1.0, now.floor() + most)
326            } else {
327                rest.floor().clamp(now.ceil() - most, now.ceil() - 1.0)
328            }
329        } else {
330            let whole = rest.floor();
331            if rest - whole > fling.positional_threshold {
332                whole + 1.0
333            } else {
334                whole
335            }
336        };
337        let last = state
338            .list
339            .with_layout_info(|info| info.total_items_count.saturating_sub(1))
340            .unwrap_or_default() as f32;
341        let target = target.clamp(0.0, last);
342        state.target.set(Some(target as usize));
343        target * geometry.extent - geometry.scrolled(first, 0.0)
344    })
345}
346
347/// Pages side by side, `page_count` of them, each as high as the pager
348/// and as wide as `spec` says — Compose's `HorizontalPager`. The pager
349/// needs a bounded size.
350#[composable(no_skip)]
351pub fn HorizontalPager<F>(
352    modifier: Modifier,
353    state: PagerState,
354    spec: PagerSpec,
355    page_count: usize,
356    page_content: F,
357) where
358    F: Fn(usize) + 'static,
359{
360    Pager(
361        modifier,
362        state,
363        spec,
364        page_count,
365        true,
366        Rc::new(page_content),
367    );
368}
369
370/// Pages stacked from top to bottom — Compose's `VerticalPager`.
371#[composable(no_skip)]
372pub fn VerticalPager<F>(
373    modifier: Modifier,
374    state: PagerState,
375    spec: PagerSpec,
376    page_count: usize,
377    page_content: F,
378) where
379    F: Fn(usize) + 'static,
380{
381    Pager(
382        modifier,
383        state,
384        spec,
385        page_count,
386        false,
387        Rc::new(page_content),
388    );
389}
390
391#[composable(no_skip)]
392fn Pager(
393    modifier: Modifier,
394    state: PagerState,
395    spec: PagerSpec,
396    page_count: usize,
397    horizontal: bool,
398    page_content: Rc<dyn Fn(usize)>,
399) {
400    cranpose_core::DisposableEffect((state, spec.fling), move |scope| {
401        state
402            .list
403            .set_settle_policy(Some(page_settle(state, spec.fling)));
404        scope.on_dispose(move || state.list.set_settle_policy(None))
405    });
406    BoxWithConstraints(modifier, move |scope| {
407        let constraints = scope.constraints();
408        let mut padding = spec.content_padding;
409        let width = constraints.max_width - padding.start - padding.end;
410        let height = constraints.max_height - padding.top - padding.bottom;
411        let (page_width, page_height) = match (spec.page_size, horizontal) {
412            (PageSize::Fill, _) => (width, height),
413            (PageSize::Fixed(length), true) => (length, height),
414            (PageSize::Fixed(length), false) => (width, length),
415        };
416        // A resting page sits at the scroll offset of its index, as at the
417        // start; padding moves where that is along the axis.
418        let room = if horizontal {
419            width - page_width
420        } else {
421            height - page_height
422        }
423        .max(0.0);
424        let before = room * spec.snap_position.fraction();
425        if horizontal {
426            padding.start += before;
427            padding.end += room - before;
428        } else {
429            padding.top += before;
430            padding.bottom += room - before;
431        }
432        let leading = if horizontal {
433            padding.start
434        } else {
435            padding.top
436        };
437        remember(|| state.place_initial_page(leading));
438        let (width, height) = (page_width, page_height);
439        let content = Rc::clone(&page_content);
440        let pages = move |pages: &mut LazyListIntervalContent| {
441            pages.items(
442                LazyItems::new(page_count).key(|page| page as u64),
443                move |page| {
444                    let content = Rc::clone(&content);
445                    Box(
446                        Modifier::empty().size_points(width, height),
447                        BoxSpec::default(),
448                        move || content(page),
449                    );
450                },
451            );
452        };
453        let fill = Modifier::empty().fill_max_size();
454        let spacing = LinearArrangement::SpacedBy(spec.page_spacing);
455        if horizontal {
456            let row = LazyRowSpec {
457                horizontal_arrangement: spacing,
458                content_padding: padding,
459                beyond_bounds_item_count: spec.beyond_viewport_page_count,
460                ..LazyRowSpec::default()
461            };
462            LazyRow(fill, state.list, row, pages);
463        } else {
464            let column = LazyColumnSpec {
465                vertical_arrangement: spacing,
466                content_padding: padding,
467                beyond_bounds_item_count: spec.beyond_viewport_page_count,
468                ..LazyColumnSpec::default()
469            };
470            LazyColumn(fill, state.list, column, pages);
471        }
472    });
473}