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