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