Skip to main content

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}