1use frus_core::{Insets, Rect, Scene};
8use frus_layout::{Dimension, Style};
9
10use crate::interaction::Status;
11use crate::physics::ScrollPhysics;
12use crate::theme::Theme;
13use crate::widget::Widget;
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
17pub enum Axis {
18 Vertical,
20 Horizontal,
22 Both,
29}
30
31impl Axis {
32 pub(crate) fn free_x(self) -> bool {
33 matches!(self, Axis::Horizontal | Axis::Both)
34 }
35 pub(crate) fn free_y(self) -> bool {
36 matches!(self, Axis::Vertical | Axis::Both)
37 }
38}
39
40pub struct SingleChildScrollView<Msg = crate::callback::Callback> {
42 width: Dimension,
43 height: Dimension,
44 width_explicit: bool,
47 height_explicit: bool,
49 flex_grow: f32,
50 axis: Axis,
51 physics: Option<ScrollPhysics>,
54 scrollbars: Option<crate::physics::Scrollbars>,
57 thumb_visibility: Option<bool>,
59 reverse: bool,
60 padding: Insets,
62 on_scroll: Option<Box<dyn Fn(crate::ScrollPosition) -> Msg>>,
64 grain: f32,
67 content: Vec<Box<dyn Widget<Msg>>>,
68}
69
70impl<Msg> SingleChildScrollView<Msg> {
71 pub fn new() -> Self {
73 Self {
74 width: Dimension::Auto,
75 height: Dimension::Length(200.0),
76 width_explicit: false,
77 height_explicit: false,
78 flex_grow: 0.0,
79 axis: Axis::Vertical,
80 physics: None,
81 scrollbars: None,
82 thumb_visibility: None,
83 reverse: false,
84 padding: Insets::ZERO,
85 on_scroll: None,
86 grain: 0.0,
87 content: Vec::new(),
88 }
89 }
90
91 pub fn axis(mut self, axis: Axis) -> Self {
93 self.axis = axis;
94 self
95 }
96
97 pub fn reverse(mut self) -> Self {
111 self.reverse = true;
112 self
113 }
114
115 pub fn physics(mut self, physics: ScrollPhysics) -> Self {
121 self.physics = Some(physics);
122 self
123 }
124
125 pub fn scrollbars(mut self, scrollbars: crate::physics::Scrollbars) -> Self {
131 self.scrollbars = Some(scrollbars);
132 self
133 }
134
135 pub fn thumb_visibility(mut self, visible: bool) -> Self {
142 self.thumb_visibility = Some(visible);
143 self
144 }
145
146 pub fn width(mut self, width: f32) -> Self {
148 self.width = Dimension::Length(width);
149 self.width_explicit = true;
150 self
151 }
152
153 pub fn height(mut self, height: f32) -> Self {
155 self.height = Dimension::Length(height);
156 self.height_explicit = true;
157 self
158 }
159
160 pub fn flex(mut self, grow: f32) -> Self {
162 self.flex_grow = grow;
163 self
164 }
165
166 pub fn padding(mut self, padding: f32) -> Self {
184 self.padding = Insets::uniform(padding);
185 self
186 }
187
188 pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
190 self.padding = Insets::new(top, right, bottom, left);
191 self
192 }
193
194 pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
204 mut self,
205 on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
206 ) -> Self {
207 self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
208 self
209 }
210
211 pub fn notify_every(mut self, px: f32) -> Self {
214 self.grain = px.max(0.0);
215 self
216 }
217
218 pub fn child(mut self, content: impl Widget<Msg> + 'static) -> Self {
220 self.content.clear();
221 self.content.push(Box::new(content));
222 self
223 }
224}
225
226impl<Msg> Default for SingleChildScrollView<Msg> {
227 fn default() -> Self {
228 Self::new()
229 }
230}
231
232impl<Msg: Clone> Widget<Msg> for SingleChildScrollView<Msg> {
233 fn style(&self) -> Style {
234 let filling = self.flex_grow > 0.0;
240 let height = if filling && !self.height_explicit && self.axis.free_y() {
241 Dimension::Auto
242 } else {
243 self.height
244 };
245 let width = if filling && !self.width_explicit && self.axis.free_x() {
246 Dimension::Auto
247 } else {
248 self.width
249 };
250 let height = if !self.height_explicit && !self.axis.free_y() {
257 Dimension::Auto
258 } else {
259 height
260 };
261 let width = if !self.width_explicit && !self.axis.free_x() {
262 Dimension::Auto
263 } else {
264 width
265 };
266 Style {
267 width,
268 height,
269 flex_grow: self.flex_grow,
270 ..Default::default()
271 }
272 }
273
274 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
275 &self.content
276 }
277
278 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
279 }
281
282 fn on_click(&self) -> Option<Msg> {
283 None
284 }
285
286 fn scrollbars(&self) -> Option<crate::physics::Scrollbars> {
287 self.scrollbars
288 }
289
290 fn thumb_visibility(&self) -> Option<bool> {
291 self.thumb_visibility
292 }
293
294 fn scroll_content(&self) -> Option<&dyn Widget<Msg>> {
295 self.content.first().map(|child| child.as_ref())
296 }
297
298 fn scroll_axis(&self) -> Axis {
299 self.axis
300 }
301
302 fn scroll_physics(&self) -> Option<ScrollPhysics> {
303 self.physics
304 }
305
306 fn scroll_reverse(&self) -> bool {
307 self.reverse
308 }
309
310 fn scroll_padding(&self) -> Insets {
311 self.padding
312 }
313
314 fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
315 self.on_scroll.as_ref().map(|report| report(position))
316 }
317
318 fn scroll_grain(&self) -> f32 {
319 self.grain
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::{build_ui, Container, Runtime, Size};
327 use frus_core::{Color, Primitive};
328
329 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
330
331 #[test]
343 fn a_viewport_takes_its_content_s_size_on_the_axis_it_does_not_scroll() {
344 let strip = SingleChildScrollView::<()>::new()
345 .axis(Axis::Horizontal)
346 .child(Container::new().width(900.0).height(48.0).color(MARK));
347 let ui = build_ui(
348 &strip,
349 Size::new(300.0, 400.0),
350 &Runtime::default(),
351 &Theme::default(),
352 );
353 let box_ = ui.scene().primitives().iter().find_map(|p| match p {
354 Primitive::Rect { rect, .. } => Some(*rect),
355 _ => None,
356 });
357 assert_eq!(
358 box_.map(|r| r.height),
359 Some(48.0),
360 "the strip is its content's height, not the vertical default"
361 );
362
363 let told = SingleChildScrollView::<()>::new()
365 .axis(Axis::Horizontal)
366 .height(120.0)
367 .child(Container::new().width(900.0).height(48.0).color(MARK));
368 assert_eq!(
369 Widget::<()>::style(&told).height,
370 Dimension::Length(120.0),
371 "an explicit height is not measured away"
372 );
373 }
374
375 #[test]
379 fn a_vertical_area_is_as_wide_as_its_content() {
380 let row = crate::Flex::<()>::row().child(
381 SingleChildScrollView::<()>::new()
382 .height(100.0)
383 .child(Container::new().width(160.0).height(400.0).color(MARK)),
384 );
385 let ui = build_ui(
386 &row,
387 Size::new(400.0, 400.0),
388 &Runtime::default(),
389 &Theme::default(),
390 );
391 let drawn = ui.scene().primitives().iter().find_map(|p| match p {
392 Primitive::Rect { rect, .. } => Some(*rect),
393 _ => None,
394 });
395 assert_eq!(drawn.map(|r| r.width), Some(160.0));
396 }
397
398 fn last_mark(scroll: SingleChildScrollView<()>, offset: f32) -> Rect {
401 let mut runtime = Runtime::default();
402 let ui = build_ui(
403 &scroll,
404 Size::new(100.0, 100.0),
405 &runtime,
406 &Theme::default(),
407 );
408 let id = ui.scroll_regions()[0].id;
409 runtime.scroll.insert(id, (0.0, offset));
410 let ui = build_ui(
411 &scroll,
412 Size::new(100.0, 100.0),
413 &runtime,
414 &Theme::default(),
415 );
416 ui.scene()
417 .primitives()
418 .iter()
419 .filter_map(|p| match p {
420 Primitive::Rect { color, rect, .. } if *color == MARK => Some(*rect),
421 _ => None,
422 })
423 .next_back()
424 .expect("the marker is painted")
425 }
426
427 fn content(n: usize) -> Container<()> {
429 let mut column = crate::Flex::<()>::column();
430 for i in 0..n {
431 let colour = if i + 1 == n {
432 MARK
433 } else {
434 Color::rgb(0.0, 0.0, 1.0)
435 };
436 column = column.child(Container::<()>::new().height(40.0).color(colour));
437 }
438 Container::<()>::new().child(column)
439 }
440
441 #[test]
444 fn short_content_sits_at_the_end_when_reversed() {
445 let plain = SingleChildScrollView::<()>::new()
446 .width(100.0)
447 .height(100.0)
448 .child(content(1));
449 assert_eq!(last_mark(plain, 0.0).y, 0.0, "at the top");
450
451 let reversed = SingleChildScrollView::<()>::new()
452 .width(100.0)
453 .height(100.0)
454 .reverse()
455 .child(content(1));
456 let r = last_mark(reversed, 0.0);
457 assert_eq!(r.y + r.height, 100.0, "against the bottom: {r:?}");
458 }
459
460 #[test]
464 fn offset_zero_is_the_end_and_stays_there() {
465 for rows in [4, 10, 40] {
466 let reversed = SingleChildScrollView::<()>::new()
467 .width(100.0)
468 .height(100.0)
469 .reverse()
470 .child(content(rows));
471 let r = last_mark(reversed, 0.0);
472 assert_eq!(
473 r.y + r.height,
474 100.0,
475 "{rows} rows, still resting at the end: {r:?}"
476 );
477 }
478 }
479
480 #[test]
483 fn a_positive_offset_moves_back_through_the_content() {
484 let scroll = || {
485 SingleChildScrollView::<()>::new()
486 .width(100.0)
487 .height(100.0)
488 .reverse()
489 .child(content(10))
490 };
491 let at_rest = last_mark(scroll(), 0.0);
492 let scrolled = last_mark(scroll(), 60.0);
493 assert_eq!(
494 scrolled.y - at_rest.y,
495 60.0,
496 "the end has moved down and out of view"
497 );
498 }
499
500 #[test]
504 fn the_glow_follows_the_axis_and_not_the_screen() {
505 use frus_core::Rect as R;
506 let area = |reverse: bool| crate::Scrollable {
507 id: crate::WidgetId::ROOT,
508 viewport: R::new(0.0, 0.0, 100.0, 100.0),
509 max_x: 0.0,
510 max_y: 300.0,
511 physics: None,
512 refresh: None,
513 page: None,
514 reverse_x: false,
515 reverse_y: reverse,
516 host: None,
517 keep_visible: None,
518 };
519 assert_eq!(area(false).refused_edge(true, -1.0), crate::GlowEdge::Top);
521 assert_eq!(
522 area(true).refused_edge(true, -1.0),
523 crate::GlowEdge::Bottom,
524 "the start of a reversed axis is the bottom of the screen"
525 );
526 assert_eq!(area(true).start_edge(true), crate::GlowEdge::Bottom);
528 }
529
530 #[test]
534 fn a_push_moves_the_content_the_way_it_pushes() {
535 let region = |reverse: bool| {
536 let mut s = SingleChildScrollView::<()>::new()
537 .width(100.0)
538 .height(100.0);
539 if reverse {
540 s = s.reverse();
541 }
542 let s = s.child(content(10));
543 let ui = build_ui(
544 &s,
545 Size::new(100.0, 100.0),
546 &Runtime::default(),
547 &Theme::default(),
548 );
549 ui.scroll_regions()[0]
550 };
551 assert_eq!(region(false).offset_delta((0.0, 10.0)).1, -10.0);
553 assert_eq!(region(true).offset_delta((0.0, 10.0)).1, 10.0);
554 }
557}
558
559#[cfg(test)]
560mod padding_tests {
561 use super::*;
562 use crate::{build_ui, Container, Runtime, Size};
563 use frus_core::{Color, Primitive};
564
565 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
566
567 fn content_rect(scroll: &SingleChildScrollView<()>) -> Rect {
569 build_ui(
570 scroll,
571 Size::new(100.0, 100.0),
572 &Runtime::default(),
573 &Theme::default(),
574 )
575 .scene()
576 .primitives()
577 .iter()
578 .find_map(|p| match p {
579 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
580 _ => None,
581 })
582 .expect("the content is painted")
583 }
584
585 fn area(height: f32) -> SingleChildScrollView<()> {
586 SingleChildScrollView::<()>::new()
587 .width(100.0)
588 .height(100.0)
589 .child(Container::<()>::new().height(height).color(MARK))
590 }
591
592 #[test]
595 fn the_content_starts_inside_the_insets() {
596 let bare = content_rect(&area(50.0));
597 assert_eq!((bare.x, bare.y, bare.width), (0.0, 0.0, 100.0));
598
599 let padded = content_rect(&area(50.0).padding_each(12.0, 8.0, 24.0, 8.0));
600 assert_eq!((padded.x, padded.y), (8.0, 12.0));
601 assert_eq!(padded.width, 84.0, "the sides come off the content");
602 }
603
604 #[test]
607 fn the_far_inset_is_reachable_rather_than_lost() {
608 let region = |scroll: &SingleChildScrollView<()>| {
609 build_ui(
610 scroll,
611 Size::new(100.0, 100.0),
612 &Runtime::default(),
613 &Theme::default(),
614 )
615 .scroll_regions()[0]
616 .max_y
617 };
618 assert_eq!(
619 region(&area(100.0)),
620 0.0,
621 "exactly fills: nothing to scroll"
622 );
623 assert_eq!(
624 region(&area(100.0).padding_each(12.0, 0.0, 24.0, 0.0)),
625 36.0,
626 "both insets joined the content"
627 );
628 }
629
630 #[test]
632 fn a_reversed_area_rests_above_its_bottom_inset() {
633 let padded = content_rect(&area(50.0).reverse().padding_each(12.0, 0.0, 24.0, 0.0));
634 assert_eq!(
635 padded.y + padded.height,
636 76.0,
637 "the content ends one bottom inset above the viewport's edge"
638 );
639 }
640}
641
642#[cfg(test)]
647mod shrunk_content_tests {
648 use super::*;
649 use crate::{build_ui, Container, ListView, PageView, Runtime, Size, Ui};
650 use frus_core::{Color, Primitive};
651
652 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
653 const FRAME: f32 = 1.0 / 60.0;
654
655 fn strip(width: f32) -> SingleChildScrollView<()> {
658 SingleChildScrollView::<()>::new()
659 .axis(Axis::Horizontal)
660 .width(100.0)
661 .height(40.0)
662 .child(Container::<()>::new().width(width).height(40.0).color(MARK))
663 }
664
665 fn rows(count: usize) -> ListView<()> {
667 ListView::<()>::new(count, 40.0, |_| {
668 Container::<()>::new().height(40.0).color(MARK)
669 })
670 .width(100.0)
671 .height(200.0)
672 }
673
674 fn frame(widget: &dyn Widget<()>, runtime: &Runtime) -> Ui<()> {
675 build_ui(widget, Size::new(300.0, 400.0), runtime, &Theme::default())
676 }
677
678 fn painted(ui: &Ui<()>, color: Color) -> Vec<Rect> {
680 ui.scene()
681 .primitives()
682 .iter()
683 .filter_map(|p| match p {
684 Primitive::Rect { rect, color: c, .. } if *c == color => Some(*rect),
685 _ => None,
686 })
687 .collect()
688 }
689
690 fn step(runtime: &mut Runtime, ui: &Ui<()>) {
692 let regions = ui.scroll_regions().to_vec();
693 runtime.advance_scroll(®ions, ScrollPhysics::Clamping, FRAME);
694 }
695
696 #[test]
699 fn a_row_that_fits_again_is_drawn_from_its_start() {
700 let mut runtime = Runtime::default();
701 let wide = strip(300.0);
702 let id = frame(&wide, &runtime).scroll_regions()[0].id;
703 runtime.scroll.insert(id, (200.0, 0.0));
705 let ui = frame(&wide, &runtime);
706 assert_eq!(painted(&ui, MARK)[0].x, -200.0, "the end of the row shows");
707 step(&mut runtime, &ui);
708
709 let narrow = strip(80.0);
710 let ui = frame(&narrow, &runtime);
711 assert_eq!(ui.scroll_regions()[0].max_x, 0.0, "nothing left to scroll");
712 assert_eq!(
713 painted(&ui, MARK)[0].x,
714 0.0,
715 "drawn from its start in the very frame it fits"
716 );
717 step(&mut runtime, &ui);
718 assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 0.0)));
719 assert_eq!(painted(&frame(&narrow, &runtime), MARK)[0].x, 0.0);
720 }
721
722 #[test]
724 fn a_row_that_still_overflows_rests_at_its_new_end() {
725 let mut runtime = Runtime::default();
726 let wide = strip(300.0);
727 let id = frame(&wide, &runtime).scroll_regions()[0].id;
728 runtime.scroll.insert(id, (200.0, 0.0));
729 for _ in 0..3 {
731 let ui = frame(&wide, &runtime);
732 step(&mut runtime, &ui);
733 }
734
735 let narrower = strip(150.0);
736 let ui = frame(&narrower, &runtime);
737 let mark = painted(&ui, MARK)[0];
738 assert_eq!(
739 (mark.x, mark.x + mark.width),
740 (-50.0, 100.0),
741 "its last pixel on the viewport's right edge"
742 );
743 for _ in 0..10 {
744 let ui = frame(&narrower, &runtime);
745 step(&mut runtime, &ui);
746 }
747 assert_eq!(runtime.scroll.get(&id), Some(&(50.0, 0.0)));
748 assert_eq!(
751 runtime.scrollbar_fade_of(id).opacity,
752 0.0,
753 "no bar summoned"
754 );
755 }
756
757 #[test]
759 fn a_list_that_loses_rows_keeps_its_last_row_on_the_floor() {
760 let mut runtime = Runtime::default();
761 let id = frame(&rows(10), &runtime).scroll_regions()[0].id;
762 runtime.scroll.insert(id, (0.0, 200.0));
763
764 let ui = frame(&rows(6), &runtime);
766 let last = painted(&ui, MARK)
767 .into_iter()
768 .max_by(|a, b| a.y.total_cmp(&b.y))
769 .expect("a row is painted");
770 assert_eq!(last.y + last.height, 200.0, "the last row on the floor");
771 step(&mut runtime, &ui);
772 assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 40.0)));
773
774 let ui = frame(&rows(3), &runtime);
776 let first = painted(&ui, MARK)
777 .into_iter()
778 .min_by(|a, b| a.y.total_cmp(&b.y))
779 .expect("a row is painted");
780 assert_eq!(first.y, 0.0);
781 assert_eq!(painted(&ui, MARK).len(), 3, "every row shows");
782 step(&mut runtime, &ui);
783 assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 0.0)));
784 }
785
786 #[test]
788 fn a_paged_view_that_loses_pages_shows_its_new_last_page() {
789 let shade = |index: usize| Color::rgb(0.0, 0.0, (index + 1) as f32 / 10.0);
790 let pages = |count: usize| {
791 PageView::<()>::new(count, move |index| {
792 Container::<()>::new().color(shade(index))
793 })
794 .width(300.0)
795 .height(400.0)
796 };
797 let mut runtime = Runtime::default();
798 let id = frame(&pages(5), &runtime).scroll_regions()[0].id;
799 runtime.scroll.insert(id, (1200.0, 0.0));
800 assert_eq!(painted(&frame(&pages(5), &runtime), shade(4))[0].x, 0.0);
801
802 let ui = frame(&pages(2), &runtime);
803 assert_eq!(
804 painted(&ui, shade(1)).first().map(|r| r.x),
805 Some(0.0),
806 "page 1 fills the viewport"
807 );
808 assert!(painted(&ui, shade(4)).is_empty(), "no page past the end");
809 step(&mut runtime, &ui);
810 assert_eq!(runtime.scroll.get(&id), Some(&(300.0, 0.0)));
811 }
812
813 #[test]
816 fn a_reversed_row_is_brought_back_the_same_way() {
817 let mut runtime = Runtime::default();
818 let wide = strip(300.0).reverse();
819 let id = frame(&wide, &runtime).scroll_regions()[0].id;
820 runtime.scroll.insert(id, (200.0, 0.0));
821 assert_eq!(
822 painted(&frame(&wide, &runtime), MARK)[0].x,
823 0.0,
824 "its start shows"
825 );
826
827 let narrower = strip(150.0).reverse();
828 let ui = frame(&narrower, &runtime);
829 assert_eq!(
830 painted(&ui, MARK)[0].x,
831 0.0,
832 "its start against the left edge: {:?}",
833 painted(&ui, MARK)
834 );
835 step(&mut runtime, &ui);
836 assert_eq!(runtime.scroll.get(&id), Some(&(50.0, 0.0)));
837 }
838
839 #[test]
842 fn a_held_row_is_not_yanked_from_under_the_finger() {
843 let mut runtime = Runtime::default();
844 let wide = strip(300.0);
845 let id = frame(&wide, &runtime).scroll_regions()[0].id;
846 runtime.scroll.insert(id, (200.0, 0.0));
847 runtime.hold_scroll(id);
848
849 let narrower = strip(150.0);
850 for _ in 0..10 {
851 let ui = frame(&narrower, &runtime);
852 assert_eq!(painted(&ui, MARK)[0].x, -200.0, "where the finger left it");
853 step(&mut runtime, &ui);
854 }
855 assert_eq!(runtime.scroll.get(&id), Some(&(200.0, 0.0)));
856
857 runtime.release_scroll(id);
858 let area = frame(&narrower, &runtime).scroll_regions()[0];
859 assert!(
860 runtime.fling_scroll(area, ScrollPhysics::Clamping, (0.0, 0.0)),
861 "a release out of range springs"
862 );
863 for _ in 0..600 {
864 let ui = frame(&narrower, &runtime);
865 step(&mut runtime, &ui);
866 }
867 let rest = runtime.scroll.get(&id).copied().unwrap_or_default();
868 assert!((rest.0 - 50.0).abs() < 1.0, "home at the new end: {rest:?}");
869 }
870}