Reputation: 3051
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) );
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
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.
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.
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.
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
scrollHandler.activate()
.if (scrollHandler.active) requestAnimationFrame(runParallax)
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 HTMLCollection
s 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:
.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.> 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..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