Skip to main content

frus_widgets/
collapsingheader.rs

1//! [`CollapsingHeader`]: a bar that is tall at the top of a page and shrinks to a plain
2//! toolbar as the page scrolls, then stays there.
3//!
4//! The composition [`ScrollOverlay`](crate::ScrollOverlay) exists for, and the one every
5//! application writes: a header with a picture or an oversized title in it, a long list
6//! under it, and the first turning into the second as the second moves.
7//!
8//! ```ignore
9//! CollapsingHeader::new(list, |state| header(state.fraction))
10//!     .collapsed_height(56.0)
11//!     .build()
12//! ```
13//!
14//! ## Where the header's room comes from
15//!
16//! **The body's own top padding**, which already means "room at the top of this list"
17//! and already scrolls with the content. So the expanded height is stated once, in the
18//! place that was going to state it anyway, and the header and the space it needs cannot
19//! drift apart:
20//!
21//! ```text
22//! ListView::new(…).padding_each(220.0, 0.0, 0.0, 0.0)   // the header's room
23//! ```
24//!
25//! [`CollapsingHeader::expanded_height`] overrides it for a body that has other reasons
26//! for its padding, and a body that scrolls with no room at the top gets a header that is
27//! already collapsed — which is what a zero-height expansion means and not a bug.
28//!
29//! ## What it does not do
30//!
31//! It does not make a page out of a list, a grid and another list scrolling as one. That
32//! wants a viewport laying out a sequence of scrollable pieces, each given a remaining
33//! extent and each reporting what it consumed, and it is a much larger piece of work —
34//! see the roadmap.
35//!
36//! Nor is it a **sticky section header**, the other thing people reach for: one header per
37//! section, each pinned until the next pushes it off. That is expressible on
38//! [`ScrollOverlay`](crate::ScrollOverlay) — work out which section the offset is in and
39//! draw that one's header — and it is not written here. This widget is the answer to one
40//! of the three, chosen as an answer rather than as a down payment on the rest.
41
42use frus_core::{Rect, Size};
43
44use crate::container::Container;
45use crate::rowcolumn::Column;
46use crate::scrolloverlay::ScrollOverlay;
47use crate::widget::Widget;
48
49/// How far collapsed the header is, handed to its builder every frame it moves.
50#[derive(Copy, Clone, Debug, PartialEq)]
51pub struct HeaderState {
52    /// `0.0` fully expanded, `1.0` fully collapsed. **What a header should be written
53    /// against**: a size, an opacity or a corner interpolated on this reads the same
54    /// whatever the two heights happen to be, and survives somebody changing them.
55    pub fraction: f32,
56    /// The height the header is being drawn at, in logical pixels.
57    pub height: f32,
58    /// How wide it is — the page, not the title.
59    ///
60    /// Handed over because a header **has** to state it: a box left to size itself is as
61    /// wide as its own content, which is a band with the list showing either side of it.
62    /// The first golden of this widget was exactly that, and no test measuring heights
63    /// could have seen it.
64    pub width: f32,
65    /// The height it has when nothing has scrolled.
66    pub expanded_height: f32,
67    /// The height it stops at, or `0` for a header that goes entirely.
68    pub collapsed_height: f32,
69}
70
71/// A header over a scroll region, interpolated between two heights by the region's offset.
72pub struct CollapsingHeader<Msg = crate::callback::Callback> {
73    body: Box<dyn Widget<Msg>>,
74    build: Box<dyn Fn(HeaderState) -> Box<dyn Widget<Msg>>>,
75    expanded: Option<f32>,
76    collapsed: f32,
77    pinned: bool,
78}
79
80/// The height a collapsed header stops at, unless told otherwise: the same toolbar
81/// height an application bar has, because what it collapses **to** is a toolbar.
82pub const COLLAPSED_HEIGHT: f32 = crate::appbar::APP_BAR_HEIGHT;
83
84impl<Msg: Clone + 'static> CollapsingHeader<Msg> {
85    /// A header built from `build`, over the scroll region `body`.
86    ///
87    /// The expanded height is the body's own top padding; see the module documentation
88    /// for why that is the number rather than a second one here.
89    pub fn new<W: Widget<Msg> + 'static>(
90        body: impl Widget<Msg> + 'static,
91        build: impl Fn(HeaderState) -> W + 'static,
92    ) -> Self {
93        Self {
94            body: Box::new(body),
95            build: Box::new(move |state| Box::new(build(state)) as Box<dyn Widget<Msg>>),
96            expanded: None,
97            collapsed: COLLAPSED_HEIGHT,
98            pinned: true,
99        }
100    }
101
102    /// The height when nothing has scrolled, overriding the body's top padding.
103    pub fn expanded_height(mut self, height: f32) -> Self {
104        self.expanded = Some(height.max(0.0));
105        self
106    }
107
108    /// The height it stops shrinking at. Ignored by [`CollapsingHeader::floating`].
109    pub fn collapsed_height(mut self, height: f32) -> Self {
110        self.collapsed = height.max(0.0);
111        self
112    }
113
114    /// **Goes entirely** instead of stopping at a toolbar, leaving the whole window to
115    /// the content — a reading view, where the chrome is worth its room only while
116    /// somebody is choosing what to read.
117    pub fn floating(mut self) -> Self {
118        self.pinned = false;
119        self
120    }
121
122    /// The widget: the body with the header drawn over it.
123    pub fn build(self) -> ScrollOverlay<Msg> {
124        // The room the body reserved at its top **is** the expanded height, unless the
125        // caller said otherwise. One number rather than two that have to agree.
126        let expanded = self
127            .expanded
128            .unwrap_or_else(|| self.body.scroll_padding().top);
129        let collapsed = if self.pinned {
130            self.collapsed.min(expanded)
131        } else {
132            0.0
133        };
134        // Never nought: a header whose two heights are the same is pinned at one height,
135        // and dividing by the difference would make the fraction meaningless rather than
136        // constant.
137        let range = (expanded - collapsed).max(1.0);
138        let build = self.build;
139        ScrollOverlay::new(self.body, move |(_, y), box_: Size| {
140            // The header gives way pixel for pixel with the content until it reaches its
141            // floor. Not a curve: a header that moved at some other rate than the thing
142            // under it reads as two pages sliding over each other.
143            let height = (expanded - y).max(collapsed);
144            let state = HeaderState {
145                fraction: ((expanded - height) / range).clamp(0.0, 1.0),
146                height,
147                width: box_.width,
148                expanded_height: expanded,
149                collapsed_height: collapsed,
150            };
151            // A floating header that has gone is **gone**, not a box of no height: an
152            // empty column draws nothing and registers nothing, where a zero-height
153            // header would still be asked to paint itself every frame.
154            let content: Box<dyn Widget<Msg>> = if height <= 0.0 {
155                Box::new(Column::new())
156            } else {
157                Box::new(
158                    Container::new()
159                        .width(box_.width)
160                        .height(height)
161                        .child(build(state)),
162                )
163            };
164            // Top of the box and no taller than it needs to be: the rest of the overlay
165            // is empty, so everything below the header belongs to the list — a finger
166            // there scrolls, and a row there is clickable.
167            //
168            // **The width is stated, not inherited**, which is why the builder is handed
169            // its box at all. A header is a band across the page, and a box left to size
170            // itself is as wide as its own title — a header with the list showing either
171            // side of it, which is exactly what the first golden of this caught and what
172            // no test measuring heights could have.
173            Column::new().child(content)
174        })
175    }
176}
177
178/// The header's own box, at the origin — for a caller placing something inside it by hand.
179pub fn header_rect(state: HeaderState) -> Rect {
180    Rect::new(0.0, 0.0, state.width, state.height)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::interaction::WidgetId;
187    use crate::ui::child_id;
188    use crate::{build_ui, ListView, Runtime, Size, Theme};
189    use frus_core::{Color, Primitive};
190
191    /// A list of a hundred rows reserving 200 px at its top, under a header that paints a
192    /// block whose height *is* the header's — so the picture states the number.
193    fn screen(expanded: f32, pinned: bool) -> ScrollOverlay<()> {
194        let list = ListView::<()>::new(100, 40.0, |_| {
195            Container::<()>::new().height(40.0).color(Color::WHITE)
196        })
197        .width(300.0)
198        .height(400.0)
199        .padding_each(expanded, 0.0, 0.0, 0.0);
200        let header = CollapsingHeader::new(list, |state| {
201            Container::<()>::new()
202                .width(state.width)
203                .height(state.height)
204                .color(Color::rgb(1.0, 0.0, 0.0))
205        })
206        .collapsed_height(60.0);
207        if pinned { header } else { header.floating() }.build()
208    }
209
210    /// The height of the red block: what the header was actually drawn at.
211    fn drawn(expanded: f32, pinned: bool, offset: f32) -> Option<f32> {
212        let mut runtime = Runtime::default();
213        let tree = screen(expanded, pinned);
214        let body = child_id(WidgetId::ROOT, 0, tree.children()[0].as_ref());
215        runtime.scroll.insert(body, (0.0, offset));
216        let tree = screen(expanded, pinned);
217        let ui = build_ui(&tree, Size::new(300.0, 400.0), &runtime, &Theme::dark());
218        ui.scene().primitives().iter().find_map(|p| match p {
219            Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
220                Some(rect.height)
221            }
222            _ => None,
223        })
224    }
225
226    /// The header's drawn box, not just its height: a header is a **band across the
227    /// page**, and the first golden of this one was a block the width of its own title
228    /// with the list showing either side of it. Heights alone never saw it.
229    fn drawn_box(expanded: f32, offset: f32) -> Option<frus_core::Rect> {
230        let mut runtime = Runtime::default();
231        let tree = screen(expanded, true);
232        let body = child_id(WidgetId::ROOT, 0, tree.children()[0].as_ref());
233        runtime.scroll.insert(body, (0.0, offset));
234        let tree = screen(expanded, true);
235        let ui = build_ui(&tree, Size::new(300.0, 400.0), &runtime, &Theme::dark());
236        ui.scene().primitives().iter().find_map(|p| match p {
237            Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => Some(*rect),
238            _ => None,
239        })
240    }
241
242    #[test]
243    fn the_header_is_a_band_across_the_whole_page() {
244        let open = drawn_box(200.0, 0.0).expect("drawn");
245        assert_eq!(
246            open.width, 300.0,
247            "a header narrower than the page is a header with the list beside it"
248        );
249        assert_eq!(open.x, 0.0, "and it starts at the edge");
250        // Still true once it has given way: the width is not a function of the height.
251        let shut = drawn_box(200.0, 400.0).expect("drawn");
252        assert_eq!(shut.width, 300.0);
253    }
254
255    #[test]
256    fn the_expanded_height_is_the_room_the_body_reserved() {
257        // The number is stated once, where it already had to be stated, so the header and
258        // the space under it cannot disagree.
259        assert_eq!(drawn(200.0, true, 0.0), Some(200.0));
260        assert_eq!(drawn(140.0, true, 0.0), Some(140.0));
261    }
262
263    #[test]
264    fn it_gives_way_pixel_for_pixel_and_then_stops() {
265        // Anything else reads as two pages sliding over each other.
266        assert_eq!(drawn(200.0, true, 50.0), Some(150.0));
267        assert_eq!(drawn(200.0, true, 140.0), Some(60.0));
268        // Its floor, and it stays there however far the list goes.
269        assert_eq!(drawn(200.0, true, 300.0), Some(60.0));
270        assert_eq!(drawn(200.0, true, 3000.0), Some(60.0));
271    }
272
273    #[test]
274    fn a_floating_header_goes_entirely() {
275        assert_eq!(drawn(200.0, false, 120.0), Some(80.0));
276        assert_eq!(
277            drawn(200.0, false, 260.0),
278            None,
279            "past its own height there is nothing left to draw"
280        );
281    }
282
283    #[test]
284    fn the_fraction_is_what_a_header_should_be_written_against() {
285        // A header interpolating on this reads the same whatever the two heights are,
286        // which is what makes it survive somebody changing them.
287        let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
288        let record = std::rc::Rc::clone(&seen);
289        let list = ListView::<()>::new(10, 40.0, |_| Container::<()>::new().height(40.0))
290            .width(300.0)
291            .height(400.0)
292            .padding_each(200.0, 0.0, 0.0, 0.0);
293        let tree = CollapsingHeader::new(list, move |state| {
294            record.borrow_mut().push((state.fraction, state.height));
295            Container::<()>::new()
296                .width(state.width)
297                .height(state.height)
298        })
299        .collapsed_height(100.0)
300        .build();
301        let mut runtime = Runtime::default();
302        let body = child_id(WidgetId::ROOT, 0, tree.children()[0].as_ref());
303        runtime.scroll.insert(body, (0.0, 50.0));
304        build_ui(&tree, Size::new(300.0, 400.0), &runtime, &Theme::dark());
305        let (fraction, height) = *seen.borrow().first().expect("the header was built");
306        assert!((height - 150.0).abs() < 0.01, "{height}");
307        assert!(
308            (fraction - 0.5).abs() < 0.01,
309            "halfway between 200 and 100 is half collapsed: {fraction}"
310        );
311    }
312
313    #[test]
314    fn a_collapsed_height_taller_than_the_expanded_one_is_held_to_it() {
315        // A header cannot collapse *upwards*, and the number that gives way is the one
316        // the caller typed rather than the one the body reserved.
317        assert_eq!(drawn(50.0, true, 0.0), Some(50.0));
318        assert_eq!(drawn(50.0, true, 200.0), Some(50.0));
319    }
320}