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,
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    pub page_size: PageSize,
42    /// Logical pixels between two pages.
43    pub page_spacing: f32,
44}
45
46impl Default for PagerSpec {
47    fn default() -> Self {
48        Self {
49            page_size: PageSize::Fill,
50            page_spacing: 0.0,
51        }
52    }
53}
54
55/// Where a pager is — Compose's `PagerState`. It is `Copy`, so it moves into
56/// any number of closures.
57#[derive(Clone, Copy, PartialEq)]
58pub struct PagerState {
59    list: LazyListState,
60}
61
62/// A [`PagerState`] that starts on `initial_page`.
63#[composable]
64pub fn rememberPagerState(initial_page: usize) -> PagerState {
65    PagerState {
66        list: rememberLazyListStateWithPosition(initial_page, 0.0),
67    }
68}
69
70impl PagerState {
71    /// The page that fills most of the pager. A composable that reads it
72    /// recomposes while the pager scrolls.
73    pub fn current_page(&self) -> usize {
74        let first = self.list.first_visible_item_index();
75        let offset = self.list.first_visible_item_scroll_offset();
76        if offset > self.page_extent() / 2.0 {
77            first + 1
78        } else {
79            first
80        }
81    }
82
83    /// How far the pager stands from [`Self::current_page`], as a fraction of
84    /// a page from -0.5 to 0.5 — Compose's `currentPageOffsetFraction`.
85    pub fn current_page_offset_fraction(&self) -> f32 {
86        let page = self.page_extent();
87        if page <= 0.0 {
88            return 0.0;
89        }
90        let fraction = self.list.first_visible_item_scroll_offset() / page;
91        if fraction > 0.5 {
92            fraction - 1.0
93        } else {
94            fraction
95        }
96    }
97
98    /// Shows `page` at once.
99    pub fn scroll_to_page(&self, page: usize) {
100        self.list.scroll_to_item(page, 0.0);
101    }
102
103    /// Scrolls to `page` with a spring — Compose's `animateScrollToPage`. A
104    /// press on the pager stops it, and letting go settles on a page.
105    pub fn animate_scroll_to_page(&self, page: usize) {
106        self.list.animate_scroll_to_item(page, 0.0);
107    }
108
109    /// The state of the lazy row or column the pages sit in, for a scroll
110    /// indicator.
111    pub fn lazy_list_state(&self) -> LazyListState {
112        self.list
113    }
114
115    /// How far the start of a page lies from the start of the next one.
116    fn page_extent(&self) -> f32 {
117        self.list
118            .with_layout_info(|info| {
119                info.visible_items_info
120                    .first()
121                    .map_or(info.viewport_size, |page| {
122                        page.size + info.main_axis_item_spacing
123                    })
124            })
125            .unwrap_or_default()
126    }
127}
128
129/// Where a scroll the user lets go of comes to rest: the start of the first
130/// page it shows or of the page after, the way the release goes when it is a
131/// fling, else the one closer to where it would stop.
132fn page_settle(state: PagerState) -> LazySettlePolicy {
133    Rc::new(move |proposed, velocity| {
134        let page = state.page_extent();
135        if page <= 0.0 {
136            return proposed;
137        }
138        let forward = if velocity.abs() >= PAGE_FLING_VELOCITY {
139            velocity > 0.0
140        } else {
141            proposed > page / 2.0
142        };
143        if forward { page } else { 0.0 }
144    })
145}
146
147/// Pages side by side, `page_count` of them, each as high as the pager
148/// and as wide as `spec` says — Compose's `HorizontalPager`. The pager
149/// needs a bounded size.
150#[composable(no_skip)]
151pub fn HorizontalPager<F>(
152    modifier: Modifier,
153    state: PagerState,
154    spec: PagerSpec,
155    page_count: usize,
156    page_content: F,
157) where
158    F: Fn(usize) + 'static,
159{
160    Pager(
161        modifier,
162        state,
163        spec,
164        page_count,
165        true,
166        Rc::new(page_content),
167    );
168}
169
170/// Pages stacked from top to bottom — Compose's `VerticalPager`.
171#[composable(no_skip)]
172pub fn VerticalPager<F>(
173    modifier: Modifier,
174    state: PagerState,
175    spec: PagerSpec,
176    page_count: usize,
177    page_content: F,
178) where
179    F: Fn(usize) + 'static,
180{
181    Pager(
182        modifier,
183        state,
184        spec,
185        page_count,
186        false,
187        Rc::new(page_content),
188    );
189}
190
191#[composable(no_skip)]
192fn Pager(
193    modifier: Modifier,
194    state: PagerState,
195    spec: PagerSpec,
196    page_count: usize,
197    horizontal: bool,
198    page_content: Rc<dyn Fn(usize)>,
199) {
200    cranpose_core::DisposableEffect(state, move |scope| {
201        state.list.set_settle_policy(Some(page_settle(state)));
202        scope.on_dispose(move || state.list.set_settle_policy(None))
203    });
204    BoxWithConstraints(modifier, move |scope| {
205        let constraints = scope.constraints();
206        let (width, height) = match (spec.page_size, horizontal) {
207            (PageSize::Fill, _) => (constraints.max_width, constraints.max_height),
208            (PageSize::Fixed(length), true) => (length, constraints.max_height),
209            (PageSize::Fixed(length), false) => (constraints.max_width, length),
210        };
211        let content = Rc::clone(&page_content);
212        let pages = move |pages: &mut LazyListIntervalContent| {
213            pages.items(
214                LazyItems::new(page_count).key(|page| page as u64),
215                move |page| {
216                    let content = Rc::clone(&content);
217                    Box(
218                        Modifier::empty().size_points(width, height),
219                        BoxSpec::default(),
220                        move || content(page),
221                    );
222                },
223            );
224        };
225        let fill = Modifier::empty().fill_max_size();
226        let spacing = LinearArrangement::SpacedBy(spec.page_spacing);
227        if horizontal {
228            let row = LazyRowSpec {
229                horizontal_arrangement: spacing,
230                ..LazyRowSpec::default()
231            };
232            LazyRow(fill, state.list, row, pages);
233        } else {
234            let column = LazyColumnSpec {
235                vertical_arrangement: spacing,
236                ..LazyColumnSpec::default()
237            };
238            LazyColumn(fill, state.list, column, pages);
239        }
240    });
241}