Calculate transform scale [ transform: scale(x, y) ] as the function of elapsed time

Issue

I have a container that is expanded and collapsed on click of chevron icon. The code to collapse/expand the container is in the function transformAnimation. The code of transformAnimation is similar to the code on MDN web docs for requestAnimationFrame. The code to animate (scale) the container has been developed on the guidelines of this article on Building performant expand & collapse animations on Chrome Developers website.

I am not able to figure out how to calculate yScale value (which is nothing but the css function scaleY() for collapse/expand animation) as a function of the time elapsed since the start of the animation.

To elaborate what I mean, let’s assume that the container is in expanded state. In this state the yScale value of the container is 6. Now when user clicks on the toggle button, in the transformAnimation function for each animation frame, i.e, execution of the requestAnimationFrame callback step function, the value of yScale should decrease from 6 (the expanded state) to 1 (the collapsed state) in the exact duration that I want the animation to run for. So, basically I want to achieve something similar to css property transition-duration: 2s, where I can control the duration.

In the present state, the code to calculate yScale is not working as expected.

const dragExpandableContainer = document.querySelector('.drag-expandable-container');
const dragExpandableContents = document.querySelector('.drag-expandable__contents');
const resizeableControlEl = document.querySelector('.drag-expandable__resize-control');
const content = document.querySelector(`.content`);
const toggleEl = document.querySelector(`.toggle`);

const collapsedHeight = calculateCollapsedHeight();

/* This height is used as the basis for calculating all the scales for the component.
 * It acts as proxy for collapsed state.
 */
dragExpandableContainer.style.height = `${collapsedHeight}px`;

// Apply iniial transform to expand
dragExpandableContainer.style.transform = `scale(1, 10)`;

// Apply iniial reverse transform on the contents 
dragExpandableContents.style.transform = `scale(1, calc(1/10))`;

let isOpen = true;

const togglePopup = () => {
  if (isOpen) {
    collapsedAnimation();
    toggleEl.classList.remove('toggle-open');
    isOpen = false;
  } else {
    expandAnimation();
    toggleEl.classList.add('toggle-open');
    isOpen = true
  };
};

function calculateCollapsedHeight() {
  const collapsedHeight = content.offsetHeight + resizeableControlEl.offsetHeight;
  return collapsedHeight;
}

const calculateCollapsedScale = function() {
  const collapsedHeight = calculateCollapsedHeight();
  const expandedHeight = dragExpandableContainer.getBoundingClientRect().height;

  return {
    /* Since we are not dealing with scaling on X axis, we keep it 1.
     * It can be inverse to if required */
    x: 1,
    y: expandedHeight / collapsedHeight,
  };
};

const calculateExpandScale = function() {
  const collapsedHeight = calculateCollapsedHeight();
  const expandedHeight = 100;

  return {
    x: 1,
    y: expandedHeight / collapsedHeight,
  };
};

function expandAnimation() {
  const {
    x,
    y
  } = calculateExpandScale();

  transformAnimation('expand', {
    x,
    y
  });
}

function collapsedAnimation() {
  const {
    x,
    y
  } = calculateCollapsedScale();

  transformAnimation('collapse', {
    x,
    y
  });
}

function transformAnimation(animationType, scale) {
  let start, previousTimeStamp;
  let done = false;

  function step(timestamp) {
    if (start === undefined) {
      start = timestamp;
    }
    const elapsed = timestamp - start;

    if (previousTimeStamp !== timestamp) {
      const count = Math.min(0.1 * elapsed, 200);
      //console.log('count', count);
      let yScale;
      
      if (animationType === 'expand') {
        yScale = (scale.y / 100) * count;
      } else yScale = scale.y - (scale.y / 100) * count;
      //console.log('yScale', yScale);
      if (yScale < 1) yScale = 1;
      
      dragExpandableContainer.style.transform = `scale(${scale.x}, ${yScale})`;

      const inverseXScale = 1;
      const inverseYScale = 1 / yScale;
      
      dragExpandableContents.style.transform = `scale(${inverseXScale}, ${inverseYScale})`;

      if (count === 200) done = true;

      //console.log('elapsed', elapsed);
      if (elapsed < 1000) {
        // Stop the animation after 2 seconds
        previousTimeStamp = timestamp;
        if (!done) requestAnimationFrame(step);
      }
    }
  }
  requestAnimationFrame(step);
}
.drag-expandable-container {
  position: absolute;
  bottom: 0px;
  display: block;
  overflow: hidden;
  width: 100%;
  background-color: #f3f7f7;
  transform-origin: bottom left;
}

.drag-expandable__contents {
  transform-origin: top left;
}

.toggle {
  position: absolute;
  top: 2px;
  right: 15px;
  height: 10px;
  width: 10px;
  transition: transform 0.2s linear;
}

.toggle-open {
  transform: rotate(180deg);
}

.drag-expandable__resize-control {
  background-color: #e7eeef;
}

.burger-icon {
  width: 12px;
  margin: 0 auto;
  padding: 2px 0;
}

.burger-icon__line {
  height: 1px;
  background-color: #738F93;
  margin: 2px 0;
}

.drag-expandable__resize-control:hover {
  border-top: 1px solid #4caf50;
  cursor: ns-resize;
}
<!DOCTYPE html>
<html>

<head>
  <link rel="stylesheet" href="css.css">
</head>

<body>
  <div class="drag-expandable-container">
    <div class="drag-expandable__contents">
      <div class="drag-expandable__resize-control">
        <div class="burger-icon">
          <div class="burger-icon__line"></div>
          <div class="burger-icon__line"></div>
          <div class="burger-icon__line"></div>
        </div>
      </div>
      <div class="content" />
      <div>
        <div class="toggle toggle-open" onclick="togglePopup()">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M416 352c-8.188 0-16.38-3.125-22.62-9.375L224 173.3l-169.4 169.4c-12.5 12.5-32.75 12.5-45.25 0s-12.5-32.75 0-45.25l192-192c12.5-12.5 32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25C432.4 348.9 424.2 352 416 352z"/></svg>
        </div>
      </div>
    </div>
  </div>
</body>
<script type="text/javascript" src="js.js"></script>

</html>

Solution

Posting this as an answer for better code formatting.

It sounds like what you are trying to do can be achieved with something called Linear Interpolation (commonly known as "lerp" or "lerping" amongst developers).

Have a look at the following function:

function lerp(v0, v1, t) {
    return v0*(1-t)+v1*t
}

This is probably the simplest possible function for linear interpolation, but it should be good enough for what you are trying to calculate. The function takes three parameters:

v0, the initial value, for example 0
v1, the final value, for example 10
t, a value between 0 and 1, representing the "percentage of progress" from v0 to v1

So for example, if you were to call lerp(0, 10, 0.5), the function would return 5, since 5 is "50% of the way" going from 0 to 10.

If you were to call lerp(0, 10, 0.9), the function would return 9.

Furthermore with t = 0, the function returns 0 and with t = 10, the function returns 10.

So applying this to your problem, you have two container heights:

y0 = container height when minimized (0)
y1 = container height when maximized (6)

and then you have the elapsed time (et) and the total time (tt) how long it should take for the container to open and close (2 seconds).

We want to make lerp to return the yScale at some point in time t.

We already have two of the lerp parameters, v0 = y0 and v1 = y1, but we can’t directly use et as the t parameter, because et goes from 0 to 2 and t has to go from 0 to 1.

To fix this, we have to scale et to go from 0 to 1.

Here is a full example code showing the scaling and the usage of lerp:

// Y scale value when minimized
let y0 = 0;
// Y scale value when maximized
let y1 = 6;
// Time it should take to go from 0 to 6
let totalTime = 2000;

// The lerp function
function lerp(v0, v1, t) {
    return v0*(1-t)+v1*t
}

// A loop going from 0ms to 2000ms to simulate how the lerp function works
// We have to use integers and therefore milliseconds with 100ms steps here,
// because JavaScript does not like incrementing by 0.1
for(let elapsed_time = 0; elapsed_time <= totalTime; elapsed_time += 100)
{
    // Scale elapsed time to go from 0 to 1, instead of 0 to 2
    let elapsed_time_scaled = elapsed_time / totalTime;
    // Get y scale with lerp at the current time
    let scale_y = lerp(y0, y1, elapsed_time_scaled).toFixed(2);

    // Log the stuff out
    console.log(elapsed_time, elapsed_time_scaled, scale_y);
}

Which returns something like this:

0 0 '0.00'
100 0.05 '0.30'
200 0.1 '0.60'
300 0.15 '0.90'
400 0.2 '1.20'
500 0.25 '1.50'
600 0.3 '1.80'
700 0.35 '2.10'
800 0.4 '2.40'
900 0.45 '2.70'
1000 0.5 '3.00'
1100 0.55 '3.30'
1200 0.6 '3.60'
1300 0.65 '3.90'
1400 0.7 '4.20'
1500 0.75 '4.50'
1600 0.8 '4.80'
1700 0.85 '5.10'
1800 0.9 '5.40'
1900 0.95 '5.70'
2000 1 '6.00'

Looks good to me! Would this help you?

Answered By – Swiffy

Answer Checked By – Terry (AngularFixing Volunteer)

Leave a Reply

Your email address will not be published.