frus_widgets/scrolloverlay.rs
1//! [`ScrollOverlay`]: something drawn **over** a scroll region, built from where that
2//! region has got to.
3//!
4//! A bar that is tall with a picture in it at the top of a page and shrinks to a plain
5//! toolbar as you scroll; a rule that fills as the page goes down; a shadow that appears
6//! under a header the moment anything has scrolled beneath it. All of them are the same
7//! shape: a thing over a list, redrawn as the list moves.
8//!
9//! ## Why this rather than a message
10//!
11//! [`Widget::on_scroll`] can already tell an application where
12//! a region is, and an application could keep that in its state and build a header from
13//! it. It would work, and it would be wrong for this: the offset would go out as a
14//! message, come back as a rebuild of the **whole** view, and arrive a frame late — a
15//! header that lags the list it is attached to by one frame, at every frame of every
16//! fling.
17//!
18//! The division is worth stating plainly, because both exist and they are not
19//! substitutes:
20//!
21//! - **`on_scroll` is for what changes the application's state** — loading the next page,
22//! remembering where somebody was. It reports the extents as well as the offset, it
23//! costs a message, and what it changes is *true* between frames.
24//! - **`ScrollOverlay` is for what changes only the picture.** It is handed the offset
25//! during the frame that draws it, so nothing lags and nothing round-trips, and it
26//! knows nothing but the offset — see below.
27//!
28//! ## What it is handed, and what it is not
29//!
30//! **The offset, and its own box** — the same box a [`LayoutBuilder`](crate::LayoutBuilder)
31//! is handed, and for the same reason: an overlay that does not know how wide it is cannot
32//! be a band across the page, and a box left to size itself is as wide as its title.
33//!
34//! **Not the extents.** How far the content *can* scroll is a fact about a layout that has
35//! not finished — this builder runs inside it — and a number handed over here would be the
36//! previous frame's without being able to say so. An overlay that genuinely needs "how near
37//! the end are we" reads it through `on_scroll`, which is measured, and keeps it in the
38//! application's state where a measured thing belongs.
39//!
40//! ## The price, which is the same one `LayoutBuilder` pays
41//!
42//! The overlay is **rebuilt every frame the region moves**, so it has **no retained
43//! state**: hover and clicks work, persistent keyboard focus and deferred overlays do
44//! not. Put a menu in the body, not in the overlay. The body itself is an ordinary child
45//! and keeps everything.
46//!
47//! ```ignore
48//! ScrollOverlay::new(list, |(_, y), box_| {
49//! let collapsed = (y / 160.0).clamp(0.0, 1.0);
50//! Column::new().child(header(collapsed, box_.width))
51//! })
52//! ```
53
54use frus_core::{Rect, Scene, Size};
55use frus_layout::Style;
56
57use crate::interaction::Status;
58use crate::theme::Theme;
59use crate::widget::Widget;
60
61/// What an overlay is built by: `offset → widget`, run once per frame the region moves.
62///
63/// Named because it appears in [`Widget::scroll_overlay`]
64/// as well, and a signature spelled out twice is a signature that can be spelled
65/// differently twice.
66pub type OverlayBuilder<Msg> = dyn Fn((f32, f32), Size) -> Box<dyn Widget<Msg>>;
67
68/// A scroll region with something drawn over it that knows where it has got to.
69///
70/// The region is the **child**, which is the whole of the identity design: the offset
71/// this reads is its own child's, so nothing has to be named, looked up, or kept in step.
72/// A builder floating free of the region it watches would need one of those three, and
73/// each of them can go stale without saying so.
74pub struct ScrollOverlay<Msg = crate::callback::Callback> {
75 /// The scroll region, as the one-element slice [`Widget::children`] hands back.
76 body: Vec<Box<dyn Widget<Msg>>>,
77 /// `(offset, box) → overlay`, run once per frame the region moves.
78 build: Box<OverlayBuilder<Msg>>,
79}
80
81impl<Msg> ScrollOverlay<Msg> {
82 /// Draws `build(offset)` over `body`, a scroll region.
83 ///
84 /// The offset is `(x, y)`, measured from the end each axis starts at — the same
85 /// numbers [`crate::ScrollPosition::offset`] carries, and the same ones a reversed
86 /// list counts from its newest end.
87 ///
88 /// `body` does not have to be a scroll region: one that is not simply never moves,
89 /// and the overlay is built at `(0, 0)` for ever. That is a useless composition
90 /// rather than a broken one, which is the right way round.
91 pub fn new<W: Widget<Msg> + 'static>(
92 body: impl Widget<Msg> + 'static,
93 build: impl Fn((f32, f32), Size) -> W + 'static,
94 ) -> Self {
95 Self {
96 body: vec![Box::new(body)],
97 build: Box::new(move |offset, size| {
98 Box::new(build(offset, size)) as Box<dyn Widget<Msg>>
99 }),
100 }
101 }
102
103 /// The body, for the walk that needs it before the children slice is handed out.
104 fn inner(&self) -> &dyn Widget<Msg> {
105 self.body[0].as_ref()
106 }
107}
108
109impl<Msg: 'static> Widget<Msg> for ScrollOverlay<Msg> {
110 /// **The body's box, exactly.** An overlay is drawn over something, so the something
111 /// is what decides the shape: a wrapper with a box of its own would put the region
112 /// somewhere other than where the caller placed it, and the overlay would be over the
113 /// wrong rectangle.
114 fn style(&self) -> Style {
115 self.inner().style()
116 }
117
118 fn style_themed(&self, theme: &Theme) -> Style {
119 self.inner().style_themed(theme)
120 }
121
122 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
123 &self.body
124 }
125
126 /// Nothing of its own: everything drawn here is drawn by the body or by the overlay.
127 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
128
129 fn on_click(&self) -> Option<Msg> {
130 None
131 }
132
133 fn debug_name(&self) -> &'static str {
134 "ScrollOverlay"
135 }
136
137 fn scroll_overlay(&self) -> Option<&OverlayBuilder<Msg>> {
138 Some(&*self.build)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::interaction::WidgetId;
146 use crate::ui::child_id;
147 use crate::{build_ui, Container, ListView, Runtime, Size};
148 use frus_core::{Color, Primitive};
149
150 /// A list of a hundred rows in a 200-tall window, with a bar over it whose colour
151 /// says how far down the list has got.
152 fn tree(runtime: &Runtime) -> (ScrollOverlay<()>, WidgetId) {
153 let list = ListView::<()>::new(100, 40.0, |_| {
154 Container::<()>::new().height(40.0).color(Color::WHITE)
155 })
156 .width(300.0)
157 .height(200.0);
158 let overlay = ScrollOverlay::new(list, |(_, y), _| {
159 // A block whose height is the offset, so the picture states the number.
160 Container::<()>::new()
161 .width(300.0)
162 .height((y / 10.0).clamp(1.0, 100.0))
163 .color(Color::rgb(1.0, 0.0, 0.0))
164 });
165 let _ = runtime;
166 let id = WidgetId::ROOT;
167 (overlay, id)
168 }
169
170 /// The height of the red block, which is this test's readout of the offset the
171 /// overlay was built with.
172 fn marker_height(runtime: &Runtime) -> Option<f32> {
173 let (overlay, _) = tree(runtime);
174 let size = Size::new(300.0, 200.0);
175 let ui = build_ui(&overlay, size, runtime, &crate::Theme::dark());
176 ui.scene().primitives().iter().find_map(|p| match p {
177 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
178 Some(rect.height)
179 }
180 _ => None,
181 })
182 }
183
184 #[test]
185 fn an_overlay_is_built_from_where_the_body_has_got_to() {
186 // Nothing scrolled: the builder sees zero and the block is at its floor.
187 let mut runtime = Runtime::default();
188 assert_eq!(marker_height(&runtime), Some(1.0));
189
190 // The region's offset is the body's, keyed by the body's own identity — which is
191 // the point of the region being the child rather than something named.
192 let (overlay, id) = tree(&runtime);
193 let body = child_id(id, 0, overlay.children()[0].as_ref());
194 runtime.scroll.insert(body, (0.0, 400.0));
195 assert_eq!(
196 marker_height(&runtime),
197 Some(40.0),
198 "the overlay follows the offset without anything being told"
199 );
200 }
201
202 #[test]
203 fn the_body_is_still_an_ordinary_scroll_region() {
204 // The wrapper must not swallow what makes the body a region: it is registered,
205 // it has somewhere to go, and it is the identity the offset is keyed by.
206 let runtime = Runtime::default();
207 let (overlay, id) = tree(&runtime);
208 let size = Size::new(300.0, 200.0);
209 let ui = build_ui(&overlay, size, &runtime, &crate::Theme::dark());
210 let body = child_id(id, 0, overlay.children()[0].as_ref());
211 let area = ui
212 .scroll_region(body)
213 .expect("the body is registered as a scroll region in its own right");
214 assert!(
215 (area.max_y - (100.0 * 40.0 - 200.0)).abs() < 1.0,
216 "a hundred rows of forty in a window of two hundred: {}",
217 area.max_y
218 );
219 }
220
221 #[test]
222 fn the_overlay_is_drawn_over_the_body_and_not_under_it() {
223 // Order is the whole of what "overlay" means, and it is also what makes a button
224 // in the overlay clickable: the hit registry is read last-first.
225 let runtime = Runtime::default();
226 let (overlay, _) = tree(&runtime);
227 let size = Size::new(300.0, 200.0);
228 let ui = build_ui(&overlay, size, &runtime, &crate::Theme::dark());
229 let prims = ui.scene().primitives();
230 let last_row = prims.iter().rposition(
231 |p| matches!(p, Primitive::Rect { color, .. } if color.r > 0.9 && color.g > 0.9),
232 );
233 let marker = prims.iter().position(
234 |p| matches!(p, Primitive::Rect { color, .. } if color.r > 0.9 && color.g < 0.1),
235 );
236 assert!(
237 matches!((last_row, marker), (Some(row), Some(marker)) if marker > row),
238 "the overlay comes after every row: {last_row:?} then {marker:?}"
239 );
240 }
241}