Skip to main content

freya_components/scrollviews/
scrollview.rs

1use std::time::Duration;
2
3use freya_core::prelude::*;
4use freya_sdk::timeout::use_timeout;
5use torin::{
6    geometry::{
7        CursorPoint,
8        Point2D,
9    },
10    node::Node,
11    prelude::{
12        Direction,
13        Length,
14    },
15    size::Size,
16};
17
18use crate::scrollviews::{
19    ScrollBar,
20    ScrollBarContext,
21    ScrollBarThumbEvents,
22    ScrollConfig,
23    ScrollController,
24    shared::{
25        Axis,
26        get_container_sizes,
27        get_corrected_scroll_position,
28        get_scroll_position_from_cursor,
29        get_scroll_position_from_wheel,
30        get_scrollbar_pos_and_size,
31        handle_key_event,
32        is_scrollbar_visible,
33    },
34    use_scroll_controller,
35};
36
37/// Scrollable area with bidirectional support and scrollbars.
38///
39/// It renders all of its children and scrolls over them, which makes it a good fit for small or
40/// medium amounts of content. For large data sets prefer
41/// [`VirtualScrollView`](crate::scrollviews::VirtualScrollView), which only renders the visible
42/// items. It scrolls vertically by default, use [`direction`](ScrollView::direction) for a
43/// horizontal layout. To drive the scroll position from code, build it with
44/// [`new_controlled`](ScrollView::new_controlled) and a [`ScrollController`].
45///
46/// # Example
47///
48/// ```rust
49/// # use freya::prelude::*;
50/// fn app() -> impl IntoElement {
51///     ScrollView::new()
52///         .child("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis. Morbi porttitor quis nisl eu vulputate. Etiam vitae ligula a purus suscipit iaculis non ac risus. Suspendisse potenti. Aenean orci massa, ornare ut elit id, tristique commodo dui. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis.")
53/// }
54/// ```
55///
56/// See the [interactive components demo](https://freyaui.dev/demo).
57#[derive(Clone, PartialEq)]
58pub struct ScrollView {
59    children: Vec<Element>,
60    layout: LayoutData,
61    show_scrollbar: bool,
62    auto_hide_scrollbars: bool,
63    scroll_with_arrows: bool,
64    scroll_controller: Option<ScrollController>,
65    invert_scroll_wheel: bool,
66    drag_scrolling: bool,
67    on_sized: Option<EventHandler<Event<SizedEventData>>>,
68    scrollbar: Callback<ScrollBarContext, Element>,
69    key: DiffKey,
70}
71
72impl ChildrenExt for ScrollView {
73    fn children_mut(&mut self) -> &mut Vec<Element> {
74        &mut self.children
75    }
76}
77
78impl KeyExt for ScrollView {
79    fn key_mut(&mut self) -> &mut DiffKey {
80        &mut self.key
81    }
82}
83
84impl Default for ScrollView {
85    fn default() -> Self {
86        Self {
87            children: Vec::default(),
88            layout: Node {
89                width: Size::fill(),
90                height: Size::fill(),
91                ..Default::default()
92            }
93            .into(),
94            show_scrollbar: true,
95            auto_hide_scrollbars: true,
96            scroll_with_arrows: true,
97            scroll_controller: None,
98            invert_scroll_wheel: false,
99            drag_scrolling: true,
100            on_sized: None,
101            scrollbar: ScrollBar::default_renderer(),
102            key: DiffKey::None,
103        }
104    }
105}
106
107impl ScrollView {
108    /// Creates an uncontrolled scroll view that manages its own scroll position.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Creates a scroll view driven by the given [`ScrollController`].
114    pub fn new_controlled(scroll_controller: ScrollController) -> Self {
115        Self {
116            scroll_controller: Some(scroll_controller),
117            ..Default::default()
118        }
119    }
120
121    /// Toggles whether the scrollbars are shown when the content overflows.
122    pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self {
123        self.show_scrollbar = show_scrollbar;
124        self
125    }
126
127    /// Toggles hiding idle scrollbars, enabled by default.
128    /// When disabled, scrollbars remain visible while the content overflows.
129    pub fn auto_hide_scrollbars(mut self, auto_hide_scrollbars: bool) -> Self {
130        self.auto_hide_scrollbars = auto_hide_scrollbars;
131        self
132    }
133
134    /// Sets the layout direction the children flow and scroll in.
135    pub fn direction(mut self, direction: Direction) -> Self {
136        self.layout.direction = direction;
137        self
138    }
139
140    /// Sets the gap between children along the scroll direction.
141    pub fn spacing(mut self, spacing: f32) -> Self {
142        self.layout.spacing = Length::new(spacing);
143        self
144    }
145
146    /// Toggles whether the arrow keys scroll the view while it is focused.
147    pub fn scroll_with_arrows(mut self, scroll_with_arrows: impl Into<bool>) -> Self {
148        self.scroll_with_arrows = scroll_with_arrows.into();
149        self
150    }
151
152    /// Inverts the direction of the mouse wheel relative to the content.
153    pub fn invert_scroll_wheel(mut self, invert_scroll_wheel: impl Into<bool>) -> Self {
154        self.invert_scroll_wheel = invert_scroll_wheel.into();
155        self
156    }
157
158    /// Toggles scrolling by dragging the content, useful mainly for touch input.
159    pub fn drag_scrolling(mut self, drag_scrolling: bool) -> Self {
160        self.drag_scrolling = drag_scrolling;
161        self
162    }
163
164    /// Sets the renderer used for each visible scrollbar.
165    pub fn scrollbar(mut self, scrollbar: impl Into<Callback<ScrollBarContext, Element>>) -> Self {
166        self.scrollbar = scrollbar.into();
167        self
168    }
169
170    /// Sets a handler called with the scroll view's area whenever it is laid out.
171    pub fn on_sized(mut self, on_sized: impl Into<EventHandler<Event<SizedEventData>>>) -> Self {
172        self.on_sized = Some(on_sized.into());
173        self
174    }
175
176    /// Caps the width of the scroll view.
177    pub fn max_width(mut self, max_width: impl Into<Size>) -> Self {
178        self.layout.maximum_width = max_width.into();
179        self
180    }
181
182    /// Caps the height of the scroll view.
183    pub fn max_height(mut self, max_height: impl Into<Size>) -> Self {
184        self.layout.maximum_height = max_height.into();
185        self
186    }
187}
188
189impl LayoutExt for ScrollView {
190    fn layout_mut(&mut self) -> &mut LayoutData {
191        &mut self.layout
192    }
193}
194
195impl ContainerSizeExt for ScrollView {}
196impl ContainerPositionExt for ScrollView {}
197
198impl Component for ScrollView {
199    fn render(self: &ScrollView) -> impl IntoElement {
200        let a11y_id = use_a11y();
201        let mut timeout = use_timeout(|| Duration::from_millis(800));
202        let mut pressing_shift = use_state(|| false);
203        let mut clicking_scrollbar = use_state::<Option<(Axis, f64)>>(|| None);
204        let mut size = use_state(SizedEventData::default);
205        let mut scroll_controller = self
206            .scroll_controller
207            .unwrap_or_else(|| use_scroll_controller(ScrollConfig::default));
208        let mut dragging_content = use_state::<Option<CursorPoint>>(|| None);
209        let mut drag_origin = use_state::<Option<CursorPoint>>(|| None);
210        let (scrolled_x, scrolled_y) = scroll_controller.into();
211        let layout = &self.layout.layout;
212        let direction = layout.direction;
213        let drag_scrolling = self.drag_scrolling;
214
215        let corrected_scrolled_x = get_corrected_scroll_position(
216            size.read().inner_sizes.width,
217            size.read().area.width(),
218            scrolled_x as f32,
219        );
220
221        let corrected_scrolled_y = get_corrected_scroll_position(
222            size.read().inner_sizes.height,
223            size.read().area.height(),
224            scrolled_y as f32,
225        );
226
227        let smooth_position = scroll_controller
228            .animated_position(Point2D::new(corrected_scrolled_x, corrected_scrolled_y));
229        let rendered_position = Point2D::new(
230            get_corrected_scroll_position(
231                size.read().inner_sizes.width,
232                size.read().area.width(),
233                smooth_position.x,
234            ),
235            get_corrected_scroll_position(
236                size.read().inner_sizes.height,
237                size.read().area.height(),
238                smooth_position.y,
239            ),
240        );
241
242        let horizontal_scrollbar_is_visible = is_scrollbar_visible(
243            self.show_scrollbar,
244            size.read().inner_sizes.width,
245            size.read().area.width(),
246        );
247        let vertical_scrollbar_is_visible = is_scrollbar_visible(
248            self.show_scrollbar,
249            size.read().inner_sizes.height,
250            size.read().area.height(),
251        );
252
253        let (scrollbar_x, scrollbar_width) = get_scrollbar_pos_and_size(
254            size.read().inner_sizes.width,
255            size.read().area.width(),
256            rendered_position.x,
257        );
258        let (scrollbar_y, scrollbar_height) = get_scrollbar_pos_and_size(
259            size.read().inner_sizes.height,
260            size.read().area.height(),
261            rendered_position.y,
262        );
263
264        let (container_width, content_width) = get_container_sizes(layout.width.clone());
265        let (container_height, content_height) = get_container_sizes(layout.height.clone());
266
267        let scroll_with_arrows = self.scroll_with_arrows;
268        let invert_scroll_wheel = self.invert_scroll_wheel;
269
270        let on_capture_global_pointer_up = move |e: Event<PointerEventData>| {
271            if clicking_scrollbar.read().is_some() {
272                e.prevent_default();
273                clicking_scrollbar.set(None);
274            }
275
276            if drag_scrolling && (dragging_content().is_some() || drag_origin().is_some()) {
277                if dragging_content().is_some() {
278                    e.prevent_default();
279                    let content = size.read().inner_sizes;
280                    let viewport = size.read().area.size;
281                    scroll_controller.release_drag(rendered_position, content, viewport);
282                }
283                dragging_content.set(None);
284                drag_origin.set(None);
285            }
286        };
287
288        let on_wheel = move |e: Event<WheelEventData>| {
289            // Only invert direction on deviced-sourced wheel events
290            let invert_direction = e.source.is_device()
291                && (*pressing_shift.read() || invert_scroll_wheel)
292                && (!*pressing_shift.read() || !invert_scroll_wheel);
293
294            let (x_movement, y_movement) = if invert_direction {
295                (e.delta_y as f32, e.delta_x as f32)
296            } else {
297                (e.delta_x as f32, e.delta_y as f32)
298            };
299
300            // TODO(marc2332): Enable and test touchpad smooth scrolling in mac and windows
301            let scroll_gesture =
302                TargetPlatform::get() == TargetPlatform::Linux && e.source == WheelSource::Pixel;
303            if scroll_gesture
304                && scroll_controller.scroll_gesture_phase(e.phase, (x_movement, y_movement).into())
305            {
306                return;
307            }
308
309            let animate = e.source == WheelSource::Line || scroll_gesture;
310            if animate {
311                scroll_controller.animate_from(rendered_position);
312            } else {
313                scroll_controller.stop();
314            }
315            let (base_x, base_y) = if animate {
316                let position = scroll_controller.position();
317                (position.x, position.y)
318            } else {
319                (rendered_position.x, rendered_position.y)
320            };
321
322            // Vertical scroll
323            let scroll_position_y = get_scroll_position_from_wheel(
324                y_movement,
325                size.read().inner_sizes.height,
326                size.read().area.height(),
327                base_y,
328            );
329            scroll_controller.scroll_to_y(scroll_position_y).then(|| {
330                e.stop_propagation();
331            });
332
333            // Horizontal scroll
334            let scroll_position_x = get_scroll_position_from_wheel(
335                x_movement,
336                size.read().inner_sizes.width,
337                size.read().area.width(),
338                base_x,
339            );
340            scroll_controller.scroll_to_x(scroll_position_x).then(|| {
341                e.stop_propagation();
342            });
343            timeout.reset();
344        };
345
346        let on_mouse_move = move |_| {
347            timeout.reset();
348        };
349
350        let on_capture_global_pointer_move = move |e: Event<PointerEventData>| {
351            if drag_scrolling {
352                if let Some(prev) = dragging_content() {
353                    let coords = e.global_location();
354                    let delta = prev - coords;
355
356                    scroll_controller.drag(delta.to_f32());
357                    scroll_controller.scroll_to_y((rendered_position.y - delta.y as f32) as i32);
358                    scroll_controller.scroll_to_x((rendered_position.x - delta.x as f32) as i32);
359
360                    dragging_content.set(Some(coords));
361                    e.prevent_default();
362                    timeout.reset();
363                    a11y_id.request_focus();
364                    return;
365                } else if let Some(origin) = drag_origin() {
366                    let coords = e.global_location();
367                    let distance = (origin - coords).abs();
368
369                    // Small threshold so taps can reach children (e.g. hover on buttons)
370                    // without being immediately consumed by drag scrolling.
371                    const DRAG_THRESHOLD: f64 = 2.0;
372
373                    if distance.x > DRAG_THRESHOLD || distance.y > DRAG_THRESHOLD {
374                        let delta = origin - coords;
375
376                        scroll_controller.drag(delta.to_f32());
377                        scroll_controller
378                            .scroll_to_y((rendered_position.y - delta.y as f32) as i32);
379                        scroll_controller
380                            .scroll_to_x((rendered_position.x - delta.x as f32) as i32);
381
382                        dragging_content.set(Some(coords));
383                        e.prevent_default();
384                        timeout.reset();
385                        a11y_id.request_focus();
386                    }
387                    return;
388                }
389            }
390
391            let clicking_scrollbar = clicking_scrollbar.peek();
392
393            if clicking_scrollbar.is_some() {
394                scroll_controller.stop();
395            }
396
397            if let Some((Axis::Y, y)) = *clicking_scrollbar {
398                let coordinates = e.element_location();
399                let cursor_y = coordinates.y - y - size.read().area.min_y() as f64;
400
401                let scroll_position = get_scroll_position_from_cursor(
402                    cursor_y as f32,
403                    size.read().inner_sizes.height,
404                    size.read().area.height(),
405                );
406
407                scroll_controller.scroll_to_y(scroll_position);
408            } else if let Some((Axis::X, x)) = *clicking_scrollbar {
409                let coordinates = e.element_location();
410                let cursor_x = coordinates.x - x - size.read().area.min_x() as f64;
411
412                let scroll_position = get_scroll_position_from_cursor(
413                    cursor_x as f32,
414                    size.read().inner_sizes.width,
415                    size.read().area.width(),
416                );
417
418                scroll_controller.scroll_to_x(scroll_position);
419            }
420
421            if clicking_scrollbar.is_some() {
422                e.prevent_default();
423                timeout.reset();
424                a11y_id.request_focus();
425            }
426        };
427
428        let on_key_down = move |e: Event<KeyboardEventData>| {
429            if !scroll_with_arrows
430                && (e.key == Key::Named(NamedKey::ArrowUp)
431                    || e.key == Key::Named(NamedKey::ArrowRight)
432                    || e.key == Key::Named(NamedKey::ArrowDown)
433                    || e.key == Key::Named(NamedKey::ArrowLeft))
434            {
435                return;
436            }
437            let x = corrected_scrolled_x;
438            let y = corrected_scrolled_y;
439            let inner_height = size.read().inner_sizes.height;
440            let inner_width = size.read().inner_sizes.width;
441            let viewport_height = size.read().area.height();
442            let viewport_width = size.read().area.width();
443            if let Some((x, y)) = handle_key_event(
444                &e.key,
445                (x, y),
446                inner_height,
447                inner_width,
448                viewport_height,
449                viewport_width,
450                direction,
451            ) {
452                scroll_controller.animate_from(rendered_position);
453                scroll_controller.scroll_to_x(x as i32);
454                scroll_controller.scroll_to_y(y as i32);
455                e.stop_propagation();
456                timeout.reset();
457            }
458        };
459
460        let on_global_key_down = move |e: Event<KeyboardEventData>| {
461            let data = e;
462            if data.key == Key::Named(NamedKey::Shift) {
463                pressing_shift.set(true);
464            }
465        };
466
467        let on_global_key_up = move |e: Event<KeyboardEventData>| {
468            let data = e;
469            if data.key == Key::Named(NamedKey::Shift) {
470                pressing_shift.set(false);
471            }
472        };
473
474        let on_pointer_down = move |e: Event<PointerEventData>| {
475            if drag_scrolling && matches!(e.data(), PointerEventData::Touch(_)) {
476                scroll_controller.begin_drag();
477                drag_origin.set(Some(e.global_location()));
478            }
479        };
480
481        rect()
482            .width(layout.width.clone())
483            .height(layout.height.clone())
484            .max_width(layout.maximum_width.clone())
485            .max_height(layout.maximum_height.clone())
486            .a11y_id(a11y_id)
487            .a11y_role(AccessibilityRole::ScrollView)
488            .a11y_builder(move |node| {
489                node.set_scroll_x(rendered_position.x as f64);
490                node.set_scroll_y(rendered_position.y as f64)
491            })
492            .scrollable(true)
493            .map(self.on_sized.clone(), |el, on_sized| el.on_sized(on_sized))
494            .on_wheel(on_wheel)
495            .on_capture_global_pointer_up(on_capture_global_pointer_up)
496            .on_mouse_move(on_mouse_move)
497            .on_capture_global_pointer_move(on_capture_global_pointer_move)
498            .on_key_down(on_key_down)
499            .on_global_key_up(on_global_key_up)
500            .on_global_key_down(on_global_key_down)
501            .on_pointer_down(on_pointer_down)
502            .child(
503                rect()
504                    .width(container_width)
505                    .height(container_height)
506                    .horizontal()
507                    .child(
508                        rect()
509                            .direction(direction)
510                            .width(content_width)
511                            .height(content_height)
512                            .max_width(layout.maximum_width.clone())
513                            .max_height(layout.maximum_height.clone())
514                            .offset_x(rendered_position.x)
515                            .offset_y(rendered_position.y)
516                            .spacing(layout.spacing.get())
517                            .overflow(Overflow::Clip)
518                            .on_sized(move |e: Event<SizedEventData>| {
519                                size.set_if_modified(e.clone());
520                                scroll_controller.apply_layout(e.inner_sizes, e.area.size);
521                            })
522                            .children(self.children.clone()),
523                    )
524                    .maybe_child(vertical_scrollbar_is_visible.then_some({
525                        rect().child(self.scrollbar.call(ScrollBarContext {
526                            axis: Axis::Y,
527                            scroll_position: rendered_position,
528                            viewport_size: size.read().area.size,
529                            content_size: size.read().inner_sizes,
530                            scroll_controller,
531                            timeout,
532                            auto_hide_scrollbars: self.auto_hide_scrollbars,
533                            clicking_scrollbar,
534                            thumb_events: ScrollBarThumbEvents::new(Axis::Y, clicking_scrollbar),
535                            thumb_offset: scrollbar_y,
536                            track_size: Size::px(size.read().area.height()),
537                            thumb_length: scrollbar_height,
538                        }))
539                    })),
540            )
541            .maybe_child(horizontal_scrollbar_is_visible.then_some({
542                rect().child(self.scrollbar.call(ScrollBarContext {
543                    axis: Axis::X,
544                    scroll_position: rendered_position,
545                    viewport_size: size.read().area.size,
546                    content_size: size.read().inner_sizes,
547                    scroll_controller,
548                    timeout,
549                    auto_hide_scrollbars: self.auto_hide_scrollbars,
550                    clicking_scrollbar,
551                    thumb_events: ScrollBarThumbEvents::new(Axis::X, clicking_scrollbar),
552                    thumb_offset: scrollbar_x,
553                    track_size: Size::px(size.read().area.width()),
554                    thumb_length: scrollbar_width,
555                }))
556            }))
557    }
558
559    fn render_key(&self) -> DiffKey {
560        self.key.clone().or(self.default_key())
561    }
562}