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::CursorPoint,
7    node::Node,
8    prelude::{
9        Direction,
10        Length,
11    },
12    size::Size,
13};
14
15use crate::scrollviews::{
16    ScrollBar,
17    ScrollConfig,
18    ScrollController,
19    ScrollThumb,
20    shared::{
21        Axis,
22        get_container_sizes,
23        get_corrected_scroll_position,
24        get_scroll_position_from_cursor,
25        get_scroll_position_from_wheel,
26        get_scrollbar_pos_and_size,
27        handle_key_event,
28        is_scrollbar_visible,
29    },
30    use_scroll_controller,
31};
32
33/// Scrollable area with bidirectional support and scrollbars.
34///
35/// It renders all of its children and scrolls over them, which makes it a good fit for small or
36/// medium amounts of content. For large data sets prefer
37/// [`VirtualScrollView`](crate::scrollviews::VirtualScrollView), which only renders the visible
38/// items. It scrolls vertically by default, use [`direction`](ScrollView::direction) for a
39/// horizontal layout. To drive the scroll position from code, build it with
40/// [`new_controlled`](ScrollView::new_controlled) and a [`ScrollController`].
41///
42/// # Example
43///
44/// ```rust
45/// # use freya::prelude::*;
46/// fn app() -> impl IntoElement {
47///     ScrollView::new()
48///         .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.")
49/// }
50///
51/// # use freya_testing::prelude::*;
52/// # launch_doc(|| {
53/// #   rect().center().expanded().child(app())
54/// # },
55/// # "./images/gallery_scrollview.png")
56/// #
57/// # .with_hook(|t| {
58/// #   t.move_cursor((125., 115.));
59/// #   t.sync_and_update();
60/// # });
61/// ```
62///
63/// # Preview
64/// ![ScrollView Preview][scrollview]
65#[cfg_attr(feature = "docs",
66    doc = embed_doc_image::embed_image!("scrollview", "images/gallery_scrollview.png")
67)]
68#[derive(Clone, PartialEq)]
69pub struct ScrollView {
70    children: Vec<Element>,
71    layout: LayoutData,
72    show_scrollbar: bool,
73    scroll_with_arrows: bool,
74    scroll_controller: Option<ScrollController>,
75    invert_scroll_wheel: bool,
76    drag_scrolling: bool,
77    on_sized: Option<EventHandler<Event<SizedEventData>>>,
78    key: DiffKey,
79}
80
81impl ChildrenExt for ScrollView {
82    fn get_children(&mut self) -> &mut Vec<Element> {
83        &mut self.children
84    }
85}
86
87impl KeyExt for ScrollView {
88    fn write_key(&mut self) -> &mut DiffKey {
89        &mut self.key
90    }
91}
92
93impl Default for ScrollView {
94    fn default() -> Self {
95        Self {
96            children: Vec::default(),
97            layout: Node {
98                width: Size::fill(),
99                height: Size::fill(),
100                ..Default::default()
101            }
102            .into(),
103            show_scrollbar: true,
104            scroll_with_arrows: true,
105            scroll_controller: None,
106            invert_scroll_wheel: false,
107            drag_scrolling: true,
108            on_sized: None,
109            key: DiffKey::None,
110        }
111    }
112}
113
114impl ScrollView {
115    /// Creates an uncontrolled scroll view that manages its own scroll position.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Creates a scroll view driven by the given [`ScrollController`].
121    pub fn new_controlled(scroll_controller: ScrollController) -> Self {
122        Self {
123            scroll_controller: Some(scroll_controller),
124            ..Default::default()
125        }
126    }
127
128    /// Toggles whether the scrollbars are shown when the content overflows.
129    pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self {
130        self.show_scrollbar = show_scrollbar;
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 a handler called with the scroll view's area whenever it is laid out.
165    pub fn on_sized(mut self, on_sized: impl Into<EventHandler<Event<SizedEventData>>>) -> Self {
166        self.on_sized = Some(on_sized.into());
167        self
168    }
169
170    /// Caps the width of the scroll view.
171    pub fn max_width(mut self, max_width: impl Into<Size>) -> Self {
172        self.layout.maximum_width = max_width.into();
173        self
174    }
175
176    /// Caps the height of the scroll view.
177    pub fn max_height(mut self, max_height: impl Into<Size>) -> Self {
178        self.layout.maximum_height = max_height.into();
179        self
180    }
181}
182
183impl LayoutExt for ScrollView {
184    fn get_layout(&mut self) -> &mut LayoutData {
185        &mut self.layout
186    }
187}
188
189impl ContainerSizeExt for ScrollView {}
190impl ContainerPositionExt for ScrollView {}
191
192impl Component for ScrollView {
193    fn render(self: &ScrollView) -> impl IntoElement {
194        let a11y_id = use_a11y();
195        let mut timeout = use_timeout(|| Duration::from_millis(800));
196        let mut pressing_shift = use_state(|| false);
197        let mut clicking_scrollbar = use_state::<Option<(Axis, f64)>>(|| None);
198        let mut size = use_state(SizedEventData::default);
199        let mut scroll_controller = self
200            .scroll_controller
201            .unwrap_or_else(|| use_scroll_controller(ScrollConfig::default));
202        let mut dragging_content = use_state::<Option<CursorPoint>>(|| None);
203        let mut drag_origin = use_state::<Option<CursorPoint>>(|| None);
204        let (scrolled_x, scrolled_y) = scroll_controller.into();
205        let layout = &self.layout.layout;
206        let direction = layout.direction;
207        let drag_scrolling = self.drag_scrolling;
208
209        scroll_controller.use_apply(
210            size.read().inner_sizes.width,
211            size.read().inner_sizes.height,
212        );
213
214        let corrected_scrolled_x = get_corrected_scroll_position(
215            size.read().inner_sizes.width,
216            size.read().area.width(),
217            scrolled_x as f32,
218        );
219
220        let corrected_scrolled_y = get_corrected_scroll_position(
221            size.read().inner_sizes.height,
222            size.read().area.height(),
223            scrolled_y as f32,
224        );
225        let horizontal_scrollbar_is_visible = !timeout.elapsed()
226            && is_scrollbar_visible(
227                self.show_scrollbar,
228                size.read().inner_sizes.width,
229                size.read().area.width(),
230            );
231        let vertical_scrollbar_is_visible = !timeout.elapsed()
232            && is_scrollbar_visible(
233                self.show_scrollbar,
234                size.read().inner_sizes.height,
235                size.read().area.height(),
236            );
237
238        let (scrollbar_x, scrollbar_width) = get_scrollbar_pos_and_size(
239            size.read().inner_sizes.width,
240            size.read().area.width(),
241            corrected_scrolled_x,
242        );
243        let (scrollbar_y, scrollbar_height) = get_scrollbar_pos_and_size(
244            size.read().inner_sizes.height,
245            size.read().area.height(),
246            corrected_scrolled_y,
247        );
248
249        let (container_width, content_width) = get_container_sizes(layout.width.clone());
250        let (container_height, content_height) = get_container_sizes(layout.height.clone());
251
252        let scroll_with_arrows = self.scroll_with_arrows;
253        let invert_scroll_wheel = self.invert_scroll_wheel;
254
255        let on_capture_global_pointer_press = move |e: Event<PointerEventData>| {
256            if clicking_scrollbar.read().is_some() {
257                e.prevent_default();
258                clicking_scrollbar.set(None);
259            }
260
261            if drag_scrolling && (dragging_content().is_some() || drag_origin().is_some()) {
262                dragging_content.set(None);
263                drag_origin.set(None);
264            }
265        };
266
267        let on_wheel = move |e: Event<WheelEventData>| {
268            // Only invert direction on deviced-sourced wheel events
269            let invert_direction = e.source == WheelSource::Device
270                && (*pressing_shift.read() || invert_scroll_wheel)
271                && (!*pressing_shift.read() || !invert_scroll_wheel);
272
273            let (x_movement, y_movement) = if invert_direction {
274                (e.delta_y as f32, e.delta_x as f32)
275            } else {
276                (e.delta_x as f32, e.delta_y as f32)
277            };
278
279            // Vertical scroll
280            let scroll_position_y = get_scroll_position_from_wheel(
281                y_movement,
282                size.read().inner_sizes.height,
283                size.read().area.height(),
284                corrected_scrolled_y,
285            );
286            scroll_controller.scroll_to_y(scroll_position_y).then(|| {
287                e.stop_propagation();
288            });
289
290            // Horizontal scroll
291            let scroll_position_x = get_scroll_position_from_wheel(
292                x_movement,
293                size.read().inner_sizes.width,
294                size.read().area.width(),
295                corrected_scrolled_x,
296            );
297            scroll_controller.scroll_to_x(scroll_position_x).then(|| {
298                e.stop_propagation();
299            });
300            timeout.reset();
301        };
302
303        let on_mouse_move = move |_| {
304            timeout.reset();
305        };
306
307        let on_capture_global_pointer_move = move |e: Event<PointerEventData>| {
308            if drag_scrolling {
309                if let Some(prev) = dragging_content() {
310                    let coords = e.global_location();
311                    let delta = prev - coords;
312
313                    scroll_controller.scroll_to_y((corrected_scrolled_y - delta.y as f32) as i32);
314                    scroll_controller.scroll_to_x((corrected_scrolled_x - delta.x as f32) as i32);
315
316                    dragging_content.set(Some(coords));
317                    e.prevent_default();
318                    timeout.reset();
319                    a11y_id.request_focus();
320                    return;
321                } else if let Some(origin) = drag_origin() {
322                    let coords = e.global_location();
323                    let distance = (origin - coords).abs();
324
325                    // Small threshold so taps can reach children (e.g. hover on buttons)
326                    // without being immediately consumed by drag scrolling.
327                    const DRAG_THRESHOLD: f64 = 2.0;
328
329                    if distance.x > DRAG_THRESHOLD || distance.y > DRAG_THRESHOLD {
330                        let delta = origin - coords;
331
332                        scroll_controller
333                            .scroll_to_y((corrected_scrolled_y - delta.y as f32) as i32);
334                        scroll_controller
335                            .scroll_to_x((corrected_scrolled_x - delta.x as f32) as i32);
336
337                        dragging_content.set(Some(coords));
338                        e.prevent_default();
339                        timeout.reset();
340                        a11y_id.request_focus();
341                    }
342                    return;
343                }
344            }
345
346            let clicking_scrollbar = clicking_scrollbar.peek();
347
348            if let Some((Axis::Y, y)) = *clicking_scrollbar {
349                let coordinates = e.element_location();
350                let cursor_y = coordinates.y - y - size.read().area.min_y() as f64;
351
352                let scroll_position = get_scroll_position_from_cursor(
353                    cursor_y as f32,
354                    size.read().inner_sizes.height,
355                    size.read().area.height(),
356                );
357
358                scroll_controller.scroll_to_y(scroll_position);
359            } else if let Some((Axis::X, x)) = *clicking_scrollbar {
360                let coordinates = e.element_location();
361                let cursor_x = coordinates.x - x - size.read().area.min_x() as f64;
362
363                let scroll_position = get_scroll_position_from_cursor(
364                    cursor_x as f32,
365                    size.read().inner_sizes.width,
366                    size.read().area.width(),
367                );
368
369                scroll_controller.scroll_to_x(scroll_position);
370            }
371
372            if clicking_scrollbar.is_some() {
373                e.prevent_default();
374                timeout.reset();
375                a11y_id.request_focus();
376            }
377        };
378
379        let on_key_down = move |e: Event<KeyboardEventData>| {
380            if !scroll_with_arrows
381                && (e.key == Key::Named(NamedKey::ArrowUp)
382                    || e.key == Key::Named(NamedKey::ArrowRight)
383                    || e.key == Key::Named(NamedKey::ArrowDown)
384                    || e.key == Key::Named(NamedKey::ArrowLeft))
385            {
386                return;
387            }
388            let x = corrected_scrolled_x;
389            let y = corrected_scrolled_y;
390            let inner_height = size.read().inner_sizes.height;
391            let inner_width = size.read().inner_sizes.width;
392            let viewport_height = size.read().area.height();
393            let viewport_width = size.read().area.width();
394            if let Some((x, y)) = handle_key_event(
395                &e.key,
396                (x, y),
397                inner_height,
398                inner_width,
399                viewport_height,
400                viewport_width,
401                direction,
402            ) {
403                scroll_controller.scroll_to_x(x as i32);
404                scroll_controller.scroll_to_y(y as i32);
405                e.stop_propagation();
406                timeout.reset();
407            }
408        };
409
410        let on_global_key_down = move |e: Event<KeyboardEventData>| {
411            let data = e;
412            if data.key == Key::Named(NamedKey::Shift) {
413                pressing_shift.set(true);
414            }
415        };
416
417        let on_global_key_up = move |e: Event<KeyboardEventData>| {
418            let data = e;
419            if data.key == Key::Named(NamedKey::Shift) {
420                pressing_shift.set(false);
421            }
422        };
423
424        let on_pointer_down = move |e: Event<PointerEventData>| {
425            if drag_scrolling && matches!(e.data(), PointerEventData::Touch(_)) {
426                drag_origin.set(Some(e.global_location()));
427            }
428        };
429
430        rect()
431            .width(layout.width.clone())
432            .height(layout.height.clone())
433            .max_width(layout.maximum_width.clone())
434            .max_height(layout.maximum_height.clone())
435            .a11y_id(a11y_id)
436            .a11y_focusable(false)
437            .a11y_role(AccessibilityRole::ScrollView)
438            .a11y_builder(move |node| {
439                node.set_scroll_x(corrected_scrolled_x as f64);
440                node.set_scroll_y(corrected_scrolled_y as f64)
441            })
442            .scrollable(true)
443            .map(self.on_sized.clone(), |el, on_sized| el.on_sized(on_sized))
444            .on_wheel(on_wheel)
445            .on_capture_global_pointer_press(on_capture_global_pointer_press)
446            .on_mouse_move(on_mouse_move)
447            .on_capture_global_pointer_move(on_capture_global_pointer_move)
448            .on_key_down(on_key_down)
449            .on_global_key_up(on_global_key_up)
450            .on_global_key_down(on_global_key_down)
451            .on_pointer_down(on_pointer_down)
452            .child(
453                rect()
454                    .width(container_width)
455                    .height(container_height)
456                    .horizontal()
457                    .child(
458                        rect()
459                            .direction(direction)
460                            .width(content_width)
461                            .height(content_height)
462                            .max_width(layout.maximum_width.clone())
463                            .max_height(layout.maximum_height.clone())
464                            .offset_x(corrected_scrolled_x)
465                            .offset_y(corrected_scrolled_y)
466                            .spacing(layout.spacing.get())
467                            .overflow(Overflow::Clip)
468                            .on_sized(move |e: Event<SizedEventData>| {
469                                size.set_if_modified(e.clone())
470                            })
471                            .children(self.children.clone()),
472                    )
473                    .maybe_child(vertical_scrollbar_is_visible.then_some({
474                        rect().child(ScrollBar {
475                            theme: None,
476                            clicking_scrollbar,
477                            axis: Axis::Y,
478                            offset: scrollbar_y,
479                            size: Size::px(size.read().area.height()),
480                            thumb: ScrollThumb {
481                                theme: None,
482                                clicking_scrollbar,
483                                axis: Axis::Y,
484                                size: scrollbar_height,
485                            },
486                        })
487                    })),
488            )
489            .maybe_child(horizontal_scrollbar_is_visible.then_some({
490                rect().child(ScrollBar {
491                    theme: None,
492                    clicking_scrollbar,
493                    axis: Axis::X,
494                    offset: scrollbar_x,
495                    size: Size::px(size.read().area.width()),
496                    thumb: ScrollThumb {
497                        theme: None,
498                        clicking_scrollbar,
499                        axis: Axis::X,
500                        size: scrollbar_width,
501                    },
502                })
503            }))
504    }
505
506    fn render_key(&self) -> DiffKey {
507        self.key.clone().or(self.default_key())
508    }
509}