Ilya Peterov
Ilya Peterov

Reputation: 2065

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>

Upvotes: 11

Views: 7592

Answers (2)

Ronnie Smith
Ronnie Smith

Reputation: 18555

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.

Upvotes: 4

joshstrike
joshstrike

Reputation: 1823

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.

Upvotes: 5

Related Questions