Skip to main content

frus_widgets/
sticky.rs

1//! [`StickyHeader`]: a section's header that stays at the top of the scroll view while its
2//! section is in view (milestone 602).
3
4use frus_core::{Rect, Scene};
5use frus_layout::{Align, FlexDirection, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::{FillAxes, Widget};
10
11/// **A section whose header sticks**: in a scroll view, the header stays at the top of the
12/// visible part while the rest of its section goes under it, and is pushed off by the next
13/// section's header as that one arrives.
14///
15/// ```
16/// use frus_widgets::{Flex, SingleChildScrollView, StickyHeader, Text};
17///
18/// let _contacts = SingleChildScrollView::<()>::new().child(
19///     Flex::column()
20///         .child(StickyHeader::new(Text::new("A"), Text::new("Ada\nAlan")))
21///         .child(StickyHeader::new(Text::new("B"), Text::new("Barbara\nBjarne"))),
22/// );
23/// ```
24///
25/// The header is laid out where a column would put it, above its section, and takes that
26/// room. Only where it is painted and where it takes taps moves: never above the section's
27/// top, never below its bottom, at the top of what is visible in between. It is drawn over
28/// the section, which goes under it. Down a vertical scroll only.
29pub struct StickyHeader<Msg = crate::callback::Callback> {
30    children: Vec<Box<dyn Widget<Msg>>>,
31}
32
33impl<Msg> StickyHeader<Msg> {
34    /// A section of `content` under `header`.
35    pub fn new(header: impl Widget<Msg> + 'static, content: impl Widget<Msg> + 'static) -> Self {
36        Self {
37            children: vec![Box::new(header), Box::new(content)],
38        }
39    }
40}
41
42impl<Msg: Clone> Widget<Msg> for StickyHeader<Msg> {
43    fn style(&self) -> Style {
44        Style {
45            flex_direction: FlexDirection::Column,
46            align: Align::Stretch,
47            flex_shrink: 0.0,
48            ..Style::default()
49        }
50    }
51
52    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
53        &self.children
54    }
55
56    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
57
58    fn on_click(&self) -> Option<Msg> {
59        None
60    }
61
62    fn sticky_header(&self) -> bool {
63        true
64    }
65
66    /// The width: a section is a band across its list.
67    fn fill_axes(&self, _theme: &Theme) -> FillAxes {
68        FillAxes {
69            horizontal: true,
70            vertical: false,
71        }
72    }
73
74    fn debug_name(&self) -> &'static str {
75        "StickyHeader"
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::interaction::WidgetId;
83    use crate::{build_ui, Container, Flex, Runtime, SingleChildScrollView};
84    use frus_core::{Color, Point, Primitive, Size};
85
86    const RED: Color = Color::rgb(1.0, 0.0, 0.0);
87    const BLUE: Color = Color::rgb(0.0, 0.0, 1.0);
88    const GREY: Color = Color::rgb(0.5, 0.5, 0.5);
89
90    /// Two 120-px sections, each a 20-px header over a 100-px grey body, in a 200-px scroll.
91    /// The red header says 1 when tapped, the bodies 9.
92    fn sections() -> SingleChildScrollView<u8> {
93        // Through a key, as a list's rows usually are: the wrapper passes the question on.
94        let section = |color: Color, says: u8| {
95            crate::keyed(
96                says,
97                StickyHeader::new(
98                    Container::new().height(20.0).color(color).on_click(says),
99                    Container::new().height(100.0).color(GREY).on_click(9),
100                ),
101            )
102        };
103        SingleChildScrollView::new()
104            .width(200.0)
105            .height(200.0)
106            .child(
107                Flex::column()
108                    .child(section(RED, 1))
109                    .child(section(BLUE, 2))
110                    .child(Container::new().height(300.0)),
111            )
112    }
113
114    /// Where each header is painted, scrolled down by `offset`.
115    fn headers(offset: f32) -> (f32, f32) {
116        let mut runtime = Runtime::default();
117        runtime.scroll.insert(WidgetId::ROOT, (0.0, offset));
118        let ui = build_ui(
119            &sections(),
120            Size::new(200.0, 200.0),
121            &runtime,
122            &Theme::dark(),
123        );
124        let top = |color: Color| {
125            ui.scene()
126                .primitives()
127                .iter()
128                .find_map(|p| match p {
129                    Primitive::Rect { rect, color: c, .. } if *c == color => Some(rect.y),
130                    _ => None,
131                })
132                .expect("the header")
133        };
134        (top(RED), top(BLUE))
135    }
136
137    /// **At rest**, each header is where the column put it.
138    #[test]
139    fn at_rest_the_headers_are_where_they_were_laid_out() {
140        assert_eq!(headers(0.0), (0.0, 120.0));
141    }
142
143    /// **Scrolled into its section**, the header stays at the top while the next comes up.
144    #[test]
145    fn a_header_sticks_while_its_section_is_in_view() {
146        assert_eq!(headers(50.0), (0.0, 70.0));
147    }
148
149    /// **At the end of its section**, the next header pushes it off, and then sticks itself.
150    #[test]
151    fn the_next_header_pushes_it_off() {
152        assert_eq!(headers(110.0), (-10.0, 10.0));
153        assert_eq!(headers(130.0), (-30.0, 0.0));
154    }
155
156    /// **It is drawn over its section**, and takes the tap there: the body under a stuck
157    /// header does not.
158    #[test]
159    fn a_stuck_header_is_over_its_section_and_takes_the_tap() {
160        let mut runtime = Runtime::default();
161        runtime.scroll.insert(WidgetId::ROOT, (0.0, 50.0));
162        let ui = build_ui(
163            &sections(),
164            Size::new(200.0, 200.0),
165            &runtime,
166            &Theme::dark(),
167        );
168        let order: Vec<Color> = ui
169            .scene()
170            .primitives()
171            .iter()
172            .filter_map(|p| match p {
173                Primitive::Rect { color, .. } if *color == RED || *color == GREY => Some(*color),
174                _ => None,
175            })
176            .collect();
177        assert_eq!(order.first(), Some(&GREY), "the body first: {order:?}");
178        assert_eq!(order.get(1), Some(&RED), "then its header: {order:?}");
179        let hit = ui.hit(Point::new(100.0, 10.0)).expect("something takes it");
180        assert_eq!(ui.msg_for(hit), Some(1));
181        let hit = ui.hit(Point::new(100.0, 40.0)).expect("the body below");
182        assert_eq!(ui.msg_for(hit), Some(9));
183    }
184
185    /// **As the rows of a list**, the same: a list lays its rows out one by one, and the
186    /// header still sticks and is pushed off.
187    #[test]
188    fn a_header_sticks_in_a_list() {
189        let list = crate::ListView::<()>::new(4, 120.0, |i| {
190            StickyHeader::new(
191                Container::<()>::new()
192                    .height(20.0)
193                    .color(if i == 0 { RED } else { BLUE }),
194                Container::<()>::new().height(100.0).color(GREY),
195            )
196        })
197        .width(200.0)
198        .height(200.0);
199        let mut runtime = Runtime::default();
200        runtime.scroll.insert(WidgetId::ROOT, (0.0, 50.0));
201        let ui = build_ui(&list, Size::new(200.0, 200.0), &runtime, &Theme::dark());
202        let tops: Vec<(Color, f32)> = ui
203            .scene()
204            .primitives()
205            .iter()
206            .filter_map(|p| match p {
207                Primitive::Rect { rect, color, .. } if *color == RED || *color == BLUE => {
208                    Some((*color, rect.y))
209                }
210                _ => None,
211            })
212            .collect();
213        assert!(tops.contains(&(RED, 0.0)), "{tops:?}");
214        assert!(tops.contains(&(BLUE, 70.0)), "{tops:?}");
215    }
216}