freya_components/scroll_views/
virtual_scroll_view.rs

1#![allow(clippy::type_complexity)]
2
3use std::ops::Range;
4
5use dioxus::prelude::*;
6use freya_elements::{
7    self as dioxus_elements,
8    events::{
9        keyboard::Key,
10        KeyboardEvent,
11        MouseEvent,
12        WheelEvent,
13    },
14};
15use freya_hooks::{
16    use_applied_theme,
17    use_focus,
18    use_node,
19    ScrollBarThemeWith,
20};
21
22use crate::{
23    get_container_size,
24    get_corrected_scroll_position,
25    get_scroll_position_from_cursor,
26    get_scroll_position_from_wheel,
27    get_scrollbar_pos_and_size,
28    is_scrollbar_visible,
29    manage_key_event,
30    scroll_views::use_scroll_controller,
31    Axis,
32    ScrollBar,
33    ScrollConfig,
34    ScrollController,
35    ScrollThumb,
36    SCROLL_SPEED_MULTIPLIER,
37};
38
39/// Properties for the [`VirtualScrollView`] component.
40#[derive(Props, Clone)]
41pub struct VirtualScrollViewProps<
42    Builder: 'static + Clone + Fn(usize, &Option<BuilderArgs>) -> Element,
43    BuilderArgs: Clone + 'static + PartialEq = (),
44> {
45    /// Width of the VirtualScrollView container. Default to `fill`.
46    #[props(default = "fill".into())]
47    pub width: String,
48    /// Height of the VirtualScrollView container. Default to `fill`.
49    #[props(default = "fill".into())]
50    pub height: String,
51    /// Padding of the VirtualScrollView container.
52    #[props(default = "0".to_string())]
53    pub padding: String,
54    /// Theme override for the scrollbars.
55    pub scrollbar_theme: Option<ScrollBarThemeWith>,
56    /// Quantity of items in the VirtualScrollView.
57    pub length: usize,
58    /// Size of the items, height for vertical direction and width for horizontal.
59    pub item_size: f32,
60    /// The item builder function.
61    pub builder: Builder,
62    /// The values for the item builder function.
63    #[props(into)]
64    pub builder_args: Option<BuilderArgs>,
65    /// Direction of the VirtualScrollView, `vertical` or `horizontal`.
66    #[props(default = "vertical".to_string(), into)]
67    pub direction: String,
68    /// Show the scrollbar, visible by default.
69    #[props(default = true, into)]
70    pub show_scrollbar: bool,
71    /// Enable scrolling with arrow keys.
72    #[props(default = true, into)]
73    pub scroll_with_arrows: bool,
74    /// Cache elements or not, changing `builder_args` will invalidate the cache if enabled.
75    /// Default is `true`.
76    #[props(default = true, into)]
77    pub cache_elements: bool,
78    /// Custom Scroll Controller for the Virtual ScrollView.
79    pub scroll_controller: Option<ScrollController>,
80    /// If `false` (default), wheel scroll with no shift will scroll vertically no matter the direction.
81    /// If `true`, wheel scroll with no shift will scroll horizontally.
82    #[props(default = false)]
83    pub invert_scroll_wheel: bool,
84}
85
86impl<
87        BuilderArgs: Clone + PartialEq,
88        Builder: Clone + Fn(usize, &Option<BuilderArgs>) -> Element,
89    > PartialEq for VirtualScrollViewProps<Builder, BuilderArgs>
90{
91    fn eq(&self, other: &Self) -> bool {
92        self.width == other.width
93            && self.height == other.height
94            && self.padding == other.padding
95            && self.length == other.length
96            && self.item_size == other.item_size
97            && self.direction == other.direction
98            && self.show_scrollbar == other.show_scrollbar
99            && self.scroll_with_arrows == other.scroll_with_arrows
100            && self.builder_args == other.builder_args
101            && self.scroll_controller == other.scroll_controller
102            && self.invert_scroll_wheel == other.invert_scroll_wheel
103    }
104}
105
106fn get_render_range(
107    viewport_size: f32,
108    scroll_position: f32,
109    item_size: f32,
110    item_length: f32,
111) -> Range<usize> {
112    let render_index_start = (-scroll_position) / item_size;
113    let potentially_visible_length = (viewport_size / item_size) + 1.0;
114    let remaining_length = item_length - render_index_start;
115
116    let render_index_end = if remaining_length <= potentially_visible_length {
117        item_length
118    } else {
119        render_index_start + potentially_visible_length
120    };
121
122    render_index_start as usize..(render_index_end as usize)
123}
124
125/// One-direction scrollable area that dynamically builds and renders items based in their size and current available size,
126/// this is intended for apps using large sets of data that need good performance.
127///
128/// Use cases: text editors, chats, etc.
129///
130/// # Example
131///
132/// ```rust
133/// # use freya::prelude::*;
134/// fn app() -> Element {
135///     rsx!(VirtualScrollView {
136///         length: 35,
137///         item_size: 20.0,
138///         direction: "vertical",
139///         builder: move |i, _other_args: &Option<()>| {
140///             rsx! {
141///                 label {
142///                     key: "{i}",
143///                     height: "20",
144///                     "Number {i}"
145///                 }
146///             }
147///         }
148///     })
149/// }
150/// # use freya_testing::prelude::*;
151/// # launch_doc(|| {
152/// #   rsx!(
153/// #       Preview {
154/// #           {app()}
155/// #       }
156/// #   )
157/// # }, (250., 250.).into(), "./images/gallery_virtual_scroll_view.png");
158/// ```
159///
160/// # With a Scroll Controller
161///
162/// ```no_run
163/// # use freya::prelude::*;
164/// fn app() -> Element {
165///     let mut scroll_controller = use_scroll_controller(|| ScrollConfig::default());
166///
167///     rsx!(VirtualScrollView {
168///         scroll_controller,
169///         length: 35,
170///         item_size: 20.0,
171///         direction: "vertical",
172///         builder: move |i, _other_args: &Option<()>| {
173///             rsx! {
174///                 label {
175///                     key: "{i}",
176///                     height: "20",
177///                     onclick: move |_| {
178///                          scroll_controller.scroll_to(ScrollPosition::Start, ScrollDirection::Vertical);
179///                     },
180///                     "Number {i}"
181///                 }
182///             }
183///         }
184///     })
185/// }
186/// ```
187///
188/// # Preview
189/// ![VirtualScrollView Preview][virtual_scroll_view]
190#[cfg_attr(feature = "docs",
191    doc = embed_doc_image::embed_image!("virtual_scroll_view", "images/gallery_virtual_scroll_view.png")
192)]
193#[allow(non_snake_case)]
194pub fn VirtualScrollView<
195    Builder: Clone + Fn(usize, &Option<BuilderArgs>) -> Element,
196    BuilderArgs: Clone + PartialEq,
197>(
198    VirtualScrollViewProps {
199        width,
200        height,
201        padding,
202        scrollbar_theme,
203        length,
204        item_size,
205        builder,
206        builder_args,
207        direction,
208        show_scrollbar,
209        scroll_with_arrows,
210        cache_elements,
211        scroll_controller,
212        invert_scroll_wheel,
213    }: VirtualScrollViewProps<Builder, BuilderArgs>,
214) -> Element {
215    let mut clicking_scrollbar = use_signal::<Option<(Axis, f64)>>(|| None);
216    let mut clicking_shift = use_signal(|| false);
217    let mut clicking_alt = use_signal(|| false);
218    let mut scroll_controller =
219        scroll_controller.unwrap_or_else(|| use_scroll_controller(ScrollConfig::default));
220    let (mut scrolled_x, mut scrolled_y) = scroll_controller.into();
221    let (node_ref, size) = use_node();
222    let mut focus = use_focus();
223    let applied_scrollbar_theme = use_applied_theme!(&scrollbar_theme, scroll_bar);
224
225    let direction_is_vertical = direction == "vertical";
226
227    let inner_size = item_size + (item_size * length as f32);
228
229    scroll_controller.use_apply(inner_size, inner_size);
230
231    let vertical_scrollbar_is_visible = direction != "horizontal"
232        && is_scrollbar_visible(show_scrollbar, inner_size, size.area.height());
233    let horizontal_scrollbar_is_visible = direction != "vertical"
234        && is_scrollbar_visible(show_scrollbar, inner_size, size.area.width());
235
236    let (container_width, content_width) =
237        get_container_size(&width, direction_is_vertical, Axis::X);
238    let (container_height, content_height) =
239        get_container_size(&height, direction_is_vertical, Axis::Y);
240
241    let corrected_scrolled_y =
242        get_corrected_scroll_position(inner_size, size.area.height(), *scrolled_y.read() as f32);
243    let corrected_scrolled_x =
244        get_corrected_scroll_position(inner_size, size.area.width(), *scrolled_x.read() as f32);
245
246    let (scrollbar_y, scrollbar_height) =
247        get_scrollbar_pos_and_size(inner_size, size.area.height(), corrected_scrolled_y);
248    let (scrollbar_x, scrollbar_width) =
249        get_scrollbar_pos_and_size(inner_size, size.area.width(), corrected_scrolled_x);
250
251    // Moves the Y axis when the user scrolls in the container
252    let onwheel = move |e: WheelEvent| {
253        let speed_multiplier = if *clicking_alt.peek() {
254            SCROLL_SPEED_MULTIPLIER
255        } else {
256            1.0
257        };
258
259        let invert_direction = (clicking_shift() || invert_scroll_wheel)
260            && (!clicking_shift() || !invert_scroll_wheel);
261
262        let (x_movement, y_movement) = if invert_direction {
263            (
264                e.get_delta_y() as f32 * speed_multiplier,
265                e.get_delta_x() as f32 * speed_multiplier,
266            )
267        } else {
268            (
269                e.get_delta_x() as f32 * speed_multiplier,
270                e.get_delta_y() as f32 * speed_multiplier,
271            )
272        };
273
274        let scroll_position_y = get_scroll_position_from_wheel(
275            y_movement,
276            inner_size,
277            size.area.height(),
278            corrected_scrolled_y,
279        );
280
281        // Only scroll when there is still area to scroll
282        if *scrolled_y.peek() != scroll_position_y {
283            e.stop_propagation();
284            *scrolled_y.write() = scroll_position_y;
285            focus.request_focus();
286        }
287
288        let scroll_position_x = get_scroll_position_from_wheel(
289            x_movement,
290            inner_size,
291            size.area.width(),
292            corrected_scrolled_x,
293        );
294
295        // Only scroll when there is still area to scroll
296        if *scrolled_x.peek() != scroll_position_x {
297            e.stop_propagation();
298            *scrolled_x.write() = scroll_position_x;
299            focus.request_focus();
300        }
301    };
302
303    // Drag the scrollbars
304    let onmousemove = move |e: MouseEvent| {
305        let clicking_scrollbar = clicking_scrollbar.peek();
306
307        if let Some((Axis::Y, y)) = *clicking_scrollbar {
308            let coordinates = e.get_element_coordinates();
309            let cursor_y = coordinates.y - y - size.area.min_y() as f64;
310
311            let scroll_position =
312                get_scroll_position_from_cursor(cursor_y as f32, inner_size, size.area.height());
313
314            *scrolled_y.write() = scroll_position;
315        } else if let Some((Axis::X, x)) = *clicking_scrollbar {
316            let coordinates = e.get_element_coordinates();
317            let cursor_x = coordinates.x - x - size.area.min_x() as f64;
318
319            let scroll_position =
320                get_scroll_position_from_cursor(cursor_x as f32, inner_size, size.area.width());
321
322            *scrolled_x.write() = scroll_position;
323        }
324
325        if clicking_scrollbar.is_some() {
326            focus.request_focus();
327        }
328    };
329
330    let onglobalkeydown = move |e: KeyboardEvent| {
331        match &e.key {
332            Key::Shift => {
333                clicking_shift.set(true);
334            }
335            Key::Alt => {
336                clicking_alt.set(true);
337            }
338            k => {
339                if !focus.is_focused() {
340                    return;
341                }
342
343                if !scroll_with_arrows
344                    && (k == &Key::ArrowUp
345                        || k == &Key::ArrowRight
346                        || k == &Key::ArrowDown
347                        || k == &Key::ArrowLeft)
348                {
349                    return;
350                }
351
352                let x = corrected_scrolled_x;
353                let y = corrected_scrolled_y;
354                let inner_height = inner_size;
355                let inner_width = inner_size;
356                let viewport_height = size.area.height();
357                let viewport_width = size.area.width();
358
359                let (x, y) = manage_key_event(
360                    e,
361                    (x, y),
362                    inner_height,
363                    inner_width,
364                    viewport_height,
365                    viewport_width,
366                );
367
368                scrolled_x.set(x as i32);
369                scrolled_y.set(y as i32);
370            }
371        };
372    };
373
374    let onglobalkeyup = move |e: KeyboardEvent| {
375        if e.key == Key::Shift {
376            clicking_shift.set(false);
377        } else if e.key == Key::Alt {
378            clicking_alt.set(false);
379        }
380    };
381
382    // Mark the Y axis scrollbar as the one being dragged
383    let onmousedown_y = move |e: MouseEvent| {
384        let coordinates = e.get_element_coordinates();
385        *clicking_scrollbar.write() = Some((Axis::Y, coordinates.y));
386    };
387
388    // Mark the X axis scrollbar as the one being dragged
389    let onmousedown_x = move |e: MouseEvent| {
390        let coordinates = e.get_element_coordinates();
391        *clicking_scrollbar.write() = Some((Axis::X, coordinates.x));
392    };
393
394    // Unmark any scrollbar
395    let onclick = move |_: MouseEvent| {
396        if clicking_scrollbar.peek().is_some() {
397            *clicking_scrollbar.write() = None;
398        }
399    };
400
401    let (viewport_size, scroll_position) = if direction == "vertical" {
402        (size.area.height(), corrected_scrolled_y)
403    } else {
404        (size.area.width(), corrected_scrolled_x)
405    };
406
407    // Calculate from what to what items must be rendered
408    let render_range = get_render_range(viewport_size, scroll_position, item_size, length as f32);
409
410    let children = if cache_elements {
411        let children = use_memo(use_reactive(
412            &(render_range, builder_args),
413            move |(render_range, builder_args)| {
414                render_range
415                    .clone()
416                    .map(|i| (builder)(i, &builder_args))
417                    .collect::<Vec<Element>>()
418            },
419        ));
420        rsx!({ children.read().iter() })
421    } else {
422        let children = render_range.map(|i| (builder)(i, &builder_args));
423        rsx!({ children })
424    };
425
426    let is_scrolling_x = clicking_scrollbar
427        .read()
428        .as_ref()
429        .map(|f| f.0 == Axis::X)
430        .unwrap_or_default();
431    let is_scrolling_y = clicking_scrollbar
432        .read()
433        .as_ref()
434        .map(|f| f.0 == Axis::Y)
435        .unwrap_or_default();
436
437    let offset_y_min = (-corrected_scrolled_y / item_size).floor() * item_size;
438    let offset_y = -corrected_scrolled_y - offset_y_min;
439
440    let a11y_id = focus.attribute();
441
442    rsx!(
443        rect {
444            a11y_role: "scroll-view",
445            overflow: "clip",
446            direction: "horizontal",
447            width: "{width}",
448            height: "{height}",
449            onglobalclick: onclick,
450            onglobalmousemove: onmousemove,
451            onglobalkeydown,
452            onglobalkeyup,
453            a11y_id,
454            rect {
455                direction: "vertical",
456                width: "{container_width}",
457                height: "{container_height}",
458                rect {
459                    overflow: "clip",
460                    padding: "{padding}",
461                    height: "{content_height}",
462                    width: "{content_width}",
463                    direction: "{direction}",
464                    offset_y: "{-offset_y}",
465                    reference: node_ref,
466                    onwheel: onwheel,
467                    {children}
468                }
469                if show_scrollbar && horizontal_scrollbar_is_visible {
470                    ScrollBar {
471                        size: &applied_scrollbar_theme.size,
472                        offset_x: scrollbar_x,
473                        clicking_scrollbar: is_scrolling_x,
474                        theme: scrollbar_theme.clone(),
475                        ScrollThumb {
476                            clicking_scrollbar: is_scrolling_x,
477                            onmousedown: onmousedown_x,
478                            width: "{scrollbar_width}",
479                            height: "100%",
480                            theme: scrollbar_theme.clone(),
481                        }
482                    }
483                }
484
485            }
486            if show_scrollbar && vertical_scrollbar_is_visible {
487                ScrollBar {
488                    is_vertical: true,
489                    size: &applied_scrollbar_theme.size,
490                    offset_y: scrollbar_y,
491                    clicking_scrollbar: is_scrolling_y,
492                    theme: scrollbar_theme.clone(),
493                    ScrollThumb {
494                        clicking_scrollbar: is_scrolling_y,
495                        onmousedown: onmousedown_y,
496                        width: "100%",
497                        height: "{scrollbar_height}",
498                        theme: scrollbar_theme,
499                    }
500                }
501            }
502        }
503    )
504}
505
506#[cfg(test)]
507mod test {
508    use freya::prelude::*;
509    use freya_testing::prelude::*;
510
511    #[tokio::test]
512    pub async fn virtual_scroll_view_wheel() {
513        fn virtual_scroll_view_wheel_app() -> Element {
514            let values = use_signal(|| ["Hello, World!"].repeat(30));
515
516            rsx!(VirtualScrollView {
517                length: values.read().len(),
518                item_size: 50.0,
519                direction: "vertical",
520                builder: move |index, _: &Option<()>| {
521                    let value = values.read()[index];
522                    rsx! {
523                        label {
524                            key: "{index}",
525                            height: "50",
526                            "{index} {value}"
527                        }
528                    }
529                }
530            })
531        }
532
533        let mut utils = launch_test(virtual_scroll_view_wheel_app);
534        let root = utils.root();
535
536        utils.wait_for_update().await;
537        utils.wait_for_update().await;
538
539        let content = root.get(0).get(0).get(0);
540        assert_eq!(content.children_ids().len(), 11);
541
542        // Check that visible items are from indexes 0 to 11, because 500 / 50 = 10 + 1 (for smooth scrolling) = 11.
543        for (n, i) in (0..11).enumerate() {
544            let child = content.get(n);
545            assert_eq!(
546                child.get(0).text(),
547                Some(format!("{i} Hello, World!").as_str())
548            );
549        }
550
551        utils.push_event(TestEvent::Wheel {
552            name: EventName::Wheel,
553            scroll: (0., -300.).into(),
554            cursor: (5., 5.).into(),
555        });
556
557        utils.wait_for_update().await;
558        utils.wait_for_update().await;
559
560        let content = root.get(0).get(0).get(0);
561        assert_eq!(content.children_ids().len(), 11);
562
563        // It has scrolled 300 pixels, which equals to 6 items since because 300 / 50 = 6
564        // So we must start checking from 6 to +10, 16 in this case because 6 + 10 = 16 + 1 (for smooths scrolling) = 17.
565        for (n, i) in (6..17).enumerate() {
566            let child = content.get(n);
567            assert_eq!(
568                child.get(0).text(),
569                Some(format!("{i} Hello, World!").as_str())
570            );
571        }
572    }
573
574    #[tokio::test]
575    pub async fn virtual_scroll_view_scrollbar() {
576        fn virtual_scroll_view_scrollar_app() -> Element {
577            let values = use_signal(|| ["Hello, World!"].repeat(30));
578
579            rsx!(VirtualScrollView {
580                length: values.read().len(),
581                item_size: 50.0,
582                direction: "vertical",
583                builder: move |index, _: &Option<()>| {
584                    let value = values.read()[index];
585                    rsx! {
586                        label {
587                            key: "{index}",
588                            height: "50",
589                            "{index} {value}"
590                        }
591                    }
592                }
593            })
594        }
595
596        let mut utils = launch_test(virtual_scroll_view_scrollar_app);
597        let root = utils.root();
598
599        utils.wait_for_update().await;
600        utils.wait_for_update().await;
601        utils.wait_for_update().await;
602
603        let content = root.get(0).get(0).get(0);
604        assert_eq!(content.children_ids().len(), 11);
605
606        // Check that visible items are from indexes 0 to 10, because 500 / 50 = 10 + 1 (for smooth scrolling) = 11.
607        for (n, i) in (0..11).enumerate() {
608            let child = content.get(n);
609            assert_eq!(
610                child.get(0).text(),
611                Some(format!("{i} Hello, World!").as_str())
612            );
613        }
614
615        // Simulate the user dragging the scrollbar
616        utils.push_event(TestEvent::Mouse {
617            name: EventName::MouseMove,
618            cursor: (495., 20.).into(),
619            button: Some(MouseButton::Left),
620        });
621        utils.push_event(TestEvent::Mouse {
622            name: EventName::MouseDown,
623            cursor: (495., 20.).into(),
624            button: Some(MouseButton::Left),
625        });
626        utils.push_event(TestEvent::Mouse {
627            name: EventName::MouseMove,
628            cursor: (495., 320.).into(),
629            button: Some(MouseButton::Left),
630        });
631        utils.push_event(TestEvent::Mouse {
632            name: EventName::MouseUp,
633            cursor: (495., 320.).into(),
634            button: Some(MouseButton::Left),
635        });
636
637        utils.wait_for_update().await;
638        utils.wait_for_update().await;
639
640        let content = root.get(0).get(0).get(0);
641        assert_eq!(content.children_ids().len(), 11);
642
643        // It has dragged the scrollbar 300 pixels
644        for (n, i) in (18..29).enumerate() {
645            let child = content.get(n);
646            assert_eq!(
647                child.get(0).text(),
648                Some(format!("{i} Hello, World!").as_str())
649            );
650        }
651
652        // Scroll up with arrows
653        for _ in 0..11 {
654            utils.push_event(TestEvent::Keyboard {
655                name: EventName::KeyDown,
656                key: Key::ArrowUp,
657                code: Code::ArrowUp,
658                modifiers: Modifiers::default(),
659            });
660            utils.wait_for_update().await;
661        }
662
663        let content = root.get(0).get(0).get(0);
664        assert_eq!(content.children_ids().len(), 11);
665
666        for (n, i) in (0..11).enumerate() {
667            let child = content.get(n);
668            assert_eq!(
669                child.get(0).text(),
670                Some(format!("{i} Hello, World!").as_str())
671            );
672        }
673
674        // Scroll to the bottom with arrows
675        utils.push_event(TestEvent::Keyboard {
676            name: EventName::KeyDown,
677            key: Key::End,
678            code: Code::End,
679            modifiers: Modifiers::default(),
680        });
681        utils.wait_for_update().await;
682        utils.wait_for_update().await;
683
684        let content = root.get(0).get(0).get(0);
685        assert_eq!(content.children_ids().len(), 9);
686
687        for (n, i) in (21..30).enumerate() {
688            let child = content.get(n);
689            assert_eq!(
690                child.get(0).text(),
691                Some(format!("{i} Hello, World!").as_str())
692            );
693        }
694    }
695}