Search code examples
javascriptperformance

How to move multiple elements with requestAnimationFrame() without having to create a new function for each element


To preface: this is something that I plan to reuse & add to for various web pages. Due to that, I don't want to use any JQuery for this, partially because many pages I use this on won't need anything else (making my use of JQuery sort of like filling a fifty gallon bucket with flour just to use a teaspoon of that flour), & partially because this is designed for mobile browsers with sparse resources.

JQuery's lack of efficiency may not seem like such a big deal, especially since the size of its consolidated library is under 31MB, but according to this page, something as fundamental to JavaScript as its document.getElementById('foo'); call is nearly 16x faster in Chrome than JQueries equivalent: $('#foo');. Also, despite $('#foo'); being faster in Firefox and Safari than it is in Chrome, document.getElementById('foo'); is still 10x faster in Safari, & an astounding 500x faster in Firefox. Though, if you have a source with contradictary info, feel free to post it in the comments & upvote the comments of any who already did.

I have a set of nav links that I'm trying to move up, at a speed, and to a postion, relative to how far they are from the first nav link. To help give you an idea of what I mean, here's an animation of my nav bar (done with JQuery slideUp & slideDown functions) so you can see how it works (& stutters): JQuery Animated Nav Bar

I'll also include the HTML of the navigation bar if that helps:

<nav>
    <ul id='nav'>
        <li><a href="index.php"><icon><img src="images/home-icon.png"></icon>Home</a></li>
        <li><a href="skillsets.php"><icon><img src="images/skills-icon.png"></icon>Skillsets</a></li>
        <li><a href="gallery.php"><icon><img src="images/gallery-icon.png"></icon>Gallery</a></li>
        <li><a href="about.php"><icon><img src="images/about-icon.png"></icon>About</a></li>
        <li><a href="contact.php" style='border-right:1px solid black;'><icon><img src="images/contact-icon.png"></icon>Contact</a></li>
    </ul>
</nav>

My JavaScript file has a function called prepareList() that is just meant to set up the list & collapse it (by moving all but the first nav link up). In order to prevent prepareList(), & any of the variables it uses, from being initialized before the HTML loads, they're all contained within this function:

document.addEventListener("DOMContentLoaded", function(event) {/*all the code*/});

I will now include my prepareList() JavaScript function, & the initializations & values of all used variables. I've commented this well enough that you may be able to understand it just from comments in the code, but I will clarify what I want to do afterwards:

//I will consolidate the first three objects into one href variable when I get the program working
var ul = document.getElementsByTagName("nav")[0].children[0],
    li = ul.children,
    href = new Array(li.length),
    is404 = true,
    isDown = false;

var iterator;
for(iterator = 0; iterator < li.length; iterator++)
    href[iterator] = li[iterator].children[0];

prepareList();

function prepareList() {
    //add a plus to the first nav link making it more obvious that it's the button to open the nav
    href[0].innerHTML = href[0].innerHTML + "<span> +</span>";
    //hide the remaining nav links with animations that move them up, at a speed,
    //and to a postion, relative to how far they are from the first nav link
    var lastTick = +new Date();
    /*This function will be called for each nav link so they move at the same time
    navElement is one of the li elements, number is the number (index) of the li element,
    and number is negative if the list is collapsing (moving up)*/
    var tick = function(navElement, number) {
        var navElementStyleTop = parseFloat(navElement.style.top) + number * navElement.clientHeight * (new Date() - lastTick) / 1000;
        //navElement.style.top = (navElementStyleTop + number * navElement.clientHeight * (new Date() - lastTick) / 1000) + "px"; //milliseconds
        navElement.style.top = navElementStyleTop + "px";
        lastTick = +new Date();
        
        //requestAnimationFrame(tick) will only loop tick if the animation can be performed: http://www.javascriptkit.com/javatutors/requestanimationframe.shtml
        //setTimeout(tick, 1000/15) will force the animation to be performed if it's been more than 1/15th of a second in order to maintain persistance of motion
        if(number < 0){
            if (navElementStyleTop > number * navElement.clientHeight)
                requestAnimationFrame(tick(navElement, number)) || setTimeout(tick, 1000/15);
            else //To prevent rounding errors from messing up the final position
                navElement.style.top = (number * navElement.clientHeight) + "px";
        }
        else{
            if (navElementStyleTop < 0)
                requestAnimationFrame(tick(navElement, number)) || setTimeout(tick, 1000/15);
            else //To prevent rounding errors from messing up the final position
                navElement.style.top = "0px";   
        }
    };
    /*The for loop is to make this work no matter the number of links in the nav bar
    (as the function executed before this may add a link to the nav bar)*/
    for(iterator = 1; iterator < li.length; iterator++){
        //necessary for top to work
        href[iterator].style.position = "relative";
        //give top a value so this doesn't rely on the browser defaulting it to 0
        href[iterator].style.top = "0px";
        tick(href[iterator], (-1) * iterator);
    }
}

If you didn't see the issue I can't blame you, it's with requestAnimationFrame(tick(navElement, number)). In order for this not to complain about recursion, it would have to be executed this way: requestAnimationFrame(tick). According to this StackOverFlow answer, requestAnimationFrame is asynchronous, meaning "the main thread does not wait for the call to requestAnimFrame to return, before" continuing with the function.

The issue is that I have to pass at least number into the function in order for the function to know which element it is using. The only way I can see to make the function not require any parameters passed in would be for it to be initialized in the for loop every time. I really don't like that solution as I would like to be able to reuse this function so as not to waste cycles (and probably lots of memory) recreating it for each nav link after the first. So, what I'm wondering is if there's a way to pass number into all of the tick functions while maintaining requestAnimationFrame's asynchronous nature.

If you have a better way that's far more efficient than JQuery's slideUp & slideDown functions, I'm all ears. Perhaps someone has a far more efficient JavaScript version somewhere online, but I haven't found it.

Feel free to let me know in the comments if you need more information, or if I have any typos.


Solution

  • It didn't take me too long to solve this by turning the variables I was trying to pass from tick() to itself every time it called itself, into external variables. If my answer was as simple as that, though, I would not make it. However, there were a few optimizations that made a huge difference for my use on mobile browsers. The first & most minor optimization I made was to remove superfluous elements from the nav:

    <nav>
        <a href="index.php"><icon><img src="images/home-icon.png"></icon><span>Home</span></a>
        <a href="skillsets.php"><icon><img src="images/skills-icon.png"></icon><span>Skillsets</span></a>
        <a href="gallery.php"><icon><img src="images/gallery-icon.png"></icon><span>Gallery</span></a>
        <a href="about.php"><icon><img src="images/about-icon.png"></icon><span>About</span></a>
        <a href="contact.php"><icon><img src="images/contact-icon.png"></icon><span>Contact</span></a>
    </nav>
    

    The style attribute that applied to the last link in the nav bar was moved to the external CSS file by applying it to the nav a:last-child class. It should be noted that this stopped me from anchoring the image & text in each link to the bottom of the link. This was necessary because it made it appear as though each nav link was actually sliding up as opposed to being cut off from the bottom. To fix this, I actually moved each of the nav links up. This required me to set the nav a class in my CSS file to have the attribute position:absolute. I found this to be a lot faster than changing the heights of all but the first nav links when the image & text in each of them was anchored to the bottom of them. I believe this is because I had to use a dynamic FLEX CSS property to do that anchoring.

    Giving each of the nav links an absolute position in this way causes them all to have the same position & for each one to show up on top of the previous one. This is the opposite of what I want to happen, so in the JavaScript I move them down & give each of them a z-index higher than the other, leaving the last nav link without such an attribute so as to have a z-index of 0:

    var href = document.getElementsByTagName("nav")[0].children;
    //Place the first link on top of the second link, on top of the third link, etcetera
    for(var iterator = 0, hrefLengthMinusOne = href.length - 1; iterator < hrefLengthMinusOne; iterator++)
        href[iterator].style.zIndex = hrefLengthMinusOne - iterator;
    //Move the links down instantaneously (to be slid up later to help the user understand the nav bar is dynamic)
    for(var iterator = 1, hrefLength = href.length, linkHeight = href[0].clientHeight; iterator < hrefLength; iterator++)
        href[iterator].style.top = iterator * linkHeight;
    

    It should be noted that if one needs all of these links to show up on top of something with a z-index higher than 0, all one needs to do is to make the z-index of the parent nav class in a referenced CSS file higher than the z-index of that element.

    My changes around the tick function were as follows:

    /*Used to determine the height each link needs to get up to when moving down the menu
    (created because referencing navHeight is a LOT faster than referencing
    href[0].clientHeight 1000 times every time the menu needs to be slid down)*/
    var navHeight = href[0].clientHeight;
    
    /*Put the first links span text into a variable so that we don't have to slice() off the last character every time to add on a '+' or '-' sign.
    It should be noted that navSpanText is not an HTML element (so changing it will not change the HTML span text).
    Because of that, & because href[0].children[1].textContent calls more than two child elements. navSpan is an HTML element,
    referencing navSpanText is over 60^2 times faster than href[0].children[1].textContent.
    but because it's only one call, it's still orders of magnitude faster than href[0].children[1].textContent*/
    var navSpan = href[0].children[1];
    var navSpanText = navSpan.textContent + " ";
    var navSpanTextLength = navSpanText.length - 1;
    //add a plus to the first nav link making it more obvious that it's the button to open the nav
    navSpan.textContent = navSpanText + "+";
    /*Create a function (& appropriate variables) to hide the remaining nav links with animations that move them up,
    at a speed, and to a postion, relative to how far they are from the first nav link.
    These functions are created locally as closures so as not to make global functions that recreate these closures every time.*/
    var currentTick;
    var lastTick = Date.now(); //System time before tick changed the DOM
    var hrefFactor = -navHeight; //navHeight when the menu needs to be slid down & -navHeight when the menu needs to be slid up
    var hrefPosition = navHeight; //Used to hold the new position of the first link so as not to have to grab it from the DOM every time
    var iterator; //Used in for loops to iterate over the nav links
    var tick = function() {
        //console.log("lastTick: " + lastTick + ", hrefPosition: " + hrefPosition + ", hrefFactor: " + hrefFactor);
        currentTick = Date.now();
        hrefPosition += (currentTick - lastTick) * hrefFactor / 400;
        lastTick = currentTick;
        /* The for loop is to make this work no matter the number of links in the nav bar
        (as the function executed before this may add a link to the nav bar)*/
        for(iterator = href.length - 1; iterator > 0; iterator--)
            href[iterator].style.top = hrefPosition * iterator;
        
        //requestAnimationFrame(tickUp) will only loop tickUp if the animation can be performed: http://www.javascriptkit.com/javatutors/requestanimationframe.shtml
        if ((hrefFactor > 0 && hrefPosition < navHeight) || (hrefFactor < 0 && hrefPosition > 0))
            requestAnimationFrame(tick);
        else if (hrefFactor > 0) //To prevent rounding errors from messing up the final positions
            for(iterator = href.length - 1; iterator > 0; iterator--)
                href[iterator].style.top = navHeight * iterator;
        else //To prevent rounding errors from messing up the final postions
            for(iterator = href.length - 1; iterator > 0; iterator--)
                href[iterator].style.removeProperty("top");
    };
    //Actually slide the nav menu up
    tick();
    
    //Add an event listener to make the menu slide up & down by tapping on the first link
    href[0].addEventListener('click', function(event){
        event.preventDefault();
        if(hrefFactor < 0){
            lastTick = Date.now();
            hrefPosition = 0;
            hrefFactor = navHeight;
            tick();
            //replace the '+' with a '-' making it more obvious that it's now the button to close the nav
            navSpan.textContent = navSpanText + "-";
        }else{
            lastTick = Date.now();
            hrefPosition = navHeight;
            hrefFactor = -navHeight;
            tick();
            //replace the '-' with a '+' making it more obvious that it's now the button to open the nav
            navSpan.textContent = navSpanText + "+";
        }
    });
    

    Now this is a lot, & the comments should explain most of it, but two of the important things are that I'm no longer using new Date() or setTimeout(tick, 1000/15) as Date.now() is twice as fast as new Date() (which really matters when tick calls it nearly 1000 times) and while setTimeout(tick, 1000/15) may stop stuttering on some computers with very slow processors it can also interfere with other parts of the page, which matters to me as this isn't the only piece of JavaScript I have.

    I also use whether hrefFactor is greater than or less than zero to see if the final hrefPosition should be >=navHeight or <=0. This takes a few more cycles than using two different functions for opening vs closing the menu, but I figured the savings in memory were worth it. It should be noted that my changing the position of each of the nav links individually slows down each iteration of tick much more than that, or even than using new Date() instead of Date.now() would, but it's impossible to assign a different position to each nav link simultaneously.