20 лютого 2024 р.

JavaScript-анімації

JavaScript-анімації можуть робити те, що не під силу CSS.

Наприклад, рухати щось по складній траєкторії з функцією часу, відмінною від кривих Безьє, або створювати анімацію в елементі canvas.

Використання setInterval

Анімація може бути реалізована як послідовність кадрів – зазвичай це невеликі зміни у властивостях HTML/CSS.

Наприклад, зміна style.left з 0px до 100px переміщує елемент. А якщо ми збільшимо її в setInterval, змінюючи на 2px з маленькою затримкою, наприклад, 50 разів на секунду, то це буде виглядати плавно. Це той самий принцип, що і в кіно: 24 кадрів на секунду достатньо, щоб все виглядало плавно.

Псевдокод може виглядати так:

let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left by 2px
}, 20); // змінюється на 2 пікселі кожні 20 мс, що приблизно 50 кадрів на секунду

Більш повний приклад анімації:

let start = Date.now(); // зберігаємо час початку

let timer = setInterval(function() {
  // скільки часу пройшло від початку?
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // завершуємо анімацію через 2 секунди
    return;
  }

  // малюємо анімацію в момент часу timePassed
  draw(timePassed);

}, 20);

// коли timePassed змінюється від 0 до 2000
// left отримує значення від 0px до 400px
function draw(timePassed) {
  train.style.left = timePassed / 5 + 'px';
}

Клацніть, щоб побачити демо-версію:

Результат
index.html
<!DOCTYPE HTML>
<html>

<head>
  <style>
    #train {
      position: relative;
      cursor: pointer;
    }
  </style>
</head>

<body>

  <img id="train" src="https://js.cx/clipart/train.gif">


  <script>
    train.onclick = function() {
      let start = Date.now();

      let timer = setInterval(function() {
        let timePassed = Date.now() - start;

        train.style.left = timePassed / 5 + 'px';

        if (timePassed > 2000) clearInterval(timer);

      }, 20);
    }
  </script>


</body>

</html>

Використання requestAnimationFrame

Уявімо, що у нас є кілька анімацій, запущених одночасно.

Якщо ми запустимо їх окремо, то навіть якщо кожен з них має setInterval(..., 20), то браузеру доведеться перемальовувати набагато частіше, ніж кожні 20ms.

Це тому, що вони мають різний час запуску, тому “кожні 20 мс” відрізняється для різних анімацій. Інтервали не вирівняні. Отже, ми матимемо кілька незалежних прогонів протягом 20ms.

Іншими словами, це:

setInterval(function() {
  animate1();
  animate2();
  animate3();
}, 20)

…легше, ніж три незалежні виклики:

setInterval(animate1, 20); // незалежні анімації
setInterval(animate2, 20); // в різних місцях коду
setInterval(animate3, 20);

Ці кілька незалежних перемальовувань слід згрупувати разом, щоб зробити перемальовування простішим для браузера, а отже, зменшити навантаження на процесор і виглядати більш плавно.

Є ще одна річ, про яку слід пам’ятати. Іноді процесор перевантажений, або є інші причини перемальовувати рідше (наприклад, коли вкладку браузера приховано), тому нам дійсно не слід запускати його кожні 20ms.

Але як дізнатися про це в JavaScript? Існує специфікація Animation timing, яка надає функцію requestAnimationFrame. Вона вирішує всі ці питання і навіть більше.

Синтаксис:

let requestId = requestAnimationFrame(callback)

Це запланує запуск функції callback на найближчий час, коли браузер захоче виконати анімацію.

Якщо ми зробимо зміни в елементах у callback, то вони будуть згруповані разом з іншими викликами requestAnimationFrame і з CSS-анімаціями. Таким чином, буде виконано один перерахунок геометрії та перемальовування замість багатьох.

Повернуте значення requestId може бути використане для скасування виклику:

// скасовуємо заплановане виконання колбеку
cancelAnimationFrame(requestId);

Функція callback отримує один аргумент – час, що пройшов з початку завантаження сторінки в мілісекундах. Цей час також можна отримати за допомогою виклику performance.now().

Зазвичай callback виконується найближчим часом, якщо тільки процесор не перевантажений або батарея ноутбука майже розрядилася, або якщо є інший привід.

Код нижче показує час між першими 10 викликами для requestAnimationFrame. Зазвичай це 10-20 мс:

<script>
  let prev = performance.now();
  let times = 0;

  requestAnimationFrame(function measure(time) {
    document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " ");
    prev = time;

    if (times++ < 10) requestAnimationFrame(measure);
  })
</script>

Структурована анімація

Тепер ми можемо зробити більш універсальну функцію анімації на основі requestAnimationFrame:

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction змінюється від 0 до 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // обчислюємо поточний стан анімації
    let progress = timing(timeFraction)

    draw(progress); // малюємо

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

Функція animate приймає 3 параметри, які описують анімацію:

duration

Загальний час анімації. Наприклад, 1000.

timing(timeFraction)

Функція часу, подібна до CSS-властивості transition-timing-function, яка отримує частку часу, що минув (0 на початку, 1 в кінці) і повертає завершення анімації (як y на кривій Безьє).

Наприклад, лінійна функція означає, що анімація триває рівномірно з однаковою швидкістю:

function linear(timeFraction) {
  return timeFraction;
}

Її графік:

Це те саме, що й transition-timing-function: linear. Також існують цікавіші варіанти, показані нижче.

draw(progress)

Функція, яка приймає стан завершення анімації і малює її. Значення progress=0 означає початковий стан анімації, а progress=1 – кінцевий.

Це та функція, яка фактично малює анімацію.

Вона може переміщати елемент:

function draw(progress) {
  train.style.left = progress + 'px';
}

…Або робити щось інше, ми можемо анімувати будь-що, у будь-який спосіб.

Давайте анімуємо width елемента від 0 до 100% за допомогою нашої функції.

Натисніть на елемент для демонстрації:

Результат
animate.js
index.html
function animate({duration, draw, timing}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    let progress = timing(timeFraction)

    draw(progress);

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <style>
    progress {
      width: 5%;
    }
  </style>
  <script src="animate.js"></script>
</head>

<body>


  <progress id="elem"></progress>

  <script>
    elem.onclick = function() {
      animate({
        duration: 1000,
        timing: function(timeFraction) {
          return timeFraction;
        },
        draw: function(progress) {
          elem.style.width = progress * 100 + '%';
        }
      });
    };
  </script>


</body>

</html>

Код до неї:

animate({
  duration: 1000,
  timing(timeFraction) {
    return timeFraction;
  },
  draw(progress) {
    elem.style.width = progress * 100 + '%';
  }
});

На відміну від CSS-анімацій, тут ми можемо створити будь-яку функцію часу і будь-яку функцію малювання. Функція часу не обмежується кривими Безьє. А draw може не тільки змінювати властивості елементів, а й створювати нові елементи, наприклад, для анімації феєрверків або чогось подібного.

Функції часу (timing functions)

Вище ми бачили найпростішу, лінійну функцію часу.

Давайте розглянемо інші варіанти. Тут ми відтворимо анімацію руху з різними функціями часу, щоб побачити, як вони працюють.

Степінь n

Якщо ми хочемо прискорити анімацію, ми можемо використати progress у степені n.

Наприклад, параболічна крива:

function quad(timeFraction) {
  return Math.pow(timeFraction, 2)
}

Графік:

Побачити в дії (натисніть, щоб активувати):

…Або кубічна крива, або навіть ще більше значення n. Збільшення степеня призводить до швидшого прискорення.

Ось графік progress в степені 5:

У дії:

Дуга

Функція:

function circ(timeFraction) {
  return 1 - Math.sin(Math.acos(timeFraction));
}

Графік:

Back: стрільба з лука

Ця функція виконує “стрільбу з лука”. Спочатку ми “натягуємо тятиву”, а потім “стріляємо”.

На відміну від попередніх функцій, вона залежить від додаткового параметра x – “коефіцієнта еластичності”. Саме ним визначається відстань “натягу тятиви”.

Код:

function back(x, timeFraction) {
  return Math.pow(timeFraction, 2) * ((x + 1) * timeFraction - x)
}

Графік для x = 1.5:.

Для анімації ми використовуємо його з певним значенням x. Приклад для x = 1.5:

Відскок

Уявіть, що ми кидаємо м’яч. Він падає вниз, потім кілька разів відскакує назад і зупиняється.

Функція bounce робить те саме, але у зворотному порядку: “відскакування” починається негайно. Для цього використовується декілька спеціальних коефіцієнтів:

function bounce(timeFraction) {
  for (let a = 0, b = 1; 1; a += b, b /= 2) {
    if (timeFraction >= (7 - 4 * a) / 11) {
      return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
    }
  }
}

У дії:

Еластична анімація

Ще одна “еластична” функція, яка приймає додатковий параметр x для “початкового діапазону”.

function elastic(x, timeFraction) {
  return Math.pow(2, 10 * (timeFraction - 1)) * Math.cos(20 * Math.PI * x / 3 * timeFraction)
}

Графік для x=1.5:.

У дії для x=1.5:

Реверс: ease*

Отже, у нас є набір функцій часу. Їх пряме використання називається “easeIn”.

Іноді нам потрібно показати анімацію у зворотному напрямку. Це робиться за допомогою трансформації “easeOut”.

easeOut

У режимі “easeOut” функція timing поміщається в обгортку timingEaseOut:

timingEaseOut(timeFraction) = 1 - timing(1 - timeFraction)

Іншими словами, у нас є функція “перетворення” makeEaseOut, яка бере “звичайну” функцію часу і повертає обгортку навколо неї:

// отримує функцію часу, повертає перетворений варіант
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

Наприклад, ми можемо взяти функцію bounce, описану вище, і застосувати її:

let bounceEaseOut = makeEaseOut(bounce);

Тоді відскік буде не на початку, а в кінці анімації. Виглядає навіть краще:

Результат
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseOut(timing) {
      return function(timeFraction) {
        return 1 - timing(1 - timeFraction);
      }
    }

    function bounce(timeFraction) {
      for (let a = 0, b = 1; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseOut = makeEaseOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

Тут ми бачимо, як перетворення змінює поведінку функції:

Якщо на початку є анімаційний ефект, наприклад, підстрибування – він буде показаний в кінці.

На графіку вище звичайний bounce має червоний колір, а easeOut bounce – синій.

  • Звичайний bounce – об’єкт відскакує внизу, а потім в кінці різко підстрибує догори.
  • Після easeOut – він спочатку стрибає вгору, а потім підстрибує там.

easeInOut

Ми також можемо показати ефект як на початку, так і в кінці анімації. Така трансформація називається “easeInOut”.

Знаючи функцію часу, ми обчислюємо стан анімації таким чином:

if (timeFraction <= 0.5) { // перша половина анімації
  return timing(2 * timeFraction) / 2;
} else { // друга половина анімації
  return (2 - timing(2 * (1 - timeFraction))) / 2;
}

Код обгортки:

function makeEaseInOut(timing) {
  return function(timeFraction) {
    if (timeFraction < .5)
      return timing(2 * timeFraction) / 2;
    else
      return (2 - timing(2 * (1 - timeFraction))) / 2;
  }
}

bounceEaseInOut = makeEaseInOut(bounce);

bounceEaseInOut в дії:

Результат
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseInOut(timing) {
      return function(timeFraction) {
        if (timeFraction < .5)
          return timing(2 * timeFraction) / 2;
        else
          return (2 - timing(2 * (1 - timeFraction))) / 2;
      }
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseInOut = makeEaseInOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseInOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

Трансформація “easeInOut” об’єднує два графіки в один: easeIn (звичайний) для першої половини анімації та easeOut (перевернутий) – для другої частини.

Ефект добре видно, якщо порівняти графіки easeIn, easeOut та easeInOut функції часу circ:

  • Червоний – звичайний варіант circ (easeIn).
  • ЗеленийeaseOut.
  • СинійeaseInOut.

Як бачимо, графік першої половини анімації – це зменшений easeIn, а другої половини – зменшений easeOut. В результаті анімація починається і закінчується з однаковим ефектом.

Більш цікава функція “draw”

Замість переміщення елемента ми можемо зробити щось інше. Все, що нам потрібно, це написати правильну функцію draw.

Ось анімований текст, що “підстрибує” при наборі:

Результат
style.css
index.html
textarea {
  display: block;
  border: 1px solid #BBB;
  color: #444;
  font-size: 110%;
}

button {
  margin-top: 10px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <textarea id="textExample" rows="5" cols="60">He took his vorpal sword in hand:
Long time the manxome foe he sought—
So rested he by the Tumtum tree,
And stood awhile in thought.
  </textarea>

  <button onclick="animateText(textExample)">Run the animated typing!</button>

  <script>
    function animateText(textArea) {
      let text = textArea.value;
      let to = text.length,
        from = 0;

      animate({
        duration: 5000,
        timing: bounce,
        draw: function(progress) {
          let result = (to - from) * progress + from;
          textArea.value = text.slice(0, Math.ceil(result))
        }
      });
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }
  </script>


</body>

</html>

Підсумки

Анімації, з якими CSS не може впоратися, або ті, що потребують чіткого контролю, можуть бути реалізовані за допомогою JavaScript. JavaScript-анімація повинна бути реалізована через requestAnimationFrame. Цей вбудований метод дозволяє передавати колбек, що буде виконуватися, коли браузер буде готуватись до перемальовування. Зазвичай це відбувається дуже швидко, але точний час залежить від браузера.

Коли сторінка знаходиться у фоновому режимі, перемальовування взагалі не відбувається, тому колбек не запуститься: анімація буде призупинена і не буде споживати ресурси. І це чудово.

Ось допоміжна функція animate для налаштування більшості анімацій:

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction змінюється від 0 до 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // обчислюємо поточний стан анімації
    let progress = timing(timeFraction);

    draw(progress); // малюємо

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

Параметри:

  • duration – загальний час анімації у мілісекундах.
  • timing – функція для обчислення прогресу анімації. Отримує проміжок часу від 0 до 1, повертає прогрес анімації, зазвичай від 0 до 1.
  • draw – функція для малювання анімації.

Звичайно, ми могли б покращити її, додати більше наворотів, але JavaScript-анімації не застосовуються щодня. Вони використовуються для того, щоб зробити щось цікаве і нестандартне. Тому варто додавати функції, які вам потрібні, тоді, коли вони вам потрібні.

В JavaScript анімації можуть використовувати будь-яку функцію часу. Ми розглянули багато прикладів і трансформацій, щоб зробити їх ще більш універсальними. На відміну від CSS, тут ми не обмежуємося кривими Безьє.

Те ж саме стосується і функції draw: ми можемо анімувати будь-що, а не лише властивості CSS.

Завдання

важливість: 5

Make a bouncing ball. Click to see how it should look:

Відкрити пісочницю для завдання.

To bounce we can use CSS property top and position:absolute for the ball inside the field with position:relative.

The bottom coordinate of the field is field.clientHeight. The CSS top property refers to the upper edge of the ball. So it should go from 0 till field.clientHeight - ball.clientHeight, that’s the final lowest position of the upper edge of the ball.

To get the “bouncing” effect we can use the timing function bounce in easeOut mode.

Here’s the final code for the animation:

let to = field.clientHeight - ball.clientHeight;

animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw(progress) {
    ball.style.top = to * progress + 'px'
  }
});

Відкрити рішення в пісочниці.

важливість: 5

Make the ball bounce to the right. Like this:

Write the animation code. The distance to the left is 100px.

Take the solution of the previous task Animate the bouncing ball as the source.

In the task Animate the bouncing ball we had only one property to animate. Now we need one more: elem.style.left.

The horizontal coordinate changes by another law: it does not “bounce”, but gradually increases shifting the ball to the right.

We can write one more animate for it.

As the time function we could use linear, but something like makeEaseOut(quad) looks much better.

The code:

let height = field.clientHeight - ball.clientHeight;
let width = 100;

// animate top (bouncing)
animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw: function(progress) {
    ball.style.top = height * progress + 'px'
  }
});

// animate left (moving to the right)
animate({
  duration: 2000,
  timing: makeEaseOut(quad),
  draw: function(progress) {
    ball.style.left = width * progress + "px"
  }
});

Відкрити рішення в пісочниці.

Навчальна карта