react horizontal scroll to section mouse wheel
Есть небольшой проект на react:
Есть горизонтальное меню и контент.
По клику на пункт меню - скролл к соответствующей секции.
При скролле контента - переключается активный пункт меню.
Меню:
<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 шт):
Попробуй использовать отсчет от отступа скрола + высота элемента.
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]);
};
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>
