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 {
79 Self {
80 width: Dimension::Auto,
81 height: Dimension::Auto,
82 width_explicit: false,
83 height_explicit: false,
84 flex_grow: 0.0,
85 axis: Axis::Vertical,
86 physics: None,
87 scrollbars: None,
88 thumb_visibility: None,
89 reverse: false,
90 padding: Insets::ZERO,
91 on_scroll: None,
92 grain: 0.0,
93 content: Vec::new(),
94 }
95 }
96
97 pub fn axis(mut self, axis: Axis) -> Self {
99 self.axis = axis;
100 self
101 }
102
103 pub fn reverse(mut self) -> Self {
117 self.reverse = true;
118 self
119 }
120
121 pub fn physics(mut self, physics: ScrollPhysics) -> Self {
127 self.physics = Some(physics);
128 self
129 }
130
131 pub fn scrollbars(mut self, scrollbars: crate::physics::Scrollbars) -> Self {
137 self.scrollbars = Some(scrollbars);
138 self
139 }
140
141 pub fn thumb_visibility(mut self, visible: bool) -> Self {
148 self.thumb_visibility = Some(visible);
149 self
150 }
151
152 pub fn width(mut self, width: f32) -> Self {
154 self.width = Dimension::Length(width);
155 self.width_explicit = true;
156 self
157 }
158
159 pub fn height(mut self, height: f32) -> Self {
161 self.height = Dimension::Length(height);
162 self.height_explicit = true;
163 self
164 }
165
166 pub fn flex(mut self, grow: f32) -> Self {
168 self.flex_grow = grow;
169 self
170 }
171
172 pub fn padding(mut self, padding: f32) -> Self {
190 self.padding = Insets::uniform(padding);
191 self
192 }
193
194 pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
196 self.padding = Insets::new(top, right, bottom, left);
197 self
198 }
199
200 pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
210 mut self,
211 on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
212 ) -> Self {
213 self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
214 self
215 }
216
217 pub fn notify_every(mut self, px: f32) -> Self {
220 self.grain = px.max(0.0);
221 self
222 }
223
224 pub fn child(mut self, content: impl Widget<Msg> + 'static) -> Self {
226 self.content.clear();
227 self.content.push(Box::new(content));
228 self
229 }
230}
231
232impl<Msg> Default for SingleChildScrollView<Msg> {
233 fn default() -> Self {
234 Self::new()
235 }
236}
237
238impl<Msg: Clone> Widget<Msg> for SingleChildScrollView<Msg> {
239 fn style(&self) -> Style {
240 let filling = self.flex_grow > 0.0;
246 let height = if filling && !self.height_explicit && self.axis.free_y() {
247 Dimension::Auto
248 } else {
249 self.height
250 };
251 let width = if filling && !self.width_explicit && self.axis.free_x() {
252 Dimension::Auto
253 } else {
254 self.width
255 };
256 let height = if !self.height_explicit && !self.axis.free_y() {
263 Dimension::Auto
264 } else {
265 height
266 };
267 let width = if !self.width_explicit && !self.axis.free_x() {
268 Dimension::Auto
269 } else {
270 width
271 };
272 Style {
273 width,
274 height,
275 flex_grow: self.flex_grow,
276 ..Default::default()
277 }
278 }
279
280 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
281 &self.content
282 }
283
284 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
285 }
287
288 fn on_click(&self) -> Option<Msg> {
289 None
290 }
291
292 fn scrollbars(&self) -> Option<crate::physics::Scrollbars> {
293 self.scrollbars
294 }
295
296 fn thumb_visibility(&self) -> Option<bool> {
297 self.thumb_visibility
298 }
299
300 fn scroll_content(&self) -> Option<&dyn Widget<Msg>> {
301 self.content.first().map(|child| child.as_ref())
302 }
303
304 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
308 if self.flex_grow > 0.0 {
309 return crate::widget::FillAxes::NONE;
310 }
311 crate::widget::FillAxes {
312 horizontal: !self.axis.free_x() && !self.width_explicit,
313 vertical: !self.axis.free_y() && !self.height_explicit,
314 }
315 }
316
317 fn scroll_axis(&self) -> Axis {
318 self.axis
319 }
320
321 fn scroll_physics(&self) -> Option<ScrollPhysics> {
322 self.physics
323 }
324
325 fn scroll_reverse(&self) -> bool {
326 self.reverse
327 }
328
329 fn scroll_padding(&self) -> Insets {
330 self.padding
331 }
332
333 fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
334 self.on_scroll.as_ref().map(|report| report(position))
335 }
336
337 fn scroll_grain(&self) -> f32 {
338 self.grain
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::{build_ui, Container, Runtime, Size};
346 use frus_core::{Color, Primitive};
347
348 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
349
350 #[test]
362 fn a_viewport_takes_its_content_s_size_on_the_axis_it_does_not_scroll() {
363 let strip = SingleChildScrollView::<()>::new()
364 .axis(Axis::Horizontal)
365 .child(Container::new().width(900.0).height(48.0).color(MARK));
366 let ui = build_ui(
367 &strip,
368 Size::new(300.0, 400.0),
369 &Runtime::default(),
370 &Theme::default(),
371 );
372 let box_ = ui.scene().primitives().iter().find_map(|p| match p {
373 Primitive::Rect { rect, .. } => Some(*rect),
374 _ => None,
375 });
376 assert_eq!(
377 box_.map(|r| r.height),
378 Some(48.0),
379 "the strip is its content's height, not the vertical default"
380 );
381
382 let told = SingleChildScrollView::<()>::new()
384 .axis(Axis::Horizontal)
385 .height(120.0)
386 .child(Container::new().width(900.0).height(48.0).color(MARK));
387 assert_eq!(
388 Widget::<()>::style(&told).height,
389 Dimension::Length(120.0),
390 "an explicit height is not measured away"
391 );
392 }
393
394 #[test]
398 fn a_vertical_area_is_as_wide_as_its_content() {
399 let row = crate::Flex::<()>::row().child(
400 SingleChildScrollView::<()>::new()
401 .height(100.0)
402 .child(Container::new().width(160.0).height(400.0).color(MARK)),
403 );
404 let ui = build_ui(
405 &row,
406 Size::new(400.0, 400.0),
407 &Runtime::default(),
408 &Theme::default(),
409 );
410 let drawn = ui.scene().primitives().iter().find_map(|p| match p {
411 Primitive::Rect { rect, .. } => Some(*rect),
412 _ => None,
413 });
414 assert_eq!(drawn.map(|r| r.width), Some(160.0));
415 }
416
417 fn last_mark(scroll: SingleChildScrollView<()>, offset: f32) -> Rect {
420 let mut runtime = Runtime::default();
421 let ui = build_ui(
422 &scroll,
423 Size::new(100.0, 100.0),
424 &runtime,
425 &Theme::default(),
426 );
427 let id = ui.scroll_regions()[0].id;
428 runtime.scroll.insert(id, (0.0, offset));
429 let ui = build_ui(
430 &scroll,
431 Size::new(100.0, 100.0),
432 &runtime,
433 &Theme::default(),
434 );
435 ui.scene()
436 .primitives()
437 .iter()
438 .filter_map(|p| match p {
439 Primitive::Rect { color, rect, .. } if *color == MARK => Some(*rect),
440 _ => None,
441 })
442 .next_back()
443 .expect("the marker is painted")
444 }
445
446 fn content(n: usize) -> Container<()> {
448 let mut column = crate::Flex::<()>::column();
449 for i in 0..n {
450 let colour = if i + 1 == n {
451 MARK
452 } else {
453 Color::rgb(0.0, 0.0, 1.0)
454 };
455 column = column.child(Container::<()>::new().height(40.0).color(colour));
456 }
457 Container::<()>::new().child(column)
458 }
459
460 #[test]
463 fn short_content_sits_at_the_end_when_reversed() {
464 let plain = SingleChildScrollView::<()>::new()
465 .width(100.0)
466 .height(100.0)
467 .child(content(1));
468 assert_eq!(last_mark(plain, 0.0).y, 0.0, "at the top");
469
470 let reversed = SingleChildScrollView::<()>::new()
471 .width(100.0)
472 .height(100.0)
473 .reverse()
474 .child(content(1));
475 let r = last_mark(reversed, 0.0);
476 assert_eq!(r.y + r.height, 100.0, "against the bottom: {r:?}");
477 }
478
479 #[test]
483 fn offset_zero_is_the_end_and_stays_there() {
484 for rows in [4, 10, 40] {
485 let reversed = SingleChildScrollView::<()>::new()
486 .width(100.0)
487 .height(100.0)
488 .reverse()
489 .child(content(rows));
490 let r = last_mark(reversed, 0.0);
491 assert_eq!(
492 r.y + r.height,
493 100.0,
494 "{rows} rows, still resting at the end: {r:?}"
495 );
496 }
497 }
498
499 #[test]
502 fn a_positive_offset_moves_back_through_the_content() {
503 let scroll = || {
504 SingleChildScrollView::<()>::new()
505 .width(100.0)
506 .height(100.0)
507 .reverse()
508 .child(content(10))
509 };
510 let at_rest = last_mark(scroll(), 0.0);
511 let scrolled = last_mark(scroll(), 60.0);
512 assert_eq!(
513 scrolled.y - at_rest.y,
514 60.0,
515 "the end has moved down and out of view"
516 );
517 }
518
519 #[test]
523 fn the_glow_follows_the_axis_and_not_the_screen() {
524 use frus_core::Rect as R;
525 let area = |reverse: bool| crate::Scrollable {
526 id: crate::WidgetId::ROOT,
527 viewport: R::new(0.0, 0.0, 100.0, 100.0),
528 max_x: 0.0,
529 max_y: 300.0,
530 axis: crate::scroll::Axis::Vertical,
531 physics: crate::ScrollPhysics::CLAMPING,
532 overscroll: Some(crate::OverscrollIndicator::Glow),
533 fling: frus_core::VelocityStrategy::Regression,
534 refresh: None,
535 page: None,
536 reverse_x: false,
537 reverse_y: reverse,
538 host: None,
539 keep_visible: None,
540 };
541 assert_eq!(area(false).refused_edge(true, -1.0), crate::GlowEdge::Top);
543 assert_eq!(
544 area(true).refused_edge(true, -1.0),
545 crate::GlowEdge::Bottom,
546 "the start of a reversed axis is the bottom of the screen"
547 );
548 assert_eq!(area(true).start_edge(true), crate::GlowEdge::Bottom);
550 }
551
552 #[test]
556 fn a_push_moves_the_content_the_way_it_pushes() {
557 let region = |reverse: bool| {
558 let mut s = SingleChildScrollView::<()>::new()
559 .width(100.0)
560 .height(100.0);
561 if reverse {
562 s = s.reverse();
563 }
564 let s = s.child(content(10));
565 let ui = build_ui(
566 &s,
567 Size::new(100.0, 100.0),
568 &Runtime::default(),
569 &Theme::default(),
570 );
571 ui.scroll_regions()[0]
572 };
573 assert_eq!(region(false).offset_delta((0.0, 10.0)).1, -10.0);
575 assert_eq!(region(true).offset_delta((0.0, 10.0)).1, 10.0);
576 }
579}
580
581#[cfg(test)]
582mod padding_tests {
583 use super::*;
584 use crate::{build_ui, Container, Runtime, Size};
585 use frus_core::{Color, Primitive};
586
587 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
588
589 fn content_rect(scroll: &SingleChildScrollView<()>) -> Rect {
591 build_ui(
592 scroll,
593 Size::new(100.0, 100.0),
594 &Runtime::default(),
595 &Theme::default(),
596 )
597 .scene()
598 .primitives()
599 .iter()
600 .find_map(|p| match p {
601 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
602 _ => None,
603 })
604 .expect("the content is painted")
605 }
606
607 fn area(height: f32) -> SingleChildScrollView<()> {
608 SingleChildScrollView::<()>::new()
609 .width(100.0)
610 .height(100.0)
611 .child(Container::<()>::new().height(height).color(MARK))
612 }
613
614 #[test]
617 fn the_content_starts_inside_the_insets() {
618 let bare = content_rect(&area(50.0));
619 assert_eq!((bare.x, bare.y, bare.width), (0.0, 0.0, 100.0));
620
621 let padded = content_rect(&area(50.0).padding_each(12.0, 8.0, 24.0, 8.0));
622 assert_eq!((padded.x, padded.y), (8.0, 12.0));
623 assert_eq!(padded.width, 84.0, "the sides come off the content");
624 }
625
626 #[test]
629 fn the_far_inset_is_reachable_rather_than_lost() {
630 let region = |scroll: &SingleChildScrollView<()>| {
631 build_ui(
632 scroll,
633 Size::new(100.0, 100.0),
634 &Runtime::default(),
635 &Theme::default(),
636 )
637 .scroll_regions()[0]
638 .max_y
639 };
640 assert_eq!(
641 region(&area(100.0)),
642 0.0,
643 "exactly fills: nothing to scroll"
644 );
645 assert_eq!(
646 region(&area(100.0).padding_each(12.0, 0.0, 24.0, 0.0)),
647 36.0,
648 "both insets joined the content"
649 );
650 }
651
652 #[test]
654 fn a_reversed_area_rests_above_its_bottom_inset() {
655 let padded = content_rect(&area(50.0).reverse().padding_each(12.0, 0.0, 24.0, 0.0));
656 assert_eq!(
657 padded.y + padded.height,
658 76.0,
659 "the content ends one bottom inset above the viewport's edge"
660 );
661 }
662}
663
664#[cfg(test)]
669mod shrunk_content_tests {
670 use super::*;
671 use crate::{build_ui, Container, ListView, PageView, Runtime, Size, Ui};
672 use frus_core::{Color, Primitive};
673
674 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
675 const FRAME: f32 = 1.0 / 60.0;
676
677 fn strip(width: f32) -> SingleChildScrollView<()> {
680 SingleChildScrollView::<()>::new()
681 .axis(Axis::Horizontal)
682 .width(100.0)
683 .height(40.0)
684 .child(Container::<()>::new().width(width).height(40.0).color(MARK))
685 }
686
687 fn rows(count: usize) -> ListView<()> {
689 ListView::<()>::new(count, 40.0, |_| {
690 Container::<()>::new().height(40.0).color(MARK)
691 })
692 .width(100.0)
693 .height(200.0)
694 }
695
696 fn frame(widget: &dyn Widget<()>, runtime: &Runtime) -> Ui<()> {
697 build_ui(widget, Size::new(300.0, 400.0), runtime, &Theme::default())
698 }
699
700 fn painted(ui: &Ui<()>, color: Color) -> Vec<Rect> {
702 ui.scene()
703 .primitives()
704 .iter()
705 .filter_map(|p| match p {
706 Primitive::Rect { rect, color: c, .. } if *c == color => Some(*rect),
707 _ => None,
708 })
709 .collect()
710 }
711
712 fn step(runtime: &mut Runtime, ui: &Ui<()>) {
714 let regions = ui.scroll_regions().to_vec();
715 runtime.advance_scroll(®ions, FRAME);
716 }
717
718 #[test]
721 fn a_row_that_fits_again_is_drawn_from_its_start() {
722 let mut runtime = Runtime::default();
723 let wide = strip(300.0);
724 let id = frame(&wide, &runtime).scroll_regions()[0].id;
725 runtime.scroll.insert(id, (200.0, 0.0));
727 let ui = frame(&wide, &runtime);
728 assert_eq!(painted(&ui, MARK)[0].x, -200.0, "the end of the row shows");
729 step(&mut runtime, &ui);
730
731 let narrow = strip(80.0);
732 let ui = frame(&narrow, &runtime);
733 assert_eq!(ui.scroll_regions()[0].max_x, 0.0, "nothing left to scroll");
734 assert_eq!(
735 painted(&ui, MARK)[0].x,
736 0.0,
737 "drawn from its start in the very frame it fits"
738 );
739 step(&mut runtime, &ui);
740 assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 0.0)));
741 assert_eq!(painted(&frame(&narrow, &runtime), MARK)[0].x, 0.0);
742 }
743
744 #[test]
746 fn a_row_that_still_overflows_rests_at_its_new_end() {
747 let mut runtime = Runtime::default();
748 let wide = strip(300.0);
749 let id = frame(&wide, &runtime).scroll_regions()[0].id;
750 runtime.scroll.insert(id, (200.0, 0.0));
751 for _ in 0..3 {
753 let ui = frame(&wide, &runtime);
754 step(&mut runtime, &ui);
755 }
756
757 let narrower = strip(150.0);
758 let ui = frame(&narrower, &runtime);
759 let mark = painted(&ui, MARK)[0];
760 assert_eq!(
761 (mark.x, mark.x + mark.width),
762 (-50.0, 100.0),
763 "its last pixel on the viewport's right edge"
764 );
765 for _ in 0..10 {
766 let ui = frame(&narrower, &runtime);
767 step(&mut runtime, &ui);
768 }
769 assert_eq!(runtime.scroll.get(&id), Some(&(50.0, 0.0)));
770 assert_eq!(
773 runtime.scrollbar_fade_of(id).opacity,
774 0.0,
775 "no bar summoned"
776 );
777 }
778
779 #[test]
781 fn a_list_that_loses_rows_keeps_its_last_row_on_the_floor() {
782 let mut runtime = Runtime::default();
783 let id = frame(&rows(10), &runtime).scroll_regions()[0].id;
784 runtime.scroll.insert(id, (0.0, 200.0));
785
786 let ui = frame(&rows(6), &runtime);
788 let last = painted(&ui, MARK)
789 .into_iter()
790 .max_by(|a, b| a.y.total_cmp(&b.y))
791 .expect("a row is painted");
792 assert_eq!(last.y + last.height, 200.0, "the last row on the floor");
793 step(&mut runtime, &ui);
794 assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 40.0)));
795
796 let ui = frame(&rows(3), &runtime);
798 let first = painted(&ui, MARK)
799 .into_iter()
800 .min_by(|a, b| a.y.total_cmp(&b.y))
801 .expect("a row is painted");
802 assert_eq!(first.y, 0.0);
803 assert_eq!(painted(&ui, MARK).len(), 3, "every row shows");
804 step(&mut runtime, &ui);
805 assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 0.0)));
806 }
807
808 #[test]
810 fn a_paged_view_that_loses_pages_shows_its_new_last_page() {
811 let shade = |index: usize| Color::rgb(0.0, 0.0, (index + 1) as f32 / 10.0);
812 let pages = |count: usize| {
813 PageView::<()>::new(count, move |index| {
814 Container::<()>::new().color(shade(index))
815 })
816 .width(300.0)
817 .height(400.0)
818 };
819 let mut runtime = Runtime::default();
820 let id = frame(&pages(5), &runtime).scroll_regions()[0].id;
821 runtime.scroll.insert(id, (1200.0, 0.0));
822 assert_eq!(painted(&frame(&pages(5), &runtime), shade(4))[0].x, 0.0);
823
824 let ui = frame(&pages(2), &runtime);
825 assert_eq!(
826 painted(&ui, shade(1)).first().map(|r| r.x),
827 Some(0.0),
828 "page 1 fills the viewport"
829 );
830 assert!(painted(&ui, shade(4)).is_empty(), "no page past the end");
831 step(&mut runtime, &ui);
832 assert_eq!(runtime.scroll.get(&id), Some(&(300.0, 0.0)));
833 }
834
835 #[test]
838 fn a_reversed_row_is_brought_back_the_same_way() {
839 let mut runtime = Runtime::default();
840 let wide = strip(300.0).reverse();
841 let id = frame(&wide, &runtime).scroll_regions()[0].id;
842 runtime.scroll.insert(id, (200.0, 0.0));
843 assert_eq!(
844 painted(&frame(&wide, &runtime), MARK)[0].x,
845 0.0,
846 "its start shows"
847 );
848
849 let narrower = strip(150.0).reverse();
850 let ui = frame(&narrower, &runtime);
851 assert_eq!(
852 painted(&ui, MARK)[0].x,
853 0.0,
854 "its start against the left edge: {:?}",
855 painted(&ui, MARK)
856 );
857 step(&mut runtime, &ui);
858 assert_eq!(runtime.scroll.get(&id), Some(&(50.0, 0.0)));
859 }
860
861 #[test]
864 fn a_held_row_is_not_yanked_from_under_the_finger() {
865 let mut runtime = Runtime::default();
866 let wide = strip(300.0);
867 let id = frame(&wide, &runtime).scroll_regions()[0].id;
868 runtime.scroll.insert(id, (200.0, 0.0));
869 runtime.hold_scroll(id);
870
871 let narrower = strip(150.0);
872 for _ in 0..10 {
873 let ui = frame(&narrower, &runtime);
874 assert_eq!(painted(&ui, MARK)[0].x, -200.0, "where the finger left it");
875 step(&mut runtime, &ui);
876 }
877 assert_eq!(runtime.scroll.get(&id), Some(&(200.0, 0.0)));
878
879 runtime.release_scroll(id);
880 let area = frame(&narrower, &runtime).scroll_regions()[0];
881 assert!(
882 runtime.fling_scroll(area, ScrollPhysics::CLAMPING, (0.0, 0.0)),
883 "a release out of range springs"
884 );
885 for _ in 0..600 {
886 let ui = frame(&narrower, &runtime);
887 step(&mut runtime, &ui);
888 }
889 let rest = runtime.scroll.get(&id).copied().unwrap_or_default();
890 assert!((rest.0 - 50.0).abs() < 1.0, "home at the new end: {rest:?}");
891 }
892}