React how to get the scroll amount on an element? - javascript

I've made a react application where I have a component that has a scroll bar at the bottom of it. It's a horizontal scroll bar and I want to get how much the user has scrolled in terms of x position.
When I look it up on internet, such as this question, I usually see results that show how to get the scroll amount on the window.
However I'm looking for the scroll amount on a component that I have created called <Timeline/>, not the window.
Here is what my component looks like on the console:
When a user scrolls on it, I need to see where exactly the current visible part of it stands so I can make calculations with it.
On a given time only a certain part of the div is visible (As big as the window) and the rest is hidden on both left and right. I want to see, when scrolled, how far the user has scrolled and how much is invisible on the left side of the screen.
How can I get that?

This is one of those situations where you use a ref. Then you can get the scrollLeft from the element. Here's a basic example:
const { useRef } = React;
const Example = () => {
const elementRef = useRef(null);
const showScroll = () => {
console.log(`scrollLeft = ${elementRef.current.scrollLeft}`);
};
return (
<div>
<input type="button" value="Show Scroll" onClick={showScroll} />
<div className="container" ref={elementRef}>
<div className="wide" />
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Example />);
.container {
border: 1px solid black;
overflow: auto;
}
.wide {
width: 400vw;
height: 50px;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.development.js"></script>

If you want to access the scrolling position during an event handler, then simply using a ref and element.scrollLeft is the correct way.
But note that this will not trigger a rerender, so it will not work if you want to use the scrolling position to determine what/how to render.
In such cases it depends on what you are trying to do. If for instance you want to use the scrolling position in order to position another element, I would first try doing it with CSS only, as it's going to be a lot more efficient. Otherwise you can use an event listener on the scroll event.
const [scrollPosition, scrollPosition] = React.useState(0);
const handleScroll = () => {
setScrollPosition(elementRef.current.scrollLeft);
}
return (
<div onScroll={handleScroll} ref={elementRef}>
[...]
</div>
);

Related

Customise cursor pointer using a react component

I'm trying to customize the cursor pointer so I used a hook to get the mouse position and then I placed the component in absolute in that coordinates.
Here a working code.
There is a flag (USE_PNG) that you can toggle to test if to use a React component or a png (I would prefer the first idea but I'm interested also to the second one if it has some advantages).
USE_PNG = false -> use a React component
As you can see, the code is very simple and it works enough but it has some problems:
when the mouse is on the left side of the window, the left half of the cursor is cut off, but when is on the right then it's not and the horizontal bar appears
it seems not so fluid. Are there some tricks I can use to optimize the code?
USE_PNG = true -> use a png
I tried also to use a png (simpler maybe) but I can't see the cursor anymore
What's the problem?
I use a ref instead of a state and performance have improved.
The first problem remains: the horizontal and vertical scroll when the cursor is on the right or bottom side of the window
I don't think simply hiding the scrollbars is an optimal solution because the window size has changed and the user can scroll.
I think we need to find a cleaner solution.
Edit - Overflowing body (AKA third problem)
if you'll add this to your body tag it should solve it:
margin: 0;
height: 100%;
overflow: hidden
edit - Regarding your second problem
to prevent scroll bars to appear, you can use overflow-y: hidden; (to disable on the x-axis just change the overflow-y to overflow-x, overflow: hidden; for both)
BUT if you would like to enable scrolling but just hide the scrollbar, use the following code:
/* hide scrollbar but allow scrolling */
body {
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* for Firefox */
overflow-y: scroll;
}
body::-webkit-scrollbar {
display: none; /* for Chrome, Safari, and Opera */
}
here is a gif of a working example on my browser:
https://imgur.com/a/wOV7car
It doesn't get cut off for me on the right side (see image below). It sounds like the second problem happens because your cursor gets re-rendered every time you move it, and that's a ton of work for your site!
you should remove the style attributes from the Cursor component and adjust the code inside your event listener for a mousemove event.
it will look like this:
onMouseMove = {e => {
const cursor = document.querySelector('.cursor')
cursor.style.top = ׳${e.pageY}px׳
cursor.style.left = ׳${e.pageX}px׳
}}
Flickering:
#01:
Simply introduce a transition style on the Cursor component, eg transition: "all 50ms". It makes the position change much more smoother and cancels the flickering.
#02:
However as Guy mentioned above, handling the cursor's position in a state means a lot of re-rendering for your component which makes you app slower in the end.
I'd recommend making a ref for the cursor and update it directly from an event listener. With that change you can even remove the useMouse hook:
const App = () => {
const containerNodeRef = useRef(null);
const cursorRef = useRef(null)
const updateMouse = (e) => {
// you can directly access the mouse's position in `e`
// you don't even need the useMouse hook
cursorRef.current.style.top = `${e.y - SIZE / 2}px`
cursorRef.current.style.left = `${e.x - SIZE / 2}px`
}
useEffect(() => {
window.addEventListener('mousemove', updateMouse)
return () => {
window.removeEventListener('mousemove', updateMouse)
}
})
return (
<div
ref={containerNodeRef}
style={{
width: window.innerWidth,
height: window.innerHeight,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
cursor: 'none'
}}
>
<Cursor ref={cursorRef} />
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
</div>
);
}
const Cursor = forwardRef(({ size = SIZE }, ref) => (
<div
ref={ref}
style={{
position: "absolute",
width: size,
height: size,
backgroundColor: "black",
color: "white",
fontWeight: "bold",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
Hello
</div>
)
)
Cut off issue:
Since you're moving around an actual div as your cursor, when it reaches the border of your document, it stretches it to make the div fit -> this is why the document doesn't fit into your window anymore thus the scrollbars are rendered.
You can fix it via css:
body { overflow: hidden }
+1:
I'm not sure how your cursor has to look like in the end, but if it'd be an image, there is an other possible solution. Add this css rule to your container, which loads an image as a cursor and then the browser takes care about the rendering automatically:
<div
ref={containerNodeRef}
style={{
// ...
cursor: 'url("url"), auto'
}}
>
If you'd use this solution then the whole Cursor component and position calculation wouldn't be needed anymore. However the downside is, that it only works with images.
There are already 2 good answers, but I'll add this one too, because other answers are overcomplicating the solution
Flickering
it doesn't matter if you use ref or state, you should just extract
your cursor to separate Component, that way your App component will not rerender
Scrollbars
as other anwers mentioned, using body { overflow: hidden; } will solve this problem, but partially. Your cursor is trying to go beyond page size, hence page is showing scrollbars, adding limitation for cursor position will solve this: cursor.y = Math.max(0, Math.min(currentPositionY, page.width)) (pseudo-code) now cursor.y will not exceed 0 or page.width

requestAnimationFrame not pausing my background color transisition

I have a header with a background color of transparent which will change to black when the user scrolls. Since I am using the requestAnimationFrame to accomplish this, the transition should pause when the tab is not active to save resources. I tested this with a function counting to 300 which indeed did pause when the tab was not active and resumed to 300 when active. However, it seems that my header's background color transition does not pause when on a new tab.
I am using mozilla's example which says "This example optimizes the scroll event for requestAnimationFrame." So I think it should work well with my use case (which it does) I would just like some insight as to why my transition doesn't pause when on a different tab to save resources and be as optimal as possible. Thanks!
https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event
"use strict";
// Reference: http://www.html5rocks.com/en/tutorials/speed/animations/
const $ = selector => document.querySelector(selector);
let lastKnownScrollPosition = 0;
let ticking = false;
function doSomething(scrollPos) {
// Do something with the scroll position
if ( scrollPos > 0 ) {
$("header").style.backgroundColor = "black";
}
else {
$("header").style.backgroundColor = "transparent";
}
}
document.addEventListener('scroll', function(e) {
lastKnownScrollPosition = window.scrollY;
if (!ticking) {
window.requestAnimationFrame(function() {
doSomething(lastKnownScrollPosition);
ticking = false;
});
ticking = true;
}
});
Here is my JSFiddle https://jsfiddle.net/Boros/zjwyamvq/3/
A different approach would be to use an IntersectionObserver.
If you place a tiny (1px x 1px) element at the top of the page you can set an IntersectionObserver on it which will tell you as soon as it goes out of the viewport or comes back in.
That way you only have to run JS when there is a change from the user having scrolled away from the top - which will happen only once until they scroll back up to the top.
I don't know how much more efficient this is than using the traditional scroll and trying to throttle it, but MDN says:
sites no longer need to do anything on the main thread to watch for this kind of element intersection, and the browser is free to optimize the management of intersections as it sees fit.
The very fact that you aren't coming back to execute some JS every time the user does a scroll must help regardless of whether the browser does additional optimisation.
Here's a trivial example. It adds the 1px div, sets an IntersectionObserver on it and if the div goes out of the viewport that means the user has scrolled and if it is in the viewport the user has scrolled back to the top or the system is at the start position.
Note, if you want to make it slightly less sensitive you can set the check div to have a height of say 20px so if the user scrolls back to pretty near the top the color changes.
const $ = selector => document.querySelector(selector);
const check = (entries) => {
$("header").style.backgroundColor = (entries[0].isIntersecting) ? 'transparent' : 'black';
};
const observer = new IntersectionObserver(check);
observer.observe($("#checker"));
<div id="checker" style="width: 1px; height: 1pc; position: absolute; top: 0; left; 50%;"></div>
<header style="width: 100vw; height: 200vh; transition: 5s;">SCROLL DOWN TO SEE BACKGROUND COLOR CHANGE TO BLACK</header>

Keep scroll position when adding element to the top

I'm trying to create a calendar that can be infinitely scrolled to the left and to the right. It has to load new content dynamically as the user scrolls forward (easy) or backward (the problem is here).
When I add content to the end of the page, it works fine - the scrollbar adjusts to the new container.scrollWidth. But when I have to add content to the start, the whole calendar moves to the right in a huge 400px jump, because the container.scrollLeft property hasn't changed and there's now a new element at the start.
I'm trying to mitigate this by increasing the container.scrollLeft by 400px - width of the newly created element. This approach works, but only when scrolling with mousewheel (shift+mousewheel to scroll sideways) or mobile touchscreen.
If I use my mouse to drag the scrollbar, it kind of glitches out - my guess is it keeps trying to scroll to the old scrollLeft position and disregards that I increased it.
Could you please suggest a way to achieve this behavior for all ways of scrolling?
It would also be great if you could just point me to a site that uses this technique so I could investigate myself.
Here's my semi-working example:
function Container() {
const rectangleWidth = 400;
const container = React.useRef(null);
const [leftRectangles, setLeftRectangles] = React.useState([0]);
const [rightRectangles, setRightRectangles] = React.useState([1, 2, 3, 4]);
// When we just rendered a new rectangle in the left of our container,
// move the scroll position back
React.useEffect(() => {
container.current.scrollLeft += rectangleWidth;
}, [leftRectangles]);
const loadLeftRectangle = () => {
const newAddress = leftRectangles[0] - 1;
setLeftRectangles([newAddress, ...leftRectangles]);
};
const loadRightRectangle = () => {
const newAddress = rightRectangles[rightRectangles.length - 1] + 1;
setRightRectangles([...rightRectangles, newAddress]);
};
const handleScroll = (e) => {
// When there is only 100px of content left, load new content
const loadingOffset = 100;
if (e.target.scrollLeft < loadingOffset) {
loadLeftRectangle(e.target);
} else if (e.target.scrollLeft > e.target.scrollWidth - e.target.clientWidth - loadingOffset) {
loadRightRectangle(e.target);
}
};
return (
<div
className="container"
onScroll={handleScroll}
ref={container}
>
{leftRectangles.map((address) => (
<div className="rectangle" key={address}>
{address}
</div>
))}
{rightRectangles.map((address) => (
<div className="rectangle" key={address}>
{address}
</div>
))}
</div>
);
}
ReactDOM.render(<Container />, document.querySelector("#app"))
.container {
display: flex;
overflow-x: scroll;
}
.rectangle {
border: 1px solid #000;
box-sizing: border-box;
flex-shrink: 0;
height: 165px;
width: 400px;
font-size: 50px;
line-height: 165px;
text-align: center;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>
<div id="app"></div>
I think this is a case where one should not use native browser scroll areas. If you think about it, scrollbars have no meaning if they continue to get longer infinitely in both directions. Or their only meaning is "area which has been viewed". A scrollbar is not a good metaphor for an infinite area, and therefore a scrollbox is a poor way to do what you want to do here.
If you think of it another way, you are only showing a set of months which fit within a known screen width. The way I would approach this would be to absolute position each calendar inside a container and calculate their positions in a render loop based on where in a virtual view the user is shuttling. This also would allow you to remove calendars once they go too far off screen and create/buffer them offscreen for display before the user scrolls to their position. It would prevent you from having an arbitrarily wide object which would eventually slow down rendering.
One approach to this would be to simply number each month since 1970 and treat your view as having a fractional position viewX along this line. The month for x should have a position of viewX - x. When the user is dragging you move viewX inversely and reposition the buffered elements. When the user stops dragging, you take the last velocity and slow it until viewX - x is an integer.
This would avoid most cross-browser issues and offset problems. It only requires a render loop while the display is in motion.
Use the read-write scrollLeft to get the scroll position prior to dynamically adding content then use the .scrollLeft method to reposition the scroll position back to where you want it. You might need fire off a dialog displaying an indeterminant progress indicator ( or simply display text "working..." ) which displays during the process to prevent janking.
The trick for cross browser functionality is that dialog element which is well known to be challenging regarding consistency across device types so I would recommend using a UI library for your dialog or build your own but keep it super simple. That progress indicator will be the fix for screen jank.
Another feature to consider regarding janking would be CSS transitions where the addition of content (e.g. a block element) would gradually fade/animate in to the viewport.

How can I use React hooks to respond to a prop change before render

I am trying to use a functional component and React hooks to implement a simplified auto-scroller that will automatically scroll a container to the bottom when the child content overflows. But the auto-scrolling should only happen when the scrollbar is already enar the bottom (e.g., if the user has scrolled up to look at output, the scroll position should not change when new content comes in).
I know how to implement the auto-scrolling behavior by using refs and performing computation on clientHeight, scrollTop, and scrollHeight.
The problem I have is that I need to compute a shouldAutoScroll() check BEFORE the component is re-rendered.
My flow needs to look like this:
<container>
{props.children}
</container>
When props.children changes:
1. Check if the scrollbar is near the bottom and store the result
2. Update container to reflect the new props.children
3. If the check from step 1 is true, scroll to the bottom
I can't seem to find a way to to this using useEffect and/or useLayoutEffec. When using these what happens is:
1. Scroll position is at bottom
2. props.children updates with new items
3. <container> is rerendered, pushing the scrollbar up
4. The checkScrollBarBottom() method is called and returns false
5. The scrollbar is not auto-scrolled
I need to keep the component generic, so that it can auto scroll regardless of what type of component or element props.children is. In some cases the change to props.chldren might be a single line. In others, it might be 20 lines, or it might be an image.
If I were using an older-style class component, I could do the computation in componentWillReceiveProps(). How do I replicate this with hooks?
I discovered one solution that works, but seems a little messy.
The solution is to calculate and update shouldAutoScroll() during the container's onScroll() event. This seems messy because I am capturing a ton of useless intermediate scroll information, when all I care about is the scroll position at the moment the update starts (but before the component is re-rendered).
Full code:
import React, { useRef, useEffect, useLayoutEffect } from 'react';
import styles from './AutoScroller.module.scss';
export function AutoScroller({children, className=''}) {
const classNames = `${styles.default} ${className}`;
const containerRef = useRef(null);
const shouldAutoScroll = useRef(false);
function updateShouldAutoScroll(element, tolerance) {
const {scrollHeight, scrollTop, clientHeight} = element;
const result = scrollHeight - scrollTop <= clientHeight + tolerance;
shouldAutoScroll.current = result;
}
function onContainerScroll(e) {
updateShouldAutoScroll(e.target, 25)
}
useEffect(function autoScroll() {
if (shouldAutoScroll.current) {
const element = containerRef.current;
element.scrollTop = element.scrollHeight;
}
});
return (
<div className={classNames} ref={containerRef} onScroll={onContainerScroll}>
{children}
</div>
)
}

React: Can't get correct width of element after render

I'm trying to setup a Marquee in React if a piece of text is greater than its container but I can't get the correct width of the container, even after the component has rendered.
I read in another answer React “after render” code? that you have to use requestAnimationFrame which I'm trying and it's still not working.
If I log the width of the container it shows a width of 147px which is set using min-width in the stylesheet but the correct width should be 320px which is set using a media query when the screens min-width is 600px.
This is a child component, the parent is rendered inside an iFrame if it makes any difference and the iFrame's width is well over 600px.
The JS:
module.exports = React.createClass({
componentDidUpdate: function () {
// Setup marquee
this.initMarquee();
},
render: function () {
// Setup marquee
this.initMarquee();
var artistName = this.props.artist.artistName;
var trackName = this.props.track.trackName;
return (
<div className="MV-player-trackData no-select" ref="mvpTrackData">
<div className="MV-player-trackData-marquee-wrap" ref="mvpMarqueeWrap">
<div className="MV-player-trackData-marquee" ref="mvpMarquee">
<a className="MV-player-trackData-link no-select" href={this.props.storeUrl} target="_blank">
<span id="mvArtistName">{artistName}</span> – <span id="mvTrackName">{trackName}</span>
</a>
</div>
</div>
</div>
)
},
initMarquee: function () {
if ( typeof requestAnimationFrame !== 'undefined' ) {
//store a this ref, and
var self = this;
//wait for a paint to setup marquee
window.requestAnimationFrame(function() {
self.marquee();
});
}
else {
// Suport older browsers
window.setTimeout(this.marquee, 2000);
}
},
marquee: function () {
var marquee = React.findDOMNode(this.refs.mvpMarquee);
var marqueeWrap = React.findDOMNode(this.refs.mvpMarqueeWrap);
// If the marquee is greater than its container then animate it
if ( marquee.clientWidth > marqueeWrap.clientWidth ) {
marquee.className += ' is-animated';
}
else {
marquee.className = marquee.className.replace('is-animated', '');
}
}
});
The CSS:
.MV-player-trackData-marquee-wrap {
height: 20px;
line-height: 20px;
min-width: 147px;
overflow: hidden;
position: relative;
width: 100%;
#media only screen and (min-width : 600px) {
min-width: 320px;
}
}
I've tried a number of different solutions including laidout and react-component-width-mixin but neither of them work. I tried react component width mixin because in another part of my app I'm trying to get the value of window.innerWidth but that also returns 0 after rendering, unless I set a timeout for around 2 seconds, unfortunately though sometimes 2 seconds isn't long enough due to data loading and other callbacks so this can brake easily.
Any help is really appreciated. Thanks.
Update:
One of the answers correctly pointed out i should be calling this.initMarquee(); inside componentDidMount which I was doing, unfortunately I pasted the wrong code from when I was testing to see if it made a difference calling it inside render. The correct code looks like this:
componentDidMount: function () {
// Setup marquee
this.initMarquee();
},
Unfortunately this doesn't work either, I still receive the incorrect width for marqueeWrap.
Update: 24/06/2015
Just to clarify, this is the marquee effect I'm trying to achieve, only when the text is bigger than its container as it's pointless scrolling it when it is not bigger.
Also here is a link to a Github Issue from the React team speaking about why React renders before the browser paints. - So as that is the case, I want to know how do I reliably get the width of the element in question.
One possible problem that can occur is that your CSS has not loaded yet when componentDidMount fires. This can happen with webpack and including the css in your bundle that also contains your js even if you have included the css before your component in the project.
There are several issues, as you've pointed out, in dealing with the virtual DOM. You definitely don't want to be attempting to use jQuery to manipulate DOM nodes and React is likely to scream at your for attempting to.
There's a library called React-Context which would do exactly what you're asking. You would expose its api to your wrapper component and then be able to listen to events on components within the virtual dom.
This library is a little dusty, however it should work with the code sample you shared.
You should not call this.initMarquee(); in the render() method.
In general, you should not work with the DOM at all in the render() method.
Try to call this.initMarquee(); in the componentDidMount method.
(and I really don't understand the usage of requestAnimationFrame in this case)

Categories