Custom Parallax без никакви библиотеки
В предходните уроци от поредицата разгледахме много опции как бихме могли да направим parallax-и. Макар, че някои от тях бяха custom и без библиотеки, те бяха доста елементарни. Тук вече ще отприщим пълния потенциал на custom parallax-ите.
Реално това е и първият примерен код, който разписах. Реших обаче, че първо трябва да поставя някаква основа (с цялата тази поредица). След като вече я имаме налице, нека продължим и с разяснителните записки тук.
Demo на custom parallax-а
За разлика от последните уроци – нека първо разгледаме самото демо: https://magadanskiuchen.github.io/weather-parallax-demo/.
Scroll-вайки надолу ще видите един грозен сив фон, на който се появяват и скриват метеорологични иконки. Отначало слънце, после облачно, а накрая дъжд примесен със сняг. Въобще прогноза точно като за това време на годината: „сутринта ще бъде слънчево, а следобед очаквайте леки превалявания на сняг“.
Source-а можете да видите тук: https://github.com/magadanskiuchen/weather-parallax-demo (заедно и със сравнително приятелски commit log).
Макар и да не е естетически издържано, това, което демонстрираме са:
- transform: rotate
- transform: translateX
- opacity
които плавно се променят / анимират при scroll от страна на потребителя.
Основни положения
В урока си Parallax Text Reveal вече споделих линк към една статия от Medium, на която обаче не дадох пълен кредит. Става въпрос за Parallax Done Right от Dave Gamache. От тази статия почерпих някои основни принципи. Част от тях съвпаднаха с твърдения, които вече ми бяха познати, но като цяло ме двъхнови как да подходя от гледна точка на код.
Производителност
Разбира се огромен фокус пада върху производителността. Една custom parallax анимация няма как да е красива, ако не върви гладко.
Паднем ли под естествените за потребителите 60fps, значи това, което правим е изгубило смисъла си.
GPU
По тази причина силно се препоръчва да се анимират само CSS property-та, които се обработват от видео картата (тъй като процесорът е зает с друга работа). За който не знае – това са transform
(с всичките му sub-property-та като translate, scale, rotate, matrix, skew и опциите им) и opacity
.
В уроците от поредицата за GSAP, си позволихме да анимираме и други, но с риск от загуба на framerate.
requestAnimationFrame
Следващото съществено нещо за производителност е да не извършваме излишни изчисления.
Щом ще си имаме взимане-даване със scroll
, трябва да знаем, че той и resize
event-ите са най-тежките откъм процесорен ресурс. Това се дължи най-вече на факта, че се викат много често. По много пъти в секунда. Даже в много случаи и по повече от 60 пъти в секунда.
Гонейки 60 кадъра в секунда, това означава, че понякога смятаме как трябва да изглежда един елемент и още преди да сме го показали – смятаме как трябва да изглежда на база нови данни от scroll / resize събитието. Това е ненужно.
Винаги wrap-вайте тялото на event handler-а за scroll и resize в requestAnimationFrame
.
Това не е само за custom parallax-и. Просто го приемете за даденост.
Цели, а не дробни числа
Dave съветва и всички px стойности, от и до които анимираме, да бъдат под формата на цели а не дробни числа.
Единствено може би при анимиране на opacity
можем да използваме десетична дроб с до 2 знака след запетаята. С други думи да ползваме decimal, а не обикновен float, макар че като тип на променлива в JavaScript няма значение. Все пак работата с по-малко значещи цифри ще улесни процесора. А ние гоним това във всяко свое действие. Бъдете фанатични по темата.
Анимирайте само елементи с абсолютна или фиксирана позиция
Когато елементи не са с position: absolute / fixed
при тяхна промяна браузърът трябва да направи пълен repaint на всички други елементи на страницата. Това е защото елементи с position: static / relative
се разместват един друг при промяна и това коства много ресурси. Много е възможно и да доведе но спад на framerate-а то 30fps.
Точно в Parallax Text Reveal урока видяхме до какво води да анимираме елемент с position: relative
.
Четимост и поддръжка на кода
Не бих се замислил много по въпроса и повечето ефекти иначе бих просто ги изпляскал някъде в кода. Даже вероятно половината ми щяха да са в CSS-а, а останалата част – в JS-а.
Dave обаче препоръчва да си създадем един обект със стойностите от и до които искаме да анимираме. Така почти като някой timeline можем да видим кога какво се случва. А и не само ние самите, но и други хора, на които може да им се наложи да четат кода ни.
Стъпка по стъпка
След като вече сме разгледали общата теория, какво е и не е добре да правим, неща разгледаме и примера.
Макар, че в записките надолу следвам commit-ите от GitHub, това не означава, че ще имам бележки за всеки един. Примерно commit-а за лиценз тотално го прескачам, тъй като не е важен за целта на урока.
Начало
Timeline на анимацията
Нека започнем с основно схема на това как си представяме, че би било добре да описваме стойностите, които ще анимираме:
const keyframes = [
{
selector: '.fa-sun',
start: 0,
end: '150%',
property: 'rotate',
from: 0,
to: 360
}
];
Естествено започваме с масив, а в него – обекти (отначало само 1), които да съдържат информация за елементите, които ще анимираме.
Имаме:
- селектор (кой елемент ще анимираме),
start
иend
, които указват от „къде“ до „къде“ при scroll на страницата ще променяме някакво CSS property- самото това CSS property
from
иto
стойности, които елементът ще има съответно в „точки“start
иend
Инициализация и responsive
Добавяме си init
функция, която да превърне процентите от start
/ stop
keyframe-овете от timeline-а в пиксели, тъй като за scroll
event-а ще трябва да следим тях:
function init() {
absoluteKeyframes = relativeToAbsolute(keyframes);
}
function relativeToAbsolute(frames) {
const absolute = [];
for (const i in frames) {
let obj = {};
for (const j in frames[i]) {
if (frames[i][j].toString().match(/%$/)) {
obj[j] = parseInt(frames[i][j]) * window.innerHeight * 0.01;
} else {
obj[j] = frames[i][j];
}
}
absolute.push(obj);
}
return absolute;
}
Викаме init()
функцията и на resize
, за да преизчислим нови точки на база оригиналните проценти:
window.addEventListener('resize', e => {
requestAnimationFrame(() => {
init();
});
});
Място за scroll
За да можем да анимираме при scroll, трябва да има налична височина на документа, през която потребителите да scroll-ват. Ако всичките ни елементи са с position: absolute / fixed
страницата няма да има подходящ scroll.
Затова изчисляваме на каква височина на документа отговаря end
стойността на последния keyframe:
document.body.style.height = Math.max.apply(Math, absoluteKeyframes.map(object => object.end)) + 'px';
Интерполиране на анимация
Щом имаме стойности за start
и end
би трябвало да можем да пресметнем и всичко помежду им.
Разписваме си една такава функция, която да се грижи за това:
window.addEventListener('scroll', e => {
requestAnimationFrame(() => {
for (frame of absoluteKeyframes) {
const element = document.querySelector(frame.selector);
if (frame.start < window.scrollY && frame.end > window.scrollY) {
const scrollProgress = (window.scrollY - frame.start) / (frame.end - window.scrollY);
switch (frame.property) {
case 'rotate':
element.style.transform = `rotate(${parseInt(frame.to * scrollProgress)}deg)`;
break;
}
}
}
});
});
При всеки scroll event трябва да обиколим всеки keyframe (обект от първоначалния масив).
След това проверяваме дали този обект има анимация в диапазона на текущия scroll. Примерно – ако имаме да променим opacity-то от 0 на 1 след scroll от 1000px, няма смисъл да изчисляваме каквото и да било, ако сме още на 200px scroll.
В противен случай изчисляваме колко процента от keyframe-а (след start
и преди end
) сме достигнали.
Примери:
- scroll 100px е 50% при
start = 0
иend = 200
- scroll 100px е 10% при
start = 0
иend = 1000
- scroll 600px е 75% при
start = 300
иend = 700
После имаме един switch
, в който проверяваме какво CSS property имаме да анимираме, тъй като при различен тип може да се наложи различна обработка.
Тъй като в първия разглеждан keyframe анимираме rotate
, за момента сме описали само него в switch-а.
Първи fix-ове
Ако следите историята на commit-ите ще видите, че в следващите няколко има fix-ове по калкулации, проверки, инстанциране на обекти и стойности по подразбиране, както и имплементиране на анимация на opacity
property-то, освен rotate
.
После добавяме и още елементи към „timeline“-а и обработка на translateX
, както и translateY
.
Промени по timeline-а
Не, че се връщаме назад във времето, за да променим настоящето. Просто в един момент си дадох сметка, че информацията, която пази в този обект е излишно много и може да се опрости.
Не само това, ами предната ситуация позволяваше да се анимира само 1 property на обект в който и да е момент. А ако сме били да опишем един и същи елемент 2 пъти и така да се опитаме да анимираме 2 property-та щеше да има други проблеми. Щеше да е излишно многословно и щеше да изисква още по-сложна обработка, така че повторната дефиниция да не презаписва първата, а да я допълва.
Новият синтаксис е във вид:
const keyframes = {
'.fa-sun': {
'rotate': { 0: 0, '100%': 180 },
'opacity': { 0: 0, '20%': 1, '80%': 1, '100%': 0 }
},
'.fa-cloud': {
'translateX': { 0: -150, '50%': -150, '85%': 0 },
'opacity': { 0: 0, '50%': 0, '75%': 1 }
}
};
Вместо да имаме keyframe обект, в който да описваме кой елемент ще анимираме и кое негово property, задаваме обект на елемента и за всяко негово property описваме изменението.
Така примерно казваме, че слънцето се завърта на 180 градуса за 100% scroll (100% от височината на монитора / viewport-а).
Синтаксисът наподобява доста и описването на linear-gradient
в CSS.
При този начин на описване не е нужно да следим и коя анимация след коя трябва да се случи.
Естествено от там се налагат и съществени промени в scroll handler-а:
window.addEventListener('scroll', e => {
requestAnimationFrame(() => {
const animations = {};
for (const selector in absoluteKeyframes) {
animations[selector] = animations[selector] || {};
for (const prop in absoluteKeyframes[selector]) {
const fromScroll = getBiggestOfSmallerThan(Object.keys(absoluteKeyframes[selector][prop]), window.scrollY);
const toScroll = getSmallestOfBiggerThan(Object.keys(absoluteKeyframes[selector][prop]), window.scrollY);
const fromValue = absoluteKeyframes[selector][prop][fromScroll];
const toValue = absoluteKeyframes[selector][prop][toScroll];
let value = fromValue;
if (fromValue !== toValue) {
const scrollProgress = (window.scrollY - fromScroll) / (toScroll - fromScroll);
value = (toValue - fromValue) * scrollProgress + fromValue;
}
switch (prop) {
case 'translateX':
case 'translateY':
animations[selector].transform = animations[selector].transform || [];
animations[selector].transform.push(`${prop}(${value}%)`);
break;
case 'rotate':
animations[selector].transform = animations[selector].transform || [];
animations[selector].transform.push(`rotate(${value}deg)`);
break;
case 'opacity':
animations[selector].opacity = (value).toFixed(2);
break;
}
}
}
for (const selector in animations) {
const element = document.querySelector(selector);
for (const property in animations[selector]) {
element.style[property] = (typeof(animations[selector][property]) == 'object') ? animations[selector][property].join(' ') : animations[selector][property];
}
}
});
});
„Най-голямо по-малко“ и „най-малко по-голямо“
Тук въвеждаме и функциите getBiggestOfSmallerThan
и getSmallestOfBiggerThan
. Тяхното предназначение е да определяме между коя двойка progress от дефиницията на timeline-а сме.
Примерно – за да знаем, че трябва да анимираме слънцето на 90 градуса завъртане, трябва да знаем, че сме на 50% прогрес от rotate анимацията му. Това определяме като знаем на какъв scroll сме и проверим между кои 2 прогрес стойности от дефиницията сме. Тогава „start“ ни се пада най-голямата по-малка от текущия scroll, а „stop“ ни е най-малката по-голяма от същия.
Ако следите commit-ите ще забележите, че няколко пъти съм пренаписвал тези функции за да обработват всички сценарии. Примерно отначало ми беше убягнало, че трябва да внимавам с безкрайност. Това може да се случи, ако вече сме след последния дефиниран прогрес, а търсим най-малкото по-голямо от него.
Дефиниране на анимации
След като вече сме изчистили функционалната логика, можете да видите как само в един commit добавяме още куп анимации бързо и лесно.
За кое не съм напълно съгласен с Dave Gamache
В статията си той пише, че използването на easing функции е едва ли не задължително.
Макар и в много ситуации наличието на easing да придава усещане за много по-естествено движение, в случая на scroll е субективно дали е добре или нужно да се ползва.
На https://magadanskiuchen.github.io/weather-parallax-demo/ виждате custom parallax, в който няма подобен easing и за който не смятам, че изглежда зле. Поне от гледна точка на анимации; иначе откъм естетика казахме, че си е грозен.
Предимства и недостатъци
С какво този custom parallax JS, който изписахме тук е по-добър от предните няколко урока (особено тези със ScrollMagic и GSAP)?
Макар и създателите на GSAP да вадят луди статистики откъм performance на анимациите, самата библиотека си е доста голяма. Поне когато добавим всички plugin-и, които бихме искали заедно с нея. Когато посетител зареди сайта, не само ще мине време докато зареди файловете от сървъра, а и самото първоначално парсиране на JS-а си отнема време.
Нашата алтернатива е наистина малка, можем да я включим в JS-а, който така или иначе пращаме на посетителите, а парсирането на JS-а от браузъра е смешно малко. Evaluate script в Chrome отнема 0.43ms. Само за сравнение – парсирането на HTML-а на страницата отнема 10-12 пъти по-дълго! Evaluate на jQuery (ако го имате някъде) отнема 40 пъти повече време.
И все пак колкото и да се говорим колко е оптимизиран кода и колко гладко върви, едва ли можем да го преизползваме за болшинството от анимации, които може да ни се наложи да пишем. Тогава пак опираме до добре познатите библиотеки, които не случайно съдържат повечко код.
Първият пропуск, който знам, че е съществен и наличен в този урок е възможност за анимиране на няколко transform
property-та наведнъж. В момента, ако се наложи да правим translateX
, translateY
, rotate
или други подобни едновременно – няма да работи.
За целта библиотеки подобни на GSAP първо обработват стойностите, които сте задали и на тяхна база изчисляват матрица, която се прилага на елемента с transform: matrix
. Не че е ядрена физика и не можем да extend-нем кода си, за да го добавим, но аз самият не съм сигурен дали си заслужава.
В заключение на parallax-ите
Затова като заключение на поредицата мога да кажа:
- за най-елементарните неща ползвайте просто
background-position: fixed
- за показване на елемент, когато scroll-нете до него – използвайте AOS.
- за елементарни parallax анимации (като например featured image-а, който виждате най-горе в статията) може да добавите 2-3 реда custom JS
- за всичко по-комплексно – използвайте ScrollMagic и GSAP.