rakete
rakete

Reputation: 3051

Parallax-Animation with transform3d, throttle, requestAnimationFrame, will-change still laggy on Mobile and Desktop, Chrome and Safari

I am currently try to develop a small lightweight vanilla-js parallax-library which gives the possibility to parallax elements (images, videos, sliders, ...) horizontally and vertically in both directions without any background-position things.

It works very well so far and does a great job expecting one important detail: Sometimes it's still laggy. I've tested it on Mac OS and iPhone, in Safari and Chromebrowser. For all the same.

I have tried to use the common practices like throttling, requestAnimationFrame and CSS's will-change but without success.

I am not sure, if it's possible to see the lags here in this video.

But I made an example where you could test it: https://codepen.io/flexplosion/pen/RwgQXxo?editors=1111

Someone has an idea, how I can improve the scrolling-performance for the parallax?

var parallaxes = document.getElementsByClassName('parallax');

const windowInnerHeight = window.innerHeight;

function throttle (callback, limit) {
    var wait = false;                 // Initially, we're not waiting
    return function () {              // We return a throttled function
        if (!wait) {                  // If we're not waiting
            callback.call();          // Execute users function
            wait = true;              // Prevent future invocations
            setTimeout(function () {  // After a period of time
                wait = false;         // And allow future invocations
            }, limit);
        }
    }
 }


Array.from(parallaxes).forEach((parallax) => {
    const movement = parseFloat(parallax.dataset.movement),
        direction = parallax.dataset.direction,
        element = parallax.children[0];
    
    if( direction == 'horizontal' ) {
        // Prepare for orzintal
        element.style.height = '100%';
        element.style.width = (100 + movement) + "%";

    } else {
        // Otherwise prepare for vertical
        element.style.height = (100 + movement) + "%";
        element.style.width = '100%';
    }
});

const handleScroll = () => {
    var parallaxes = document.getElementsByClassName('parallax');
    Array.from(parallaxes).forEach((parallax) => {
        const movement = parseFloat(parallax.dataset.movement),
            direction = parallax.dataset.direction,
            reverse = parallax.dataset.reverse === 'true',
            element = parallax.children[0];

        var containerReact = parallax.getBoundingClientRect();
        var scrolledInContainer = 0 - (containerReact.top -  windowInnerHeight);
        var scrollArea = windowInnerHeight + containerReact.height;
        var progress = scrolledInContainer / scrollArea;
        var scrolledInContainer = window.pageYOffset - (containerReact.top - windowInnerHeight);
        
        if(progress > 0 && progress < 1) {
            
            requestAnimationFrame(() => {
                var position = reverse
                    ? movement * progress
                    : movement - movement * progress;
                
                element.style[ direction == 'horizontal' ? 'left' : 'top'] = "-" + position + "%";
            });
        }
    });
}

// Initialize
handleScroll();

// Hook into event
window.addEventListener('scroll', throttle(handleScroll, 10) );

UPDATE 1:

I tried to switch the script to use translate3d instead of top/left properties. The animation on Desktop (Chrome) is now ultra-smooth. Exactly how it should be. In Safari and all mobile Browsers it didn't really help...

https://codepen.io/flexplosion/pen/BaZrKYv

In Safari (same on mobile Chrome and Safari): https://jmp.sh/n4JG8J5 In Chrome: https://jmp.sh/EMM7y1Z

Upvotes: 2

Views: 839

Answers (1)

Gust van de Wal
Gust van de Wal

Reputation: 5291

Right, so,
Your parallax implementation is very basic and should definitely be able to run every 10ms, even on the crappiest mobile device you can find. Why it doesn't has to do with a few flaws, two of which are critical.

1. Your throttle function doesn't do anything

Animations can get costly when you have to recalculate stuff every single frame. Naturally, you wanted to help the processor out by limiting the animation to every so many ticks. However, inside your throttle function, you merely set a wait variable and don't do anything with it. The throttle function ends up hurting the implementation — for every scroll event (which are a lot, possibly more than 1 per frame, go figure), you trigger the recalculations and then create a timeout that alters the wait variable without any purpose.

For a throttle function to work, you will have to keep track of a state that is known to all of its calls. This change requires the most rethinking of everything I mention here and is shown below.

2. The eventlistener should be passive

EDIT:
See the first comment below this answer. I had remembered optimizing scrolling behavior by adding this option, but those optimizations were done by adding the flag to other eventListeners that could prevent the browser from going on with the rest of the events.

ORIGINAL:
eventListeners can take an options argument. Among other things, it can contain a passive flag that, if set to true, tells the browser that you won't trigger event.preventDefault() inside the callback. This helps the browser, because it doesn't have to run the entire function to figure out if it should even go on with the event. Where this optimization comes from isn't entirely clear to me, but I guess it has to do with the way events are propagated.

3. You re-query the parallax elements on every iteration

A simple optimization that doesn't need much of an explanation, I think. You query the parallax elements at the start of the script and don't need to re-query them every time you recalculate the parallax state. This optimization is literally a matter of removing 1 line from the code.

Here is a Fiddle with all of the changes and here is the scrollHandler part because I can't post without showing code:

const scrollHandler = (() => {
  const ret = { active: false }
  let timeout

  ret.activate = function activate() {
    if (ret.active) clearTimeout(timeout)
    else {
      ret.active = true
      requestAnimationFrame(runParallax)
    }
    timeout = setTimeout(() => ret.active = false, 100)
  }

  return ret
})()

The throttle functionality is part of its returned object. It contains a flag active and a method activate. If you activate it, a timeout will deactivate it after 20ms. This means that

  • The animation runs as soon as there's scrolling by doing scrollHandler.activate().
  • Then runs once every frame by doing if (scrollHandler.active) requestAnimationFrame(runParallax)
  • Then stops 20ms after the user stopped scrolling.

I also changed some minor things, like added template literals and did some destructuring. One change you might find interesting is that you don't need to cast HTMLCollections to an array before being able to forEach them. Just call Array's forEach directly with the collection as its context. As long as you pass it an iterable, it will work.

Here are some other, less important, things of notice:

  • I added a pseudo element to indicate where the .visual parent is located and how the inner element moves. This was, you can clearly see that the parallax elements move precisely from edge to edge.
  • You also checked > 0 and < 1, but these should probably be >= 0 and <= 1 or even better > -0.1 and < 1.1, so that they are moved in the right place before you enter the screen. This prevents a sudden flicker/movement when the element comes into view.
  • The part that said .call() was unnecessary. If you aren't planning on giving your function a new context, you can just use ().

Lastly, as discussed, the direction and reverse attributes are now replaced with a single angle attribute that maps to two values for the X and Y directions. This way, the translate3d can be formatted like this: `translate3d(${position * multipliers[0]}px, ${position * multipliers[1]}px, 0)`. It might seem verbose, but simply always running a calculation with a chance of the outcome constancly being useless (0, that is) is often faster than doing even a single if statement.

Sorry for the incredibly long answer 😛

Upvotes: 3

Related Questions