Skip to main content

ravenrs_widgets/
scroll_scene.rs

1//! Scroll-driven pages: content that stays pinned while its section scrolls past
2//! ([`Sticky`], [`ScrollScene`]) and effects that follow how far the page has
3//! scrolled ([`ScrollLinked`], [`ScrollLink`]).
4//!
5//! Everything here is resolved while frames are placed and painted. Scrolling still
6//! neither rebuilds the views nor runs layout again: a pinned element is moved when it
7//! is placed, so clicks, focus and screen readers find it where it shows, and a linked
8//! effect is one small calculation per element when it is painted.
9//!
10//! ```
11//! use ravenrs_widgets::*;
12//! use ravenrs_core::Effect;
13//!
14//! // A section that holds its content on screen for 1,200 pixels of scrolling,
15//! // fading the first panel out and the second one in halfway through.
16//! let scene = ScrollScene::new(ZStack::new((
17//!     ScrollLinked::new(Text::new("State"), ScrollLink::fade_out().between(0.4, 0.6)),
18//!     ScrollLinked::new(Text::new("View"), ScrollLink::fade_in().between(0.4, 0.6)),
19//! )))
20//! .length(1200.0)
21//! .top(64.0);
22//! ```
23
24use std::fmt;
25use std::rc::Rc;
26
27use ravenrs_animation::Easing;
28use ravenrs_core::{Effect, Insets, Rect};
29use ravenrs_layout::{Align, Axis, LayoutStyle};
30
31use crate::View;
32use crate::context::{BuildCx, NodeId, Paint};
33
34/// Which stretch of scrolling a [`ScrollLink`] counts as going from 0 to 1.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36#[non_exhaustive]
37#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
38pub enum ScrollRange {
39    /// The nearest [`ScrollScene`] around the element: 0 when the scene pins, 1 when it
40    /// lets go. Outside a scene, the same as [`Self::Through`].
41    #[default]
42    Scene,
43    /// The element's whole pass across the visible area: 0 when its top reaches the
44    /// bottom edge, 1 when its bottom leaves through the top edge.
45    Through,
46    /// The element coming in: 0 when its top reaches the bottom edge, 1 when it is fully
47    /// visible (or, if taller than the view, when its top reaches the top edge).
48    Enter,
49    /// The element going out: 0 when its top reaches the top edge, 1 when its bottom does.
50    Exit,
51}
52
53/// A scene's track, as [`BuildCx::scroll_track`] marks it.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub(crate) struct ScrollTrack {
56    pub top: f32,
57    pub length: f32,
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose)]
61impl ScrollRange {
62    /// How far along this range an element at `frame` is, inside the visible area
63    /// `view` of its scroll container, from 0 to 1. `scene` is the frame and track of
64    /// the nearest scene, which [`Self::Scene`] measures instead of the element.
65    pub(crate) fn progress(self, frame: Rect, view: Rect, scene: Option<(Rect, ScrollTrack)>) -> f32 {
66        let ratio = |num: f32, den: f32| {
67            if den > 0.0 {
68                (num / den).clamp(0.0, 1.0)
69            } else if num >= 0.0 {
70                1.0
71            } else {
72                0.0
73            }
74        };
75        match (self, scene) {
76            (Self::Scene, Some((track, t))) => ratio(view.y() + t.top - track.y(), t.length),
77            (Self::Scene | Self::Through, _) => {
78                ratio(view.max_y() - frame.y(), view.height() + frame.height())
79            }
80            (Self::Enter, _) => {
81                let span = frame.height().min(view.height());
82                ratio(view.max_y() - frame.y(), span)
83            }
84            (Self::Exit, _) => ratio(view.y() - frame.y(), frame.height()),
85        }
86    }
87}
88
89/// Turns scroll progress into an [`Effect`]: opacity, movement and scale.
90///
91/// Pick a [`ScrollRange`], narrow it to a window with [`Self::between`], shape it with
92/// an [`Easing`], and describe the effect with keyframes or any function. Presets cover
93/// the usual entrances and exits.
94#[derive(Clone)]
95#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
96pub struct ScrollLink {
97    range: ScrollRange,
98    from: f32,
99    to: f32,
100    easing: Easing,
101    map: Rc<dyn Fn(f32) -> Effect>,
102}
103
104impl fmt::Debug for ScrollLink {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.debug_struct("ScrollLink")
107            .field("range", &self.range)
108            .field("from", &self.from)
109            .field("to", &self.to)
110            .field("easing", &self.easing)
111            .finish_non_exhaustive()
112    }
113}
114
115#[cfg_attr(feature = "live", ravenrs_live::expose)]
116impl ScrollLink {
117    /// The effect is `map(t)`, where `t` runs from 0 to 1 over the window, after easing.
118    pub fn custom(map: impl Fn(f32) -> Effect + 'static) -> Self {
119        Self { range: ScrollRange::default(), from: 0.0, to: 1.0, easing: Easing::Linear, map: Rc::new(map) }
120    }
121
122    /// Interpolates between effects at given points of the window, like CSS keyframes.
123    /// Before the first stop the first effect holds, after the last the last one does.
124    /// No stops is no effect.
125    pub fn keyframes(stops: impl IntoIterator<Item = (f32, Effect)>) -> Self {
126        let mut stops: Vec<(f32, Effect)> = stops.into_iter().collect();
127        stops.sort_by(|a, b| a.0.total_cmp(&b.0));
128        Self::custom(move |t| keyframe_at(&stops, t))
129    }
130
131    /// Fades in and rises 24 pixels into place.
132    pub fn fade_in() -> Self {
133        Self::keyframes([(0.0, Effect::hidden().then(Effect::moved(0.0, 24.0))), (1.0, Effect::IDENTITY)])
134    }
135
136    /// Fades out and lifts 24 pixels away.
137    pub fn fade_out() -> Self {
138        Self::keyframes([(0.0, Effect::IDENTITY), (1.0, Effect::hidden().then(Effect::moved(0.0, -24.0)))])
139    }
140
141    /// Moves `distance` pixels down over the window: a negative distance moves it up.
142    /// Slower than the scroll, it reads as depth.
143    pub fn parallax(distance: f32) -> Self {
144        Self::custom(move |t| Effect::moved(0.0, distance * t)).easing(Easing::Linear)
145    }
146
147    /// Grows from `from` to full size.
148    pub fn grow_from(from: f32) -> Self {
149        Self::keyframes([(0.0, Effect::scaled(from)), (1.0, Effect::IDENTITY)])
150    }
151
152    /// Measures this range instead of the default [`ScrollRange::Scene`].
153    pub fn range(mut self, range: ScrollRange) -> Self {
154        self.range = range;
155        self
156    }
157
158    /// Plays the whole effect inside this part of the range, `0.0..=1.0`: before `from`
159    /// it shows its start, after `to` its end. Scenes use it to give each step its turn.
160    pub fn between(mut self, from: f32, to: f32) -> Self {
161        self.from = from;
162        self.to = to;
163        self
164    }
165
166    /// Shapes the progress inside the window (linear by default).
167    pub fn easing(mut self, easing: Easing) -> Self {
168        self.easing = easing;
169        self
170    }
171
172    /// The range this link measures.
173    pub fn scroll_range(&self) -> ScrollRange {
174        self.range
175    }
176
177    /// The effect at `progress` along the range, from 0 to 1.
178    pub fn effect_at(&self, progress: f32) -> Effect {
179        let span = self.to - self.from;
180        let t = if span.abs() <= f32::EPSILON {
181            if progress >= self.to { 1.0 } else { 0.0 }
182        } else {
183            ((progress - self.from) / span).clamp(0.0, 1.0)
184        };
185        (self.map)(self.easing.sample(t))
186    }
187}
188
189fn keyframe_at(stops: &[(f32, Effect)], t: f32) -> Effect {
190    let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Effect::IDENTITY };
191    if t <= first.0 {
192        return first.1;
193    }
194    if t >= last.0 {
195        return last.1;
196    }
197    for pair in stops.windows(2) {
198        let ((a, ea), (b, eb)) = (pair[0], pair[1]);
199        if t <= b {
200            let local = if b > a { (t - a) / (b - a) } else { 1.0 };
201            return ea.lerp(eb, local);
202        }
203    }
204    last.1
205}
206
207/// Keeps its content on screen while its parent scrolls past, like CSS
208/// `position: sticky`: it stays `top` pixels below the top of the scroll container
209/// and leaves with the bottom of its parent.
210#[derive(Debug)]
211pub struct Sticky<C> {
212    content: C,
213    top: f32,
214}
215
216#[cfg_attr(feature = "live", ravenrs_live::expose)]
217impl<C: View> Sticky<C> {
218    /// Sticks to the very top of the scroll container.
219    pub fn new(content: C) -> Self {
220        Self { content, top: 0.0 }
221    }
222
223    /// Distance from the top of the scroll container, for a bar that stays above it.
224    pub fn top(mut self, top: f32) -> Self {
225        self.top = top;
226        self
227    }
228}
229
230impl<C: View> View for Sticky<C> {
231    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
232        let child = cx.child(&self.content);
233        let node = cx.container(
234            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
235            &[child],
236            Paint::None,
237        );
238        cx.pin(node, self.top);
239        node
240    }
241}
242
243/// A section that holds its content on screen for `length` pixels of scrolling: the
244/// stage for steps that change as the reader scrolls. Inside it, [`ScrollLinked`]
245/// views measure [`ScrollRange::Scene`], from 0 when the content pins to 1 when the
246/// scene lets go.
247#[derive(Debug)]
248pub struct ScrollScene<C> {
249    content: C,
250    length: f32,
251    top: f32,
252}
253
254#[cfg_attr(feature = "live", ravenrs_live::expose)]
255impl<C: View> ScrollScene<C> {
256    /// Holds `content` for one screen's worth of scrolling (800 pixels) by default.
257    pub fn new(content: C) -> Self {
258        Self { content, length: 800.0, top: 0.0 }
259    }
260
261    /// How many pixels of scrolling the content stays pinned.
262    pub fn length(mut self, length: f32) -> Self {
263        self.length = length.max(0.0);
264        self
265    }
266
267    /// Distance from the top of the scroll container while pinned.
268    pub fn top(mut self, top: f32) -> Self {
269        self.top = top;
270        self
271    }
272}
273
274impl<C: View> View for ScrollScene<C> {
275    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
276        let child = cx.child(&self.content);
277        let pinned = cx.container(
278            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
279            &[child],
280            Paint::None,
281        );
282        cx.pin(pinned, self.top);
283        let style = LayoutStyle {
284            padding: Insets { bottom: self.length, ..Insets::ZERO },
285            align: Align::Stretch,
286            ..LayoutStyle::stack(Axis::Vertical)
287        };
288        let track = cx.container(style, &[pinned], Paint::None);
289        cx.scroll_track(track, self.top, self.length);
290        track
291    }
292}
293
294/// Paints its content with the effect a [`ScrollLink`] gives for the current scroll
295/// position. Like the effects of [`crate::Motion`], it changes how the content looks,
296/// not its layout or where it is clicked.
297#[derive(Debug)]
298pub struct ScrollLinked<C> {
299    content: C,
300    link: ScrollLink,
301}
302
303#[cfg_attr(feature = "live", ravenrs_live::expose)]
304impl<C: View> ScrollLinked<C> {
305    /// `content` painted through `link`.
306    pub fn new(content: C, link: ScrollLink) -> Self {
307        Self { content, link }
308    }
309}
310
311impl<C: View> View for ScrollLinked<C> {
312    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
313        let child = cx.child(&self.content);
314        let node = cx.container(
315            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
316            &[child],
317            Paint::None,
318        );
319        cx.scroll_linked(node, self.link.clone());
320        node
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use ravenrs_core::Point;
327
328    use super::*;
329
330    fn view() -> Rect {
331        Rect::new(0.0, 0.0, 800.0, 600.0)
332    }
333
334    #[test]
335    fn through_runs_from_the_bottom_edge_to_the_top_edge() {
336        let at = |y| ScrollRange::Through.progress(Rect::new(0.0, y, 800.0, 200.0), view(), None);
337        assert_eq!(at(600.0), 0.0);
338        assert_eq!(at(-200.0), 1.0);
339        assert!((at(200.0) - 0.5).abs() < 1e-6);
340    }
341
342    #[test]
343    fn scene_progress_follows_the_track_through_its_length() {
344        let track = ScrollTrack { top: 64.0, length: 1000.0 };
345        let at = |y| {
346            ScrollRange::Scene.progress(Rect::ZERO, view(), Some((Rect::new(0.0, y, 800.0, 1600.0), track)))
347        };
348        assert_eq!(at(64.0), 0.0);
349        assert!((at(-436.0) - 0.5).abs() < 1e-6);
350        assert_eq!(at(-936.0), 1.0);
351    }
352
353    #[test]
354    fn enter_and_exit_cover_their_own_edges() {
355        let r = |y| Rect::new(0.0, y, 800.0, 200.0);
356        assert_eq!(ScrollRange::Enter.progress(r(600.0), view(), None), 0.0);
357        assert_eq!(ScrollRange::Enter.progress(r(400.0), view(), None), 1.0);
358        assert_eq!(ScrollRange::Exit.progress(r(0.0), view(), None), 0.0);
359        assert!((ScrollRange::Exit.progress(r(-100.0), view(), None) - 0.5).abs() < 1e-6);
360    }
361
362    #[test]
363    fn a_window_holds_its_ends_and_keyframes_interpolate() {
364        let link = ScrollLink::fade_in().between(0.4, 0.6);
365        assert_eq!(link.effect_at(0.1).opacity, 0.0);
366        assert_eq!(link.effect_at(0.9), Effect::IDENTITY);
367        let mid = link.effect_at(0.5);
368        assert!((mid.opacity - 0.5).abs() < 1e-6);
369        assert!((mid.offset.y - 12.0).abs() < 1e-4);
370        let stops = ScrollLink::keyframes([
371            (1.0, Effect::scaled(2.0)),
372            (0.0, Effect::IDENTITY),
373            (0.5, Effect::scaled(1.5)),
374        ]);
375        assert!((stops.effect_at(0.75).scale - 1.75).abs() < 1e-6, "stops are sorted");
376        assert_eq!(ScrollLink::keyframes([]).effect_at(0.5), Effect::IDENTITY);
377    }
378
379    #[test]
380    fn effects_compose_and_interpolate() {
381        let a = Effect::moved(0.0, 10.0).with_opacity(0.5);
382        let b = Effect::scaled(2.0).with_opacity(0.5);
383        let both = a.then(b);
384        assert_eq!(both.opacity, 0.25);
385        assert_eq!(both.scale, 2.0);
386        assert_eq!(both.offset, Point::new(0.0, 10.0));
387        assert_eq!(Effect::IDENTITY.lerp(Effect::hidden(), 0.25).opacity, 0.75);
388    }
389}