Skip to main content

freya_components/scrollviews/
use_scroll_controller.rs

1use std::time::Instant;
2
3use freya_core::prelude::*;
4use torin::{
5    geometry::{
6        Point2D,
7        Size2D,
8        Vector2D,
9    },
10    prelude::Direction,
11};
12
13use crate::scrollviews::shared::get_corrected_scroll_position;
14
15/// Where along an axis a scroll should land, the beginning or the end.
16#[derive(Default, PartialEq, Eq)]
17pub enum ScrollPosition {
18    #[default]
19    Start,
20    End,
21}
22
23impl ScrollPosition {
24    /// Scroll offset in pixels of this position.
25    fn offset(&self) -> i32 {
26        match self {
27            Self::Start => 0,
28            Self::End => ScrollController::END,
29        }
30    }
31}
32
33/// Initial configuration for a [`ScrollController`] created with [`use_scroll_controller`].
34#[derive(Default)]
35pub struct ScrollConfig {
36    /// Where the vertical axis starts scrolled to when first laid out.
37    pub default_vertical_position: ScrollPosition,
38    /// Where the horizontal axis starts scrolled to when first laid out.
39    pub default_horizontal_position: ScrollPosition,
40}
41
42/// Handle to drive a scrollview programmatically.
43///
44/// By default a scrollview owns its scroll position and only the user can move it, through the
45/// wheel, the scrollbar, arrow keys or dragging. A [`ScrollController`] lets your own code read and
46/// change that position instead. Create one with [`use_scroll_controller`] and hand it to a
47/// scrollview through its `new_controlled` constructor.
48///
49/// Some cases where a controller is needed:
50///
51/// - Jumping to the top or bottom in response to an action, for example scrolling a chat to the
52///   newest message after sending one.
53/// - Keeping several scrollviews in sync, like a diff view with two panes that move together.
54/// - Reading the current scroll position to drive something else, such as a "scroll to top" button
55///   that only appears once the user has scrolled down.
56///
57/// # Scrolling from code
58///
59/// [`scroll_to`](ScrollController::scroll_to) jumps to the start or end of an axis, with the end
60/// resolved against the content on the next layout. This is the common way to snap a list to its
61/// top or bottom.
62///
63/// ```rust
64/// # use freya::prelude::*;
65/// fn app() -> impl IntoElement {
66///     let mut scroll_controller = use_scroll_controller(ScrollConfig::default);
67///
68///     rect()
69///         .child(
70///             Button::new()
71///                 .on_press(move |_| {
72///                     scroll_controller.scroll_to(ScrollPosition::End, Direction::Vertical);
73///                 })
74///                 .child("Scroll to bottom"),
75///         )
76///         .child(
77///             ScrollView::new_controlled(scroll_controller)
78///                 .children((0..100).map(|i| label().key(i).text(format!("Item {i}")))),
79///         )
80/// }
81/// ```
82///
83/// For an exact pixel offset use [`scroll_to_y`](ScrollController::scroll_to_y) or
84/// [`scroll_to_x`](ScrollController::scroll_to_x). The current position is available by converting
85/// the controller into a `(i32, i32)` tuple of `(x, y)` pixels.
86///
87/// # Keeping scrollviews in sync
88///
89/// Because a [`ScrollController`] is a cheap [`Copy`] handle, you can pass the same one to several
90/// scrollviews and they share a single scroll position, moving any of them moves the rest.
91///
92/// ```rust
93/// # use freya::prelude::*;
94/// fn app() -> impl IntoElement {
95///     let scroll_controller = use_scroll_controller(ScrollConfig::default);
96///
97///     rect()
98///         .horizontal()
99///         .spacing(6.)
100///         .child(
101///             ScrollView::new_controlled(scroll_controller)
102///                 .width(Size::flex(1.))
103///                 .children((0..30).map(|i| label().key(i).text(format!("Left {i}")))),
104///         )
105///         .child(
106///             ScrollView::new_controlled(scroll_controller)
107///                 .width(Size::flex(1.))
108///                 .children((0..30).map(|i| label().key(i).text(format!("Right {i}")))),
109///         )
110/// }
111/// ```
112///
113/// # Starting position
114///
115/// The [`ScrollConfig`] passed to [`use_scroll_controller`] also decides where each axis starts.
116/// Set [`default_vertical_position`](ScrollConfig::default_vertical_position) to
117/// [`ScrollPosition::End`] to open a list already scrolled to the bottom.
118///
119/// ```rust
120/// # use freya::prelude::*;
121/// fn app() -> impl IntoElement {
122///     let scroll_controller = use_scroll_controller(|| ScrollConfig {
123///         default_vertical_position: ScrollPosition::End,
124///         ..Default::default()
125///     });
126///
127///     ScrollView::new_controlled(scroll_controller)
128///         .children((0..100).map(|i| label().key(i).text(format!("Item {i}"))))
129/// }
130/// ```
131#[derive(PartialEq, Clone, Copy)]
132pub struct ScrollController {
133    pub(crate) scroll: State<(i32, i32)>,
134    bounds: State<Option<(Size2D, Size2D)>>,
135    pub(crate) damp: State<SmoothDamp>,
136    pub(crate) drag: State<Drag>,
137    pub(crate) task: State<Option<TaskHandle>>,
138}
139
140impl From<ScrollController> for (i32, i32) {
141    /// Reads the current `(x, y)` scroll position in pixels.
142    fn from(val: ScrollController) -> Self {
143        *val.scroll.read()
144    }
145}
146
147impl ScrollController {
148    /// Offset of an axis scrolled to its end, resolved against the content size on the next layout.
149    const END: i32 = i32::MIN;
150
151    /// Creates a controller starting at the scroll position `(x, y)`.
152    pub fn new(x: i32, y: i32) -> Self {
153        Self {
154            scroll: State::create((x, y)),
155            bounds: State::create(None),
156            damp: State::create(SmoothDamp::new()),
157            drag: State::create(Drag::default()),
158            task: State::create(None),
159        }
160    }
161
162    /// Updates the content and viewport bounds used to clamp scroll positions.
163    pub(crate) fn apply_layout(&mut self, content_size: Size2D, viewport_size: Size2D) {
164        self.bounds.set(Some((content_size, viewport_size)));
165
166        let (x, y) = *self.scroll.peek();
167        self.scroll_to_x(x);
168        self.scroll_to_y(y);
169    }
170
171    pub(crate) fn position(self) -> Point2D {
172        let (x, y): (i32, i32) = self.into();
173        Point2D::new(x as f32, y as f32)
174    }
175
176    /// Scrolls the horizontal axis to `to` pixels. Returns whether the position actually changed.
177    pub fn scroll_to_x(&mut self, to: i32) -> bool {
178        let to = self.bounded_position(to, Direction::Horizontal);
179        let changed = self.scroll.peek().0 != to;
180        if changed {
181            self.scroll.write().0 = to;
182        }
183        changed
184    }
185
186    /// Scrolls the vertical axis to `to` pixels. Returns whether the position actually changed.
187    pub fn scroll_to_y(&mut self, to: i32) -> bool {
188        let to = self.bounded_position(to, Direction::Vertical);
189        let changed = self.scroll.peek().1 != to;
190        if changed {
191            self.scroll.write().1 = to;
192        }
193        changed
194    }
195
196    fn bounded_position(&self, position: i32, direction: Direction) -> i32 {
197        let Some((content_size, viewport_size)) = *self.bounds.read() else {
198            return position;
199        };
200
201        let (content_size, viewport_size) = match direction {
202            Direction::Horizontal => (content_size.width, viewport_size.width),
203            Direction::Vertical => (content_size.height, viewport_size.height),
204        };
205        get_corrected_scroll_position(content_size, viewport_size, position as f32) as i32
206    }
207
208    /// Scrolls `scroll_direction` to `scroll_position`.
209    pub fn scroll_to(&mut self, scroll_position: ScrollPosition, scroll_direction: Direction) {
210        let to = scroll_position.offset();
211        match scroll_direction {
212            Direction::Vertical => self.scroll_to_y(to),
213            Direction::Horizontal => self.scroll_to_x(to),
214        };
215    }
216}
217
218/// Creates a [`ScrollController`], configured by the returned [`ScrollConfig`].
219pub fn use_scroll_controller(config: impl FnOnce() -> ScrollConfig) -> ScrollController {
220    use_hook(|| {
221        let config = config();
222
223        ScrollController::new(
224            config.default_horizontal_position.offset(),
225            config.default_vertical_position.offset(),
226        )
227    })
228}
229
230/// Distance under which the animation is close enough to snap, in pixels.
231const SETTLE_DISTANCE: f32 = 0.5;
232/// Speed under which the animation is slow enough to stop, in pixels per second.
233const SETTLE_SPEED: f32 = 20.0;
234
235/// Slowest drag release speed that still starts a fling, in pixels per second.
236const FLING_MIN_SPEED: f32 = 50.0;
237
238/// Scrolling feel of a [`TargetPlatform`].
239pub(crate) trait ScrollFeel {
240    /// Seconds wheel and keyboard scrolls take to reach their destination.
241    fn scroll_smoothing_time(&self) -> f32;
242    /// Seconds a fling takes to stop, which also scales how far it travels.
243    fn scroll_fling_time(&self) -> f32;
244}
245
246impl ScrollFeel for TargetPlatform {
247    fn scroll_smoothing_time(&self) -> f32 {
248        if self.is_mobile() { 0.1 } else { 0.06 }
249    }
250
251    fn scroll_fling_time(&self) -> f32 {
252        if self.is_mobile() { 0.35 } else { 0.5 }
253    }
254}
255
256/// Moves a value towards a target with a smooth and continuous animation.
257#[derive(Clone, Copy, Default)]
258pub(crate) struct SmoothDamp {
259    position: Point2D,
260    velocity: Vector2D,
261    smooth_time: f32,
262}
263
264impl SmoothDamp {
265    pub(crate) fn new() -> Self {
266        Self {
267            position: Point2D::zero(),
268            velocity: Vector2D::zero(),
269            smooth_time: TargetPlatform::Unknown.scroll_smoothing_time(),
270        }
271    }
272
273    /// Returns whether it has settled onto `target`.
274    fn advance(&mut self, target: Point2D, elapsed_seconds: f32) -> bool {
275        let omega = 2.0 / self.smooth_time;
276        let decay = (-omega * elapsed_seconds).exp();
277        let change = self.position - target;
278        let linear_term = (self.velocity + change * omega) * elapsed_seconds;
279
280        let velocity = (self.velocity - linear_term * omega) * decay;
281        let position = target + (change + linear_term) * decay;
282
283        if (target - position).length() < SETTLE_DISTANCE && velocity.length() < SETTLE_SPEED {
284            self.position = target;
285            self.velocity = Vector2D::zero();
286            return true;
287        }
288
289        self.position = position;
290        self.velocity = velocity;
291        false
292    }
293}
294
295/// Velocity tracked while the content is dragged, to fling with on release.
296#[derive(Clone, Copy)]
297pub(crate) struct Drag {
298    velocity: Vector2D,
299    last_move: Instant,
300}
301
302impl Default for Drag {
303    fn default() -> Self {
304        Self {
305            velocity: Vector2D::zero(),
306            last_move: Instant::now(),
307        }
308    }
309}
310
311impl Drag {
312    fn track(&mut self, delta: Vector2D) {
313        let now = Instant::now();
314        let elapsed_seconds = now.duration_since(self.last_move).as_secs_f32();
315        if elapsed_seconds > 0.0 {
316            self.velocity = self.velocity.lerp(-delta / elapsed_seconds, 0.5);
317        }
318        self.last_move = now;
319    }
320}
321
322/// Follows the target held by a [`ScrollController`].
323impl ScrollController {
324    /// Position to render, the animated one while a scroll animation is running.
325    pub fn animated_position(&self, target: Point2D) -> Point2D {
326        if self.task.read().is_some() {
327            self.damp.read().position
328        } else {
329            target
330        }
331    }
332
333    /// Chases the controller position from `current`, keeping the current velocity.
334    pub fn animate_from(&mut self, current: Point2D) {
335        self.start(current, None, TargetPlatform::get().scroll_smoothing_time());
336    }
337
338    /// Like [`Self::animate_from`] but launched at `velocity` and slower to stop.
339    fn fling_from(&mut self, current: Point2D, velocity: Vector2D) {
340        self.start(
341            current,
342            Some(velocity),
343            TargetPlatform::get().scroll_fling_time(),
344        );
345    }
346
347    fn start(&mut self, current: Point2D, velocity: Option<Vector2D>, smooth_time: f32) {
348        let is_animating = self.task.read().is_some();
349        {
350            let mut damp = self.damp.write();
351            damp.smooth_time = smooth_time;
352            if let Some(velocity) = velocity {
353                damp.velocity = velocity;
354            }
355            if !is_animating {
356                damp.position = current;
357            }
358        }
359        if is_animating {
360            return;
361        }
362
363        let ticker = RenderingTicker::get();
364        let platform = Platform::get().current_window();
365        let animation_clock = AnimationClock::get();
366        let scroll_controller = *self;
367        let mut damp = self.damp;
368        let mut task = self.task;
369
370        let animation_task = spawn(async move {
371            platform.send(UserEvent::RequestRedraw);
372            let mut previous_frame = Instant::now();
373
374            loop {
375                ticker.tick().await;
376
377                let elapsed_seconds = animation_clock
378                    .correct_elapsed_duration(previous_frame.elapsed())
379                    .as_secs_f32();
380                previous_frame = Instant::now();
381
382                let target = scroll_controller.position();
383                if damp.write().advance(target, elapsed_seconds) {
384                    break;
385                }
386
387                platform.send(UserEvent::RequestRedraw);
388            }
389
390            task.write().take();
391        });
392        task.write().replace(animation_task);
393    }
394
395    /// Freezes the animation and starts a drag from the momentum it caught.
396    pub fn begin_drag(&mut self) {
397        let caught_velocity = self.stop();
398        self.drag.set(Drag {
399            velocity: caught_velocity,
400            last_move: Instant::now(),
401        });
402    }
403
404    /// Feeds a drag movement into the tracked velocity.
405    pub fn drag(&mut self, delta: Vector2D) {
406        self.stop();
407        self.drag.write().track(delta);
408    }
409
410    /// Ends a drag, flinging when it was fast enough to be a flick.
411    pub fn release_drag(&mut self, from: Point2D, content: Size2D, viewport: Size2D) {
412        let velocity = self.drag.peek().velocity;
413        if velocity.length() < FLING_MIN_SPEED {
414            return;
415        }
416
417        let projected = from + velocity * TargetPlatform::get().scroll_fling_time();
418        let target_x = get_corrected_scroll_position(content.width, viewport.width, projected.x);
419        let target_y = get_corrected_scroll_position(content.height, viewport.height, projected.y);
420
421        self.fling_from(from, velocity);
422        self.scroll_to_x(target_x as i32);
423        self.scroll_to_y(target_y as i32);
424    }
425
426    /// Freezes the scroll where it is, returning the velocity it was moving at.
427    pub fn stop(&mut self) -> Vector2D {
428        let task = self.task.write().take();
429        if let Some(task) = task {
430            task.cancel();
431
432            let position = self.damp.peek().position.to_i32();
433            self.scroll_to_x(position.x);
434            self.scroll_to_y(position.y);
435        }
436
437        let velocity = self.damp.peek().velocity;
438        self.damp.write().velocity = Vector2D::zero();
439        velocity
440    }
441}