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: f32,
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: 0.0,
21            user_scroll_enabled: true,
22            content_padding: PaddingValues::default(),
23        }
24    }
25}
26
27/// State for a horizontal pager with page snapping.
28pub struct PagerState {
29    current_page: Signal<usize>,
30    page_count: Signal<usize>,
31}
32
33impl PagerState {
34    pub fn new(page_count: usize) -> Self {
35        Self {
36            current_page: signal(0),
37            page_count: signal(page_count.max(1)),
38        }
39    }
40
41    pub fn current_page(&self) -> usize {
42        self.current_page.get()
43    }
44
45    /// Programmatically set the current page (with animation).
46    pub fn set_page(&self, page: usize) {
47        let max_page = self.page_count.get().saturating_sub(1);
48        self.current_page.set(page.min(max_page));
49    }
50
51    pub fn page_count(&self) -> usize {
52        self.page_count.get()
53    }
54}
55
56/// A horizontally swipable pager with animated page transitions.
57///
58/// Supports both programmatic page changes via `state.set_page()` and
59/// drag/swipe gestures for page flipping.
60///
61/// Renders only the current page (previous page fades/slides out,
62/// new page fades/slides in).
63///
64/// # Example
65/// ```ignore
66/// let state = Rc::new(PagerState::new(5));
67/// HorizontalPager(
68///     "demo",
69///     state.clone(),
70///     Modifier::new().fill_max_width().height(300.0),
71///     |page| Text(format!("Page {}", page + 1)).size(48.0),
72/// )
73/// ```
74#[allow(non_snake_case)]
75pub fn HorizontalPager(
76    key: impl Into<String>,
77    state: Rc<PagerState>,
78    page_builder: impl Fn(usize) -> View + 'static,
79    config: PagerConfig,
80) -> View {
81    let key = key.into();
82    let page = state.current_page.get();
83    let page_spacing = config.page_spacing;
84    let slide_offset = 800.0 + page_spacing;
85
86    // Drag-to-swipe gesture handling
87    let drag_start_x = Rc::new(remember_with_key(format!("pager_drag:{key}"), || {
88        RefCell::new(None::<f32>)
89    }));
90
91    let on_down = {
92        let d = drag_start_x.clone();
93        move |e: PointerEvent| {
94            *d.borrow_mut() = Some(e.position.x);
95        }
96    };
97
98    let st = state.clone();
99    let on_up = {
100        let d = drag_start_x.clone();
101        move |e: PointerEvent| {
102            if let Some(start_x) = *d.borrow() {
103                let delta = e.position.x - start_x;
104                let threshold = 50.0;
105                if delta.abs() > threshold {
106                    if delta < 0.0 {
107                        let next =
108                            (st.current_page.get() + 1).min(st.page_count.get().saturating_sub(1));
109                        st.current_page.set(next);
110                    } else {
111                        let prev = st.current_page.get().saturating_sub(1);
112                        st.current_page.set(prev);
113                    }
114                }
115            }
116            *d.borrow_mut() = None;
117        }
118    };
119
120    let content = AnimatedContent(
121        page,
122        page_builder,
123        AnimatedContentConfig {
124            key: format!("page_content:{key}"),
125            spec: AnimationSpec::spring_gentle(),
126            enter: EnterTransition::Composite(vec![
127                EnterTransition::FadeIn,
128                EnterTransition::SlideIn {
129                    offset_x: slide_offset,
130                    offset_y: 0.0,
131                },
132            ]),
133            exit: ExitTransition::Composite(vec![
134                ExitTransition::FadeOut,
135                ExitTransition::SlideOut {
136                    offset_x: -(slide_offset),
137                    offset_y: 0.0,
138                },
139            ]),
140        },
141    );
142
143    let gesture = if config.user_scroll_enabled {
144        crate::Box(
145            Modifier::new()
146                .fill_max_size()
147                .hit_passthrough()
148                .on_pointer_down(on_down)
149                .on_pointer_up(on_up),
150        )
151    } else {
152        crate::Box(Modifier::new().fill_max_size().hit_passthrough())
153    };
154
155    crate::ZStack(Modifier::new().fill_max_size().then(config.modifier)).with_children(vec![
156        crate::Box(Modifier::new().fill_max_size()).with_children(vec![content]),
157        gesture,
158    ])
159}
160
161/// A vertically swipable pager with animated page transitions.
162///
163/// Mirror of `HorizontalPager` - drag up/down to flip pages.
164#[allow(non_snake_case)]
165pub fn VerticalPager(
166    key: impl Into<String>,
167    state: Rc<PagerState>,
168    page_builder: impl Fn(usize) -> View + 'static,
169    config: PagerConfig,
170) -> View {
171    let key = key.into();
172    let page = state.current_page.get();
173    let page_spacing = config.page_spacing;
174    let slide_offset = 600.0 + page_spacing;
175
176    let drag_start_y = Rc::new(remember_with_key(format!("vpager_drag:{key}"), || {
177        RefCell::new(None::<f32>)
178    }));
179
180    let on_down = {
181        let d = drag_start_y.clone();
182        move |e: PointerEvent| {
183            *d.borrow_mut() = Some(e.position.y);
184        }
185    };
186
187    let st = state.clone();
188    let on_up = {
189        let d = drag_start_y.clone();
190        move |e: PointerEvent| {
191            if let Some(start_y) = *d.borrow() {
192                let delta = e.position.y - start_y;
193                let threshold = 50.0;
194                if delta.abs() > threshold {
195                    if delta < 0.0 {
196                        let next =
197                            (st.current_page.get() + 1).min(st.page_count.get().saturating_sub(1));
198                        st.current_page.set(next);
199                    } else {
200                        let prev = st.current_page.get().saturating_sub(1);
201                        st.current_page.set(prev);
202                    }
203                }
204            }
205            *d.borrow_mut() = None;
206        }
207    };
208
209    let content = AnimatedContent(
210        page,
211        page_builder,
212        AnimatedContentConfig {
213            key: format!("vpage_content:{key}"),
214            spec: AnimationSpec::spring_gentle(),
215            enter: EnterTransition::Composite(vec![
216                EnterTransition::FadeIn,
217                EnterTransition::SlideIn {
218                    offset_x: 0.0,
219                    offset_y: slide_offset,
220                },
221            ]),
222            exit: ExitTransition::Composite(vec![
223                ExitTransition::FadeOut,
224                ExitTransition::SlideOut {
225                    offset_x: 0.0,
226                    offset_y: -(slide_offset),
227                },
228            ]),
229        },
230    );
231
232    let gesture = if config.user_scroll_enabled {
233        crate::Box(
234            Modifier::new()
235                .fill_max_size()
236                .hit_passthrough()
237                .on_pointer_down(on_down)
238                .on_pointer_up(on_up),
239        )
240    } else {
241        crate::Box(Modifier::new().fill_max_size().hit_passthrough())
242    };
243
244    crate::ZStack(Modifier::new().fill_max_size().then(config.modifier)).with_children(vec![
245        crate::Box(Modifier::new().fill_max_size()).with_children(vec![content]),
246        gesture,
247    ])
248}