1use 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, PaddingValues,
20 },
21};
22
23const PAGE_FLING_VELOCITY: f32 = 400.0;
26
27#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum PageSize {
30 Fill,
32 Fixed(f32),
35}
36
37#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct PagerSpec {
41 pub page_size: PageSize,
43 pub page_spacing: f32,
45 pub content_padding: PaddingValues,
49 pub beyond_viewport_page_count: usize,
52}
53
54impl Default for PagerSpec {
55 fn default() -> Self {
56 Self {
57 page_size: PageSize::Fill,
58 page_spacing: 0.0,
59 content_padding: PaddingValues::default(),
60 beyond_viewport_page_count: 2,
61 }
62 }
63}
64
65#[derive(Clone, Copy, PartialEq)]
68pub struct PagerState {
69 list: LazyListState,
70}
71
72#[composable]
74pub fn rememberPagerState(initial_page: usize) -> PagerState {
75 PagerState {
76 list: rememberLazyListStateWithPosition(initial_page, 0.0),
77 }
78}
79
80impl PagerState {
81 pub fn new(initial_page: usize) -> Self {
84 Self {
85 list: LazyListState::new(initial_page, 0.0),
86 }
87 }
88
89 pub fn try_first_page(&self) -> Option<usize> {
92 self.list.try_position().map(|(page, _)| page)
93 }
94
95 pub fn current_page(&self) -> usize {
98 self.page_at(
99 self.list.first_visible_item_index(),
100 self.list.first_visible_item_scroll_offset(),
101 )
102 }
103
104 pub fn settled_page(&self) -> usize {
109 let (first, offset) = match self.list.position_before_scroll() {
110 Some(start) if self.list.is_scroll_in_progress() => start,
111 _ => (
114 self.list.first_visible_item_index(),
115 self.list.first_visible_item_scroll_offset_non_reactive(),
116 ),
117 };
118 self.page_at(first, offset)
119 }
120
121 pub fn is_scroll_in_progress(&self) -> bool {
124 self.list.is_scroll_in_progress()
125 }
126
127 pub fn current_page_offset_fraction(&self) -> f32 {
130 let page = self.page_extent();
131 if page <= 0.0 {
132 return 0.0;
133 }
134 let fraction = self.list.first_visible_item_scroll_offset() / page;
135 if fraction > 0.5 {
136 fraction - 1.0
137 } else {
138 fraction
139 }
140 }
141
142 pub fn scroll_to_page(&self, page: usize) {
144 self.list.scroll_to_item(page, 0.0);
145 }
146
147 pub fn animate_scroll_to_page(&self, page: usize) {
150 self.list.animate_scroll_to_item(page, 0.0);
151 }
152
153 pub fn lazy_list_state(&self) -> LazyListState {
156 self.list
157 }
158
159 fn page_at(&self, first: usize, offset: f32) -> usize {
162 if offset > self.page_extent() / 2.0 {
163 first + 1
164 } else {
165 first
166 }
167 }
168
169 fn page_extent(&self) -> f32 {
171 self.list
172 .with_layout_info(|info| {
173 info.visible_items_info
174 .first()
175 .map_or(info.viewport_size, |page| {
176 page.size + info.main_axis_item_spacing
177 })
178 })
179 .unwrap_or_default()
180 }
181}
182
183fn page_settle(state: PagerState) -> LazySettlePolicy {
187 Rc::new(move |proposed, velocity| {
188 let page = state.page_extent();
189 if page <= 0.0 {
190 return proposed;
191 }
192 let forward = if velocity.abs() >= PAGE_FLING_VELOCITY {
193 velocity > 0.0
194 } else {
195 proposed > page / 2.0
196 };
197 if forward { page } else { 0.0 }
198 })
199}
200
201#[composable(no_skip)]
205pub fn HorizontalPager<F>(
206 modifier: Modifier,
207 state: PagerState,
208 spec: PagerSpec,
209 page_count: usize,
210 page_content: F,
211) where
212 F: Fn(usize) + 'static,
213{
214 Pager(
215 modifier,
216 state,
217 spec,
218 page_count,
219 true,
220 Rc::new(page_content),
221 );
222}
223
224#[composable(no_skip)]
226pub fn VerticalPager<F>(
227 modifier: Modifier,
228 state: PagerState,
229 spec: PagerSpec,
230 page_count: usize,
231 page_content: F,
232) where
233 F: Fn(usize) + 'static,
234{
235 Pager(
236 modifier,
237 state,
238 spec,
239 page_count,
240 false,
241 Rc::new(page_content),
242 );
243}
244
245#[composable(no_skip)]
246fn Pager(
247 modifier: Modifier,
248 state: PagerState,
249 spec: PagerSpec,
250 page_count: usize,
251 horizontal: bool,
252 page_content: Rc<dyn Fn(usize)>,
253) {
254 cranpose_core::DisposableEffect(state, move |scope| {
255 state.list.set_settle_policy(Some(page_settle(state)));
256 scope.on_dispose(move || state.list.set_settle_policy(None))
257 });
258 BoxWithConstraints(modifier, move |scope| {
259 let constraints = scope.constraints();
260 let padding = spec.content_padding;
261 let width = constraints.max_width - padding.start - padding.end;
262 let height = constraints.max_height - padding.top - padding.bottom;
263 let (width, height) = match (spec.page_size, horizontal) {
264 (PageSize::Fill, _) => (width, height),
265 (PageSize::Fixed(length), true) => (length, height),
266 (PageSize::Fixed(length), false) => (width, length),
267 };
268 let content = Rc::clone(&page_content);
269 let pages = move |pages: &mut LazyListIntervalContent| {
270 pages.items(
271 LazyItems::new(page_count).key(|page| page as u64),
272 move |page| {
273 let content = Rc::clone(&content);
274 Box(
275 Modifier::empty().size_points(width, height),
276 BoxSpec::default(),
277 move || content(page),
278 );
279 },
280 );
281 };
282 let fill = Modifier::empty().fill_max_size();
283 let spacing = LinearArrangement::SpacedBy(spec.page_spacing);
284 if horizontal {
285 let row = LazyRowSpec {
286 horizontal_arrangement: spacing,
287 content_padding: padding,
288 beyond_bounds_item_count: spec.beyond_viewport_page_count,
289 ..LazyRowSpec::default()
290 };
291 LazyRow(fill, state.list, row, pages);
292 } else {
293 let column = LazyColumnSpec {
294 vertical_arrangement: spacing,
295 content_padding: padding,
296 beyond_bounds_item_count: spec.beyond_viewport_page_count,
297 ..LazyColumnSpec::default()
298 };
299 LazyColumn(fill, state.list, column, pages);
300 }
301 });
302}