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_sizes,
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 inner_size = item_size * length as f32;
226
227    scroll_controller.use_apply(inner_size, inner_size);
228
229    let vertical_scrollbar_is_visible = direction != "horizontal"
230        && is_scrollbar_visible(show_scrollbar, inner_size, size.area.height());
231    let horizontal_scrollbar_is_visible = direction != "vertical"
232        && is_scrollbar_visible(show_scrollbar, inner_size, size.area.width());
233
234    let (container_width, content_width) = get_container_sizes(&width);
235    let (container_height, content_height) = get_container_sizes(&height);
236
237    let corrected_scrolled_y =
238        get_corrected_scroll_position(inner_size, size.area.height(), *scrolled_y.read() as f32);
239    let corrected_scrolled_x =
240        get_corrected_scroll_position(inner_size, size.area.width(), *scrolled_x.read() as f32);
241
242    let (scrollbar_y, scrollbar_height) =
243        get_scrollbar_pos_and_size(inner_size, size.area.height(), corrected_scrolled_y);
244    let (scrollbar_x, scrollbar_width) =
245        get_scrollbar_pos_and_size(inner_size, size.area.width(), corrected_scrolled_x);
246
247    // Moves the Y axis when the user scrolls in the container
248    let onwheel = move |e: WheelEvent| {
249        let speed_multiplier = if *clicking_alt.peek() {
250            SCROLL_SPEED_MULTIPLIER
251        } else {
252            1.0
253        };
254
255        let invert_direction = (clicking_shift() || invert_scroll_wheel)
256            && (!clicking_shift() || !invert_scroll_wheel);
257
258        let (x_movement, y_movement) = if invert_direction {
259            (
260                e.get_delta_y() as f32 * speed_multiplier,
261                e.get_delta_x() as f32 * speed_multiplier,
262            )
263        } else {
264            (
265                e.get_delta_x() as f32 * speed_multiplier,
266                e.get_delta_y() as f32 * speed_multiplier,
267            )
268        };
269
270        let scroll_position_y = get_scroll_position_from_wheel(
271            y_movement,
272            inner_size,
273            size.area.height(),
274            corrected_scrolled_y,
275        );
276
277        // Only scroll when there is still area to scroll
278        if *scrolled_y.peek() != scroll_position_y {
279            e.stop_propagation();
280            *scrolled_y.write() = scroll_position_y;
281            focus.request_focus();
282        }
283
284        let scroll_position_x = get_scroll_position_from_wheel(
285            x_movement,
286            inner_size,
287            size.area.width(),
288            corrected_scrolled_x,
289        );
290
291        // Only scroll when there is still area to scroll
292        if *scrolled_x.peek() != scroll_position_x {
293            e.stop_propagation();
294            *scrolled_x.write() = scroll_position_x;
295            focus.request_focus();
296        }
297    };
298
299    // Drag the scrollbars
300    let onmousemove = move |e: MouseEvent| {
301        let clicking_scrollbar = clicking_scrollbar.peek();
302
303        if let Some((Axis::Y, y)) = *clicking_scrollbar {
304            let coordinates = e.get_element_coordinates();
305            let cursor_y = coordinates.y - y - size.area.min_y() as f64;
306
307            let scroll_position =
308                get_scroll_position_from_cursor(cursor_y as f32, inner_size, size.area.height());
309
310            *scrolled_y.write() = scroll_position;
311        } else if let Some((Axis::X, x)) = *clicking_scrollbar {
312            let coordinates = e.get_element_coordinates();
313            let cursor_x = coordinates.x - x - size.area.min_x() as f64;
314
315            let scroll_position =
316                get_scroll_position_from_cursor(cursor_x as f32, inner_size, size.area.width());
317
318            *scrolled_x.write() = scroll_position;
319        }
320
321        if clicking_scrollbar.is_some() {
322            focus.request_focus();
323        }
324    };
325
326    let onglobalkeydown = move |e: KeyboardEvent| {
327        match &e.key {
328            Key::Shift => {
329                clicking_shift.set(true);
330            }
331            Key::Alt => {
332                clicking_alt.set(true);
333            }
334            k => {
335                if !focus.is_focused() {
336                    return;
337                }
338
339                if !scroll_with_arrows
340                    && (k == &Key::ArrowUp
341                        || k == &Key::ArrowRight
342                        || k == &Key::ArrowDown
343                        || k == &Key::ArrowLeft)
344                {
345                    return;
346                }
347
348                let x = corrected_scrolled_x;
349                let y = corrected_scrolled_y;
350                let inner_height = inner_size;
351                let inner_width = inner_size;
352                let viewport_height = size.area.height();
353                let viewport_width = size.area.width();
354
355                let (x, y) = manage_key_event(
356                    e,
357                    (x, y),
358                    inner_height,
359                    inner_width,
360                    viewport_height,
361                    viewport_width,
362                );
363
364                scrolled_x.set(x as i32);
365                scrolled_y.set(y as i32);
366            }
367        };
368    };
369
370    let onglobalkeyup = move |e: KeyboardEvent| {
371        if e.key == Key::Shift {
372            clicking_shift.set(false);
373        } else if e.key == Key::Alt {
374            clicking_alt.set(false);
375        }
376    };
377
378    // Mark the Y axis scrollbar as the one being dragged
379    let onmousedown_y = move |e: MouseEvent| {
380        let coordinates = e.get_element_coordinates();
381        *clicking_scrollbar.write() = Some((Axis::Y, coordinates.y));
382    };
383
384    // Mark the X axis scrollbar as the one being dragged
385    let onmousedown_x = move |e: MouseEvent| {
386        let coordinates = e.get_element_coordinates();
387        *clicking_scrollbar.write() = Some((Axis::X, coordinates.x));
388    };
389
390    // Unmark any scrollbar
391    let onclick = move |_: MouseEvent| {
392        if clicking_scrollbar.peek().is_some() {
393            *clicking_scrollbar.write() = None;
394        }
395    };
396
397    let (viewport_size, scroll_position) = if direction == "vertical" {
398        (size.area.height(), corrected_scrolled_y)
399    } else {
400        (size.area.width(), corrected_scrolled_x)
401    };
402
403    // Calculate from what to what items must be rendered
404    let render_range = get_render_range(viewport_size, scroll_position, item_size, length as f32);
405
406    let children = if cache_elements {
407        let children = use_memo(use_reactive(
408            &(render_range, builder_args),
409            move |(render_range, builder_args)| {
410                render_range
411                    .clone()
412                    .map(|i| (builder)(i, &builder_args))
413                    .collect::<Vec<Element>>()
414            },
415        ));
416        rsx!({ children.read().iter() })
417    } else {
418        let children = render_range.map(|i| (builder)(i, &builder_args));
419        rsx!({ children })
420    };
421
422    let is_scrolling_x = clicking_scrollbar
423        .read()
424        .as_ref()
425        .map(|f| f.0 == Axis::X)
426        .unwrap_or_default();
427    let is_scrolling_y = clicking_scrollbar
428        .read()
429        .as_ref()
430        .map(|f| f.0 == Axis::Y)
431        .unwrap_or_default();
432
433    let offset_y_min = (-corrected_scrolled_y / item_size).floor() * item_size;
434    let offset_y = -corrected_scrolled_y - offset_y_min;
435
436    let a11y_id = focus.attribute();
437
438    rsx!(
439        rect {
440            a11y_role: "scroll-view",
441            overflow: "clip",
442            direction: "horizontal",
443            width: "{width}",
444            height: "{height}",
445            onglobalclick: onclick,
446            onglobalmousemove: onmousemove,
447            onglobalkeydown,
448            onglobalkeyup,
449            a11y_id,
450            rect {
451                direction: "vertical",
452                width: "{container_width}",
453                height: "{container_height}",
454                rect {
455                    overflow: "clip",
456                    padding: "{padding}",
457                    height: "{content_height}",
458                    width: "{content_width}",
459                    direction: "{direction}",
460                    offset_y: "{-offset_y}",
461                    reference: node_ref,
462                    onwheel: onwheel,
463                    {children}
464                }
465                if show_scrollbar && horizontal_scrollbar_is_visible {
466                    ScrollBar {
467                        size: &applied_scrollbar_theme.size,
468                        offset_x: scrollbar_x,
469                        clicking_scrollbar: is_scrolling_x,
470                        theme: scrollbar_theme.clone(),
471                        ScrollThumb {
472                            clicking_scrollbar: is_scrolling_x,
473                            onmousedown: onmousedown_x,
474                            width: "{scrollbar_width}",
475                            height: "100%",
476                            theme: scrollbar_theme.clone(),
477                        }
478                    }
479                }
480
481            }
482            if show_scrollbar && vertical_scrollbar_is_visible {
483                ScrollBar {
484                    is_vertical: true,
485                    size: &applied_scrollbar_theme.size,
486                    offset_y: scrollbar_y,
487                    clicking_scrollbar: is_scrolling_y,
488                    theme: scrollbar_theme.clone(),
489                    ScrollThumb {
490                        clicking_scrollbar: is_scrolling_y,
491                        onmousedown: onmousedown_y,
492                        width: "100%",
493                        height: "{scrollbar_height}",
494                        theme: scrollbar_theme,
495                    }
496                }
497            }
498        }
499    )
500}
501
502#[cfg(test)]
503mod test {
504    use freya::prelude::*;
505    use freya_testing::prelude::*;
506
507    #[tokio::test]
508    pub async fn virtual_scroll_view_wheel() {
509        fn virtual_scroll_view_wheel_app() -> Element {
510            let values = use_signal(|| ["Hello, World!"].repeat(30));
511
512            rsx!(VirtualScrollView {
513                length: values.read().len(),
514                item_size: 50.0,
515                direction: "vertical",
516                builder: move |index, _: &Option<()>| {
517                    let value = values.read()[index];
518                    rsx! {
519                        label {
520                            key: "{index}",
521                            height: "50",
522                            "{index} {value}"
523                        }
524                    }
525                }
526            })
527        }
528
529        let mut utils = launch_test(virtual_scroll_view_wheel_app);
530        let root = utils.root();
531
532        utils.wait_for_update().await;
533        utils.wait_for_update().await;
534
535        let content = root.get(0).get(0).get(0);
536        assert_eq!(content.children_ids().len(), 11);
537
538        // Check that visible items are from indexes 0 to 11, because 500 / 50 = 10 + 1 (for smooth scrolling) = 11.
539        for (n, i) in (0..11).enumerate() {
540            let child = content.get(n);
541            assert_eq!(
542                child.get(0).text(),
543                Some(format!("{i} Hello, World!").as_str())
544            );
545        }
546
547        utils.push_event(TestEvent::Wheel {
548            name: EventName::Wheel,
549            scroll: (0., -300.).into(),
550            cursor: (5., 5.).into(),
551        });
552
553        utils.wait_for_update().await;
554        utils.wait_for_update().await;
555
556        let content = root.get(0).get(0).get(0);
557        assert_eq!(content.children_ids().len(), 11);
558
559        // It has scrolled 300 pixels, which equals to 6 items since because 300 / 50 = 6
560        // So we must start checking from 6 to +10, 16 in this case because 6 + 10 = 16 + 1 (for smooths scrolling) = 17.
561        for (n, i) in (6..17).enumerate() {
562            let child = content.get(n);
563            assert_eq!(
564                child.get(0).text(),
565                Some(format!("{i} Hello, World!").as_str())
566            );
567        }
568    }
569
570    #[tokio::test]
571    pub async fn virtual_scroll_view_scrollbar() {
572        fn virtual_scroll_view_scrollar_app() -> Element {
573            let values = use_signal(|| ["Hello, World!"].repeat(30));
574
575            rsx!(VirtualScrollView {
576                length: values.read().len(),
577                item_size: 50.0,
578                direction: "vertical",
579                builder: move |index, _: &Option<()>| {
580                    let value = values.read()[index];
581                    rsx! {
582                        label {
583                            key: "{index}",
584                            height: "50",
585                            "{index} {value}"
586                        }
587                    }
588                }
589            })
590        }
591
592        let mut utils = launch_test(virtual_scroll_view_scrollar_app);
593        let root = utils.root();
594
595        utils.wait_for_update().await;
596        utils.wait_for_update().await;
597        utils.wait_for_update().await;
598
599        let content = root.get(0).get(0).get(0);
600        assert_eq!(content.children_ids().len(), 11);
601
602        // Check that visible items are from indexes 0 to 10, because 500 / 50 = 10 + 1 (for smooth scrolling) = 11.
603        for (n, i) in (0..11).enumerate() {
604            let child = content.get(n);
605            assert_eq!(
606                child.get(0).text(),
607                Some(format!("{i} Hello, World!").as_str())
608            );
609        }
610
611        // Simulate the user dragging the scrollbar
612        utils.push_event(TestEvent::Mouse {
613            name: EventName::MouseMove,
614            cursor: (495., 20.).into(),
615            button: Some(MouseButton::Left),
616        });
617        utils.push_event(TestEvent::Mouse {
618            name: EventName::MouseDown,
619            cursor: (495., 20.).into(),
620            button: Some(MouseButton::Left),
621        });
622        utils.push_event(TestEvent::Mouse {
623            name: EventName::MouseMove,
624            cursor: (495., 320.).into(),
625            button: Some(MouseButton::Left),
626        });
627        utils.push_event(TestEvent::Mouse {
628            name: EventName::MouseUp,
629            cursor: (495., 320.).into(),
630            button: Some(MouseButton::Left),
631        });
632
633        utils.wait_for_update().await;
634        utils.wait_for_update().await;
635
636        let content = root.get(0).get(0).get(0);
637        assert_eq!(content.children_ids().len(), 11);
638
639        // It has dragged the scrollbar 300 pixels
640        for (n, i) in (18..29).enumerate() {
641            let child = content.get(n);
642            assert_eq!(
643                child.get(0).text(),
644                Some(format!("{i} Hello, World!").as_str())
645            );
646        }
647
648        // Scroll up with arrows
649        for _ in 0..11 {
650            utils.push_event(TestEvent::Keyboard {
651                name: EventName::KeyDown,
652                key: Key::ArrowUp,
653                code: Code::ArrowUp,
654                modifiers: Modifiers::default(),
655            });
656            utils.wait_for_update().await;
657        }
658
659        let content = root.get(0).get(0).get(0);
660        assert_eq!(content.children_ids().len(), 11);
661
662        for (n, i) in (0..11).enumerate() {
663            let child = content.get(n);
664            assert_eq!(
665                child.get(0).text(),
666                Some(format!("{i} Hello, World!").as_str())
667            );
668        }
669
670        // Scroll to the bottom with arrows
671        utils.push_event(TestEvent::Keyboard {
672            name: EventName::KeyDown,
673            key: Key::End,
674            code: Code::End,
675            modifiers: Modifiers::default(),
676        });
677        utils.wait_for_update().await;
678        utils.wait_for_update().await;
679
680        let content = root.get(0).get(0).get(0);
681        assert_eq!(content.children_ids().len(), 10);
682
683        for (n, i) in (20..30).enumerate() {
684            let child = content.get(n);
685            assert_eq!(
686                child.get(0).text(),
687                Some(format!("{i} Hello, World!").as_str())
688            );
689        }
690    }
691}