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
//! [`ScrollOverlay`]: something drawn **over** a scroll region, built from where that
//! region has got to.
//!
//! A bar that is tall with a picture in it at the top of a page and shrinks to a plain
//! toolbar as you scroll; a rule that fills as the page goes down; a shadow that appears
//! under a header the moment anything has scrolled beneath it. All of them are the same
//! shape: a thing over a list, redrawn as the list moves.
//!
//! ## Why this rather than a message
//!
//! [`Widget::on_scroll`] can already tell an application where
//! a region is, and an application could keep that in its state and build a header from
//! it. It would work, and it would be wrong for this: the offset would go out as a
//! message, come back as a rebuild of the **whole** view, and arrive a frame late — a
//! header that lags the list it is attached to by one frame, at every frame of every
//! fling.
//!
//! The division is worth stating plainly, because both exist and they are not
//! substitutes:
//!
//! - **`on_scroll` is for what changes the application's state** — loading the next page,
//! remembering where somebody was. It reports the extents as well as the offset, it
//! costs a message, and what it changes is *true* between frames.
//! - **`ScrollOverlay` is for what changes only the picture.** It is handed the offset
//! during the frame that draws it, so nothing lags and nothing round-trips, and it
//! knows nothing but the offset — see below.
//!
//! ## What it is handed, and what it is not
//!
//! **The offset, and its own box** — the same box a [`LayoutBuilder`](crate::LayoutBuilder)
//! is handed, and for the same reason: an overlay that does not know how wide it is cannot
//! be a band across the page, and a box left to size itself is as wide as its title.
//!
//! **Not the extents.** How far the content *can* scroll is a fact about a layout that has
//! not finished — this builder runs inside it — and a number handed over here would be the
//! previous frame's without being able to say so. An overlay that genuinely needs "how near
//! the end are we" reads it through `on_scroll`, which is measured, and keeps it in the
//! application's state where a measured thing belongs.
//!
//! ## The price, which is the same one `LayoutBuilder` pays
//!
//! The overlay is **rebuilt every frame the region moves**, so it has **no retained
//! state**: hover and clicks work, persistent keyboard focus and deferred overlays do
//! not. Put a menu in the body, not in the overlay. The body itself is an ordinary child
//! and keeps everything.
//!
//! ```ignore
//! ScrollOverlay::new(list, |(_, y), box_| {
//! let collapsed = (y / 160.0).clamp(0.0, 1.0);
//! Column::new().child(header(collapsed, box_.width))
//! })
//! ```
use frus_core::{Rect, Scene, Size};
use frus_layout::Style;
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::Widget;
/// What an overlay is built by: `offset → widget`, run once per frame the region moves.
///
/// Named because it appears in [`Widget::scroll_overlay`]
/// as well, and a signature spelled out twice is a signature that can be spelled
/// differently twice.
pub type OverlayBuilder<Msg> = dyn Fn((f32, f32), Size) -> Box<dyn Widget<Msg>>;
/// A scroll region with something drawn over it that knows where it has got to.
///
/// The region is the **child**, which is the whole of the identity design: the offset
/// this reads is its own child's, so nothing has to be named, looked up, or kept in step.
/// A builder floating free of the region it watches would need one of those three, and
/// each of them can go stale without saying so.
pub struct ScrollOverlay<Msg = crate::callback::Callback> {
/// The scroll region, as the one-element slice [`Widget::children`] hands back.
body: Vec<Box<dyn Widget<Msg>>>,
/// `(offset, box) → overlay`, run once per frame the region moves.
build: Box<OverlayBuilder<Msg>>,
}
impl<Msg> ScrollOverlay<Msg> {
/// Draws `build(offset)` over `body`, a scroll region.
///
/// The offset is `(x, y)`, measured from the end each axis starts at — the same
/// numbers [`crate::ScrollPosition::offset`] carries, and the same ones a reversed
/// list counts from its newest end.
///
/// `body` does not have to be a scroll region: one that is not simply never moves,
/// and the overlay is built at `(0, 0)` for ever. That is a useless composition
/// rather than a broken one, which is the right way round.
pub fn new<W: Widget<Msg> + 'static>(
body: impl Widget<Msg> + 'static,
build: impl Fn((f32, f32), Size) -> W + 'static,
) -> Self {
Self {
body: vec![Box::new(body)],
build: Box::new(move |offset, size| {
Box::new(build(offset, size)) as Box<dyn Widget<Msg>>
}),
}
}
/// The body, for the walk that needs it before the children slice is handed out.
fn inner(&self) -> &dyn Widget<Msg> {
self.body[0].as_ref()
}
}
impl<Msg: 'static> Widget<Msg> for ScrollOverlay<Msg> {
/// **The body's box, exactly.** An overlay is drawn over something, so the something
/// is what decides the shape: a wrapper with a box of its own would put the region
/// somewhere other than where the caller placed it, and the overlay would be over the
/// wrong rectangle.
fn style(&self) -> Style {
self.inner().style()
}
fn style_themed(&self, theme: &Theme) -> Style {
self.inner().style_themed(theme)
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.body
}
/// Nothing of its own: everything drawn here is drawn by the body or by the overlay.
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn debug_name(&self) -> &'static str {
"ScrollOverlay"
}
fn scroll_overlay(&self) -> Option<&OverlayBuilder<Msg>> {
Some(&*self.build)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interaction::WidgetId;
use crate::ui::child_id;
use crate::{build_ui, Container, ListView, Runtime, Size};
use frus_core::{Color, Primitive};
/// A list of a hundred rows in a 200-tall window, with a bar over it whose colour
/// says how far down the list has got.
fn tree(runtime: &Runtime) -> (ScrollOverlay<()>, WidgetId) {
let list = ListView::<()>::new(100, 40.0, |_| {
Container::<()>::new().height(40.0).color(Color::WHITE)
})
.width(300.0)
.height(200.0);
let overlay = ScrollOverlay::new(list, |(_, y), _| {
// A block whose height is the offset, so the picture states the number.
Container::<()>::new()
.width(300.0)
.height((y / 10.0).clamp(1.0, 100.0))
.color(Color::rgb(1.0, 0.0, 0.0))
});
let _ = runtime;
let id = WidgetId::ROOT;
(overlay, id)
}
/// The height of the red block, which is this test's readout of the offset the
/// overlay was built with.
fn marker_height(runtime: &Runtime) -> Option<f32> {
let (overlay, _) = tree(runtime);
let size = Size::new(300.0, 200.0);
let ui = build_ui(&overlay, size, runtime, &crate::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,
})
}
#[test]
fn an_overlay_is_built_from_where_the_body_has_got_to() {
// Nothing scrolled: the builder sees zero and the block is at its floor.
let mut runtime = Runtime::default();
assert_eq!(marker_height(&runtime), Some(1.0));
// The region's offset is the body's, keyed by the body's own identity — which is
// the point of the region being the child rather than something named.
let (overlay, id) = tree(&runtime);
let body = child_id(id, 0, overlay.children()[0].as_ref());
runtime.scroll.insert(body, (0.0, 400.0));
assert_eq!(
marker_height(&runtime),
Some(40.0),
"the overlay follows the offset without anything being told"
);
}
#[test]
fn the_body_is_still_an_ordinary_scroll_region() {
// The wrapper must not swallow what makes the body a region: it is registered,
// it has somewhere to go, and it is the identity the offset is keyed by.
let runtime = Runtime::default();
let (overlay, id) = tree(&runtime);
let size = Size::new(300.0, 200.0);
let ui = build_ui(&overlay, size, &runtime, &crate::Theme::dark());
let body = child_id(id, 0, overlay.children()[0].as_ref());
let area = ui
.scroll_region(body)
.expect("the body is registered as a scroll region in its own right");
assert!(
(area.max_y - (100.0 * 40.0 - 200.0)).abs() < 1.0,
"a hundred rows of forty in a window of two hundred: {}",
area.max_y
);
}
#[test]
fn the_overlay_is_drawn_over_the_body_and_not_under_it() {
// Order is the whole of what "overlay" means, and it is also what makes a button
// in the overlay clickable: the hit registry is read last-first.
let runtime = Runtime::default();
let (overlay, _) = tree(&runtime);
let size = Size::new(300.0, 200.0);
let ui = build_ui(&overlay, size, &runtime, &crate::Theme::dark());
let prims = ui.scene().primitives();
let last_row = prims.iter().rposition(
|p| matches!(p, Primitive::Rect { color, .. } if color.r > 0.9 && color.g > 0.9),
);
let marker = prims.iter().position(
|p| matches!(p, Primitive::Rect { color, .. } if color.r > 0.9 && color.g < 0.1),
);
assert!(
matches!((last_row, marker), (Some(row), Some(marker)) if marker > row),
"the overlay comes after every row: {last_row:?} then {marker:?}"
);
}
}