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    key: DiffKey,
78}
79
80impl ChildrenExt for ScrollView {
81    fn get_children(&mut self) -> &mut Vec<Element> {
82        &mut self.children
83    }
84}
85
86impl KeyExt for ScrollView {
87    fn write_key(&mut self) -> &mut DiffKey {
88        &mut self.key
89    }
90}
91
92impl Default for ScrollView {
93    fn default() -> Self {
94        Self {
95            children: Vec::default(),
96            layout: Node {
97                width: Size::fill(),
98                height: Size::fill(),
99                ..Default::default()
100            }
101            .into(),
102            show_scrollbar: true,
103            scroll_with_arrows: true,
104            scroll_controller: None,
105            invert_scroll_wheel: false,
106            drag_scrolling: true,
107            key: DiffKey::None,
108        }
109    }
110}
111
112impl ScrollView {
113    /// Creates an uncontrolled scroll view that manages its own scroll position.
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Creates a scroll view driven by the given [`ScrollController`].
119    pub fn new_controlled(scroll_controller: ScrollController) -> Self {
120        Self {
121            scroll_controller: Some(scroll_controller),
122            ..Default::default()
123        }
124    }
125
126    /// Toggles whether the scrollbars are shown when the content overflows.
127    pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self {
128        self.show_scrollbar = show_scrollbar;
129        self
130    }
131
132    /// Sets the layout direction the children flow and scroll in.
133    pub fn direction(mut self, direction: Direction) -> Self {
134        self.layout.direction = direction;
135        self
136    }
137
138    /// Sets the gap between children along the scroll direction.
139    pub fn spacing(mut self, spacing: impl Into<f32>) -> Self {
140        self.layout.spacing = Length::new(spacing.into());
141        self
142    }
143
144    /// Toggles whether the arrow keys scroll the view while it is focused.
145    pub fn scroll_with_arrows(mut self, scroll_with_arrows: impl Into<bool>) -> Self {
146        self.scroll_with_arrows = scroll_with_arrows.into();
147        self
148    }
149
150    /// Inverts the direction of the mouse wheel relative to the content.
151    pub fn invert_scroll_wheel(mut self, invert_scroll_wheel: impl Into<bool>) -> Self {
152        self.invert_scroll_wheel = invert_scroll_wheel.into();
153        self
154    }
155
156    /// Toggles scrolling by dragging the content, useful mainly for touch input.
157    pub fn drag_scrolling(mut self, drag_scrolling: bool) -> Self {
158        self.drag_scrolling = drag_scrolling;
159        self
160    }
161
162    /// Caps the width of the scroll view.
163    pub fn max_width(mut self, max_width: impl Into<Size>) -> Self {
164        self.layout.maximum_width = max_width.into();
165        self
166    }
167
168    /// Caps the height of the scroll view.
169    pub fn max_height(mut self, max_height: impl Into<Size>) -> Self {
170        self.layout.maximum_height = max_height.into();
171        self
172    }
173}
174
175impl LayoutExt for ScrollView {
176    fn get_layout(&mut self) -> &mut LayoutData {
177        &mut self.layout
178    }
179}
180
181impl ContainerSizeExt for ScrollView {}
182impl ContainerPositionExt for ScrollView {}
183
184impl Component for ScrollView {
185    fn render(self: &ScrollView) -> impl IntoElement {
186        let a11y_id = use_a11y();
187        let mut timeout = use_timeout(|| Duration::from_millis(800));
188        let mut pressing_shift = use_state(|| false);
189        let mut clicking_scrollbar = use_state::<Option<(Axis, f64)>>(|| None);
190        let mut size = use_state(SizedEventData::default);
191        let mut scroll_controller = self
192            .scroll_controller
193            .unwrap_or_else(|| use_scroll_controller(ScrollConfig::default));
194        let mut dragging_content = use_state::<Option<CursorPoint>>(|| None);
195        let mut drag_origin = use_state::<Option<CursorPoint>>(|| None);
196        let (scrolled_x, scrolled_y) = scroll_controller.into();
197        let layout = &self.layout.layout;
198        let direction = layout.direction;
199        let drag_scrolling = self.drag_scrolling;
200
201        scroll_controller.use_apply(
202            size.read().inner_sizes.width,
203            size.read().inner_sizes.height,
204        );
205
206        let corrected_scrolled_x = get_corrected_scroll_position(
207            size.read().inner_sizes.width,
208            size.read().area.width(),
209            scrolled_x as f32,
210        );
211
212        let corrected_scrolled_y = get_corrected_scroll_position(
213            size.read().inner_sizes.height,
214            size.read().area.height(),
215            scrolled_y as f32,
216        );
217        let horizontal_scrollbar_is_visible = !timeout.elapsed()
218            && is_scrollbar_visible(
219                self.show_scrollbar,
220                size.read().inner_sizes.width,
221                size.read().area.width(),
222            );
223        let vertical_scrollbar_is_visible = !timeout.elapsed()
224            && is_scrollbar_visible(
225                self.show_scrollbar,
226                size.read().inner_sizes.height,
227                size.read().area.height(),
228            );
229
230        let (scrollbar_x, scrollbar_width) = get_scrollbar_pos_and_size(
231            size.read().inner_sizes.width,
232            size.read().area.width(),
233            corrected_scrolled_x,
234        );
235        let (scrollbar_y, scrollbar_height) = get_scrollbar_pos_and_size(
236            size.read().inner_sizes.height,
237            size.read().area.height(),
238            corrected_scrolled_y,
239        );
240
241        let (container_width, content_width) = get_container_sizes(layout.width.clone());
242        let (container_height, content_height) = get_container_sizes(layout.height.clone());
243
244        let scroll_with_arrows = self.scroll_with_arrows;
245        let invert_scroll_wheel = self.invert_scroll_wheel;
246
247        let on_capture_global_pointer_press = move |e: Event<PointerEventData>| {
248            if clicking_scrollbar.read().is_some() {
249                e.prevent_default();
250                clicking_scrollbar.set(None);
251            }
252
253            if drag_scrolling && (dragging_content().is_some() || drag_origin().is_some()) {
254                dragging_content.set(None);
255                drag_origin.set(None);
256            }
257        };
258
259        let on_wheel = move |e: Event<WheelEventData>| {
260            // Only invert direction on deviced-sourced wheel events
261            let invert_direction = e.source == WheelSource::Device
262                && (*pressing_shift.read() || invert_scroll_wheel)
263                && (!*pressing_shift.read() || !invert_scroll_wheel);
264
265            let (x_movement, y_movement) = if invert_direction {
266                (e.delta_y as f32, e.delta_x as f32)
267            } else {
268                (e.delta_x as f32, e.delta_y as f32)
269            };
270
271            // Vertical scroll
272            let scroll_position_y = get_scroll_position_from_wheel(
273                y_movement,
274                size.read().inner_sizes.height,
275                size.read().area.height(),
276                corrected_scrolled_y,
277            );
278            scroll_controller.scroll_to_y(scroll_position_y).then(|| {
279                e.stop_propagation();
280            });
281
282            // Horizontal scroll
283            let scroll_position_x = get_scroll_position_from_wheel(
284                x_movement,
285                size.read().inner_sizes.width,
286                size.read().area.width(),
287                corrected_scrolled_x,
288            );
289            scroll_controller.scroll_to_x(scroll_position_x).then(|| {
290                e.stop_propagation();
291            });
292            timeout.reset();
293        };
294
295        let on_mouse_move = move |_| {
296            timeout.reset();
297        };
298
299        let on_capture_global_pointer_move = move |e: Event<PointerEventData>| {
300            if drag_scrolling {
301                if let Some(prev) = dragging_content() {
302                    let coords = e.global_location();
303                    let delta = prev - coords;
304
305                    scroll_controller.scroll_to_y((corrected_scrolled_y - delta.y as f32) as i32);
306                    scroll_controller.scroll_to_x((corrected_scrolled_x - delta.x as f32) as i32);
307
308                    dragging_content.set(Some(coords));
309                    e.prevent_default();
310                    timeout.reset();
311                    a11y_id.request_focus();
312                    return;
313                } else if let Some(origin) = drag_origin() {
314                    let coords = e.global_location();
315                    let distance = (origin - coords).abs();
316
317                    // Small threshold so taps can reach children (e.g. hover on buttons)
318                    // without being immediately consumed by drag scrolling.
319                    const DRAG_THRESHOLD: f64 = 2.0;
320
321                    if distance.x > DRAG_THRESHOLD || distance.y > DRAG_THRESHOLD {
322                        let delta = origin - coords;
323
324                        scroll_controller
325                            .scroll_to_y((corrected_scrolled_y - delta.y as f32) as i32);
326                        scroll_controller
327                            .scroll_to_x((corrected_scrolled_x - delta.x as f32) as i32);
328
329                        dragging_content.set(Some(coords));
330                        e.prevent_default();
331                        timeout.reset();
332                        a11y_id.request_focus();
333                    }
334                    return;
335                }
336            }
337
338            let clicking_scrollbar = clicking_scrollbar.peek();
339
340            if let Some((Axis::Y, y)) = *clicking_scrollbar {
341                let coordinates = e.element_location();
342                let cursor_y = coordinates.y - y - size.read().area.min_y() as f64;
343
344                let scroll_position = get_scroll_position_from_cursor(
345                    cursor_y as f32,
346                    size.read().inner_sizes.height,
347                    size.read().area.height(),
348                );
349
350                scroll_controller.scroll_to_y(scroll_position);
351            } else if let Some((Axis::X, x)) = *clicking_scrollbar {
352                let coordinates = e.element_location();
353                let cursor_x = coordinates.x - x - size.read().area.min_x() as f64;
354
355                let scroll_position = get_scroll_position_from_cursor(
356                    cursor_x as f32,
357                    size.read().inner_sizes.width,
358                    size.read().area.width(),
359                );
360
361                scroll_controller.scroll_to_x(scroll_position);
362            }
363
364            if clicking_scrollbar.is_some() {
365                e.prevent_default();
366                timeout.reset();
367                a11y_id.request_focus();
368            }
369        };
370
371        let on_key_down = move |e: Event<KeyboardEventData>| {
372            if !scroll_with_arrows
373                && (e.key == Key::Named(NamedKey::ArrowUp)
374                    || e.key == Key::Named(NamedKey::ArrowRight)
375                    || e.key == Key::Named(NamedKey::ArrowDown)
376                    || e.key == Key::Named(NamedKey::ArrowLeft))
377            {
378                return;
379            }
380            let x = corrected_scrolled_x;
381            let y = corrected_scrolled_y;
382            let inner_height = size.read().inner_sizes.height;
383            let inner_width = size.read().inner_sizes.width;
384            let viewport_height = size.read().area.height();
385            let viewport_width = size.read().area.width();
386            if let Some((x, y)) = handle_key_event(
387                &e.key,
388                (x, y),
389                inner_height,
390                inner_width,
391                viewport_height,
392                viewport_width,
393                direction,
394            ) {
395                scroll_controller.scroll_to_x(x as i32);
396                scroll_controller.scroll_to_y(y as i32);
397                e.stop_propagation();
398                timeout.reset();
399            }
400        };
401
402        let on_global_key_down = move |e: Event<KeyboardEventData>| {
403            let data = e;
404            if data.key == Key::Named(NamedKey::Shift) {
405                pressing_shift.set(true);
406            }
407        };
408
409        let on_global_key_up = move |e: Event<KeyboardEventData>| {
410            let data = e;
411            if data.key == Key::Named(NamedKey::Shift) {
412                pressing_shift.set(false);
413            }
414        };
415
416        let on_pointer_down = move |e: Event<PointerEventData>| {
417            if drag_scrolling && matches!(e.data(), PointerEventData::Touch(_)) {
418                drag_origin.set(Some(e.global_location()));
419            }
420        };
421
422        rect()
423            .width(layout.width.clone())
424            .height(layout.height.clone())
425            .max_width(layout.maximum_width.clone())
426            .max_height(layout.maximum_height.clone())
427            .a11y_id(a11y_id)
428            .a11y_focusable(false)
429            .a11y_role(AccessibilityRole::ScrollView)
430            .a11y_builder(move |node| {
431                node.set_scroll_x(corrected_scrolled_x as f64);
432                node.set_scroll_y(corrected_scrolled_y as f64)
433            })
434            .scrollable(true)
435            .on_wheel(on_wheel)
436            .on_capture_global_pointer_press(on_capture_global_pointer_press)
437            .on_mouse_move(on_mouse_move)
438            .on_capture_global_pointer_move(on_capture_global_pointer_move)
439            .on_key_down(on_key_down)
440            .on_global_key_up(on_global_key_up)
441            .on_global_key_down(on_global_key_down)
442            .on_pointer_down(on_pointer_down)
443            .child(
444                rect()
445                    .width(container_width)
446                    .height(container_height)
447                    .horizontal()
448                    .child(
449                        rect()
450                            .direction(direction)
451                            .width(content_width)
452                            .height(content_height)
453                            .max_width(layout.maximum_width.clone())
454                            .max_height(layout.maximum_height.clone())
455                            .offset_x(corrected_scrolled_x)
456                            .offset_y(corrected_scrolled_y)
457                            .spacing(layout.spacing.get())
458                            .overflow(Overflow::Clip)
459                            .on_sized(move |e: Event<SizedEventData>| {
460                                size.set_if_modified(e.clone())
461                            })
462                            .children(self.children.clone()),
463                    )
464                    .maybe_child(vertical_scrollbar_is_visible.then_some({
465                        rect().child(ScrollBar {
466                            theme: None,
467                            clicking_scrollbar,
468                            axis: Axis::Y,
469                            offset: scrollbar_y,
470                            size: Size::px(size.read().area.height()),
471                            thumb: ScrollThumb {
472                                theme: None,
473                                clicking_scrollbar,
474                                axis: Axis::Y,
475                                size: scrollbar_height,
476                            },
477                        })
478                    })),
479            )
480            .maybe_child(horizontal_scrollbar_is_visible.then_some({
481                rect().child(ScrollBar {
482                    theme: None,
483                    clicking_scrollbar,
484                    axis: Axis::X,
485                    offset: scrollbar_x,
486                    size: Size::px(size.read().area.width()),
487                    thumb: ScrollThumb {
488                        theme: None,
489                        clicking_scrollbar,
490                        axis: Axis::X,
491                        size: scrollbar_width,
492                    },
493                })
494            }))
495    }
496
497    fn render_key(&self) -> DiffKey {
498        self.key.clone().or(self.default_key())
499    }
500}