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}