freya_components/scroll_views/
scroll_view.rs

1use dioxus::prelude::*;
2use freya_elements::{
3    self as dioxus_elements,
4    events::{
5        keyboard::Key,
6        KeyboardEvent,
7        MouseEvent,
8        WheelEvent,
9    },
10};
11use freya_hooks::{
12    use_applied_theme,
13    use_focus,
14    use_node_from_signal,
15    ScrollBarThemeWith,
16};
17
18use super::use_scroll_controller::ScrollController;
19use crate::{
20    get_container_sizes,
21    get_corrected_scroll_position,
22    get_scroll_position_from_cursor,
23    get_scroll_position_from_wheel,
24    get_scrollbar_pos_and_size,
25    is_scrollbar_visible,
26    manage_key_event,
27    scroll_views::use_scroll_controller::{
28        use_scroll_controller,
29        ScrollConfig,
30    },
31    Axis,
32    ScrollBar,
33    ScrollThumb,
34    SCROLL_SPEED_MULTIPLIER,
35};
36
37/// Properties for the [`ScrollView`] component.
38#[derive(Props, Clone, PartialEq)]
39pub struct ScrollViewProps {
40    /// Width of the ScrollView container. Default to `fill`.
41    #[props(default = "fill".into())]
42    pub width: String,
43    /// Height of the ScrollView container. Default to `fill`.
44    #[props(default = "fill".into())]
45    pub height: String,
46    /// Minimum width of the ScrollView container.
47    pub min_width: Option<f32>,
48    /// Minimum height of the ScrollView container.
49    pub min_height: Option<f32>,
50    /// Maximum width of the ScrollView container.
51    pub max_width: Option<f32>,
52    /// Maximum height of the ScrollView container.
53    pub max_height: Option<f32>,
54    /// Padding of the ScrollView container.
55    #[props(default = "0".to_string())]
56    pub padding: String,
57    /// Spacing for the ScrollView container.
58    #[props(default = "0".to_string())]
59    pub spacing: String,
60    /// Theme override for the scrollbars.
61    pub scrollbar_theme: Option<ScrollBarThemeWith>,
62    /// Inner children for the ScrollView.
63    pub children: Element,
64    /// Direction of the ScrollView, `vertical` or `horizontal`.
65    #[props(default = "vertical".to_string(), into)]
66    pub direction: String,
67    /// Show the scrollbar, visible by default.
68    #[props(default = true, into)]
69    pub show_scrollbar: bool,
70    /// Enable scrolling with arrow keys.
71    #[props(default = true, into)]
72    pub scroll_with_arrows: bool,
73    /// Custom Scroll Controller for the ScrollView.
74    pub scroll_controller: Option<ScrollController>,
75    /// If `false` (default), wheel scroll with no shift will scroll vertically no matter the direction.
76    /// If `true`, wheel scroll with no shift will scroll horizontally.
77    #[props(default = false)]
78    pub invert_scroll_wheel: bool,
79}
80
81/// Scrollable area with bidirectional support and scrollbars.
82///
83/// # Example
84///
85/// ```no_run
86/// # use freya::prelude::*;
87/// fn app() -> Element {
88///     rsx!(
89///         ScrollView {
90///             rect {
91///                 background: "blue",
92///                 height: "400",
93///                 width: "100%"
94///             }
95///             rect {
96///                 background: "red",
97///                 height: "400",
98///                 width: "100%"
99///              }
100///         }
101///     )
102/// }
103/// ```
104///
105/// # With a Scroll Controller
106///
107/// ```rust
108/// # use freya::prelude::*;
109/// fn app() -> Element {
110///     let mut scroll_controller = use_scroll_controller(|| ScrollConfig::default());
111///
112///     rsx!(
113///         ScrollView {
114///             scroll_controller,
115///             rect {
116///                 background: "blue",
117///                 height: "400",
118///                 width: "100%"
119///             }
120///             Button {
121///                 onpress: move |_| {
122///                    scroll_controller.scroll_to(ScrollPosition::Start, ScrollDirection::Vertical);
123///                 },
124///                 label {
125///                     label {
126///                         "Scroll up"
127///                     }
128///                 }
129///             }
130///             rect {
131///                 background: "red",
132///                 height: "400",
133///                 width: "100%"
134///             }
135///         }
136///     )
137/// }
138/// # use freya_testing::prelude::*;
139/// # launch_doc(|| {
140/// #   rsx!(
141/// #       Preview {
142/// #           ScrollView {
143/// #               label {
144/// #                   "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis. Morbi porttitor quis nisl eu vulputate. Etiam vitae ligula a purus suscipit iaculis non ac risus. Suspendisse potenti. Aenean orci massa, ornare ut elit id, tristique commodo dui. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis. Vestibulum laoreet tristique diam, ut gravida enim. Phasellus viverra vitae risus sit amet iaculis."
145/// #               }
146/// #           }
147/// #       }
148/// #   )
149/// # }, (250., 250.).into(), "./images/gallery_scroll_view.png");
150/// ```
151///
152/// # Preview
153/// ![ScrollView Preview][scroll_view]
154#[cfg_attr(feature = "docs",
155    doc = embed_doc_image::embed_image!("scroll_view", "images/gallery_scroll_view.png")
156)]
157#[allow(non_snake_case)]
158pub fn ScrollView(
159    ScrollViewProps {
160        width,
161        height,
162        min_width,
163        min_height,
164        max_width,
165        max_height,
166        padding,
167        spacing,
168        scrollbar_theme,
169        children,
170        direction,
171        show_scrollbar,
172        scroll_with_arrows,
173        scroll_controller,
174        invert_scroll_wheel,
175    }: ScrollViewProps,
176) -> Element {
177    let mut clicking_scrollbar = use_signal::<Option<(Axis, f64)>>(|| None);
178    let mut clicking_shift = use_signal(|| false);
179    let mut clicking_alt = use_signal(|| false);
180    let mut scroll_controller =
181        scroll_controller.unwrap_or_else(|| use_scroll_controller(ScrollConfig::default));
182    let (mut scrolled_x, mut scrolled_y) = scroll_controller.into();
183    let (node_ref, size) = use_node_from_signal(|| scroll_controller.layout());
184
185    let mut focus = use_focus();
186    let applied_scrollbar_theme = use_applied_theme!(&scrollbar_theme, scroll_bar);
187
188    scroll_controller.use_apply(size.inner.width, size.inner.height);
189
190    let vertical_scrollbar_is_visible = is_scrollbar_visible(
191        show_scrollbar,
192        size.inner.height.floor(),
193        size.area.height().floor(),
194    );
195    let horizontal_scrollbar_is_visible = is_scrollbar_visible(
196        show_scrollbar,
197        size.inner.width.floor(),
198        size.area.width().floor(),
199    );
200
201    let (container_width, content_width) = get_container_sizes(&width);
202    let (container_height, content_height) = get_container_sizes(&height);
203
204    let corrected_scrolled_y = get_corrected_scroll_position(
205        size.inner.height,
206        size.area.height(),
207        *scrolled_y.read() as f32,
208    );
209    let corrected_scrolled_x = get_corrected_scroll_position(
210        size.inner.width,
211        size.area.width(),
212        *scrolled_x.read() as f32,
213    );
214
215    let (scrollbar_y, scrollbar_height) =
216        get_scrollbar_pos_and_size(size.inner.height, size.area.height(), corrected_scrolled_y);
217    let (scrollbar_x, scrollbar_width) =
218        get_scrollbar_pos_and_size(size.inner.width, size.area.width(), corrected_scrolled_x);
219
220    // Moves the axis when the user scrolls in the container
221    let onwheel = move |e: WheelEvent| {
222        let speed_multiplier = if *clicking_alt.peek() {
223            SCROLL_SPEED_MULTIPLIER
224        } else {
225            1.0
226        };
227
228        let invert_direction = (clicking_shift() || invert_scroll_wheel)
229            && (!clicking_shift() || !invert_scroll_wheel);
230
231        let (x_movement, y_movement) = if invert_direction {
232            (
233                e.get_delta_y() as f32 * speed_multiplier,
234                e.get_delta_x() as f32 * speed_multiplier,
235            )
236        } else {
237            (
238                e.get_delta_x() as f32 * speed_multiplier,
239                e.get_delta_y() as f32 * speed_multiplier,
240            )
241        };
242
243        let scroll_position_y = get_scroll_position_from_wheel(
244            y_movement,
245            size.inner.height,
246            size.area.height(),
247            corrected_scrolled_y,
248        );
249
250        // Only scroll when there is still area to scroll
251        if *scrolled_y.peek() != scroll_position_y {
252            e.stop_propagation();
253            *scrolled_y.write() = scroll_position_y;
254        }
255
256        let scroll_position_x = get_scroll_position_from_wheel(
257            x_movement,
258            size.inner.width,
259            size.area.width(),
260            corrected_scrolled_x,
261        );
262
263        // Only scroll when there is still area to scroll
264        if *scrolled_x.peek() != scroll_position_x {
265            e.stop_propagation();
266            *scrolled_x.write() = scroll_position_x;
267        }
268    };
269
270    // Drag the scrollbars
271    let onmousemove = move |e: MouseEvent| {
272        let clicking_scrollbar = clicking_scrollbar.peek();
273
274        if let Some((Axis::Y, y)) = *clicking_scrollbar {
275            let coordinates = e.get_element_coordinates();
276            let cursor_y = coordinates.y - y - size.area.min_y() as f64;
277
278            let scroll_position = get_scroll_position_from_cursor(
279                cursor_y as f32,
280                size.inner.height,
281                size.area.height(),
282            );
283
284            *scrolled_y.write() = scroll_position;
285        } else if let Some((Axis::X, x)) = *clicking_scrollbar {
286            let coordinates = e.get_element_coordinates();
287            let cursor_x = coordinates.x - x - size.area.min_x() as f64;
288
289            let scroll_position = get_scroll_position_from_cursor(
290                cursor_x as f32,
291                size.inner.width,
292                size.area.width(),
293            );
294
295            *scrolled_x.write() = scroll_position;
296        }
297
298        if clicking_scrollbar.is_some() {
299            focus.request_focus();
300        }
301    };
302
303    let onglobalkeydown = move |e: KeyboardEvent| {
304        match &e.key {
305            Key::Shift => {
306                clicking_shift.set(true);
307            }
308            Key::Alt => {
309                clicking_alt.set(true);
310            }
311            k => {
312                if !focus.is_focused() {
313                    return;
314                }
315                if !scroll_with_arrows
316                    && (k == &Key::ArrowUp
317                        || k == &Key::ArrowRight
318                        || k == &Key::ArrowDown
319                        || k == &Key::ArrowLeft)
320                {
321                    return;
322                }
323
324                let x = corrected_scrolled_x;
325                let y = corrected_scrolled_y;
326                let inner_height = size.inner.height;
327                let inner_width = size.inner.width;
328                let viewport_height = size.area.height();
329                let viewport_width = size.area.width();
330
331                let (x, y) = manage_key_event(
332                    e,
333                    (x, y),
334                    inner_height,
335                    inner_width,
336                    viewport_height,
337                    viewport_width,
338                );
339
340                scrolled_x.set(x as i32);
341                scrolled_y.set(y as i32);
342            }
343        };
344    };
345
346    let onglobalkeyup = move |e: KeyboardEvent| {
347        if e.key == Key::Shift {
348            clicking_shift.set(false);
349        } else if e.key == Key::Alt {
350            clicking_alt.set(false);
351        }
352    };
353
354    // Mark the Y axis scrollbar as the one being dragged
355    let onmousedown_y = move |e: MouseEvent| {
356        let coordinates = e.get_element_coordinates();
357        *clicking_scrollbar.write() = Some((Axis::Y, coordinates.y));
358    };
359
360    // Mark the X axis scrollbar as the one being dragged
361    let onmousedown_x = move |e: MouseEvent| {
362        let coordinates = e.get_element_coordinates();
363        *clicking_scrollbar.write() = Some((Axis::X, coordinates.x));
364    };
365
366    // Unmark any scrollbar
367    let onclick = move |_: MouseEvent| {
368        if clicking_scrollbar.peek().is_some() {
369            *clicking_scrollbar.write() = None;
370        }
371    };
372
373    let is_scrolling_x = clicking_scrollbar
374        .read()
375        .as_ref()
376        .map(|f| f.0 == Axis::X)
377        .unwrap_or_default();
378    let is_scrolling_y = clicking_scrollbar
379        .read()
380        .as_ref()
381        .map(|f| f.0 == Axis::Y)
382        .unwrap_or_default();
383
384    let a11y_id = focus.attribute();
385
386    rsx!(
387        rect {
388            a11y_role:"scroll-view",
389            overflow: "clip",
390            direction: "horizontal",
391            width: width.clone(),
392            height: height.clone(),
393            min_width: min_width.map(|x| x.to_string()),
394            min_height: min_height.map(|x| x.to_string()),
395            max_width: max_width.map(|x| x.to_string()),
396            max_height: max_height.map(|x| x.to_string()),
397            onglobalclick: onclick,
398            onglobalmousemove: onmousemove,
399            onglobalkeydown,
400            onglobalkeyup,
401            a11y_id,
402            a11y_focusable: "false",
403            rect {
404                direction: "vertical",
405                width: container_width,
406                height: container_height,
407                rect {
408                    overflow: "clip",
409                    spacing,
410                    padding,
411                    width: content_width,
412                    height: content_height,
413                    min_width: min_width.map(|x| x.to_string()),
414                    min_height: min_height.map(|x| x.to_string()),
415                    max_width: max_width.map(|x| x.to_string()),
416                    max_height: max_height.map(|x| x.to_string()),
417                    direction: direction,
418                    offset_y: "{corrected_scrolled_y}",
419                    offset_x: "{corrected_scrolled_x}",
420                    reference: node_ref,
421                    onwheel,
422                    {children}
423                }
424                if show_scrollbar && horizontal_scrollbar_is_visible {
425                    ScrollBar {
426                        size: &applied_scrollbar_theme.size,
427                        offset_x: scrollbar_x,
428                        clicking_scrollbar: is_scrolling_x,
429                        theme: scrollbar_theme.clone(),
430                        ScrollThumb {
431                            clicking_scrollbar: is_scrolling_x,
432                            onmousedown: onmousedown_x,
433                            width: "{scrollbar_width}",
434                            height: "100%",
435                            theme: scrollbar_theme.clone()
436                        }
437                    }
438                }
439            }
440            if show_scrollbar && vertical_scrollbar_is_visible {
441                ScrollBar {
442                    is_vertical: true,
443                    size: &applied_scrollbar_theme.size,
444                    offset_y: scrollbar_y,
445                    clicking_scrollbar: is_scrolling_y,
446                    theme: scrollbar_theme.clone(),
447                    ScrollThumb {
448                        clicking_scrollbar: is_scrolling_y,
449                        onmousedown: onmousedown_y,
450                        width: "100%",
451                        height: "{scrollbar_height}",
452                        theme: scrollbar_theme
453                    }
454                }
455            }
456        }
457    )
458}
459
460#[cfg(test)]
461mod test {
462    use freya::prelude::*;
463    use freya_testing::prelude::*;
464
465    #[tokio::test]
466    pub async fn scroll_view_wheel() {
467        fn scroll_view_wheel_app() -> Element {
468            rsx!(
469                ScrollView {
470                    rect {
471                        height: "200",
472                        width: "200",
473                    }
474                    rect {
475                        height: "200",
476                        width: "200",
477                    }
478                    rect {
479                        height: "200",
480                        width: "200",
481                    }
482                    rect {
483                        height: "200",
484                        width: "200",
485                    }
486                }
487            )
488        }
489
490        let mut utils = launch_test(scroll_view_wheel_app);
491        let root = utils.root();
492        let content = root.get(0).get(0).get(0);
493        utils.wait_for_update().await;
494
495        // Only the first three items are visible
496        // Scrollview height is 500 and the user hasn't scrolled yet
497        assert!(content.get(0).is_visible()); // 1. 0   -> 200, 200 < 500
498        assert!(content.get(1).is_visible()); // 2. 200 -> 400, 200 < 500
499        assert!(content.get(2).is_visible()); // 3. 400 -> 600, 400 < 500
500        assert!(!content.get(3).is_visible()); // 4. 600 -> 800, 600 is NOT < 500, which means it is not visible.
501
502        utils.push_event(TestEvent::Wheel {
503            name: EventName::Wheel,
504            scroll: (0., -300.).into(),
505            cursor: (5., 5.).into(),
506        });
507
508        utils.wait_for_update().await;
509
510        // Only the last three items are visible
511        // Scrollview height is 500 but the user has scrolled 300 pixels
512        assert!(!content.get(0).is_visible()); // 1. 0   -> 200, 200 is NOT > 300, which means it is not visible.
513        assert!(content.get(1).is_visible()); // 2. 200 -> 400, 400 > 300
514        assert!(content.get(2).is_visible()); // 3. 400 -> 600, 600 > 300
515        assert!(content.get(3).is_visible()); // 4. 600 -> 800, 800 > 300
516    }
517
518    #[tokio::test]
519    pub async fn scroll_view_scrollbar() {
520        fn scroll_view_scrollbar_app() -> Element {
521            rsx!(
522                ScrollView {
523                    rect {
524                        height: "200",
525                        width: "200",
526                    }
527                    rect {
528                        height: "200",
529                        width: "200",
530                    }
531                    rect {
532                        height: "200",
533                        width: "200",
534                    }
535                    rect {
536                        height: "200",
537                        width: "200",
538                    }
539                }
540            )
541        }
542
543        let mut utils = launch_test(scroll_view_scrollbar_app);
544        let root = utils.root();
545        let content = root.get(0).get(0).get(0);
546        utils.wait_for_update().await;
547
548        // Only the first three items are visible
549        // Scrollview height is 500 and the user hasn't scrolled yet
550        assert!(content.get(0).is_visible()); // 1. 0   -> 200, 200 < 500
551        assert!(content.get(1).is_visible()); // 2. 200 -> 400, 200 < 500
552        assert!(content.get(2).is_visible()); // 3. 400 -> 600, 400 < 500
553        assert!(!content.get(3).is_visible()); // 4. 600 -> 800, 600 is NOT < 500, which means it is not visible.
554
555        // Simulate the user dragging the scrollbar
556        utils.push_event(TestEvent::Mouse {
557            name: EventName::MouseMove,
558            cursor: (495., 20.).into(),
559            button: Some(MouseButton::Left),
560        });
561        utils.push_event(TestEvent::Mouse {
562            name: EventName::MouseDown,
563            cursor: (495., 20.).into(),
564            button: Some(MouseButton::Left),
565        });
566        utils.push_event(TestEvent::Mouse {
567            name: EventName::MouseMove,
568            cursor: (495., 320.).into(),
569            button: Some(MouseButton::Left),
570        });
571        utils.push_event(TestEvent::Mouse {
572            name: EventName::MouseUp,
573            cursor: (495., 320.).into(),
574            button: Some(MouseButton::Left),
575        });
576
577        utils.wait_for_update().await;
578
579        // Only the last three items are visible
580        // Scrollview height is 500 but the user has dragged the scrollbar 300 pixels
581        assert!(!content.get(0).is_visible()); // 1. 0   -> 200, 200 is NOT > 300, which means it is not visible.
582        assert!(content.get(1).is_visible()); // 2. 200 -> 400, 400 > 300
583        assert!(content.get(2).is_visible()); // 3. 400 -> 600, 600 > 300
584        assert!(content.get(3).is_visible()); // 4. 600 -> 800, 800 > 300
585
586        // Scroll up with arrows
587        for _ in 0..5 {
588            utils.push_event(TestEvent::Keyboard {
589                name: EventName::KeyDown,
590                key: Key::ArrowUp,
591                code: Code::ArrowUp,
592                modifiers: Modifiers::default(),
593            });
594            utils.wait_for_update().await;
595        }
596
597        assert!(content.get(0).is_visible());
598        assert!(content.get(1).is_visible());
599        assert!(content.get(2).is_visible());
600        assert!(!content.get(3).is_visible());
601
602        // Scroll to the bottom with arrows
603        utils.push_event(TestEvent::Keyboard {
604            name: EventName::KeyDown,
605            key: Key::End,
606            code: Code::End,
607            modifiers: Modifiers::default(),
608        });
609        utils.wait_for_update().await;
610
611        assert!(!content.get(0).is_visible());
612        assert!(content.get(1).is_visible());
613        assert!(content.get(2).is_visible());
614        assert!(content.get(3).is_visible());
615    }
616}