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::{Axis, 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, 0.0);
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.animate_scroll_to_item(page, 0.0);
217    }
218
219    /// The state of the lazy row or column the pages sit in, for a scroll
220    /// indicator.
221    pub fn lazy_list_state(&self) -> LazyListState {
222        self.list
223    }
224
225    /// The page nearest to where it rests when page `first` is the first in
226    /// view, scrolled by `offset`.
227    fn page_at(&self, first: usize, offset: f32) -> usize {
228        self.pages_at(first, offset).round().max(0.0) as usize
229    }
230
231    /// Where the pager stands in pages: page `p` rests at `p`, and 2.25 is
232    /// a quarter of the way from page 2 to page 3.
233    fn pages_at(&self, first: usize, offset: f32) -> f32 {
234        self.geometry()
235            .map_or(first as f32, |geometry| geometry.pages_at(first, offset))
236    }
237
238    /// The pages' geometry from the pager's last layout.
239    fn geometry(&self) -> Option<PageGeometry> {
240        self.list
241            .with_layout_info(|info| {
242                let page = info.visible_items_info.first()?;
243                let extent = page.size + info.main_axis_item_spacing;
244                (extent > 0.0).then_some(PageGeometry { extent })
245            })
246            .flatten()
247    }
248}
249
250/// Where a pager's pages lie along its axis.
251#[derive(Clone, Copy)]
252struct PageGeometry {
253    /// How far the start of a page lies from the start of the next one.
254    extent: f32,
255}
256
257impl PageGeometry {
258    /// How far the list has scrolled when page `first` is the first in view
259    /// at `offset`. Page `p` rests at `p` extents.
260    fn scrolled(self, first: usize, offset: f32) -> f32 {
261        first as f32 * self.extent + offset
262    }
263
264    fn pages_at(self, first: usize, offset: f32) -> f32 {
265        self.scrolled(first, offset) / self.extent
266    }
267}
268
269/// Where a scroll the user lets go of comes to rest: a fling turns toward
270/// its direction as many pages as it would carry the pager, from one to
271/// `fling.max_pages`; a slow release goes to the page it would stop in, or
272/// the next one past `fling.positional_threshold` of it.
273fn page_settle(state: PagerState, fling: PagerFling) -> LazySettlePolicy {
274    Rc::new(move |proposed, velocity| {
275        let Some(geometry) = state.geometry() else {
276            return proposed;
277        };
278        // In pages: where the pager stands and where the release would stop.
279        let first = state.list.first_visible_item_index_non_reactive();
280        let now = geometry.pages_at(
281            first,
282            state.list.first_visible_item_scroll_offset_non_reactive(),
283        );
284        let rest = geometry.pages_at(first, proposed);
285        let target = if velocity.abs() >= PAGE_FLING_VELOCITY {
286            let most = fling.max_pages.max(1) as f32;
287            if velocity > 0.0 {
288                rest.ceil().clamp(now.floor() + 1.0, now.floor() + most)
289            } else {
290                rest.floor().clamp(now.ceil() - most, now.ceil() - 1.0)
291            }
292        } else {
293            let whole = rest.floor();
294            if rest - whole > fling.positional_threshold {
295                whole + 1.0
296            } else {
297                whole
298            }
299        };
300        let last = state
301            .list
302            .with_layout_info(|info| info.total_items_count.saturating_sub(1))
303            .unwrap_or_default() as f32;
304        let target = target.clamp(0.0, last);
305        state.target.set(Some(target as usize));
306        target * geometry.extent - geometry.scrolled(first, 0.0)
307    })
308}
309
310/// Pages side by side, `page_count` of them, each as high as the pager
311/// and as wide as `spec` says — Compose's `HorizontalPager`. The pager
312/// needs a bounded size.
313#[composable(no_skip)]
314pub fn HorizontalPager<F>(
315    modifier: Modifier,
316    state: PagerState,
317    spec: PagerSpec,
318    page_count: usize,
319    page_content: F,
320) where
321    F: Fn(usize) + 'static,
322{
323    Pager(
324        modifier,
325        state,
326        spec,
327        page_count,
328        Axis::Horizontal,
329        Rc::new(page_content),
330    );
331}
332
333/// Pages stacked from top to bottom — Compose's `VerticalPager`.
334#[composable(no_skip)]
335pub fn VerticalPager<F>(
336    modifier: Modifier,
337    state: PagerState,
338    spec: PagerSpec,
339    page_count: usize,
340    page_content: F,
341) where
342    F: Fn(usize) + 'static,
343{
344    Pager(
345        modifier,
346        state,
347        spec,
348        page_count,
349        Axis::Vertical,
350        Rc::new(page_content),
351    );
352}
353
354#[composable(no_skip)]
355fn Pager(
356    modifier: Modifier,
357    state: PagerState,
358    spec: PagerSpec,
359    page_count: usize,
360    axis: Axis,
361    page_content: Rc<dyn Fn(usize)>,
362) {
363    cranpose_core::DisposableEffect((state, spec.fling), move |scope| {
364        state
365            .list
366            .set_settle_policy(Some(page_settle(state, spec.fling)));
367        scope.on_dispose(move || state.list.set_settle_policy(None))
368    });
369    BoxWithConstraints(modifier, move |scope| {
370        let constraints = scope.constraints();
371        let mut padding = spec.content_padding;
372        let width = constraints.max_width - padding.start - padding.end;
373        let height = constraints.max_height - padding.top - padding.bottom;
374        let (page_width, page_height) = match (spec.page_size, axis) {
375            (PageSize::Fill, _) => (width, height),
376            (PageSize::Fixed(length), Axis::Horizontal) => (length, height),
377            (PageSize::Fixed(length), Axis::Vertical) => (width, length),
378        };
379        // A resting page sits at the scroll offset of its index, as at the
380        // start; padding moves where that is along the axis.
381        let room = if axis.is_horizontal() {
382            width - page_width
383        } else {
384            height - page_height
385        }
386        .max(0.0);
387        let before = room * spec.snap_position.fraction();
388        if axis.is_horizontal() {
389            padding.start += before;
390            padding.end += room - before;
391        } else {
392            padding.top += before;
393            padding.bottom += room - before;
394        }
395        let (width, height) = (page_width, page_height);
396        let content = Rc::clone(&page_content);
397        let pages = move |pages: &mut LazyListIntervalContent| {
398            pages.items(
399                LazyItems::new(page_count).key(|page| page as u64),
400                move |page| {
401                    let content = Rc::clone(&content);
402                    Box(
403                        Modifier::empty().size_points(width, height),
404                        BoxSpec::default(),
405                        move || content(page),
406                    );
407                },
408            );
409        };
410        let fill = Modifier::empty().fill_max_size();
411        let spacing = LinearArrangement::SpacedBy(spec.page_spacing);
412        if axis.is_horizontal() {
413            let row = LazyRowSpec {
414                horizontal_arrangement: spacing,
415                content_padding: padding,
416                beyond_bounds_item_count: spec.beyond_viewport_page_count,
417                ..LazyRowSpec::default()
418            };
419            LazyRow(fill, state.list, row, pages);
420        } else {
421            let column = LazyColumnSpec {
422                vertical_arrangement: spacing,
423                content_padding: padding,
424                beyond_bounds_item_count: spec.beyond_viewport_page_count,
425                ..LazyColumnSpec::default()
426            };
427            LazyColumn(fill, state.list, column, pages);
428        }
429    });
430}