在 React js 中检测滚动方向

Detect scroll direction in React js

我正在尝试检测滚动事件是向上还是向下,但我找不到解决方案。

import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";

const Navbar = ({ className }) => {
  const [y, setY] = useState(0);

  const handleNavigation = (e) => {
    const window = e.currentTarget;
    if (y > window.scrollY) {
      console.log("scrolling up");
    } else if (y < window.scrollY) {
      console.log("scrolling down");
    }
    setY(window.scrollY);
  };

  useEffect(() => {
    setY(window.scrollY);

    window.addEventListener("scroll", (e) => handleNavigation(e));
  }, []);

  return (
    <nav className={className}>
      <p>
        <i className="fas fa-pizza-slice"></i>Food finder
      </p>
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/about">About</Link>
        </li>
      </ul>
    </nav>
  );
};

export default Navbar;

基本上它总是被检测为“关闭”,因为 handleNavigation 中的 y 始终为 0。如果我检查 DevTool 中的状态,y 状态更新但在 handleNavigation 没有。

有什么建议我做错了什么吗?

感谢您的帮助

这是因为你定义了一个没有任何依赖的useEffect(),所以你的useEffect()只会运行一次,并且它从不在 y 更改 上调用 handleNavigation()。要解决此问题,您需要将 y 添加到依赖项数组中,以便在 y 值发生变化时告诉您的 useEffect() 运行。然后您需要另一个更改才能在您的代码中生效,您正在尝试使用 window.scrollY 初始化 y,因此您应该在 useState() 中执行此操作,例如:

const [y, setY] = useState(window.scrollY);

useEffect(() => {
  window.addEventListener("scroll", (e) => handleNavigation(e));

  return () => { // return a cleanup function to unregister our function since its gonna run multiple times
    window.removeEventListener("scroll", (e) => handleNavigation(e));
  };
}, [y]);

如果由于某种原因 window 在那里不可用或者您不想在这里使用,您可以在两个单独的 useEffect() 中使用。

所以你的 useEffect() 应该是这样的:

useEffect(() => {
  setY(window.scrollY);
}, []);

useEffect(() => {
  window.addEventListener("scroll", (e) => handleNavigation(e));

  return () => { // return a cleanup function to unregister our function since its gonna run multiple times
    window.removeEventListener("scroll", (e) => handleNavigation(e));
  };
}, [y]);

更新(工作解决方案)

在我自己实现这个解决方案之后。我发现有一些注释应该应用于此解决方案。所以 因为 handleNavigation() 会直接改变 y 值,我们可以忽略 y 作为我们的依赖,然后添加 handleNavigation() 作为我们的 [=14] 的依赖=],那么由于这个变化我们应该优化handleNavigation(),所以我们应该使用useCallback()。那么最后的结果会是这样的:

const [y, setY] = useState(window.scrollY);

const handleNavigation = useCallback(
  e => {
    const window = e.currentTarget;
    if (y > window.scrollY) {
      console.log("scrolling up");
    } else if (y < window.scrollY) {
      console.log("scrolling down");
    }
    setY(window.scrollY);
  }, [y]
);

useEffect(() => {
  setY(window.scrollY);
  window.addEventListener("scroll", handleNavigation);

  return () => {
    window.removeEventListener("scroll", handleNavigation);
  };
}, [handleNavigation]);

在@RezaSam 发表评论后,我注意到我在记忆版本中犯了一个微小的错误。在另一个箭头函数中调用 handleNavigation 的地方,我发现(通过浏览器开发工具,事件侦听器选项卡)在每个组件重新渲染时它会向 window 注册一个新事件,因此它可能会破坏整个搞事情。

工作演示:


最终优化方案

毕竟,我最终认为 memoization 在这种情况下将帮助我们注册单个事件,以识别滚动方向,但它在打印控制台时并未完全优化,因为我们在handleNavigation 函数,并且没有其他方法可以在当前实现中打印所需的控制台。

所以,我意识到每次我们想要检查新位置时,有一种更好的方法来存储最后一页滚动位置。此外,为了摆脱大量的安慰 向上滚动 向下滚动 ,我们应该定义一个阈值 (使用去抖动方法) 来触发滚动事件变化。所以我只是在网上搜索了一下,最后得到了这个非常有用的 gist。然后在它的启发下,实现了一个更简单的版本。

这是它的样子:

const [scrollDir, setScrollDir] = useState("scrolling down");

useEffect(() => {
  const threshold = 0;
  let lastScrollY = window.pageYOffset;
  let ticking = false;

  const updateScrollDir = () => {
    const scrollY = window.pageYOffset;

    if (Math.abs(scrollY - lastScrollY) < threshold) {
      ticking = false;
      return;
    }
    setScrollDir(scrollY > lastScrollY ? "scrolling down" : "scrolling up");
    lastScrollY = scrollY > 0 ? scrollY : 0;
    ticking = false;
  };

  const onScroll = () => {
    if (!ticking) {
      window.requestAnimationFrame(updateScrollDir);
      ticking = true;
    }
  };

  window.addEventListener("scroll", onScroll);
  console.log(scrollDir);

  return () => window.removeEventListener("scroll", onScroll);
}, [scrollDir]);

它是如何工作的?

我会简单地从上到下解释每个代码块。

  • 所以我刚刚定义了一个初始值为0的阈值点,然后每当滚动向上或向下时,它都会进行新的计算,如果你不这样做,你可以增加它想立即计算新的页面偏移量。

  • 然后我决定使用 pageYOffset 而不是使用 scrollY,这在交叉浏览中更可靠。

  • updateScrollDir函数中,我们将简单地检查是否满足阈值,如果满足,我将根据当前页面和上一页偏移指定滚动方向。

  • 其中最重要的部分是onScroll函数。我只是使用 requestAnimationFrame 来确保我们在滚动后页面完全呈现后计算新的偏移量。然后使用 ticking 标志,我们将确保在每个 requestAnimationFrame.

    中我们只是 运行 我们的事件监听器回调一次
  • 最后,我们定义了监听器和清理函数。

  • 然后 scrollDir 状态将包含实际滚动方向。

工作演示:

我环顾四周,找不到简单的解决方案,所以我调查了事件本身,发现存在一个“deltaY”,它使一切变得更简单(无需保留最后滚动值的状态)。 “deltaY”值显示事件在“y”中的变化(正 deltaY 表示它是向下滚动事件,负 deltaY 表示它是向上滚动事件)。

工作原理如下:

componentDidMount() {
    window.addEventListener('scroll', e => this.handleNavigation(e));
}

handleNavigation = (e) => {
    if (e.deltaY > 0) {
        console.log("scrolling down");
    } else if (e.deltaY < 0) {
        console.log("scrolling up");
    }
};

这是我的 React 钩子解决方案,useScrollDirection:

import { useEffect, useState } from 'react'

export type ScrollDirection = '' | 'up' | 'down'

type HistoryItem = { y: number; t: number }

const historyLength = 32 // Ticks to keep in history.
const historyMaxAge = 512 // History data time-to-live (ms).
const thresholdPixels = 64 // Ignore moves smaller than this.

let lastEvent: Event
let frameRequested: Boolean = false
let history: HistoryItem[] = Array(historyLength)
let pivot: HistoryItem = { t: 0, y: 0 }

export function useScrollDirection({
  scrollingElement,
}: { scrollingElement?: HTMLElement | null } = {}): ScrollDirection {
  const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('')

  useEffect(() => {
    const element: Element | null =
      scrollingElement !== undefined ? scrollingElement : document.scrollingElement
    if (!element) return

    const tick = () => {
      if (!lastEvent) return
      frameRequested = false

      let y = element.scrollTop
      const t = lastEvent.timeStamp
      const furthest = scrollDirection === 'down' ? Math.max : Math.min

      // Apply bounds to handle rubber banding
      const yMax = element.scrollHeight - element.clientHeight
      y = Math.max(0, y)
      y = Math.min(yMax, y)

      // Update history
      history.unshift({ t, y })
      history.pop()

      // Are we continuing in the same direction?
      if (y === furthest(pivot.y, y)) {
        // Update "high-water mark" for current direction
        pivot = { t, y }
        return
      }
      // else we have backed off high-water mark

      // Apply max age to find current reference point
      const cutoffTime = t - historyMaxAge
      if (cutoffTime > pivot.t) {
        pivot.y = y
        history.filter(Boolean).forEach(({ y, t }) => {
          if (t > cutoffTime) pivot.y = furthest(pivot.y, y)
        })
      }

      // Have we exceeded threshold?
      if (Math.abs(y - pivot.y) > thresholdPixels) {
        pivot = { t, y }
        setScrollDirection(scrollDirection === 'down' ? 'up' : 'down')
      }
    }

    const onScroll = (event: Event) => {
      lastEvent = event
      if (!frameRequested) {
        requestAnimationFrame(tick)
        frameRequested = true
      }
    }

    element.addEventListener('scroll', onScroll)
    return () => element.removeEventListener('scroll', onScroll)
  }, [scrollDirection, scrollingElement])

  return scrollDirection
}

用法:

const [scrollingElement, setScrollingElement] = useState<HTMLElement | null>(null)
const ref = useCallback(node => setScrollingElement(node), [setScrollingElement])
const scrollDirection = useScrollDirection({ scrollingElement })

<ScrollingContainer {...{ ref }}>
  <Header {...{ scrollDirection }}>
</ScrollingContainer>

基于https://github.com/pwfisher/scroll-intent and https://github.com/dollarshaveclub/scrolldir. Also ported to React here: https://github.com/AnakinYuen/scroll-direction

我发现这个简单明了的解决方案只需要几行代码


<div onWheel={ event => {
   if (event.nativeEvent.wheelDelta > 0) {
     console.log('scroll up');
   } else {
     console.log('scroll down');
   }
 }}
>
  scroll on me!
</div>

onWheel synthetic event returns 具有名为 nativeEvent 的属性的事件对象,其中包含原始事件信息。 wheelDelta用于即使没有有效滚动也能检测方向(overflow:hidden).

这是原始来源 -> http://blog.jonathanargentiero.com/detect-scroll-direction-on-react/

在我看来,大多数答案似乎都设计过度了。

这是我在我的 nextjs 项目中使用的:

function useVerticalScrollDirection() {
    const [direction, setDirection] = useState('up');

    let prevScrollY = 0;

    useEffect(() => {
        // Using lodash, we set a throttle to the scroll event
        // making it not fire more than once every 500 ms.
        window.onscroll = throttle(() => {

            // This value keeps the latest scrollY position
            const { scrollY } = window;

            // Checks if previous scrollY is less than latest scrollY
            // If true, we are scrolling downwards, else scrollig upwards
            const direction = prevScrollY < scrollY ? 'down' : 'up';

            // Updates the previous scroll variable AFTER the direction is set.
            // The order of events is key to making this work, as assigning
            // the previous scroll before checking the direction will result
            // in the direction always being 'up'.
            prevScrollY = scrollY;

            // Set the state to trigger re-rendering
            setDirection(direction);
        }, 500);

        return () => {
            // Remove scroll event on unmount
            window.onscroll = null;
        };
    }, []);

    return direction;
}

然后我像这样使用我的组件:

function MyComponent() {
    const verticalScrollDirection = useVerticalScrollDirection();
    
    {....}
}

在Next.js试试这个(如果你正在挣扎)-

我用过这个包 - react-use-scroll-direction

import React from 'react'
import { useScrollDirection } from 'react-use-scroll-direction'

export const Window_Scroll_Direction = () => {
const [direction, setDirection] = React.useState(String)
const { isScrollingUp, isScrollingDown } = useScrollDirection()

React.useEffect(() => {
  isScrollingDown && setDirection('down')
  isScrollingUp && setDirection('up')
}, [isScrollingDown, isScrollingUp])

return (
  <>
    <div className="fixed top-0 bg-white">
      {direction === 'down' ? 'Scrolling down' : 'scrolling up'}
    </div>
 </>
 )
}

只是想提出一个简洁的解决方案,它与 habbahans 非常相似,但在我看来看起来更简洁一些。

let oldScrollY = 0;

const [direction, setDirection] = useState('up');

const controlDirection = () => {
    if(window.scrollY > oldScrollY) {
        setDirection('down');
    } else {
        setDirection('up');
    }
    oldScrollY = window.scrollY;
}

useEffect(() => {
    window.addEventListener('scroll', controlDirection);
    return () => {
        window.removeEventListener('scroll', controlDirection);
    };
},[]);

在这里,您只需访问 hidden 状态即可在代码中执行您希望的操作。

这是我的解决方案,它扩展了此处找到的一些想法。它只在每个方向改变时触发一次,并添加一些参数来微调挂钩调用

const useScrollDirection = ({
    ref,
    threshold,
    debounce,
    scrollHeightThreshold,
}) => {
    threshold = threshold || 10;
    debounce = debounce || 10;
    scrollHeightThreshold = scrollHeightThreshold || 0;
    const [scrollDir, setScrollDir] = useState(null);
    const debouncedSetScrollDir = _.debounce(setScrollDir, debounce);

    useEffect(() => {
        let lastScrollY = ref?.current?.scrollTop;
        let lastScrollDir;
        let ticking = false;
        const hasScrollHeightThreshold =
            ref?.current?.scrollHeight - ref?.current?.clientHeight >
            scrollHeightThreshold;

        const updateScrollDir = () => {
            const scrollY = ref?.current?.scrollTop;
            if (
                Math.abs(scrollY - lastScrollY) < threshold ||
                !hasScrollHeightThreshold
            ) {
                ticking = false;
                return;
            }
            const newScroll = scrollY > lastScrollY ? 'down' : 'up';
            if (newScroll !== lastScrollDir) {
                debouncedSetScrollDir(newScroll);
            }
            lastScrollY = scrollY > 0 ? scrollY : 0;
            lastScrollDir = newScroll;
            ticking = false;
        };

        const onScroll = () => {
            if (!ticking) {
                window.requestAnimationFrame(updateScrollDir);
                ticking = true;
            }
        };

        ref?.current?.addEventListener('scroll', onScroll);

        return () => window.removeEventListener('scroll', onScroll);
    }, []);

    return scrollDir;
};

Codepen demo