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