frus_widgets/pageview.rs
1//! [`PageView`]: a scrollable that comes to rest **on a page**, never between two.
2//!
3//! Onboarding screens, an image gallery, the tabs of a phone home screen: the
4//! content is a sequence of full-viewport panels, and the only resting positions
5//! are the panels themselves. Everything else — the drag, the edges, the glow —
6//! is the ordinary scrollable machinery; what a page view changes is the single
7//! moment the finger lifts, where a fling is replaced by a spring to one page.
8//!
9//! Like [`crate::ListView`], it is **virtualised**: pages are built by an
10//! `index → widget` closure and only the ones on screen exist. A hundred-page
11//! walkthrough costs the same per frame as a two-page one, and — the other side
12//! of the same coin — a page has **no retained state** while it is off screen.
13
14use frus_core::{Rect, Scene};
15use frus_layout::{Dimension, Style};
16
17use crate::interaction::Status;
18use crate::physics::ScrollPhysics;
19use crate::scroll::Axis;
20use crate::theme::Theme;
21use crate::widget::Widget;
22
23/// How a scrollable snaps to pages, as the shell and the runtime see it.
24///
25/// This is what turns an ordinary scroll region into a paged one: it travels on
26/// the region's [`crate::Scrollable`] description, so the release path can ask
27/// "is this area paged?" without knowing anything about the widget.
28#[derive(Copy, Clone, Debug, PartialEq)]
29pub struct PageSnap {
30 /// Distance from one page to the next, in logical pixels.
31 pub extent: f32,
32 /// How many pages there are.
33 pub count: usize,
34 /// The page the application is asking for, applied on the first frame and on
35 /// every change; see [`PageView::page`].
36 pub requested: usize,
37 /// Does the view page sideways?
38 pub horizontal: bool,
39 /// Does a release come to rest **on** a page?
40 ///
41 /// False and the region flings like any other scroll, which is what a
42 /// free-running carousel wants. The rest of the paged machinery stays: the
43 /// page a reader would name is still reported, and a requested page still
44 /// glides. Only the release changes. See [`PageView::page_snapping`].
45 pub snapping: bool,
46}
47
48impl PageSnap {
49 /// The offset at which `index` rests.
50 pub fn offset_of(&self, index: usize) -> f32 {
51 index as f32 * self.extent
52 }
53
54 /// The page an `offset` reads as — the one a reader would say they are on.
55 pub fn page_at(&self, offset: f32) -> usize {
56 let last = self.count.saturating_sub(1);
57 (crate::physics::page_of(offset, self.extent)
58 .round()
59 .max(0.0) as usize)
60 .min(last)
61 }
62}
63
64/// The description of a paged view, exposed to the render driver.
65pub struct PagedView<'a, Msg = crate::callback::Callback> {
66 /// Total number of pages.
67 pub count: usize,
68 /// Which way the pages are laid out.
69 pub axis: Axis,
70 /// The fraction of the viewport one page occupies along that axis.
71 pub viewport_fraction: f32,
72 /// A page extent in **pixels**, overriding the fraction when set.
73 ///
74 /// A page view states a fraction because a page is a share of the window it is read
75 /// in. A [`crate::ListWheel`] states pixels, because a row of a wheel is a line of
76 /// text and a line of text is not a share of anything.
77 pub extent: Option<f32>,
78 /// The cylinder the pages are laid on, when they are laid on one at all — a
79 /// [`crate::ListWheel`] and nothing else. See [`crate::WheelGeometry`].
80 pub wheel: Option<crate::wheel::WheelGeometry>,
81 /// The page asked for by the application.
82 pub requested: usize,
83 /// Room at both ends, so a page narrower than the viewport rests centred in
84 /// it; see [`PageView::pad_ends`].
85 pub pad_ends: bool,
86 /// Does a release come to rest on a page? See [`PageView::page_snapping`].
87 pub snapping: bool,
88 /// Builds one page per index.
89 pub build: &'a dyn Fn(usize) -> Box<dyn Widget<Msg>>,
90}
91
92impl<Msg> PagedView<'_, Msg> {
93 /// The snapping description for a viewport of this size.
94 pub fn snap(&self, viewport: Rect) -> PageSnap {
95 let horizontal = !matches!(self.axis, Axis::Vertical);
96 let along = if horizontal {
97 viewport.width
98 } else {
99 viewport.height
100 };
101 PageSnap {
102 extent: self
103 .extent
104 .unwrap_or(along * self.viewport_fraction)
105 .max(1.0),
106 count: self.count,
107 requested: self.requested,
108 horizontal,
109 snapping: self.snapping,
110 }
111 }
112
113 /// The room at each end of the content, in logical pixels.
114 ///
115 /// Zero at the default fraction of one, where a page already fills the
116 /// viewport and there is nothing to centre.
117 pub fn end_padding(&self, viewport: Rect) -> f32 {
118 if !self.pad_ends {
119 return 0.0;
120 }
121 let snap = self.snap(viewport);
122 let along = match snap.horizontal {
123 true => viewport.width,
124 false => viewport.height,
125 };
126 ((along - snap.extent) / 2.0).max(0.0)
127 }
128}
129
130/// A scrollable that rests only on page boundaries.
131pub struct PageView<Msg = crate::callback::Callback> {
132 count: usize,
133 axis: Axis,
134 viewport_fraction: f32,
135 requested: usize,
136 /// Room at both ends; see [`PageView::pad_ends`].
137 pad_ends: bool,
138 /// Does a release rest on a page? See [`PageView::page_snapping`].
139 snapping: bool,
140 /// Does page 0 sit at the end the axis finishes at? See [`PageView::reverse`].
141 reverse: bool,
142 width: Dimension,
143 height: Dimension,
144 /// Was the width **set** explicitly? In flex mode an unset dimension on the
145 /// paging axis must not serve as a basis; see [`PageView::style`].
146 width_explicit: bool,
147 /// Was the height **set** explicitly? The same question for the other axis.
148 height_explicit: bool,
149 flex_grow: f32,
150 physics: Option<ScrollPhysics>,
151 build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
152 on_page_changed: Option<Box<dyn Fn(usize) -> Msg>>,
153}
154
155impl<Msg> PageView<Msg> {
156 /// Creates a view of `count` pages, each built on demand by `build(index)`.
157 ///
158 /// Pages run **horizontally** and fill the viewport, which is what a page view
159 /// is nine times out of ten; [`PageView::axis`] and
160 /// [`PageView::viewport_fraction`] change both.
161 pub fn new<W: Widget<Msg> + 'static>(
162 count: usize,
163 build: impl Fn(usize) -> W + 'static,
164 ) -> Self {
165 Self {
166 count,
167 axis: Axis::Horizontal,
168 viewport_fraction: 1.0,
169 requested: 0,
170 pad_ends: true,
171 snapping: true,
172 reverse: false,
173 width: Dimension::Auto,
174 height: Dimension::Length(200.0),
175 width_explicit: false,
176 height_explicit: false,
177 flex_grow: 0.0,
178 physics: None,
179 build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
180 on_page_changed: None,
181 }
182 }
183
184 /// Lays the pages out along `axis`. Only [`Axis::Horizontal`] and
185 /// [`Axis::Vertical`] mean anything here: a page view pages one way.
186 pub fn axis(mut self, axis: Axis) -> Self {
187 self.axis = axis;
188 self
189 }
190
191 /// The fraction of the viewport one page occupies; `1.0` — the default — makes
192 /// each page fill it.
193 ///
194 /// Below one, the neighbouring pages show at the edges, which is how a carousel
195 /// says "there is more this way" without a hint or an arrow. The value is what
196 /// the pages are measured *and* snapped by, so the two cannot disagree.
197 pub fn viewport_fraction(mut self, fraction: f32) -> Self {
198 self.viewport_fraction = fraction.max(0.05);
199 self
200 }
201
202 /// Whether there is room at **both ends** of the content, so that the first and
203 /// last pages come to rest centred in the viewport. On by default, and nil at the
204 /// default [`viewport_fraction`](PageView::viewport_fraction) of one, where a page
205 /// already fills the viewport and there is nothing to centre.
206 ///
207 /// It is not only a matter of looks. Without it the travel stops with the last
208 /// page's trailing edge on the viewport's, which for a carousel is **short of
209 /// where the last page rests**: nine pages at 0.8 of a 300 px viewport give 1620 px
210 /// of travel and the ninth page rests at 1728. It would never quite arrive, and a
211 /// snap towards it would be pulled back by the edge every time.
212 ///
213 /// Padded, the travel is exactly `extent × (count - 1)`, which is the last
214 /// page's own resting offset. Every page is reachable and the arithmetic says so.
215 ///
216 /// Turn it off for a carousel that starts flush against its leading edge, with the
217 /// following pages peeking in from the other side.
218 pub fn pad_ends(mut self, pad: bool) -> Self {
219 self.pad_ends = pad;
220 self
221 }
222
223 /// Whether a release comes to rest **on** a page. On by default — it is what
224 /// makes this a page view rather than a scroll.
225 ///
226 /// Off, the region flings like any other scrollable and stops wherever the
227 /// physics leaves it, which is what a free-running carousel wants. Everything else
228 /// stays: [`on_page_changed`](PageView::on_page_changed) still reports the page a
229 /// reader would name, and [`page`](PageView::page) still glides across to what the
230 /// application asks for. Only the release changes.
231 pub fn page_snapping(mut self, snapping: bool) -> Self {
232 self.snapping = snapping;
233 self
234 }
235
236 /// Pages **from the far end**: page 0 sits at the edge the axis finishes at — the
237 /// right of a horizontal view, the bottom of a vertical one — page 1 before it,
238 /// and the view opens there.
239 ///
240 /// The same idea as [`crate::ListView::reverse`]: not a mirrored picture, but a
241 /// different answer to *which end is index 0*.
242 pub fn reverse(mut self) -> Self {
243 self.reverse = true;
244 self
245 }
246
247 /// The page to show.
248 ///
249 /// Applied when the view first appears — so this is also the initial page — and
250 /// again **whenever the value changes**, gliding across to it. Between changes
251 /// the finger has the offset: a view that re-asserted its page every frame
252 /// could not be swiped at all. An application that never sets it gets an
253 /// uncontrolled view starting at page 0.
254 pub fn page(mut self, page: usize) -> Self {
255 self.requested = page;
256 self
257 }
258
259 /// The message sent when the page a reader would name changes — mid-drag, as
260 /// soon as the rounding tips, not once the motion has settled. A title above a
261 /// gallery should follow the picture, not trail it.
262 pub fn on_page_changed<R: crate::callback::IntoMsg<Msg>>(
263 mut self,
264 message: impl Fn(usize) -> R + 'static,
265 ) -> Self {
266 self.on_page_changed = Some(Box::new(crate::callback::handler1(message)));
267 self
268 }
269
270 /// Overrides how the view behaves at its edges; see [`crate::SingleChildScrollView::physics`].
271 /// The **fling** is a page view's own business either way.
272 pub fn physics(mut self, physics: ScrollPhysics) -> Self {
273 self.physics = Some(physics);
274 self
275 }
276
277 /// Sets the viewport width, in logical pixels.
278 pub fn width(mut self, width: f32) -> Self {
279 self.width = Dimension::Length(width);
280 self.width_explicit = true;
281 self
282 }
283
284 /// Sets the viewport height, in logical pixels.
285 pub fn height(mut self, height: f32) -> Self {
286 self.height = Dimension::Length(height);
287 self.height_explicit = true;
288 self
289 }
290
291 /// Flex growth factor along the parent's main axis.
292 pub fn flex(mut self, grow: f32) -> Self {
293 self.flex_grow = grow;
294 self
295 }
296}
297
298impl<Msg> Widget<Msg> for PageView<Msg> {
299 fn style(&self) -> Style {
300 // Filling then paging: a default dimension that was never asked for must not
301 // stand as a flex **basis**, or the view would need `+200` of free room
302 // before it grew at all. The same trap as [`crate::SingleChildScrollView::style`], and the
303 // same answer: an `Auto` basis of 0, and `flex_grow` does the filling.
304 let filling = self.flex_grow > 0.0;
305 let width = if filling && !self.width_explicit {
306 Dimension::Auto
307 } else {
308 self.width
309 };
310 let height = if filling && !self.height_explicit {
311 Dimension::Auto
312 } else {
313 self.height
314 };
315 Style {
316 width,
317 height,
318 flex_grow: self.flex_grow,
319 ..Default::default()
320 }
321 }
322
323 /// **All the room on offer** on an axis nobody sized, as the reference's takes
324 /// (milestone 590) — told to grow or not: a flex factor is only heard by a row or a
325 /// column, and a page view in a stack's layer still takes the stack's room
326 /// (milestone 639).
327 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
328 crate::widget::FillAxes {
329 horizontal: !self.width_explicit,
330 vertical: !self.height_explicit,
331 }
332 }
333
334 /// The default size is only a default: the room replaces it wherever the layout can
335 /// give the room, and it stands where the reference would have nothing to give.
336 fn soft_extent(&self) -> crate::widget::FillAxes {
337 crate::widget::FillAxes {
338 horizontal: false,
339 vertical: !self.height_explicit,
340 }
341 }
342
343 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
344 &[]
345 }
346
347 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
348 // The viewport is transparent: only the pages are drawn.
349 }
350
351 fn on_click(&self) -> Option<Msg> {
352 None
353 }
354
355 fn page_view(&self) -> Option<PagedView<'_, Msg>> {
356 Some(PagedView {
357 count: self.count,
358 axis: self.axis,
359 viewport_fraction: self.viewport_fraction,
360 extent: None,
361 wheel: None,
362 requested: self.requested,
363 pad_ends: self.pad_ends,
364 snapping: self.snapping,
365 build: &*self.build,
366 })
367 }
368
369 fn on_page_changed(&self, page: usize) -> Option<Msg> {
370 self.on_page_changed.as_ref().map(|make| make(page))
371 }
372
373 fn scroll_axis(&self) -> Axis {
374 self.axis
375 }
376
377 fn scroll_reverse(&self) -> bool {
378 self.reverse
379 }
380
381 fn scroll_physics(&self) -> Option<ScrollPhysics> {
382 self.physics
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::{build_ui, Container, Runtime, Size};
390 use frus_core::{Color, Primitive};
391 use std::cell::Cell;
392 use std::rc::Rc;
393
394 fn view(built: Rc<Cell<usize>>) -> PageView<()> {
395 PageView::new(50, move |index| {
396 built.set(built.get() + 1);
397 Container::new().color(Color::rgb(index as f32 / 50.0, 0.0, 0.0))
398 })
399 .width(300.0)
400 .height(400.0)
401 }
402
403 fn ui_of(widget: PageView<()>, runtime: &Runtime) -> crate::Ui<()> {
404 build_ui(&widget, Size::new(300.0, 400.0), runtime, &Theme::default())
405 }
406
407 #[test]
408 fn only_the_pages_on_screen_are_built() {
409 let built = Rc::new(Cell::new(0));
410 let runtime = Runtime::default();
411 ui_of(view(built.clone()), &runtime);
412 // Page 0 fills the viewport; page 1 starts exactly at its right edge, so it
413 // is not on screen yet. Fifty pages, one built.
414 assert_eq!(built.get(), 1);
415 }
416
417 #[test]
418 fn a_half_scrolled_view_shows_both_neighbours() {
419 let built = Rc::new(Cell::new(0));
420 let mut runtime = Runtime::default();
421 let widget = view(built.clone());
422 let id = crate::interaction::WidgetId::ROOT;
423 runtime.scroll.insert(id, (150.0, 0.0));
424 ui_of(widget, &runtime);
425 assert_eq!(built.get(), 2);
426 }
427
428 #[test]
429 fn the_content_is_as_long_as_the_pages_put_together() {
430 let runtime = Runtime::default();
431 let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
432 let area = ui
433 .scroll_regions()
434 .first()
435 .copied()
436 .expect("a scroll region");
437 // 50 pages of 300 px in a 300 px viewport: 49 pages' worth of travel.
438 assert_eq!(area.max_x, 49.0 * 300.0);
439 assert_eq!(area.max_y, 0.0);
440 let snap = area.page.expect("a paged region");
441 assert_eq!(snap.extent, 300.0);
442 assert_eq!(snap.count, 50);
443 assert!(snap.horizontal);
444 }
445
446 #[test]
447 fn narrower_pages_let_the_next_one_show() {
448 let runtime = Runtime::default();
449 let ui = ui_of(view(Rc::new(Cell::new(0))).viewport_fraction(0.8), &runtime);
450 let area = ui
451 .scroll_regions()
452 .first()
453 .copied()
454 .expect("a scroll region");
455 let snap = area.page.expect("a paged region");
456 assert_eq!(snap.extent, 240.0);
457 // Padded ends: the travel is exactly the last page's resting offset, so the
458 // fiftieth page can be reached. Unpadded it would stop 60 px short of it.
459 assert_eq!(area.max_x, 49.0 * 240.0);
460 assert_eq!(snap.offset_of(49), area.max_x);
461 }
462
463 #[test]
464 fn a_vertical_view_pages_downwards() {
465 let runtime = Runtime::default();
466 let ui = ui_of(view(Rc::new(Cell::new(0))).axis(Axis::Vertical), &runtime);
467 let area = ui
468 .scroll_regions()
469 .first()
470 .copied()
471 .expect("a scroll region");
472 let snap = area.page.expect("a paged region");
473 assert!(!snap.horizontal);
474 assert_eq!(snap.extent, 400.0);
475 assert_eq!(area.max_x, 0.0);
476 assert_eq!(area.max_y, 49.0 * 400.0);
477 }
478
479 #[test]
480 fn a_page_is_painted_where_the_offset_says() {
481 let mut runtime = Runtime::default();
482 let widget = PageView::new(3, |_| Container::new().color(Color::rgb(1.0, 0.0, 0.0)))
483 .width(300.0)
484 .height(400.0);
485 let id = crate::interaction::WidgetId::ROOT;
486 runtime.scroll.insert(id, (300.0, 0.0));
487 let ui = ui_of(widget, &runtime);
488 // Page 1 is scrolled exactly into place: its box starts at the viewport's
489 // left edge, not at 300.
490 let left = ui
491 .scene()
492 .primitives()
493 .iter()
494 .filter_map(|p| match p {
495 Primitive::Rect { rect, .. } if rect.width > 100.0 => Some(rect.x),
496 _ => None,
497 })
498 .next()
499 .expect("a page");
500 assert!(left.abs() < 0.5, "page 1 drawn at {left}");
501 }
502
503 /// Where the pages were actually drawn, in build order.
504 fn page_lefts(ui: &crate::Ui<()>) -> Vec<f32> {
505 ui.scene()
506 .primitives()
507 .iter()
508 .filter_map(|p| match p {
509 Primitive::Rect { rect, .. } if rect.width > 100.0 => Some(rect.x),
510 _ => None,
511 })
512 .collect()
513 }
514
515 /// A carousel opens with its first page **centred**, not jammed against the
516 /// leading edge. Half the slack either side, which is what the room at the ends
517 /// is for.
518 #[test]
519 fn a_carousel_rests_its_first_page_in_the_middle() {
520 let runtime = Runtime::default();
521 let ui = ui_of(view(Rc::new(Cell::new(0))).viewport_fraction(0.8), &runtime);
522 // 300 px viewport, 240 px page: 60 px of slack, 30 either side.
523 let lefts = page_lefts(&ui);
524 assert!((lefts[0] - 30.0).abs() < 0.5, "page 0 drawn at {lefts:?}");
525 assert!((lefts[1] - 270.0).abs() < 0.5, "page 1 drawn at {lefts:?}");
526 }
527
528 /// Without the room at the ends the first page starts flush and the following
529 /// ones peek in from the other side — and the travel is 60 px shorter, which is
530 /// 60 px short of where the last page rests.
531 #[test]
532 fn unpadded_ends_start_flush_and_stop_early() {
533 let runtime = Runtime::default();
534 let ui = ui_of(
535 view(Rc::new(Cell::new(0)))
536 .viewport_fraction(0.8)
537 .pad_ends(false),
538 &runtime,
539 );
540 assert!(page_lefts(&ui)[0].abs() < 0.5);
541 let area = ui.scroll_regions()[0];
542 assert_eq!(area.max_x, 50.0 * 240.0 - 300.0);
543 // The last page's resting offset is past the end of the travel: it can be
544 // snapped at but never reached, which is why the ends are padded by default.
545 let snap = area.page.expect("a paged region");
546 assert!(snap.offset_of(49) > area.max_x);
547 }
548
549 /// A reversed view puts page 0 at the end the axis finishes at.
550 #[test]
551 fn a_reversed_view_starts_at_the_far_end() {
552 let runtime = Runtime::default();
553 let ui = ui_of(
554 view(Rc::new(Cell::new(0)))
555 .viewport_fraction(0.8)
556 .pad_ends(false)
557 .reverse(),
558 &runtime,
559 );
560 // 300 px viewport, 240 px page: page 0's right edge is the viewport's.
561 let lefts = page_lefts(&ui);
562 assert!((lefts[0] - 60.0).abs() < 0.5, "page 0 drawn at {lefts:?}");
563 // Page 1 is before it, off the leading edge.
564 assert!(lefts[1] < lefts[0], "page 1 drawn at {lefts:?}");
565 let area = ui.scroll_regions()[0];
566 assert!(area.reverse_x, "the region says which way its offsets run");
567 assert!(!area.reverse_y);
568 // The travel is unchanged: only which end index 0 is at has moved.
569 assert_eq!(area.max_x, 50.0 * 240.0 - 300.0);
570 }
571
572 /// Snapping off, a release is an ordinary fling: a view left between two pages
573 /// with no speed behind it **stays** there.
574 #[test]
575 fn a_view_that_does_not_snap_is_released_like_a_scroll() {
576 let id = crate::interaction::WidgetId::ROOT;
577 let physics = ScrollPhysics::CLAMPING;
578 let mut runtime = Runtime::default();
579
580 let free = {
581 let ui = ui_of(view(Rc::new(Cell::new(0))).page_snapping(false), &runtime);
582 ui.scroll_regions()[0]
583 };
584 let snapping = {
585 let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
586 ui.scroll_regions()[0]
587 };
588 assert!(!free.page.expect("still a paged region").snapping);
589
590 // A fifth of a page along, released without a throw.
591 runtime.scroll.insert(id, (60.0, 0.0));
592 assert!(
593 !runtime.fling_scroll(free, physics, (0.0, 0.0)),
594 "nothing to do: it is in range and going nowhere"
595 );
596 assert_eq!(runtime.scroll[&id], (60.0, 0.0));
597 // The same release on a snapping view springs back to page 0.
598 assert!(runtime.fling_scroll(snapping, physics, (0.0, 0.0)));
599 }
600
601 /// Everything but the release stays: the page a reader would name is still
602 /// reported, and the page the application asks for is still honoured.
603 #[test]
604 fn a_view_that_does_not_snap_still_knows_its_pages() {
605 let id = crate::interaction::WidgetId::ROOT;
606 let mut runtime = Runtime::default();
607 let area = {
608 let ui = ui_of(view(Rc::new(Cell::new(0))).page_snapping(false), &runtime);
609 ui.scroll_regions()[0]
610 };
611 assert!(
612 runtime.page_changes(&[area]).is_empty(),
613 "opening is not a change"
614 );
615 runtime.scroll.insert(id, (300.0, 0.0));
616 assert_eq!(runtime.page_changes(&[area]), vec![(id, 1)]);
617
618 // And a request still moves it: first sighting, so straight there.
619 let mut fresh = Runtime::default();
620 let asked = {
621 let ui = ui_of(
622 view(Rc::new(Cell::new(0))).page_snapping(false).page(3),
623 &fresh,
624 );
625 ui.scroll_regions()[0]
626 };
627 fresh.sync_pages(&[asked]);
628 assert_eq!(fresh.scroll[&id], (900.0, 0.0));
629 }
630
631 /// The whole loop, as the shell drives it: build, drag, release, settle.
632 ///
633 /// Everything below the shell's pointer plumbing is exercised here — the extent
634 /// read off the viewport, the release, the spring, and the page reported — which
635 /// is as close to the gesture as a test without a window gets.
636 #[test]
637 fn a_short_flick_turns_the_page_and_says_so() {
638 let mut runtime = Runtime::default();
639 let id = crate::interaction::WidgetId::ROOT;
640 let physics = ScrollPhysics::CLAMPING;
641
642 let area = crate::Scrollable {
643 physics,
644 ..{
645 let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
646 ui.scroll_regions()[0]
647 }
648 };
649 // Opening on page 0 is not a change to report.
650 assert!(runtime.page_changes(&[area]).is_empty());
651
652 // The finger drags a fifth of a page across and throws.
653 runtime.scroll.insert(id, (60.0, 0.0));
654 assert!(runtime.fling_scroll(area, physics, (700.0, 0.0)));
655 let mut reported = Vec::new();
656 for _ in 0..240 {
657 runtime.advance_scroll(&[area], 1.0 / 60.0);
658 reported.extend(runtime.page_changes(&[area]));
659 }
660
661 assert_eq!(reported, vec![(id, 1)], "reported once, on the way");
662 let (x, _) = runtime.scroll[&id];
663 assert!((x - 300.0).abs() < 1.0, "settled between pages, at {x}");
664 }
665
666 #[test]
667 fn the_page_a_reader_would_name_is_the_nearest_one() {
668 let snap = PageSnap {
669 extent: 300.0,
670 count: 3,
671 requested: 0,
672 horizontal: true,
673 snapping: true,
674 };
675 assert_eq!(snap.page_at(0.0), 0);
676 assert_eq!(snap.page_at(149.0), 0);
677 assert_eq!(snap.page_at(151.0), 1);
678 assert_eq!(snap.offset_of(2), 600.0);
679 // An overscroll is still the page it is pulling away from.
680 assert_eq!(snap.page_at(-40.0), 0);
681 assert_eq!(snap.page_at(9_000.0), 2);
682 }
683}