Skip to main content

repose_ui/
pager.rs

1use repose_core::*;
2use std::cell::RefCell;
3use std::rc::Rc;
4
5use crate::anim_ext::{AnimatedContent, AnimatedContentConfig, EnterTransition, ExitTransition};
6
7/// Configuration for [`HorizontalPager`] and [`VerticalPager`].
8#[derive(Clone)]
9pub struct PagerConfig {
10    pub modifier: Modifier,
11    pub page_spacing: Dp,
12    pub user_scroll_enabled: bool,
13    pub content_padding: PaddingValues,
14}
15
16impl Default for PagerConfig {
17    fn default() -> Self {
18        Self {
19            modifier: Modifier::new(),
20            page_spacing: Dp::ZERO,
21            user_scroll_enabled: true,
22            content_padding: PaddingValues::default(),
23        }
24    }
25}
26
27/// Shared swipe-gesture state for pagers: start point + per-gesture handling
28/// with axis lock, density-scaled threshold, and cancel safety.
29struct PagerDrag {
30    start: Option<(f32, f32)>,
31}
32
33fn pager_threshold_px() -> f32 {
34    Dp(24.0).to_px().0.max(8.0)
35}
36
37/// Build down/up/cancel handlers for a horizontal pager.
38fn horizontal_handlers(
39    key: &str,
40    state: &Rc<PagerState>,
41) -> (
42    impl Fn(PointerEvent) + Clone + 'static,
43    impl Fn(PointerEvent) + Clone + 'static,
44    impl Fn(PointerEvent) + Clone + 'static,
45) {
46    let drag = Rc::new(remember_with_key(format!("pager_drag:{key}"), || {
47        RefCell::new(PagerDrag { start: None })
48    }));
49    let st = state.clone();
50    let on_down = {
51        let drag = drag.clone();
52        move |e: PointerEvent| {
53            drag.borrow_mut().start = Some((e.position.x, e.position.y));
54        }
55    };
56    let on_up = {
57        let drag = drag.clone();
58        move |e: PointerEvent| {
59            if let Some((sx, sy)) = drag.borrow().start {
60                let dx = e.position.x - sx;
61                let dy = e.position.y - sy;
62                let threshold = pager_threshold_px();
63                if dx.abs() > threshold && dx.abs() > dy.abs() * 1.5 {
64                    if dx < 0.0 {
65                        st.set_page(st.current_page.get().saturating_add(1));
66                    } else {
67                        st.set_page(st.current_page.get().saturating_sub(1));
68                    }
69                }
70            }
71            drag.borrow_mut().start = None;
72        }
73    };
74    let on_cancel = {
75        let drag = drag.clone();
76        move |_: PointerEvent| {
77            drag.borrow_mut().start = None;
78        }
79    };
80    (on_down, on_up, on_cancel)
81}
82
83fn vertical_handlers(
84    key: &str,
85    state: &Rc<PagerState>,
86) -> (
87    impl Fn(PointerEvent) + Clone + 'static,
88    impl Fn(PointerEvent) + Clone + 'static,
89    impl Fn(PointerEvent) + Clone + 'static,
90) {
91    let drag = Rc::new(remember_with_key(format!("vpager_drag:{key}"), || {
92        RefCell::new(PagerDrag { start: None })
93    }));
94    let st = state.clone();
95    let on_down = {
96        let drag = drag.clone();
97        move |e: PointerEvent| {
98            drag.borrow_mut().start = Some((e.position.x, e.position.y));
99        }
100    };
101    let on_up = {
102        let drag = drag.clone();
103        move |e: PointerEvent| {
104            if let Some((sx, sy)) = drag.borrow().start {
105                let dx = e.position.x - sx;
106                let dy = e.position.y - sy;
107                let threshold = pager_threshold_px();
108                if dy.abs() > threshold && dy.abs() > dx.abs() * 1.5 {
109                    if dy < 0.0 {
110                        st.set_page(st.current_page.get().saturating_add(1));
111                    } else {
112                        st.set_page(st.current_page.get().saturating_sub(1));
113                    }
114                }
115            }
116            drag.borrow_mut().start = None;
117        }
118    };
119    let on_cancel = {
120        let drag = drag.clone();
121        move |_: PointerEvent| {
122            drag.borrow_mut().start = None;
123        }
124    };
125    (on_down, on_up, on_cancel)
126}
127
128/// State for a horizontal pager with page snapping.
129pub struct PagerState {
130    current_page: Signal<usize>,
131    previous_page: Signal<usize>,
132    page_count: Signal<usize>,
133}
134
135impl PagerState {
136    pub fn new(page_count: usize) -> Self {
137        Self {
138            current_page: signal(0),
139            previous_page: signal(0),
140            page_count: signal(page_count.max(1)),
141        }
142    }
143
144    pub fn current_page(&self) -> usize {
145        self.current_page.get()
146    }
147
148    /// Programmatically set the current page (with animation).
149    pub fn set_page(&self, page: usize) {
150        let max_page = self.page_count.get().saturating_sub(1);
151        let prev = self.current_page.get();
152        let next = page.min(max_page);
153        if next == prev {
154            return;
155        }
156        // Batched: observers of both signals recompute once on the final
157        // state instead of seeing torn (prev, next) / (prev, prev) pairs.
158        batch(|| {
159            self.previous_page.set(prev);
160            self.current_page.set(next);
161        });
162    }
163
164    /// Direction of the last page change: +1 forward, -1 backward, 0 none yet.
165    /// Used to sign enter/exit slide offsets so back navigation animates the
166    /// correct way instead of reusing a fixed direction.
167    fn direction(&self) -> i32 {
168        let cur = self.current_page.get() as i64;
169        let prev = self.previous_page.get() as i64;
170        (cur - prev).signum() as i32
171    }
172
173    pub fn page_count(&self) -> usize {
174        self.page_count.get()
175    }
176
177    /// Update the page count (e.g. list shrank/grew). Clamps `current_page`
178    /// so it can never go out of bounds against a stale count.
179    pub fn set_page_count(&self, count: usize) {
180        let count = count.max(1);
181        let cur = self.current_page.get();
182        let max_page = count - 1;
183        batch(|| {
184            self.page_count.set(count);
185            if cur > max_page {
186                self.previous_page.set(cur);
187                self.current_page.set(max_page);
188            }
189        });
190    }
191}
192
193/// A horizontally swipable pager with animated page transitions.
194///
195/// Supports both programmatic page changes via `state.set_page()` and
196/// drag/swipe gestures for page flipping.
197///
198/// Renders only the current page (previous page fades/slides out,
199/// new page fades/slides in).
200///
201/// # Example
202/// ```ignore
203/// let state = Rc::new(PagerState::new(5));
204/// HorizontalPager(
205///     "demo",
206///     state.clone(),
207///     Modifier::new().fill_max_width().height(300.0),
208///     |page| Text(format!("Page {}", page + 1)).size(48.0),
209/// )
210/// ```
211#[allow(non_snake_case)]
212pub fn HorizontalPager(
213    key: impl Into<String>,
214    state: Rc<PagerState>,
215    page_builder: impl Fn(usize) -> View + 'static,
216    config: PagerConfig,
217) -> View {
218    let key = key.into();
219    let page = state.current_page.get();
220    let page_spacing = config.page_spacing;
221    let dir = if state.direction() < 0 { -1.0 } else { 1.0 };
222    let slide_offset = (Dp(800.0) + page_spacing) * dir;
223
224    let (on_down, on_up, on_cancel) = horizontal_handlers(&key, &state);
225
226    let content = AnimatedContent(
227        page,
228        page_builder,
229        AnimatedContentConfig {
230            key: format!("page_content:{key}"),
231            spec: AnimationSpec::spring_gentle(),
232            enter: EnterTransition::Composite(vec![
233                EnterTransition::FadeIn,
234                EnterTransition::SlideIn {
235                    offset_x: slide_offset,
236                    offset_y: Dp::ZERO,
237                },
238            ]),
239            exit: ExitTransition::Composite(vec![
240                ExitTransition::FadeOut,
241                ExitTransition::SlideOut {
242                    offset_x: -(slide_offset),
243                    offset_y: Dp::ZERO,
244                },
245            ]),
246        },
247    );
248
249    let pager_mod = Modifier::new().fill_max_size().then(config.modifier);
250    if config.user_scroll_enabled {
251        crate::Box(
252            pager_mod
253                .on_pointer_down(on_down)
254                .on_pointer_up(on_up)
255                .on_pointer_cancel(on_cancel),
256        )
257        .with_children(vec![
258            crate::Box(Modifier::new().fill_max_size()).with_children(vec![content]),
259        ])
260    } else {
261        crate::Box(pager_mod).with_children(vec![
262            crate::Box(Modifier::new().fill_max_size()).with_children(vec![content]),
263        ])
264    }
265}
266
267/// A vertically swipable pager with animated page transitions.
268///
269/// Mirror of `HorizontalPager` - drag up/down to flip pages.
270#[allow(non_snake_case)]
271pub fn VerticalPager(
272    key: impl Into<String>,
273    state: Rc<PagerState>,
274    page_builder: impl Fn(usize) -> View + 'static,
275    config: PagerConfig,
276) -> View {
277    let key = key.into();
278    let page = state.current_page.get();
279    let page_spacing = config.page_spacing;
280    let dir = if state.direction() < 0 { -1.0 } else { 1.0 };
281    let slide_offset = (Dp(600.0) + page_spacing) * dir;
282
283    let (on_down, on_up, on_cancel) = vertical_handlers(&key, &state);
284
285    let content = AnimatedContent(
286        page,
287        page_builder,
288        AnimatedContentConfig {
289            key: format!("vpage_content:{key}"),
290            spec: AnimationSpec::spring_gentle(),
291            enter: EnterTransition::Composite(vec![
292                EnterTransition::FadeIn,
293                EnterTransition::SlideIn {
294                    offset_x: Dp::ZERO,
295                    offset_y: slide_offset,
296                },
297            ]),
298            exit: ExitTransition::Composite(vec![
299                ExitTransition::FadeOut,
300                ExitTransition::SlideOut {
301                    offset_x: Dp::ZERO,
302                    offset_y: -(slide_offset),
303                },
304            ]),
305        },
306    );
307
308    let pager_mod = Modifier::new().fill_max_size().then(config.modifier);
309    if config.user_scroll_enabled {
310        crate::Box(
311            pager_mod
312                .on_pointer_down(on_down)
313                .on_pointer_up(on_up)
314                .on_pointer_cancel(on_cancel),
315        )
316        .with_children(vec![
317            crate::Box(Modifier::new().fill_max_size()).with_children(vec![content]),
318        ])
319    } else {
320        crate::Box(pager_mod).with_children(vec![
321            crate::Box(Modifier::new().fill_max_size()).with_children(vec![content]),
322        ])
323    }
324}