Skip to main content

freya_components/scrollviews/
virtual_scrollview.rs

1use std::{
2    ops::Range,
3    time::Duration,
4};
5
6use freya_core::prelude::*;
7use freya_sdk::timeout::use_timeout;
8use torin::{
9    geometry::CursorPoint,
10    node::Node,
11    prelude::Direction,
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/// One-direction scrollable area that dynamically builds and renders items based in their size and current available size,
34/// this is intended for apps using large sets of data that need good performance.
35///
36/// Unlike [`ScrollView`](crate::scrollviews::ScrollView), which lays out every child even when it is
37/// off screen, a `VirtualScrollView` takes a builder closure and only calls it for the items that
38/// are actually visible, so the cost stays roughly constant no matter how long the list is.
39///
40/// It needs two things to know which items fall inside the viewport:
41/// [`item_size`](VirtualScrollView::item_size), the fixed size of each item along the scroll axis,
42/// and [`length`](VirtualScrollView::length), the total number of items.
43///
44/// # Example
45///
46/// ```rust
47/// # use freya::prelude::*;
48/// fn app() -> impl IntoElement {
49///     rect().child(
50///         VirtualScrollView::new(|i, _| {
51///             rect()
52///                 .key(i)
53///                 .height(Size::px(25.))
54///                 .padding(4.)
55///                 .child(format!("Item {i}"))
56///                 .into()
57///         })
58///         .length(300usize)
59///         .item_size(25.),
60///     )
61/// }
62///
63/// # use freya_testing::prelude::*;
64/// # launch_doc(|| {
65/// #   rect().center().expanded().child(app())
66/// # }, "./images/gallery_virtual_scrollview.png").with_hook(|t| {
67/// #   t.move_cursor((125., 115.));
68/// #   t.sync_and_update();
69/// # });
70/// ```
71///
72/// # Preview
73/// ![VirtualScrollView Preview][virtual_scrollview]
74#[cfg_attr(feature = "docs",
75    doc = embed_doc_image::embed_image!("virtual_scrollview", "images/gallery_virtual_scrollview.png")
76)]
77#[derive(Clone)]
78pub struct VirtualScrollView<D, B: Fn(usize, &D) -> Element> {
79    builder: B,
80    builder_data: D,
81    item_size: f32,
82    length: usize,
83    layout: LayoutData,
84    show_scrollbar: bool,
85    scroll_with_arrows: bool,
86    scroll_controller: Option<ScrollController>,
87    invert_scroll_wheel: bool,
88    drag_scrolling: bool,
89    key: DiffKey,
90}
91
92impl<D: PartialEq, B: Fn(usize, &D) -> Element> LayoutExt for VirtualScrollView<D, B> {
93    fn get_layout(&mut self) -> &mut LayoutData {
94        &mut self.layout
95    }
96}
97
98impl<D: PartialEq, B: Fn(usize, &D) -> Element> ContainerSizeExt for VirtualScrollView<D, B> {}
99
100impl<D: PartialEq, B: Fn(usize, &D) -> Element> KeyExt for VirtualScrollView<D, B> {
101    fn write_key(&mut self) -> &mut DiffKey {
102        &mut self.key
103    }
104}
105
106impl<D: PartialEq, B: Fn(usize, &D) -> Element> PartialEq for VirtualScrollView<D, B> {
107    fn eq(&self, other: &Self) -> bool {
108        self.builder_data == other.builder_data
109            && self.item_size == other.item_size
110            && self.length == other.length
111            && self.layout == other.layout
112            && self.show_scrollbar == other.show_scrollbar
113            && self.scroll_with_arrows == other.scroll_with_arrows
114            && self.scroll_controller == other.scroll_controller
115            && self.invert_scroll_wheel == other.invert_scroll_wheel
116    }
117}
118
119impl<B: Fn(usize, &()) -> Element> VirtualScrollView<(), B> {
120    /// Creates a virtual scroll view that builds each item on demand from its index.
121    pub fn new(builder: B) -> Self {
122        Self {
123            builder,
124            builder_data: (),
125            item_size: 0.,
126            length: 0,
127            layout: {
128                let mut l = LayoutData::default();
129                l.layout.width = Size::fill();
130                l.layout.height = Size::fill();
131                l
132            },
133            show_scrollbar: true,
134            scroll_with_arrows: true,
135            scroll_controller: None,
136            invert_scroll_wheel: false,
137            drag_scrolling: true,
138            key: DiffKey::None,
139        }
140    }
141
142    /// Like [`new`](Self::new) but driven by the given [`ScrollController`].
143    pub fn new_controlled(builder: B, scroll_controller: ScrollController) -> Self {
144        Self {
145            builder,
146            builder_data: (),
147            item_size: 0.,
148            length: 0,
149            layout: {
150                let mut l = LayoutData::default();
151                l.layout.width = Size::fill();
152                l.layout.height = Size::fill();
153                l
154            },
155            show_scrollbar: true,
156            scroll_with_arrows: true,
157            scroll_controller: Some(scroll_controller),
158            invert_scroll_wheel: false,
159            drag_scrolling: true,
160            key: DiffKey::None,
161        }
162    }
163}
164
165impl<D, B: Fn(usize, &D) -> Element> VirtualScrollView<D, B> {
166    /// Like [`new`](Self::new) but passes shared `builder_data` to every item build.
167    ///
168    /// The builder closure cannot be compared across renders, so data captured inside it never
169    /// triggers a rebuild. Passing the data here instead makes it part of the view's `PartialEq`,
170    /// so the visible items are rebuilt whenever it changes.
171    ///
172    /// ```rust
173    /// # use freya::prelude::*;
174    /// fn app() -> impl IntoElement {
175    ///     let items = use_state(|| vec!["a".to_string(), "b".to_string(), "c".to_string()]);
176    ///
177    ///     // The current items are passed as data, so editing `items` rebuilds the visible rows.
178    ///     VirtualScrollView::new_with_data(items.read().clone(), |i, items: &Vec<String>| {
179    ///         rect()
180    ///             .key(i)
181    ///             .height(Size::px(25.))
182    ///             .child(items[i].clone())
183    ///             .into()
184    ///     })
185    ///     .length(items.read().len())
186    ///     .item_size(25.)
187    /// }
188    /// ```
189    pub fn new_with_data(builder_data: D, builder: B) -> Self {
190        Self {
191            builder,
192            builder_data,
193            item_size: 0.,
194            length: 0,
195            layout: Node {
196                width: Size::fill(),
197                height: Size::fill(),
198                ..Default::default()
199            }
200            .into(),
201            show_scrollbar: true,
202            scroll_with_arrows: true,
203            scroll_controller: None,
204            invert_scroll_wheel: false,
205            drag_scrolling: true,
206            key: DiffKey::None,
207        }
208    }
209
210    /// Like [`new_with_data`](Self::new_with_data) but driven by the given [`ScrollController`].
211    pub fn new_with_data_controlled(
212        builder_data: D,
213        builder: B,
214        scroll_controller: ScrollController,
215    ) -> Self {
216        Self {
217            builder,
218            builder_data,
219            item_size: 0.,
220            length: 0,
221
222            layout: Node {
223                width: Size::fill(),
224                height: Size::fill(),
225                ..Default::default()
226            }
227            .into(),
228            show_scrollbar: true,
229            scroll_with_arrows: true,
230            scroll_controller: Some(scroll_controller),
231            invert_scroll_wheel: false,
232            drag_scrolling: true,
233            key: DiffKey::None,
234        }
235    }
236
237    /// Toggles whether the scrollbar is shown when the content overflows.
238    pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self {
239        self.show_scrollbar = show_scrollbar;
240        self
241    }
242
243    /// Sets the axis the items flow and scroll in.
244    pub fn direction(mut self, direction: Direction) -> Self {
245        self.layout.direction = direction;
246        self
247    }
248
249    /// Toggles whether the arrow keys scroll the view while it is focused.
250    pub fn scroll_with_arrows(mut self, scroll_with_arrows: impl Into<bool>) -> Self {
251        self.scroll_with_arrows = scroll_with_arrows.into();
252        self
253    }
254
255    /// Sets the fixed size of every item along the scroll axis, used to decide which items to render.
256    pub fn item_size(mut self, item_size: impl Into<f32>) -> Self {
257        self.item_size = item_size.into();
258        self
259    }
260
261    /// Sets the total number of items the view can scroll through.
262    pub fn length(mut self, length: impl Into<usize>) -> Self {
263        self.length = length.into();
264        self
265    }
266
267    /// Inverts the direction of the mouse wheel relative to the content.
268    pub fn invert_scroll_wheel(mut self, invert_scroll_wheel: impl Into<bool>) -> Self {
269        self.invert_scroll_wheel = invert_scroll_wheel.into();
270        self
271    }
272
273    /// Toggles scrolling by dragging the content, useful mainly for touch input.
274    pub fn drag_scrolling(mut self, drag_scrolling: bool) -> Self {
275        self.drag_scrolling = drag_scrolling;
276        self
277    }
278
279    /// Attaches a [`ScrollController`] to drive this view externally.
280    pub fn scroll_controller(
281        mut self,
282        scroll_controller: impl Into<Option<ScrollController>>,
283    ) -> Self {
284        self.scroll_controller = scroll_controller.into();
285        self
286    }
287
288    /// Caps the width of the scroll view.
289    pub fn max_width(mut self, max_width: impl Into<Size>) -> Self {
290        self.layout.maximum_width = max_width.into();
291        self
292    }
293
294    /// Caps the height of the scroll view.
295    pub fn max_height(mut self, max_height: impl Into<Size>) -> Self {
296        self.layout.maximum_height = max_height.into();
297        self
298    }
299}
300
301impl<D: PartialEq + 'static, B: Fn(usize, &D) -> Element + 'static> Component
302    for VirtualScrollView<D, B>
303{
304    fn render(self: &VirtualScrollView<D, B>) -> impl IntoElement {
305        let a11y_id = use_a11y();
306        let mut timeout = use_timeout(|| Duration::from_millis(800));
307        let mut pressing_shift = use_state(|| false);
308        let mut clicking_scrollbar = use_state::<Option<(Axis, f64)>>(|| None);
309        let mut size = use_state(SizedEventData::default);
310        let mut scroll_controller = self
311            .scroll_controller
312            .unwrap_or_else(|| use_scroll_controller(ScrollConfig::default));
313        let mut dragging_content = use_state::<Option<CursorPoint>>(|| None);
314        let mut drag_origin = use_state::<Option<CursorPoint>>(|| None);
315        let (scrolled_x, scrolled_y) = scroll_controller.into();
316        let layout = &self.layout.layout;
317        let direction = layout.direction;
318        let drag_scrolling = self.drag_scrolling;
319
320        let (inner_width, inner_height) = match direction {
321            Direction::Vertical => (
322                size.read().inner_sizes.width,
323                self.item_size * self.length as f32,
324            ),
325            Direction::Horizontal => (
326                self.item_size * self.length as f32,
327                size.read().inner_sizes.height,
328            ),
329        };
330
331        scroll_controller.use_apply(inner_width, inner_height);
332
333        let corrected_scrolled_x =
334            get_corrected_scroll_position(inner_width, size.read().area.width(), scrolled_x as f32);
335
336        let corrected_scrolled_y = get_corrected_scroll_position(
337            inner_height,
338            size.read().area.height(),
339            scrolled_y as f32,
340        );
341        let horizontal_scrollbar_is_visible = !timeout.elapsed()
342            && is_scrollbar_visible(self.show_scrollbar, inner_width, size.read().area.width());
343        let vertical_scrollbar_is_visible = !timeout.elapsed()
344            && is_scrollbar_visible(self.show_scrollbar, inner_height, size.read().area.height());
345
346        let (scrollbar_x, scrollbar_width) =
347            get_scrollbar_pos_and_size(inner_width, size.read().area.width(), corrected_scrolled_x);
348        let (scrollbar_y, scrollbar_height) = get_scrollbar_pos_and_size(
349            inner_height,
350            size.read().area.height(),
351            corrected_scrolled_y,
352        );
353
354        let (container_width, content_width) = get_container_sizes(self.layout.width.clone());
355        let (container_height, content_height) = get_container_sizes(self.layout.height.clone());
356
357        let scroll_with_arrows = self.scroll_with_arrows;
358        let invert_scroll_wheel = self.invert_scroll_wheel;
359
360        let on_capture_global_pointer_press = move |e: Event<PointerEventData>| {
361            if clicking_scrollbar.read().is_some() {
362                e.prevent_default();
363                clicking_scrollbar.set(None);
364            }
365
366            if drag_scrolling && (dragging_content().is_some() || drag_origin().is_some()) {
367                dragging_content.set(None);
368                drag_origin.set(None);
369            }
370        };
371
372        let on_wheel = move |e: Event<WheelEventData>| {
373            // Only invert direction on deviced-sourced wheel events
374            let invert_direction = e.source == WheelSource::Device
375                && (*pressing_shift.read() || invert_scroll_wheel)
376                && (!*pressing_shift.read() || !invert_scroll_wheel);
377
378            let (x_movement, y_movement) = if invert_direction {
379                (e.delta_y as f32, e.delta_x as f32)
380            } else {
381                (e.delta_x as f32, e.delta_y as f32)
382            };
383
384            // Vertical scroll
385            let scroll_position_y = get_scroll_position_from_wheel(
386                y_movement,
387                inner_height,
388                size.read().area.height(),
389                corrected_scrolled_y,
390            );
391            scroll_controller.scroll_to_y(scroll_position_y).then(|| {
392                e.stop_propagation();
393            });
394
395            // Horizontal scroll
396            let scroll_position_x = get_scroll_position_from_wheel(
397                x_movement,
398                inner_width,
399                size.read().area.width(),
400                corrected_scrolled_x,
401            );
402            scroll_controller.scroll_to_x(scroll_position_x).then(|| {
403                e.stop_propagation();
404            });
405            timeout.reset();
406        };
407
408        let on_mouse_move = move |_| {
409            timeout.reset();
410        };
411
412        let on_capture_global_pointer_move = move |e: Event<PointerEventData>| {
413            if drag_scrolling {
414                if let Some(prev) = dragging_content() {
415                    let coords = e.global_location();
416                    let delta = prev - coords;
417
418                    scroll_controller.scroll_to_y((corrected_scrolled_y - delta.y as f32) as i32);
419                    scroll_controller.scroll_to_x((corrected_scrolled_x - delta.x as f32) as i32);
420
421                    dragging_content.set(Some(coords));
422                    e.prevent_default();
423                    timeout.reset();
424                    a11y_id.request_focus();
425                    return;
426                } else if let Some(origin) = drag_origin() {
427                    let coords = e.global_location();
428                    let distance = (origin - coords).abs();
429
430                    // Small threshold so taps can reach children (e.g. hover on buttons)
431                    // without being immediately consumed by drag scrolling.
432                    const DRAG_THRESHOLD: f64 = 2.0;
433
434                    if distance.x > DRAG_THRESHOLD || distance.y > DRAG_THRESHOLD {
435                        let delta = origin - coords;
436
437                        scroll_controller
438                            .scroll_to_y((corrected_scrolled_y - delta.y as f32) as i32);
439                        scroll_controller
440                            .scroll_to_x((corrected_scrolled_x - delta.x as f32) as i32);
441
442                        dragging_content.set(Some(coords));
443                        e.prevent_default();
444                        timeout.reset();
445                        a11y_id.request_focus();
446                    }
447                    return;
448                }
449            }
450
451            let clicking_scrollbar = clicking_scrollbar.peek();
452
453            if let Some((Axis::Y, y)) = *clicking_scrollbar {
454                let coordinates = e.element_location();
455                let cursor_y = coordinates.y - y - size.read().area.min_y() as f64;
456
457                let scroll_position = get_scroll_position_from_cursor(
458                    cursor_y as f32,
459                    inner_height,
460                    size.read().area.height(),
461                );
462
463                scroll_controller.scroll_to_y(scroll_position);
464            } else if let Some((Axis::X, x)) = *clicking_scrollbar {
465                let coordinates = e.element_location();
466                let cursor_x = coordinates.x - x - size.read().area.min_x() as f64;
467
468                let scroll_position = get_scroll_position_from_cursor(
469                    cursor_x as f32,
470                    inner_width,
471                    size.read().area.width(),
472                );
473
474                scroll_controller.scroll_to_x(scroll_position);
475            }
476
477            if clicking_scrollbar.is_some() {
478                e.prevent_default();
479                timeout.reset();
480                a11y_id.request_focus();
481            }
482        };
483
484        let on_key_down = move |e: Event<KeyboardEventData>| {
485            if !scroll_with_arrows
486                && (e.key == Key::Named(NamedKey::ArrowUp)
487                    || e.key == Key::Named(NamedKey::ArrowRight)
488                    || e.key == Key::Named(NamedKey::ArrowDown)
489                    || e.key == Key::Named(NamedKey::ArrowLeft))
490            {
491                return;
492            }
493            let x = corrected_scrolled_x;
494            let y = corrected_scrolled_y;
495            let inner_height = inner_height;
496            let inner_width = inner_width;
497            let viewport_height = size.read().area.height();
498            let viewport_width = size.read().area.width();
499            if let Some((x, y)) = handle_key_event(
500                &e.key,
501                (x, y),
502                inner_height,
503                inner_width,
504                viewport_height,
505                viewport_width,
506                direction,
507            ) {
508                scroll_controller.scroll_to_x(x as i32);
509                scroll_controller.scroll_to_y(y as i32);
510                e.stop_propagation();
511                timeout.reset();
512            }
513        };
514
515        let on_global_key_down = move |e: Event<KeyboardEventData>| {
516            let data = e;
517            if data.key == Key::Named(NamedKey::Shift) {
518                pressing_shift.set(true);
519            }
520        };
521
522        let on_global_key_up = move |e: Event<KeyboardEventData>| {
523            let data = e;
524            if data.key == Key::Named(NamedKey::Shift) {
525                pressing_shift.set(false);
526            }
527        };
528
529        let (viewport_size, scroll_position) = if direction == Direction::vertical() {
530            (size.read().area.height(), corrected_scrolled_y)
531        } else {
532            (size.read().area.width(), corrected_scrolled_x)
533        };
534
535        let render_range = get_render_range(
536            viewport_size,
537            scroll_position,
538            self.item_size,
539            self.length as f32,
540        );
541
542        let children = render_range
543            .map(|i| (self.builder)(i, &self.builder_data))
544            .collect::<Vec<Element>>();
545
546        let (offset_x, offset_y) = match direction {
547            Direction::Vertical => {
548                let offset_y_min =
549                    (-corrected_scrolled_y / self.item_size).floor() * self.item_size;
550                let offset_y = -(-corrected_scrolled_y - offset_y_min);
551
552                (corrected_scrolled_x, offset_y)
553            }
554            Direction::Horizontal => {
555                let offset_x_min =
556                    (-corrected_scrolled_x / self.item_size).floor() * self.item_size;
557                let offset_x = -(-corrected_scrolled_x - offset_x_min);
558
559                (offset_x, corrected_scrolled_y)
560            }
561        };
562
563        let on_pointer_down = move |e: Event<PointerEventData>| {
564            if drag_scrolling && matches!(e.data(), PointerEventData::Touch(_)) {
565                drag_origin.set(Some(e.global_location()));
566            }
567        };
568
569        rect()
570            .width(layout.width.clone())
571            .height(layout.height.clone())
572            .a11y_id(a11y_id)
573            .a11y_focusable(false)
574            .a11y_role(AccessibilityRole::ScrollView)
575            .a11y_builder(move |node| {
576                node.set_scroll_x(corrected_scrolled_x as f64);
577                node.set_scroll_y(corrected_scrolled_y as f64)
578            })
579            .scrollable(true)
580            .on_wheel(on_wheel)
581            .on_capture_global_pointer_press(on_capture_global_pointer_press)
582            .on_mouse_move(on_mouse_move)
583            .on_capture_global_pointer_move(on_capture_global_pointer_move)
584            .on_key_down(on_key_down)
585            .on_global_key_up(on_global_key_up)
586            .on_global_key_down(on_global_key_down)
587            .on_pointer_down(on_pointer_down)
588            .child(
589                rect()
590                    .width(container_width)
591                    .height(container_height)
592                    .horizontal()
593                    .child(
594                        rect()
595                            .direction(direction)
596                            .width(content_width)
597                            .height(content_height)
598                            .offset_x(offset_x)
599                            .offset_y(offset_y)
600                            .overflow(Overflow::Clip)
601                            .on_sized(move |e: Event<SizedEventData>| {
602                                size.set_if_modified(e.clone())
603                            })
604                            .children(children),
605                    )
606                    .maybe_child(vertical_scrollbar_is_visible.then_some({
607                        rect().child(ScrollBar {
608                            theme: None,
609                            clicking_scrollbar,
610                            axis: Axis::Y,
611                            offset: scrollbar_y,
612                            size: Size::px(size.read().area.height()),
613                            thumb: ScrollThumb {
614                                theme: None,
615                                clicking_scrollbar,
616                                axis: Axis::Y,
617                                size: scrollbar_height,
618                            },
619                        })
620                    })),
621            )
622            .maybe_child(horizontal_scrollbar_is_visible.then_some({
623                rect().child(ScrollBar {
624                    theme: None,
625                    clicking_scrollbar,
626                    axis: Axis::X,
627                    offset: scrollbar_x,
628                    size: Size::px(size.read().area.width()),
629                    thumb: ScrollThumb {
630                        theme: None,
631                        clicking_scrollbar,
632                        axis: Axis::X,
633                        size: scrollbar_width,
634                    },
635                })
636            }))
637    }
638
639    fn render_key(&self) -> DiffKey {
640        self.key.clone().or(self.default_key())
641    }
642}
643
644fn get_render_range(
645    viewport_size: f32,
646    scroll_position: f32,
647    item_size: f32,
648    item_length: f32,
649) -> Range<usize> {
650    let render_index_start = (-scroll_position) / item_size;
651    let potentially_visible_length = (viewport_size / item_size) + 1.0;
652    let remaining_length = item_length - render_index_start;
653
654    let render_index_end = if remaining_length <= potentially_visible_length {
655        item_length
656    } else {
657        render_index_start + potentially_visible_length
658    };
659
660    render_index_start as usize..(render_index_end as usize)
661}