1use 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::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, Default, PartialEq, Eq)]
40pub enum SnapPosition {
41 #[default]
43 Start,
44 Center,
47 End,
49}
50
51impl SnapPosition {
52 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#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct PagerFling {
66 pub max_pages: usize,
70 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#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct PagerSpec {
89 pub page_size: PageSize,
91 pub page_spacing: f32,
93 pub content_padding: PaddingValues,
97 pub beyond_viewport_page_count: usize,
100 pub fling: PagerFling,
102 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#[derive(Clone, Copy, PartialEq)]
123pub struct PagerState {
124 list: LazyListState,
125 target: MutableState<Option<usize>>,
127}
128
129#[composable]
131pub fn rememberPagerState(initial_page: usize) -> PagerState {
132 remember(|| PagerState::new(initial_page)).with(|state| *state)
133}
134
135impl PagerState {
136 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 pub fn try_first_page(&self) -> Option<usize> {
148 self.list.try_position().map(|(page, _)| page)
149 }
150
151 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 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 _ => (
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 pub fn is_scroll_in_progress(&self) -> bool {
180 self.list.is_scroll_in_progress()
181 }
182
183 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 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 pub fn scroll_to_page(&self, page: usize) {
209 self.list.scroll_to_item(page, self.rest_offset(page));
210 }
211
212 pub fn animate_scroll_to_page(&self, page: usize) {
215 self.target.set(Some(page));
216 self.list
217 .animate_scroll_to_item(page, self.rest_offset(page));
218 }
219
220 fn rest_offset(&self, page: usize) -> f32 {
224 match self.geometry() {
225 Some(geometry) if page > 0 => -geometry.before,
226 _ => 0.0,
227 }
228 }
229
230 fn place_initial_page(&self, before: f32) {
233 let untouched = self
234 .list
235 .with_layout_info(|info| info.total_items_count == 0)
236 .unwrap_or(false);
237 let Some((page, offset)) = self.list.try_position() else {
238 return;
239 };
240 if untouched && page > 0 && offset == 0.0 && before > 0.0 {
241 self.list.scroll_to_item(page, -before);
242 }
243 }
244
245 pub fn lazy_list_state(&self) -> LazyListState {
248 self.list
249 }
250
251 fn page_at(&self, first: usize, offset: f32) -> usize {
254 self.pages_at(first, offset).round().max(0.0) as usize
255 }
256
257 fn pages_at(&self, first: usize, offset: f32) -> f32 {
260 self.geometry()
261 .map_or(first as f32, |geometry| geometry.pages_at(first, offset))
262 }
263
264 fn geometry(&self) -> Option<PageGeometry> {
266 self.list
267 .with_layout_info(|info| {
268 let page = info.visible_items_info.first()?;
269 let extent = page.size + info.main_axis_item_spacing;
270 (extent > 0.0).then_some(PageGeometry {
271 extent,
272 before: info.before_content_padding,
273 })
274 })
275 .flatten()
276 }
277}
278
279#[derive(Clone, Copy)]
281struct PageGeometry {
282 extent: f32,
284 before: f32,
287}
288
289impl PageGeometry {
290 fn scrolled(self, first: usize, offset: f32) -> f32 {
293 let start = if first == 0 {
294 0.0
295 } else {
296 self.before + first as f32 * self.extent
297 };
298 start + offset
299 }
300
301 fn pages_at(self, first: usize, offset: f32) -> f32 {
302 self.scrolled(first, offset) / self.extent
303 }
304}
305
306fn page_settle(state: PagerState, fling: PagerFling) -> LazySettlePolicy {
311 Rc::new(move |proposed, velocity| {
312 let Some(geometry) = state.geometry() else {
313 return proposed;
314 };
315 let first = state.list.first_visible_item_index_non_reactive();
317 let now = geometry.pages_at(
318 first,
319 state.list.first_visible_item_scroll_offset_non_reactive(),
320 );
321 let rest = geometry.pages_at(first, proposed);
322 let target = if velocity.abs() >= PAGE_FLING_VELOCITY {
323 let most = fling.max_pages.max(1) as f32;
324 if velocity > 0.0 {
325 rest.ceil().clamp(now.floor() + 1.0, now.floor() + most)
326 } else {
327 rest.floor().clamp(now.ceil() - most, now.ceil() - 1.0)
328 }
329 } else {
330 let whole = rest.floor();
331 if rest - whole > fling.positional_threshold {
332 whole + 1.0
333 } else {
334 whole
335 }
336 };
337 let last = state
338 .list
339 .with_layout_info(|info| info.total_items_count.saturating_sub(1))
340 .unwrap_or_default() as f32;
341 let target = target.clamp(0.0, last);
342 state.target.set(Some(target as usize));
343 target * geometry.extent - geometry.scrolled(first, 0.0)
344 })
345}
346
347#[composable(no_skip)]
351pub fn HorizontalPager<F>(
352 modifier: Modifier,
353 state: PagerState,
354 spec: PagerSpec,
355 page_count: usize,
356 page_content: F,
357) where
358 F: Fn(usize) + 'static,
359{
360 Pager(
361 modifier,
362 state,
363 spec,
364 page_count,
365 true,
366 Rc::new(page_content),
367 );
368}
369
370#[composable(no_skip)]
372pub fn VerticalPager<F>(
373 modifier: Modifier,
374 state: PagerState,
375 spec: PagerSpec,
376 page_count: usize,
377 page_content: F,
378) where
379 F: Fn(usize) + 'static,
380{
381 Pager(
382 modifier,
383 state,
384 spec,
385 page_count,
386 false,
387 Rc::new(page_content),
388 );
389}
390
391#[composable(no_skip)]
392fn Pager(
393 modifier: Modifier,
394 state: PagerState,
395 spec: PagerSpec,
396 page_count: usize,
397 horizontal: bool,
398 page_content: Rc<dyn Fn(usize)>,
399) {
400 cranpose_core::DisposableEffect((state, spec.fling), move |scope| {
401 state
402 .list
403 .set_settle_policy(Some(page_settle(state, spec.fling)));
404 scope.on_dispose(move || state.list.set_settle_policy(None))
405 });
406 BoxWithConstraints(modifier, move |scope| {
407 let constraints = scope.constraints();
408 let mut padding = spec.content_padding;
409 let width = constraints.max_width - padding.start - padding.end;
410 let height = constraints.max_height - padding.top - padding.bottom;
411 let (page_width, page_height) = match (spec.page_size, horizontal) {
412 (PageSize::Fill, _) => (width, height),
413 (PageSize::Fixed(length), true) => (length, height),
414 (PageSize::Fixed(length), false) => (width, length),
415 };
416 let room = if horizontal {
419 width - page_width
420 } else {
421 height - page_height
422 }
423 .max(0.0);
424 let before = room * spec.snap_position.fraction();
425 if horizontal {
426 padding.start += before;
427 padding.end += room - before;
428 } else {
429 padding.top += before;
430 padding.bottom += room - before;
431 }
432 let leading = if horizontal {
433 padding.start
434 } else {
435 padding.top
436 };
437 remember(|| state.place_initial_page(leading));
438 let (width, height) = (page_width, page_height);
439 let content = Rc::clone(&page_content);
440 let pages = move |pages: &mut LazyListIntervalContent| {
441 pages.items(
442 LazyItems::new(page_count).key(|page| page as u64),
443 move |page| {
444 let content = Rc::clone(&content);
445 Box(
446 Modifier::empty().size_points(width, height),
447 BoxSpec::default(),
448 move || content(page),
449 );
450 },
451 );
452 };
453 let fill = Modifier::empty().fill_max_size();
454 let spacing = LinearArrangement::SpacedBy(spec.page_spacing);
455 if horizontal {
456 let row = LazyRowSpec {
457 horizontal_arrangement: spacing,
458 content_padding: padding,
459 beyond_bounds_item_count: spec.beyond_viewport_page_count,
460 ..LazyRowSpec::default()
461 };
462 LazyRow(fill, state.list, row, pages);
463 } else {
464 let column = LazyColumnSpec {
465 vertical_arrangement: spacing,
466 content_padding: padding,
467 beyond_bounds_item_count: spec.beyond_viewport_page_count,
468 ..LazyColumnSpec::default()
469 };
470 LazyColumn(fill, state.list, column, pages);
471 }
472 });
473}