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}