Skip to main content

freya_components/scrollviews/
use_scroll_controller.rs

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