Skip to main content

leptos_use/
use_scroll.rs

1use crate::UseEventListenerOptions;
2use crate::core::{Direction, Directions, IntoElementMaybeSignal};
3use cfg_if::cfg_if;
4use default_struct_builder::DefaultBuilder;
5use leptos::prelude::*;
6use leptos::reactive::wrappers::read::Signal;
7use std::rc::Rc;
8
9cfg_if! { if #[cfg(not(feature = "ssr"))] {
10use crate::use_event_listener::use_event_listener_with_options;
11use crate::{
12    sendwrap_fn, use_debounce_fn_with_arg, use_throttle_fn_with_arg_and_options, ThrottleOptions,
13};
14use leptos::ev;
15use leptos::ev::scrollend;
16use send_wrapper::SendWrapper;
17use wasm_bindgen::JsCast;
18
19
20/// We have to check if the scroll amount is close enough to some threshold in order to
21/// more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
22/// numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
23/// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
24const ARRIVED_STATE_THRESHOLD_PIXELS: f64 = 1.0;
25}}
26
27/// Reactive scroll position and state.
28///
29/// ## Demo
30///
31/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_scroll)
32///
33/// ## Usage
34///
35/// ```
36/// # use leptos::prelude::*;
37/// # use leptos::ev::resize;
38/// # use leptos::html::Div;
39/// # use leptos_use::{use_scroll, UseScrollReturn};
40/// #
41/// # #[component]
42/// # fn Demo() -> impl IntoView {
43/// let element = NodeRef::<Div>::new();
44///
45/// let UseScrollReturn {
46///     x, y, set_x, set_y, is_scrolling, arrived_state, directions, ..
47/// } = use_scroll(element);
48///
49/// view! {
50///     <div node_ref=element>"..."</div>
51/// }
52/// # }
53/// ```
54///
55/// ### With Offsets
56///
57/// You can provide offsets when you use [`use_scroll_with_options`].
58/// These offsets are thresholds in pixels when a side is considered to have arrived. This is reflected in the return field `arrived_state`.
59///
60/// ```
61/// # use leptos::prelude::*;
62/// # use leptos::html::Div;
63/// # use leptos::ev::resize;
64/// # use leptos_use::{use_scroll_with_options, UseScrollReturn, UseScrollOptions, ScrollOffset};
65/// #
66/// # #[component]
67/// # fn Demo() -> impl IntoView {
68/// # let element = NodeRef::<Div>::new();
69/// #
70/// let UseScrollReturn {
71///     x,
72///     y,
73///     set_x,
74///     set_y,
75///     is_scrolling,
76///     arrived_state,
77///     directions,
78///     ..
79/// } = use_scroll_with_options(
80///     element,
81///     UseScrollOptions::default().offset(ScrollOffset {
82///         top: 30.0,
83///         bottom: 30.0,
84///         right: 30.0,
85///         left: 30.0,
86///     }),
87/// );
88/// #
89/// #     view! { /// #         <div node_ref=element>"..."</div>
90/// #     }
91/// # }
92/// ```
93///
94/// ### Setting Scroll Position
95///
96/// Set the `x` and `y` values to make the element scroll to that position.
97///
98/// ```
99/// # use leptos::prelude::*;
100/// # use leptos::html::Div;
101/// # use leptos::ev::resize;
102/// # use leptos_use::{use_scroll, UseScrollReturn};
103/// #
104/// # #[component]
105/// # fn Demo() -> impl IntoView {
106/// let element = NodeRef::<Div>::new();
107///
108/// let UseScrollReturn {
109///     x, y, set_x, set_y, ..
110/// } = use_scroll(element);
111///
112/// view! {
113///     <div node_ref=element>"..."</div>
114///     <button on:click=move |_| set_x(x.get_untracked() + 10.0)>"Scroll right 10px"</button>
115///     <button on:click=move |_| set_y(y.get_untracked() + 10.0)>"Scroll down 10px"</button>
116/// }
117/// # }
118/// ```
119///
120/// ### Smooth Scrolling
121///
122/// Set `behavior: smooth` to enable smooth scrolling. The `behavior` option defaults to `auto`,
123/// which means no smooth scrolling. See the `behavior` option on
124/// [Element.scrollTo](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo) for more information.
125///
126/// ```
127/// # use leptos::prelude::*;
128/// # use leptos::ev::resize;
129/// # use leptos::html::Div;
130/// # use leptos_use::{use_scroll_with_options, UseScrollReturn, UseScrollOptions, ScrollBehavior};
131/// #
132/// # #[component]
133/// # fn Demo() -> impl IntoView {
134/// # let element = NodeRef::<Div>::new();
135/// #
136/// let UseScrollReturn {
137///     x, y, set_x, set_y, ..
138/// } = use_scroll_with_options(
139///     element,
140///     UseScrollOptions::default().behavior(ScrollBehavior::Smooth),
141/// );
142/// #
143/// # view! { /// #     <div node_ref=element>"..."</div>
144/// # }
145/// # }
146/// ```
147///
148/// or as a `Signal`:
149///
150/// ```
151/// # use leptos::prelude::*;
152/// # use leptos::ev::resize;
153/// # use leptos::html::Div;
154/// # use leptos_use::{use_scroll_with_options, UseScrollReturn, UseScrollOptions, ScrollBehavior};
155/// #
156/// # #[component]
157/// # fn Demo() -> impl IntoView {
158/// # let element = NodeRef::<Div>::new();
159/// #
160/// let (smooth, set_smooth) = signal(false);
161///
162/// let behavior = Signal::derive(move || {
163///     if smooth.get() { ScrollBehavior::Smooth } else { ScrollBehavior::Auto }
164/// });
165///
166/// let UseScrollReturn {
167///     x, y, set_x, set_y, ..
168/// } = use_scroll_with_options(
169///     element,
170///     UseScrollOptions::default().behavior(behavior),
171/// );
172/// #
173/// # view! { /// #     <div node_ref=element>"..."</div>
174/// # }
175/// # }
176/// ```
177///
178/// ## SendWrapped Return
179///
180/// The returned closures `set_x`, `set_y` and `measure` are sendwrapped functions. They can
181/// only be called from the same thread that called `use_scroll`.
182///
183/// ## Server-Side Rendering
184///
185/// > Make sure you follow the [instructions in Server-Side Rendering](https://leptos-use.rs/server_side_rendering.html).
186///
187/// On the server this returns signals that don't change and setters that are noops.
188pub fn use_scroll<El, M>(
189    element: El,
190) -> UseScrollReturn<
191    impl Fn(f64) + Clone + Send + Sync,
192    impl Fn(f64) + Clone + Send + Sync,
193    impl Fn() + Clone + Send + Sync,
194>
195where
196    El: IntoElementMaybeSignal<web_sys::Element, M>,
197{
198    use_scroll_with_options(element, Default::default())
199}
200
201/// Version of [`use_scroll`] with options. See [`use_scroll`] for how to use.
202#[cfg_attr(feature = "ssr", allow(unused_variables))]
203pub fn use_scroll_with_options<El, M>(
204    element: El,
205    options: UseScrollOptions,
206) -> UseScrollReturn<
207    impl Fn(f64) + Clone + Send + Sync,
208    impl Fn(f64) + Clone + Send + Sync,
209    impl Fn() + Clone + Send + Sync,
210>
211where
212    El: IntoElementMaybeSignal<web_sys::Element, M>,
213{
214    let (internal_x, set_internal_x) = signal(0.0);
215    let (internal_y, set_internal_y) = signal(0.0);
216
217    let (is_scrolling, set_is_scrolling) = signal(false);
218
219    let arrived_state = RwSignal::new(Directions {
220        left: true,
221        right: false,
222        top: true,
223        bottom: false,
224    });
225    let directions = RwSignal::new(Directions {
226        left: false,
227        right: false,
228        top: false,
229        bottom: false,
230    });
231
232    let set_x;
233    let set_y;
234    let measure;
235
236    #[cfg(feature = "ssr")]
237    {
238        set_x = |_| {};
239        set_y = |_| {};
240        measure = || {};
241    }
242
243    #[cfg(not(feature = "ssr"))]
244    {
245        let signal = element.into_element_maybe_signal();
246        let behavior = options.behavior;
247
248        let scroll_to = move |x: Option<f64>, y: Option<f64>| {
249            let element = signal.get_untracked();
250
251            if let Some(element) = element {
252                let scroll_options = web_sys::ScrollToOptions::new();
253                scroll_options.set_behavior(behavior.get_untracked().into());
254
255                if let Some(x) = x {
256                    scroll_options.set_left(x);
257                }
258                if let Some(y) = y {
259                    scroll_options.set_top(y);
260                }
261
262                element.scroll_to_with_scroll_to_options(&scroll_options);
263            }
264        };
265
266        set_x = sendwrap_fn!(move |x| scroll_to(Some(x), None));
267
268        set_y = sendwrap_fn!(move |y| scroll_to(None, Some(y)));
269
270        let on_scroll_end = {
271            let on_stop = Rc::clone(&options.on_stop);
272
273            move |e| {
274                if !is_scrolling.try_get_untracked().unwrap_or_default() {
275                    return;
276                }
277
278                set_is_scrolling.set(false);
279                directions.update(|directions| {
280                    directions.left = false;
281                    directions.right = false;
282                    directions.top = false;
283                    directions.bottom = false;
284                    on_stop.clone()(e);
285                });
286            }
287        };
288
289        let throttle = options.throttle;
290
291        let on_scroll_end_debounced =
292            use_debounce_fn_with_arg(on_scroll_end.clone(), throttle + options.idle);
293
294        let offset = options.offset;
295
296        #[allow(clippy::unnecessary_cast)]
297        let set_arrived_state = move |target: web_sys::Element| {
298            let style = window()
299                .get_computed_style(&target)
300                .expect("failed to get computed style");
301
302            if let Some(style) = style {
303                let display = style
304                    .get_property_value("display")
305                    .expect("failed to get display");
306                let flex_direction = style
307                    .get_property_value("flex-direction")
308                    .expect("failed to get flex-direction");
309                let direction = style.get_property_value("direction").unwrap_or_default();
310
311                let scroll_left = target.scroll_left() as f64;
312                let scroll_left_abs = scroll_left.abs();
313
314                directions.update(|directions| {
315                    directions.left = scroll_left < internal_x.get_untracked();
316                    directions.right = scroll_left > internal_x.get_untracked();
317                });
318
319                let left = scroll_left_abs <= offset.left;
320                let right = scroll_left_abs + target.client_width() as f64
321                    >= target.scroll_width() as f64 - offset.right - ARRIVED_STATE_THRESHOLD_PIXELS;
322
323                // Right-to-left containers use the "negative scrollLeft" model:
324                // 0 is the right-hand edge and the value decreases towards the
325                // left one, so taking the absolute value above reports the two
326                // edges swapped. A row-reverse flex box swaps them as well, and
327                // a container that is both swaps twice.
328                let is_reversed =
329                    (display == "flex" && flex_direction == "row-reverse") != (direction == "rtl");
330
331                arrived_state.update(|arrived_state| {
332                    if is_reversed {
333                        arrived_state.left = right;
334                        arrived_state.right = left;
335                    } else {
336                        arrived_state.left = left;
337                        arrived_state.right = right;
338                    }
339                });
340                set_internal_x.set(scroll_left);
341
342                let mut scroll_top = target.scroll_top() as f64;
343
344                // patch for mobile compatibility
345                if target == document().unchecked_into::<web_sys::Element>() && scroll_top == 0.0 {
346                    scroll_top = document().body().expect("failed to get body").scroll_top() as f64;
347                }
348
349                let scroll_top_abs = scroll_top.abs();
350
351                directions.update(|directions| {
352                    directions.top = scroll_top < internal_y.get_untracked();
353                    directions.bottom = scroll_top > internal_y.get_untracked();
354                });
355
356                let top = scroll_top_abs <= offset.top;
357                let bottom = scroll_top_abs + target.client_height() as f64
358                    >= target.scroll_height() as f64
359                        - offset.bottom
360                        - ARRIVED_STATE_THRESHOLD_PIXELS;
361
362                // reverse columns and rows behave exactly the other way around,
363                // bottom is treated as top and top is treated as the negative version of bottom
364                arrived_state.update(|arrived_state| {
365                    if display == "flex" && flex_direction == "column-reverse" {
366                        arrived_state.top = bottom;
367                        arrived_state.bottom = top;
368                    } else {
369                        arrived_state.top = top;
370                        arrived_state.bottom = bottom;
371                    }
372                });
373
374                set_internal_y.set(scroll_top);
375            }
376        };
377
378        let on_scroll_handler = {
379            let on_scroll = Rc::clone(&options.on_scroll);
380
381            move |e: web_sys::Event| {
382                let target: web_sys::Element = event_target(&e);
383
384                set_arrived_state(target);
385                set_is_scrolling.set(true);
386
387                on_scroll_end_debounced.clone()(e.clone());
388                on_scroll.clone()(e);
389            }
390        };
391
392        let target = Signal::derive(move || {
393            let element = signal.get();
394            element.map(|element| {
395                SendWrapper::new(element.take().unchecked_into::<web_sys::EventTarget>())
396            })
397        });
398
399        if throttle >= 0.0 {
400            use send_wrapper::SendWrapper;
401
402            let throttled_scroll_handler = use_throttle_fn_with_arg_and_options(
403                on_scroll_handler.clone(),
404                throttle,
405                ThrottleOptions {
406                    trailing: true,
407                    leading: false,
408                },
409            );
410
411            let handler = move |e: web_sys::Event| {
412                throttled_scroll_handler.clone()(e);
413            };
414
415            let _ = use_event_listener_with_options::<
416                _,
417                Signal<Option<SendWrapper<web_sys::EventTarget>>>,
418                _,
419                _,
420            >(target, ev::scroll, handler, options.event_listener_options);
421        } else {
422            let _ = use_event_listener_with_options::<
423                _,
424                Signal<Option<SendWrapper<web_sys::EventTarget>>>,
425                _,
426                _,
427            >(
428                target,
429                ev::scroll,
430                on_scroll_handler,
431                options.event_listener_options,
432            );
433        }
434
435        let _ = use_event_listener_with_options::<
436            _,
437            Signal<Option<SendWrapper<web_sys::EventTarget>>>,
438            _,
439            _,
440        >(
441            target,
442            scrollend,
443            on_scroll_end,
444            options.event_listener_options,
445        );
446
447        measure = sendwrap_fn!(move || {
448            if let Some(el) = signal.try_get_untracked().flatten() {
449                set_arrived_state(el.take());
450            }
451        });
452    }
453
454    UseScrollReturn {
455        x: internal_x.into(),
456        set_x,
457        y: internal_y.into(),
458        set_y,
459        is_scrolling: is_scrolling.into(),
460        arrived_state: arrived_state.into(),
461        directions: directions.into(),
462        measure,
463    }
464}
465
466/// Options for [`use_scroll`].
467#[derive(DefaultBuilder)]
468/// Options for [`use_scroll_with_options`].
469#[cfg_attr(feature = "ssr", allow(dead_code))]
470pub struct UseScrollOptions {
471    /// Throttle time in milliseconds for the scroll events. Defaults to 0 (disabled).
472    throttle: f64,
473
474    /// After scrolling ends we wait idle + throttle milliseconds before we consider scrolling to have stopped.
475    /// Defaults to 200.
476    idle: f64,
477
478    /// Threshold in pixels when we consider a side to have arrived (`UseScrollReturn::arrived_state`).
479    offset: ScrollOffset,
480
481    /// Callback when scrolling is happening.
482    on_scroll: Rc<dyn Fn(web_sys::Event)>,
483
484    /// Callback when scrolling stops (after `idle` + `throttle` milliseconds have passed).
485    on_stop: Rc<dyn Fn(web_sys::Event)>,
486
487    /// Options passed to the `addEventListener("scroll", ...)` call
488    event_listener_options: UseEventListenerOptions,
489
490    /// When changing the `x` or `y` signals this specifies the scroll behaviour.
491    /// Can be `Auto` (= not smooth) or `Smooth`. Defaults to `Auto`.
492    #[builder(into)]
493    behavior: Signal<ScrollBehavior>,
494}
495
496impl Default for UseScrollOptions {
497    fn default() -> Self {
498        Self {
499            throttle: 0.0,
500            idle: 200.0,
501            offset: ScrollOffset::default(),
502            on_scroll: Rc::new(|_| {}),
503            on_stop: Rc::new(|_| {}),
504            event_listener_options: Default::default(),
505            behavior: Default::default(),
506        }
507    }
508}
509
510/// The scroll behavior.
511/// Can be `Auto` (= not smooth) or `Smooth`. Defaults to `Auto`.
512#[derive(Default, Copy, Clone)]
513pub enum ScrollBehavior {
514    #[default]
515    Auto,
516    Smooth,
517}
518
519impl From<ScrollBehavior> for web_sys::ScrollBehavior {
520    fn from(val: ScrollBehavior) -> Self {
521        match val {
522            ScrollBehavior::Auto => web_sys::ScrollBehavior::Auto,
523            ScrollBehavior::Smooth => web_sys::ScrollBehavior::Smooth,
524        }
525    }
526}
527
528/// The return value of [`use_scroll`].
529pub struct UseScrollReturn<SetXFn, SetYFn, MFn>
530where
531    SetXFn: Fn(f64) + Clone + Send + Sync,
532    SetYFn: Fn(f64) + Clone + Send + Sync,
533    MFn: Fn() + Clone + Send + Sync,
534{
535    /// X coordinate of scroll position
536    pub x: Signal<f64>,
537
538    /// Sets the value of `x`. This does also scroll the element.
539    pub set_x: SetXFn,
540
541    /// Y coordinate of scroll position
542    pub y: Signal<f64>,
543
544    /// Sets the value of `y`. This does also scroll the element.
545    pub set_y: SetYFn,
546
547    /// Is true while the element is being scrolled.
548    pub is_scrolling: Signal<bool>,
549
550    /// Sets the field that represents a direction to true if the
551    /// element is scrolled all the way to that side.
552    pub arrived_state: Signal<Directions>,
553
554    /// The directions in which the element is being scrolled are set to true.
555    pub directions: Signal<Directions>,
556
557    /// Re-evaluates the `arrived_state`.
558    pub measure: MFn,
559}
560
561#[derive(Default, Copy, Clone, Debug)]
562/// Threshold in pixels when we consider a side to have arrived (`UseScrollReturn::arrived_state`).
563pub struct ScrollOffset {
564    pub left: f64,
565    pub top: f64,
566    pub right: f64,
567    pub bottom: f64,
568}
569
570impl ScrollOffset {
571    /// Sets the value of the provided direction
572    pub fn set_direction(mut self, direction: Direction, value: f64) -> Self {
573        match direction {
574            Direction::Top => self.top = value,
575            Direction::Bottom => self.bottom = value,
576            Direction::Left => self.left = value,
577            Direction::Right => self.right = value,
578        }
579
580        self
581    }
582}