At the moment, I’m creating two hooks for the React-Waypoint component.
What is React Waypoint?
React-Waypoint lets you monitor the scroll position of your browser and once it hits the waypoint it fires an “enter” event, once it is leaving the screen it fires the “leave” event.
useWaypoint
hook
This hook can be used to to create waypoints.
Usage example
const [Waypoint, { scrollToWaypoint }] = useWaypoint({
component: <div>If I'm visible I will trigger an event!</div>,
enter: () => console.log("waypoint enter"),
leave: () => console.log("waypoint leave")
});
return (
{/* other content */}
<Waypoint/>
)
With this, it will trigger the enter/leave events once the waypoint div gets visible/invisible.
The method scrollToWaypoint
can be used to scroll to the element.
useWaypointNavigation
hook
The idea is to have a navigation between waypoints. With previous and next buttons (see below screenshot).
This is where I’m having the following issues:
- Fast scrolling creates an inconsistent state but not sure why (this can be fixed later)
- The main issue is that the first and last prev/next navigation is not working if scrolled manually before clicking.
Scrolling issue
Scroll the screen so that “w0” is visible (like in the below screenshot), then click next. The expected behavior is to scroll “w0” into view but it scrolls to “w1”.
Same issue at the last position. Scroll below “w3” and hit prev. The expected behavior is to scroll “w3” into view but it scrolls to “w2”.
Maybe it’s just logic that’s missing but I’m struggling with it for some time now and I’m not sure how to fix this.
How does the hook work?
First, the useWaypoint
hooks are initialized in an array
const waypoints: Array<UseWaypointReturn> = [
useWaypoint({
component: <Box index="w0" />,
enter: enter("w0"),
leave: leave("w0")
}),
useWaypoint({
component: <Box index="w1" />,
enter: enter("w1"),
leave: leave("w1")
}),
useWaypoint({
component: <Box index="w2" />,
enter: enter("w2"),
leave: leave("w2")
}),
useWaypoint({
component: <Box index="w3" />,
enter: enter("w3"),
leave: leave("w3")
})
];
And then passed to the hook, so the hook get the information about each waypoint.
const {
visibilityMap,
nextWaypointIndex,
prevWaypointIndex,
next,
prev
} = useWaypointNavigation({ waypoints });
The return values from the hook are some debug information and the next
and prev
method that scroll to the next or prev. waypoint.
visibilityMap
is used to determine the prev / next index. The index is the position in the waypoints
array, so we can use the scrollToWaypoint
method.
The logic of the prev/next index calucalation is inside the useEffect
:
- Iterate through the
visibilityMap
e.g. [false,false,true,false] to determine the last visible index lastVisibleIndex
and the visibleCount
- Two cases here:
- If one item is visible, we can use the
lastVisibleIndex
to calculate the prev & next index
- If two items are visible, we’re taking the
lastVisibleIndex
as the next index as we’re between two waypoints –> could be problematic for 3 visible but it’s working
- Special case: Scrolled below all waypoints should hold the last waypoint. With-out holding it was resetting prev and next index to zero. Holding works but there is something missing to get the expected behavior.
Any ideas are highly appreciated. Would be great if I’m just not seeing the cause and there is an easy fix.
Things I’ve tried:
onPositionChange
or onEnter
/ onLeave
are containing information about the position but I couldn’t get it to work (maybe I have to check this again). Position information is “inside”, “above”, “below” but the previousPosition
wasn’t helping (most of the time it was undefined
)
- Tried to add more logic around
lastVisibleIndex
or visibibleCount
but without success
Source code
You can find the code in the following Codesandbox and below.
useWaypoint hook
import React, {
FC,
MutableRefObject,
ReactNode,
useRef,
useState
} from "react";
import { Waypoint } from "react-waypoint";
type useWaypointProps = {
enter?: () => void;
leave?: () => void;
component?: ReactNode;
};
export type UseWaypointReturn = [
FC<any>,
{
ref: MutableRefObject<ReactNode>;
isVisible: boolean; ///> true = waypoint entered & not left yet
scrollToWaypoint: () => void;
}
];
interface IUseWaypoint {
(props: useWaypointProps): UseWaypointReturn;
}
export const useWaypoint: IUseWaypoint = ({
component,
enter,
leave,
...hookProps
}) => {
const waypointRef = useRef<HTMLDivElement | null>(null);
const [isVisible, setVisible] = useState(false);
const scrollToWaypoint = () => {
if (waypointRef && waypointRef.current) {
waypointRef.current.scrollIntoView({
block: "start",
behavior: "smooth"
});
}
};
const onEnter = () => {
setVisible(true);
if (enter) {
enter();
}
};
const onLeave = () => {
setVisible(false);
if (leave) {
leave();
}
};
return [
({ children, ...props }) => (
<div ref={waypointRef}>
<Waypoint
debug={false}
{...props}
{...hookProps}
onEnter={onEnter}
onLeave={onLeave}
>
{component ? <div>{component}</div> : <div>{children}</div>}
</Waypoint>
</div>
),
{
ref: waypointRef,
isVisible,
scrollToWaypoint
}
];
};
useWaypointNavigigation hook
import React, { FC, useEffect, useState } from "react";
import "./styles.css";
import { useWaypoint, UseWaypointReturn } from "./hooks/useWaypoint";
// Issues:
// - Fast scrolling missing leave events? --> inconsistent navigation state
// --> check "rapid fire" prop on Waypoint component - should be active by default
//
// Todo:
// - Add prev - next waypoint - requires a check which waypoints are on screen
// --> create hook useWaypointNavigation
type useWaypointNavigationProps = {
waypoints: Array<UseWaypointReturn>;
};
type UseWaypointNavigationReturn = {
next: () => void;
prev: () => void;
nextWaypointIndex: number;
prevWaypointIndex: number;
visibilityMap: Array<Boolean>;
};
interface IUseWaypointNavigation {
(props: useWaypointNavigationProps): UseWaypointNavigationReturn;
}
const createVisiblityMap = (
waypoints: Array<UseWaypointReturn>
): Array<Boolean> => waypoints.map((w: any) => w[1].isVisible);
const useWaypointNavigation: IUseWaypointNavigation = ({ waypoints }) => {
const visibilityMap = createVisiblityMap(waypoints);
const [holdLastWaypoint, setHoldLastWaypoint] = useState(false);
const [nextWaypointIndex, setNext] = useState(0);
const [prevWaypointIndex, setPrev] = useState(0);
/*
// not needed as we're calculating prev/next from waypoints
// (were used for testing prev/next scrolling)
// --> can be tested if useEffect block is commented
const incrementWaypointIndex = () => {
const lastWaypointIndex = waypoints.length - 1;
if (nextWaypointIndex + 1 < lastWaypointIndex) {
setPrev(nextWaypointIndex);
setNext(nextWaypointIndex + 1);
} else {
setPrev(lastWaypointIndex - 1);
setNext(lastWaypointIndex);
}
};
const decrementWaypointIndex = () => {
if (prevWaypointIndex - 1 >= 0) {
setPrev(prevWaypointIndex - 1);
setNext(prevWaypointIndex);
} else {
setPrev(0);
setNext(0);
}
};*/
useEffect(() => {
console.log("update next / prev index");
const mapLength = visibilityMap.length;
// "scroll fast issue" --> why?
// Visibility map inconsistent and can't be used to detect
// position
// true = one waypoint in view
// true, true = two waypoints in view --> next = last true
// special case all false --> above or below
// -- waypoints "below" handled by visibility count + start values
// -- waypoints "above" handled by holding last prev/next by disabling updating
let lastVisibleIndex = 0;
let visibleCount = 0;
visibilityMap.forEach((val, index) => {
if (val) {
visibleCount++;
lastVisibleIndex = index;
if (mapLength - 1 === index) {
// last element visible --> hold setting
setHoldLastWaypoint(true);
} else {
setHoldLastWaypoint(false);
}
}
});
let next;
if (visibleCount === 1) {
// only one element in view -> +1 to get next waypoint index
next =
lastVisibleIndex + 1 >= mapLength
? mapLength - 1
: lastVisibleIndex + 1;
} else {
// one or more items visible -> last element in view = next element
next = lastVisibleIndex;
}
if (!holdLastWaypoint) {
// only update if not holding last waypoint
// --> holding needed if waypoints are scrolled out of view
// so they're above and we'd like to navigate back
setPrev(lastVisibleIndex > 1 ? lastVisibleIndex - 1 : 0);
setNext(visibleCount === 0 ? 0 : next);
}
}, [
visibilityMap,
holdLastWaypoint,
setHoldLastWaypoint,
setNext,
setPrev,
nextWaypointIndex,
prevWaypointIndex
]);
return {
next: () => {
waypoints[nextWaypointIndex][1].scrollToWaypoint();
},
prev: () => {
waypoints[prevWaypointIndex][1].scrollToWaypoint();
},
nextWaypointIndex, // just for debugging
prevWaypointIndex,
visibilityMap
};
};
type BoxProps = {
index?: string | number | null;
};
const Box: FC<BoxProps> = ({ index }) => (
<div className="box">
<h1>{index}</h1>
</div>
);
const Spacer: FC = () => (
<div style={{ height: "1000px", border: "1px solid red" }}></div>
);
export default function App() {
const enter = (index: number | string) => () => {
console.log(`waypoint ${index} enter`);
};
const leave = (index: number | string) => () => {
console.log(`waypoint ${index} leave`);
};
/*
Re-add usage demo later
const [Waypoint0, { scrollToWaypoint: scrollWay0 }] = useWaypoint({
component: <Box index="0" />,
enter: enter(0),
leave: leave(0)
});
const [Waypoint1] = useWaypoint({
//component: <Box index="0" />,
// added as child to Waypoint1
enter: enter(1),
leave: leave(1)
});
const [WaypointInvisble] = useWaypoint({
//component: <Box index="0" />,
// with-out component or children to have a invisible waypoint
enter: enter("invisible"),
leave: leave("invisible")
});
*/
const waypoints: Array<UseWaypointReturn> = [
useWaypoint({
component: <Box index="w0" />,
enter: enter("w0"),
leave: leave("w0")
}),
useWaypoint({
component: <Box index="w1" />,
enter: enter("w1"),
leave: leave("w1")
}),
useWaypoint({
component: <Box index="w2" />,
enter: enter("w2"),
leave: leave("w2")
}),
useWaypoint({
component: <Box index="w3" />,
enter: enter("w3"),
leave: leave("w3")
})
];
const {
visibilityMap,
nextWaypointIndex,
prevWaypointIndex,
next,
prev
} = useWaypointNavigation({ waypoints });
return (
<div className="App">
<div className="controls">
<button onClick={waypoints[0][1].scrollToWaypoint}>
Scroll to Waypoint w0
</button>
<h3>Debug</h3>
<pre>
{JSON.stringify(visibilityMap, null, 2)}
<br />
next {nextWaypointIndex}
<br />
prev {prevWaypointIndex}
<br />
<button onClick={prev}>Prev</button>
<button onClick={next}>Next</button>
</pre>
</div>
<Spacer />
{/* Re-add the usage demo later
<Waypoint0 />
<Waypoint1>
<h2>Also possible to add the content here</h2>
</Waypoint1>
<WaypointInvisble />
*/}
{/* more waypoints (named w0, w1, w2,...*/}
{waypoints.map((w, i) => {
// index 0 = Component,
// index 1 = {ref, scrollToWaypoint, ...rest}
const Waypoint = w[0];
return <Waypoint key={i} />;
})}
<Spacer />
</div>
);
}