在React js中检测滚动方向

29

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

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;

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

有什么建议吗?

谢谢你的帮助


1
如果有人想要查看自定义React hooks的实现细节,以检测滚动方向,请访问以下链接:https://www.robinwieruch.de/react-hook-scroll-direction/ - Robin Wieruch
@RobinWieruch,你的钩子比我的性能好得多。 - imback0526
10个回答

73

TLDR;

由于这个答案引起了一些关注,我刚刚开发了一个基于它的npm包,让每个人都可以将其作为独立的包/库在他们的项目中使用。因此,如果你想要在React本身或者像Nextjs、Remixjs、Gatsbyjs等服务器端渲染框架/库中立即使用它,你可以将其添加到你的项目中作为一个依赖项:

演示

Edit react-scroll-direction

npm i @smakss/react-scroll-direction
or
yarn add @smakss/react-scroll-direction

点击此处阅读更多

答案及其描述

这是因为您定义了一个useEffect()没有任何依赖项,所以您的useEffect()只会运行一次,并且它从不在y更改时调用handleNavigation()。要解决这个问题,您必须将y添加到您的依赖项数组中,以告诉您的useEffect()y值更改时运行。然后,在您尝试使用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 it will 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 it will run multiple times
    window.removeEventListener("scroll", (e) => handleNavigation(e));
  };
}, [y]);

更新(解决方案)

在自己实施这个解决方案后,我发现应该对这个解决方案进行一些注释。因此,由于handleNavigation()将直接改变y的值,我们可以忽略y作为我们的依赖项,然后将handleNavigation()作为我们useEffect()的依赖项添加进去,然后由于这个改变,我们应该对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注册一个新的事件,这可能会破坏整个功能。 工作演示:

CodeSandbox


最终优化方案

经过一番思考,我得出结论,在这种情况下,记忆化将帮助我们注册一个单一事件来识别滚动方向。然而,对于在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 状态将包含实际的滚动方向。

工作演示:

CodeSandbox


1
哇,太棒了!非常感谢您详细的工作! - user13084463
1
handleNavigation作为匿名函数传递,因此removeListner将无法工作,并且会向window添加无数个监听器。 - Reza Sam
尝试在表格上不起作用。 - Игор Ташевски
除了理论上的便利性外,将其包装在React中是否有好处?为什么不只使用vanilla JS;为什么要限制您的工作只能使用React?您的逻辑只是在重新构建已经完成100次的内容。 - Kalnode
@kalnode 感谢您详细阐述您的观点。不幸的是,在SO中,我们通常必须根据OP问题的上下文来回答问题,因此在这种情况下使用ReactJS的主要原因是OP问题的上下文。我并不坚持认为它应该仅限于React,因为OP希望这样做,但当问题标记为Reactjs时,采用一般方法并引用其他已经以不同方式解决此问题的现有线程就没有太多意义了。但是您的评论在某种程度上启发了我,感谢您。 - SMAKSS

4

我想提出一个简洁的解决方案,它与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状态并根据您的代码需求进行操作。


3

试试这个包 - react-use-scroll-direction

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

export const MyComponent = () => {
  const { isScrollingDown } = useScrollDirection()

  return (
    <div>
      {isScrollingDown ? 'Scrolling down' : 'scrolling up'}
    </div>
  )
}

2

在我看来,大多数答案都有点过于工程化了。

以下是我在我的Next.js项目中使用的方法:

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();
    
    {....}
}


2
我在寻找简单的解决方案时没有找到,所以我研究了事件本身,发现存在一个“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");
    }
};

2
对我来说不起作用。在桌面版Chrome中,deltaY未定义。 - Quv

1
我发现这个简洁而实用的解决方案只需要几行代码。

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

onWheel 合成事件 返回一个包含原始事件信息的属性名为 nativeEvent 的事件对象。即使没有有效的滚动 (overflow:hidden),也可以使用 wheelDelta 来检测方向。

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


1
WheelData不再存在,取而代之的是新属性。请查看此答案以获取更多信息:https://dev59.com/4LHma4cB1Zd3GeqPIDh9#54444535 - em_code

0

我已经搜索了这些东西一个小时了。 但没有一个解决方案适用于我,所以我就这样写了,这对我的 next.js 项目有效。

const [currentScroll, setCurrentScroll] = useState(0)
const [lastScroll, setLastScroll] = useState(0)
const [scrollUp, setScrollUp] = useState(false)

useEffect(()=>{
  function handleScroll(){
    setCurrentScroll(scrollY)

    // check if current scroll 
    // more than last scroll
    if(currentScroll>lastScroll){
      setScrollUp('Down')
    } else {
      setScrollUp('Up')
    }
  }

  // change the last scroll
  setLastScroll(scrollY)
  
  window.addEventListener('scroll', handleScroll)
  
  return () => {
   window.removeEventListener('scroll', handleScroll)
  }


// this needed to change last scroll
// if currentscroll has change
},[currentScroll]) }

也许我的答案受到了@smakks的启发。 - Taufik Nurhidayat

0

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

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演示


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

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

因为你将一个空数组传递给了useEffect,所以这个钩子只运行一次,不会在每次y改变时重新渲染。
要改变y的值,我们需要在滚动时重新渲染,因此我们需要将一个状态传递到这个数组中。在这里,我们看到window.scrollY在滚动时总是会改变,因此[window.scrollY]可能是解决您问题的最佳方案。
useEffect(() => {
    setY(window.scrollY);

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

CopePen Demo

你的问题与在useEffect中使用依赖项有关,你可以参考这个链接来自React文档(注意一部分):useEffect Dependency


0

这是我的React Hook解决方案,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-intenthttps://github.com/dollarshaveclub/scrolldir。此外,这里还有React版本:https://github.com/AnakinYuen/scroll-direction


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接