react horizontal scroll to section mouse wheel

Есть небольшой проект на react:

Edit React Scroll Nav To

Есть горизонтальное меню и контент.
По клику на пункт меню - скролл к соответствующей секции.
При скролле контента - переключается активный пункт меню.

Меню:

 <ul
    className="nav list-unstyled d-flex flex-nowrap fixed-top"
    ref={scrollNavRefs}
    onScroll={handleScroll}
  >
    {list.map((item, i) => (
      <li className="nav-item" key={i}>
        <a
          href={`#s-${i}`}
          className={`nav-link text-nowrap ${
            active === i ? "text-danger" : ""
          }`}
          onClick={scrollTo(i)}
        >
          {item}
        </a>
      </li>
    ))}
  </ul>

Контент:

  <ul className="mb-100 list-unstyled">
    {list.map((item, i) => (
      <li id={`s-${i}`} ref={scrollRefs.current[i]} className="py-100 px-3">
        <h3>{item}</h3>
        <p>
          Lorem ipsum dolor sit amet consectetur adipisicing elit. Nisi,
          dicta.
        </p>
      </li>
    ))}
  </ul>

>>>:

  const scrollRefs = useRef([]);
  const scrollNavRefs = useRef();

  const [active, setActive] = useState(0);

  const list = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"];

  scrollRefs.current = [...Array(list.length).keys()].map(
    (_, i) => scrollRefs.current[i] ?? createRef()
  );

  const scrollTo = (index) => () => {
    scrollRefs.current[index].current.scrollIntoView({ behavior: "smooth" });
    setActive(index);
  };


  const scrollHandler = () => {
    const scrollRefsElements = scrollRefs.current;

    scrollRefsElements.forEach((el, i) => {
      const rect = el.current.getBoundingClientRect();

      const elemTop = rect.top;
      const elemBottom = rect.bottom;

      const isVisible = elemTop >= 0 && elemBottom <= window.innerHeight;

      if (isVisible) {
        setActive(i);
      }
    });
  };

  const onWheel = (e) => {
    if (e.deltaY === 0) return;
    e.preventDefault();

    const scrollNavRefsElement = scrollNavRefs.current;
    const scrollNavRefsElementLeft = scrollNavRefs.current.scrollLeft;

    scrollNavRefsElement.scrollTo({
      left: scrollNavRefsElementLeft + e.deltaY,
      behavior: "smooth"
    });
  };

  useEffect(() => {
    window.addEventListener("scroll", scrollHandler, true);
    return () => {
      window.removeEventListener("scroll", scrollHandler, true);
    };
  }, []);

  useEffect(() => {
    scrollNavRefs.current.addEventListener("wheel", onWheel, true);
    return () => {
      scrollNavRefs.current.removeEventListener("wheel", onWheel, true);
    };
  }, []);

Вопрос: Как связать меню и контент, так чтобы при скролле основного контента, когда меняется активный класс пункта меню - меню скроллилось, так чтобы активный пункт меню был видимым? И при скролле меню, чтобы переключался активный класс пункта меню и скроллился основной контент к соответствующей секции По аналогии с приемером?


Ответы (2 шт):

Автор решения: Oleksandr

Попробуй использовать отсчет от отступа скрола + высота элемента.

const scrollHandler = () => {
  const scrollRefsElements = scrollRefs.current;

  const elementsInViewport = scrollRefsElements
    .filter((el, i) => {
      const rect = el.current.getBoundingClientRect();

      const elemTop = rect.top;

      return (
        window.pageYOffset + rect.height >= el.current.offsetTop + elemTop
      );
    })
    .map((_, i) => i);

  setActive(elementsInVievport[elementsInVievport.length - 1]);
};

→ Ссылка
Автор решения: Daniil Loban

Cразу скажу, что задача не тривиальная, хотя с виду кажется простой. Решать ее можно по разному. Я получил удовольствие решая эту задачу (больше бы таких), надеюсь Вы получите пользу от моего решения. Ниже я постарался как можно подробнее прокомментировать свой подход.

Я не очень люблю использовать useRef в своих проектах, конечно бывают случаи когда без него не обойтись. Дело в том, что не то чтобы я не умею им пользоваться просто иногда он пораждает глюки которые сложно понять. И в принципе, я сокращаю все что можно сократить, даже если это не глючит.

Так же я отказался от навешивания события scroll через useEffect. Оказалось что scroll нужно было навесить именно на контент а не все окно так как иначе при попытке прокрутки меню порождалось еще одно событие скрола, в итоге одно стопорило результат другого т.е. обрубался скрол контента.

Функцию onWheel я переделал просто на изменение индекса (active) и эмуляцию click - нажатия на ссылку.

Для скрола я решил использовать scrollLeft если посмотреть исходники вебкомпонента в приведенном Вами проекте со Vue там тоже его используют, однако кода там гораздо больше, поэтому я даже не стал его рассматривать как вариант ответа и решил все писать самостоятельно, Если будет интересно посмотрите в проекте node_modules/vant/lib/tabs/utils.js там тоже есть свои фишки.

current.scrollLeft += rect.left - (width - rect.width)/2

Эта формула позволяет прокрутить начало элемента влево, затем на середину страницы и после чуть левее чтобы центр элемента был по центру страницы:

введите сюда описание изображения

Обратите внимание, что я использую += вместо обычного = так как тут нужно учитывать смещение, которое было уже сделано до этого.

Функция getElementIndex занимается тем, что берет родителя элемента и после по нему ищет индекс элемента. Таким образом через нее я поулчаю в функции scrollToIndex активный индекс и устанавливаю его.

Еще один момент не давал мне покоя - это цикл forEach для поиска индекса элемента в контенте. Поэтому я решил воспользоваться сразу фунцией которая по позиции в документе может найти элемент elementFromPoint(x,y), а далее я снова получал его индекс с помощью getElementIndex. Чтобы определится с позицией по которой стоит искать элемент я воспользовался известной Вам функцией getBoundingClientRect для меню после чего брал точку чуть ниже середины после меню.

Ну и на последок, я поиграл немного со стилями (.content и .smooth-scroll ), прежде всего приcпустил контент и добавил через стили плавный скрол для обоих частей.

PS: К сожалению выяснилось уже после того как я дал ответ, что стиль .smooth-scroll в Chrome ломает функционал, если это произойдет у Вас попробуйте заккоментировать этот стиль. Как вариант можно попробовать использовать анимацию css с переменными. Я еще посмотрю как это можно будет решить.

const {useRef, useState, useEffect} = React

function App() {
  const scrollNavRefs = useRef();
  const [active, setActive] = useState(0);
  const list = new Array(20).fill().map((e, i) => `Item ${i+1}`)

  const scrollNav = (i) => {
    if (!scrollNavRefs.current) return
    const { current } = scrollNavRefs
    const rect = current.children[i].getBoundingClientRect();
    const width = window.innerWidth;
    current.scrollLeft += rect.left - (width - rect.width)/2
  }

  const getElementIndex = (element) => {
    const parent = element.parentNode;
    return [...parent.children].indexOf(element);
  }

  const scrollToIndex = (event) => {
    const navLi = event.target.parentNode;
    const index = getElementIndex(navLi);
    setActive(index);
  };

  const scrollHandler = (event) => {
    if (!scrollNavRefs.current) return
    const rectNav = scrollNavRefs.current.getBoundingClientRect();
    const el = document.elementFromPoint(rectNav.width / 2, rectNav.height + 10)
    if(!el || el.tagName !== 'LI') return
    const newIndex = getElementIndex(el)
    const rect = el.getBoundingClientRect();
    setActive(newIndex);
    scrollNav(newIndex)
  };
  
  const onWheel = (e) => {
    if (e.deltaY === 0 || !scrollNavRefs.current ) return;
    const {length} = scrollNavRefs.current.children;
    const delta = e.deltaY < 1 ? -1 : 1;
    const lastIndex = active;
    const nextIndex = delta + lastIndex;
    const newIndex = nextIndex >= 0 && nextIndex < length
        ? nextIndex
        : lastIndex;
    const link =  scrollNavRefs.current.children[newIndex].children[0];
    link.click();
    setActive(newIndex);
    scrollNav(newIndex);
  };
  return (
    <div className="container">
      <ul
        onWheel={onWheel}
        className="nav list-unstyled d-flex flex-nowrap fixed-top smooth-scroll"
        ref={scrollNavRefs}
        style={{background:'white'}}
      >
        {list.map((item, i) => (
          <li className="nav-item" key={i}>
            <a
              onClick={scrollToIndex} 
              href={`#s-${i}`}
              className={`nav-link text-nowrap ${
                active === i ? "text-danger" : ""
              }`}
            >
              {item}
            </a>
          </li>
        ))}
      </ul>
      <div className="content smooth-scroll" onScroll={scrollHandler} >
        <ul className="mb-100 list-unstyled">
          {list.map((item, i) => (
            <li key={i} id={`s-${i}`}  className="py-100 px-3">
              <h3>{item}</h3>
              <p>
                Lorem ipsum dolor sit amet consectetur adipisicing elit. Nisi,
                dicta.
              </p>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
h1,
p {
  font-family: Lato;
}
/*
html {
  scroll-behavior: smooth;
}
*/
.nav {
  overflow: hidden;
  overflow-x: auto;
  z-index: 1000;
  background: #fff;
}

.py-100 {
  padding-top: 100px;
  padding-bottom: 100px;
  border: 1px solid;
}

.mb-100 {
  margin-bottom: 200px;
}

.smooth-scroll{
  scroll-behavior: smooth; 
}

.content{
  height: calc(100vh - 55px);
  margin-top: 55px;
  overflow-y: scroll;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Add React in One Minute</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin ></script>
    <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin ></script>
    <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
    <!-- Load our React component. -->
    <script type="text/babel">
    </script>
    <link
      href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
      crossorigin="anonymous"
    />
  </body>
</html>

→ Ссылка