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::{Axis, 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, 0.0);
210 }
211
212 pub fn animate_scroll_to_page(&self, page: usize) {
215 self.target.set(Some(page));
216 self.list.animate_scroll_to_item(page, 0.0);
217 }
218
219 pub fn lazy_list_state(&self) -> LazyListState {
222 self.list
223 }
224
225 fn page_at(&self, first: usize, offset: f32) -> usize {
228 self.pages_at(first, offset).round().max(0.0) as usize
229 }
230
231 fn pages_at(&self, first: usize, offset: f32) -> f32 {
234 self.geometry()
235 .map_or(first as f32, |geometry| geometry.pages_at(first, offset))
236 }
237
238 fn geometry(&self) -> Option<PageGeometry> {
240 self.list
241 .with_layout_info(|info| {
242 let page = info.visible_items_info.first()?;
243 let extent = page.size + info.main_axis_item_spacing;
244 (extent > 0.0).then_some(PageGeometry { extent })
245 })
246 .flatten()
247 }
248}
249
250#[derive(Clone, Copy)]
252struct PageGeometry {
253 extent: f32,
255}
256
257impl PageGeometry {
258 fn scrolled(self, first: usize, offset: f32) -> f32 {
261 first as f32 * self.extent + offset
262 }
263
264 fn pages_at(self, first: usize, offset: f32) -> f32 {
265 self.scrolled(first, offset) / self.extent
266 }
267}
268
269fn page_settle(state: PagerState, fling: PagerFling) -> LazySettlePolicy {
274 Rc::new(move |proposed, velocity| {
275 let Some(geometry) = state.geometry() else {
276 return proposed;
277 };
278 let first = state.list.first_visible_item_index_non_reactive();
280 let now = geometry.pages_at(
281 first,
282 state.list.first_visible_item_scroll_offset_non_reactive(),
283 );
284 let rest = geometry.pages_at(first, proposed);
285 let target = if velocity.abs() >= PAGE_FLING_VELOCITY {
286 let most = fling.max_pages.max(1) as f32;
287 if velocity > 0.0 {
288 rest.ceil().clamp(now.floor() + 1.0, now.floor() + most)
289 } else {
290 rest.floor().clamp(now.ceil() - most, now.ceil() - 1.0)
291 }
292 } else {
293 let whole = rest.floor();
294 if rest - whole > fling.positional_threshold {
295 whole + 1.0
296 } else {
297 whole
298 }
299 };
300 let last = state
301 .list
302 .with_layout_info(|info| info.total_items_count.saturating_sub(1))
303 .unwrap_or_default() as f32;
304 let target = target.clamp(0.0, last);
305 state.target.set(Some(target as usize));
306 target * geometry.extent - geometry.scrolled(first, 0.0)
307 })
308}
309
310#[composable(no_skip)]
314pub fn HorizontalPager<F>(
315 modifier: Modifier,
316 state: PagerState,
317 spec: PagerSpec,
318 page_count: usize,
319 page_content: F,
320) where
321 F: Fn(usize) + 'static,
322{
323 Pager(
324 modifier,
325 state,
326 spec,
327 page_count,
328 Axis::Horizontal,
329 Rc::new(page_content),
330 );
331}
332
333#[composable(no_skip)]
335pub fn VerticalPager<F>(
336 modifier: Modifier,
337 state: PagerState,
338 spec: PagerSpec,
339 page_count: usize,
340 page_content: F,
341) where
342 F: Fn(usize) + 'static,
343{
344 Pager(
345 modifier,
346 state,
347 spec,
348 page_count,
349 Axis::Vertical,
350 Rc::new(page_content),
351 );
352}
353
354#[composable(no_skip)]
355fn Pager(
356 modifier: Modifier,
357 state: PagerState,
358 spec: PagerSpec,
359 page_count: usize,
360 axis: Axis,
361 page_content: Rc<dyn Fn(usize)>,
362) {
363 cranpose_core::DisposableEffect((state, spec.fling), move |scope| {
364 state
365 .list
366 .set_settle_policy(Some(page_settle(state, spec.fling)));
367 scope.on_dispose(move || state.list.set_settle_policy(None))
368 });
369 BoxWithConstraints(modifier, move |scope| {
370 let constraints = scope.constraints();
371 let mut padding = spec.content_padding;
372 let width = constraints.max_width - padding.start - padding.end;
373 let height = constraints.max_height - padding.top - padding.bottom;
374 let (page_width, page_height) = match (spec.page_size, axis) {
375 (PageSize::Fill, _) => (width, height),
376 (PageSize::Fixed(length), Axis::Horizontal) => (length, height),
377 (PageSize::Fixed(length), Axis::Vertical) => (width, length),
378 };
379 let room = if axis.is_horizontal() {
382 width - page_width
383 } else {
384 height - page_height
385 }
386 .max(0.0);
387 let before = room * spec.snap_position.fraction();
388 if axis.is_horizontal() {
389 padding.start += before;
390 padding.end += room - before;
391 } else {
392 padding.top += before;
393 padding.bottom += room - before;
394 }
395 let (width, height) = (page_width, page_height);
396 let content = Rc::clone(&page_content);
397 let pages = move |pages: &mut LazyListIntervalContent| {
398 pages.items(
399 LazyItems::new(page_count).key(|page| page as u64),
400 move |page| {
401 let content = Rc::clone(&content);
402 Box(
403 Modifier::empty().size_points(width, height),
404 BoxSpec::default(),
405 move || content(page),
406 );
407 },
408 );
409 };
410 let fill = Modifier::empty().fill_max_size();
411 let spacing = LinearArrangement::SpacedBy(spec.page_spacing);
412 if axis.is_horizontal() {
413 let row = LazyRowSpec {
414 horizontal_arrangement: spacing,
415 content_padding: padding,
416 beyond_bounds_item_count: spec.beyond_viewport_page_count,
417 ..LazyRowSpec::default()
418 };
419 LazyRow(fill, state.list, row, pages);
420 } else {
421 let column = LazyColumnSpec {
422 vertical_arrangement: spacing,
423 content_padding: padding,
424 beyond_bounds_item_count: spec.beyond_viewport_page_count,
425 ..LazyColumnSpec::default()
426 };
427 LazyColumn(fill, state.list, column, pages);
428 }
429 });
430}