3

I am trying to animate elements while scrolling based on the elements progress through the scene. I am running into the issue of the browser returning intermittent scroll positions making it difficult to be exact when starting and stopping element animation.

Minimal example:

Fiddle Fiddle Demo

const tweens = document.querySelectorAll('.tween');
const trigger = document.querySelector('.start');
const triggerRect = trigger.getBoundingClientRect();

let yScroll = window.scrollY;
let yCurrent = yScroll;
let AF = null;

const start = triggerRect.top - yScroll - 200;
const end = start + 400;

const updateScroll = () => {
 yScroll = window.scrollY;
  startAnimation();
}

const startAnimation = () => {
  if(!AF) AF = requestAnimationFrame(animate)
}

const cancelAnimation = () => {
  yCurrent = yScroll;
  cancelAnimationFrame(AF);
  AF = null;
}

const animate = () => {
  if(yCurrent === yScroll) cancelAnimation();
  else {
   updateElements();
    yCurrent = yScroll;
    AF = requestAnimationFrame(animate);
  }
}

const updateElements = () => {
  const pos = yCurrent - start;
  if(inScene()){
    tweens[0].style.transform = `translateX(${pos}px)`;
    tweens[1].style.transform = `translateY(${pos}px)`;
  }
}

const inScene = () => {
  return start <= yCurrent && end >= yCurrent ? true : false;
};


window.addEventListener('scroll', updateScroll);
.wrapper{
  position:relative;
  margin: 100vh 20px;
  background: rgb(240,240,240);
  height: 900px;
  border: 1px solid red;
}

.line{
  position:absolute;
  left: 0;
  width: 100%;
  height: 1px;
  background: red;
}

.start{
  top: 200px;
}

.end{
  bottom: 200px;
}

.tween{
  position:absolute;
  top:200px;
  width: 100px;
  height: 100px;
  background: blue;
}

.left{
  left: 0;
}

.right{
  right: 0;
}
<h1>Scroll Down</h1>
<div class="wrapper">
  <div class="line start trigger"></div>
  <div class="line end"></div>
  <div class="tween left"></div>
  <div class="tween right"></div>
</div>

As you can see the elements are supposed to stop on the lines but when you scroll they never really do. And the faster you scroll the worse it becomes.

So is there a technique to either return the correct scroll position or somehow debounce the elements so then can reach the full range when scrolling instead of constantly coming up short of their intended position?

I know this is possible because scrolling libraries like ScrollMagic do a pretty good job of handling this but I don't really want to deconstruct then entire ScrollMagic framework to find out how they achieve it. I would use the ScrollMagic framework itself but I am trying to have a momentum style scrolling container wrapping the page and translating it on scroll along with elements inside this container and using ScrollMagic it is pretty buggy. So I figured I would post the question here and see if anyone had any experience or insight on the situation.

Any guidance would be appreciated as I have been mulling over this for a while.

Steve K
  • 8,505
  • 2
  • 20
  • 35
  • I once came across Intersection observer but didn't find the time to implement it, I think it can help somehow but you have to do it yourself, or find some helper library, here's a reference to start with if you didn't hear about them https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API – Louay Al-osh Feb 15 '20 at 16:10
  • @LouayAlosh my problem with the intersection observer is that I want to create a progress based animation. I don't know if its possible with the intersection observer to get the progress the element has made through the scene. So if I want to set the element to move 200px from start to finish. With intersection observer it only checks if the element is intersecting with either the top or bottom. There is no way to check the progress inbetween the intersection points to my knowledge. The callback only fires when the element is intersecting. – Steve K Feb 15 '20 at 17:21
  • you are right, sorry I thought it could help. – Louay Al-osh Feb 16 '20 at 08:34
  • You may want to [hook into the `scroll` event](https://stackoverflow.com/questions/12522807/scroll-event-listener-javascript), instead, as [passive event listener](https://stackoverflow.com/questions/37721782/what-are-passive-event-listeners) (so that the browser can send you high-fidelity event updates, in a way that won't choppily scroll the content) – Mike 'Pomax' Kamermans Feb 16 '20 at 19:04

3 Answers3

2

Let's assume that browser is what it is and not all pixels travelled by will result in an event. You end up with elements away from the end because you check for the scroll position if it's "in scene". When it's 5x before - it's fine. Next thing it is 5px after - you ignore it. What you can do is to animate objects to their final positions whenever you scroll outside the scene instead of ignoring it.

So just to start it, I would change this:

const updateElements = () => {
  let pos = yCurrent - start;
  pos = Math.min(400, Math.max(0, pos));
  tweens[0].style.transform = `translateX(${pos}px)`;
  tweens[1].style.transform = `translateY(${pos}px)`;
}

It can be optimised more so it will start animation only once when it's outside the "scene". Let me know if you have problem with that as well.

UPDATE:

Here's the version that animates only if needed:

const updateElements = () => {
  let pos = yCurrent - start;
  pos = Math.min(400, Math.max(0, pos));
  const styles = [...tweens].map(tween => window.getComputedStyle(tween, null));
  const transforms = styles.map(style => new WebKitCSSMatrix(style.getPropertyValue('transform')));
  if (transforms[0].m41 !== pos) {
    tweens[0].style.transform = `translateX(${pos}px)`;
  }

  if (transforms[1].m42 !== pos) {
    tweens[1].style.transform = `translateY(${pos}px)`;
  }
}

Reading translate is hell, so if we care only about Webkit browsers we can use WebKitCSSMatrix, but Firefox doesn't have anything like that so you can use something external

And I agree with artanik it does work better without requestAnimationFrame

Buszmen
  • 1,978
  • 18
  • 25
2

https://jsfiddle.net/b2pqndvh/

First, you need to set “boundaries” like Math.min(END, Math.max(0, scrollY - start)); if there is not enough time to update a position by scrolling. Second, I would recommend removing requestAnimationFrame, because this method tells the browser that you wish to perform an update after the next repaint. That's why it looks more clunky with it. The performance tests (in chrome and firefox) shows that performance doesn't suffer without requestAnimationFrame in this example, but it may suffer from the more complex layout.

const tweens = document.querySelectorAll('.tween');
const trigger = document.querySelector('.start');
const triggerRect = trigger.getBoundingClientRect();

const START = 0;
const MIDDLE = 200;
const END = 400;

const start = triggerRect.top - window.scrollY - MIDDLE;
const end = start + END;

const inSceneStart = scrollY => start <= scrollY;
const inSceneEnd = scrollY => end >= scrollY;

const updateTransform = value => {
  tweens[0].style.transform = `translateX(${value}px)`;
  tweens[1].style.transform = `translateY(${value}px)`;
}

const updateScroll = () => {
  const scrollY = window.scrollY;
  const pos = Math.min(END, Math.max(0, scrollY - start));
  updateTransform(pos);
}

window.addEventListener('scroll', updateScroll);
.wrapper{
  position:relative;
  margin: 100vh 20px;
  background: rgb(240,240,240);
  height: 900px;
}

.line{
  position:absolute;
  left: 0;
  width: 100%;
  height: 1px;
  background: red;
}

.start{
  top: 200px;
}

.end{
  bottom: 200px;
}

.tween{
  position:absolute;
  top:200px;
  width: 100px;
  height: 100px;
  background: blue;
}

.left{
  left: 0;
}

.right{
  right: 0;
}
<h1>Scroll Down</h1>
<div class="wrapper">
  <div class="line start trigger"></div>
  <div class="line end"></div>
  <div class="tween left"></div>
  <div class="tween right"></div>
</div>
artanik
  • 2,599
  • 15
  • 24
  • 2
    `requestAnimationFrame`'s callback is called before repaint, not after. Fact that it is consistently called before each paint just once and **only** before paint makes it ideal for animations, unlike setTimeout or other methods. In previous Safari and Edge versions however, `requestAnimationFrame`'s cb was incorrectly called after the paint, which is in contradiction with standards https://bugs.webkit.org/show_bug.cgi?id=177484. – Dan Macak Feb 17 '20 at 05:30
1

The problem is down to inScene() becoming false and you aren't setting the final animation position to the minimum / maximum position. It was also jerking because yScroll value doesn't update until animation starts and that is too late.

const updateElements = () => {
  yScroll = window.scrollY;
  var pos = yScroll - start;

  if (pos < 0) {
    pos = 0;
  }
  else if (pos > end - start) {
    pos = end - start;
  }


  //if (inScene()){
    tweens[0].style.transform = `translateX(${pos}px)`;
    tweens[1].style.transform = `translateY(${pos}px)`;
  //}
}

All this works now see jsfiddle: https://jsfiddle.net/nLh748y3/1/

I've commented out the inScene() for now. If you want this back in then work backward from this working version until you get what you want to achieve.

John
  • 3,716
  • 2
  • 19
  • 21