Pull to refresh

Пилим веб-демку — Wavescroll

Reading time11 min
Views4.3K
В этой статье я постараюсь доходчиво рассказать о процессе создания демки Wavescroll.

image

О коде в статье


Цель этой статьи, попытаться научить людей реализовывать различные нетривиальные эффекты, не полагаясь всю жизнь на готовые плагины и подобные решения для каждого чиха. Код в демо (как и сама статья) написаны прямолинейно и весьма доходчиво (надеюсь), без использования множества лишних вещей, которыми нынче переполнены все возможные обучалки. Здесь нету ES6, вебпака, реакта, модульных систем и прочих вещей. Вам не потребуется сидеть 5-15 минут в командной строке, устанавливая различные зависимости для окружения, дабы потом наконец-то вывести заветный Hello World на экран. Мы будем писать «топорный» код, использовать jQuery потому-что он понятен почти любому человеку, кто хоть раз работал с javascript, и в конце получим нечто, что можно пощупать.

Все демки я создаю на codepen'е, где различные библиотеки и препроцессоры подключаются парой кликов и вам необходимо только лишь писать код.

Предыстория


Началось все с того, что на просторах интернета я наткнулся на прекрасный сайт jetlag.photos, который поразил меня своим эффектом свайпа бэкграунда (зажмите лефтклик мыши где угодно и тяните). Сразу же появилось желание реализовать клон без подглядываний «под капот». Как уже потом выяснилось, даже при желании я бы не смог извлечь никакой полезной информации из devTools, ибо все было реализовано на canvas'е. А копаться в сжатом&аглифицированном коде канваса это то еще извращение. Так что демку я начал пилить, руководствуясь лишь тем, что вижу на экране, используя стандартную связку html+css+js (с канвасом я все равно не особо дружу).

Стартуем


Для начала нам необходимо создать фуллскрин-контейнер, который будет содержать внутри блоки с бэкграундами.

<div class="ws-pages">
  <div class="ws-bgs">
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
  </div>
</div>

Все бэкграунды вложены в дополнительный блок, так как потом у нас еще появится блок для текстов. Теперь стилизуем их:

.ws {

  &-pages {
    overflow: hidden;
    position: relative;
    height: 100vh; // основной контейнер будет занимать 100% высоты экрана
  }

  &-bgs {
    position: relative;
    height: 100%;
  }

  &-bg {
    height: 100%;
    background-size: cover;
    background-position: center center;

    // очищаем поток, для частей, которые будут вставлены позже
    &:after {
      content: "";
      display: table;
      clear: both;
    }
}

О CSS
Как вы уже заметили, я использую препроцессор. В данном случае это SASS с синтаксисом SCSS.

Теперь нам необходимо создать части бэкграундов, добавить их в соответствующие блоки и стилизовать. Наша задача сделать так, чтобы все кусочки в итоге смотрелись как единый блок, бэкграунд которого растянут с помощью background-size: cover и отцентрован. Для этого внутрь каждой части будет добавлен дополнительный блок, которому и будет выставляться фоновая картинка. Каждый внутренний блок будет занимать 100% ширины экрана и смещаться влево с каждым шагом, чтобы в итоге получилась цельная картинка.

.ws-bg {

  &__part {
    overflow: hidden; // каждая часть должна показывать только контент в рамках своих размеров
    position: relative;
    float: left; // располагаем части с помощью обычных флоатов
    height: 100%;
    cursor: grab;
    user-select: none; // существенно улучшает качество свайпа мышкой

    &-inner {
      position: absolute;
      top: 0;
      // left будем указывать уже с помощью js
      width: 100vw; // каждый внутренний блок занимает 100% ширины экрана
      height: 100%;
      background-size: cover;
      background-position: center center;
    }

  }
}

var $wsPages = $(".ws-pages");
var bgParts = 24; // пусть у нас будет 24 части.
var $parts;

function initBgs() {
  var arr = [];
  var partW = 100 / bgParts; // длина одной части, в %
    
  for (var i = 1; i <= bgParts; i++) {
    var $part = $('<div class="ws-bg__part">'); // создаем часть бэкграунда
    var $inner = $('<div class="ws-bg__part-inner">'); // а так же внутренний её блок
    var innerLeft = 100 / bgParts * (1 - i); // расчитываем позицию внутреннго блока
      
    $inner.css("left", innerLeft + "vw");
    $part.append($inner);
    $part.addClass("ws-bg__part-" + i).width(partW + "%"); // добавляем класс с индексом для каждой части и назначаем ширину
    arr.push($part);
  }
    
  $(".ws-bg").append(arr); // в каждый блок вставляем массив с частями
  $wsPages.addClass("s--ready"); // об этом будет написано ниже
  $parts = $(".ws-bg__part");
};

initBgs();

Как это было реализовано изначально
Изначально я использовал :after для .ws-bg__part и циклом в sass выставлял left для псевдоэлементов. Но потом решил что необходимость синхронизации переменных количества частей в js и sass это плохая практика и сделал все полностью на js.

Итак, под конец функции мы добавляем к контейнеру класс s--ready. Нам это необходимо для того, чтобы убрать фон с блоков .ws-bg, которые изначально будут отображаться с фоном, чтобы юзер видел контент, до того как javascript добавит части. После того как части добавлены, фон у основных блоков не нужен, ибо они двигаться не будут. Так что добавляем следующее для .ws-bg.

.ws-bg {

  .ws-pages.s--ready & { // компилируется в .ws-pages.s--ready .ws-bg
    background: none;
  }
}

// ну и не забываем добавить сами фоновые картинки для .ws-bg и вложенных в него .ws-bg__part-inner c помощью css
// над вариантом, где картинки нельзя вставить с помощью css я не думал, но мне кажется решение можно легко найти

Move your mouse


Пришло время навесить обработчики для свайпа мышкой и реализовать переключение страниц.

var curPage = 1; // переменная для текущей страницы
var numOfPages = $(".ws-bg").length; // количество страниц

// в целях простейшей оптимизации сохраняем размеры окна в переменные
var winW = $(window).width();
var winH = $(window).height();

// вешаем обработчик на ресайз окна, дабы обновлять переменные его размеров
$(window).on("resize", function() {
  winW = $(window).width();
  winH = $(window).height();
});

var startY = 0;
var deltaY = 0;

$(document).on("mousedown", ".ws-bg__part", function(e) { // вешаем обработчик с помощью делегирования событий
  startY = e.pageY; // стартовая Y позиция мыши в начале свайпа
  deltaY = 0; // обнуляем переменную при каждом новом свайпе

  $(document).on("mousemove", mousemoveHandler); // вешаем обработчик для движения мышью

  $(document).on("mouseup", swipeEndHandler); // и для окончания свайпа
});

var mousemoveHandler = function(e) {
  var y = e.pageY;

  // с помощью X координаты получаем индекс той части, на которой в данный момент находится указатель мыши
  var x = e.pageX;
  index = Math.ceil(x / winW * bgParts);

  deltaY = y - startY; // вычисляем разницу между текущей и стартовой позициями
  moveParts(deltaY, index); // двигать части будем в отдельной функции
};

var swipeEndHandler = function() {
  // снимаем обработчики движения/окончания свайпа
  $(document).off("mousemove", mousemoveHandler);
  $(document).off("mouseup", swipeEndHandler);

  if (!deltaY) return; // если движения по оси Y не было, то здесь и заканчиваем

  // если "расстояние свайпа" больше половины экрана в определенном направлении, вызываем функцию для смены страницы
  if (deltaY / winH >= 0.5) navigateUp();
  if (deltaY / winH <= -0.5) navigateDown();

  // двигаем все части
  // даже если страница осталась той же, нам необходимо вернуть все части на исходную позицию для текущей страницы
  changePages(); 
};

// крайне простые функции для изменения переменной текущей страницы
function navigateUp() {
  if (curPage > 1) curPage--;
};

function navigateDown() {
  if (curPage < numOfPages) curPage++;
};

Добавляем функционал движения частей, на основе переменных deltaY и index (вызов функции moveParts внутри mousemoveHandler). Нам необходимо сделать эффект двусторонней лесенки, когда каждая часть, находящаяся с любой из сторон от активной части (той, на которой находится указатель) начинает двигаться по оси Y с определенной задержкой по расстоянию свайпа (описать на словах это намного сложнее чем понять визуально). При этом чем дальше часть находится от активной, тем меньше должна быть её «высота ступеньки».

Только не бейте
Функционал движения частей я писал поздней ночью, когда уже должен был сладко спать. А потом мне было лень все это дело рефакторить по уму, так что вам остается лишь лицезреть этот ужас. Если кто-то в состоянии это красиво зарефакторить, буду рад помощи в комментах.

Так же я испытал существенные проблемы с адекватным описанием происходящего, на самом деле там все проще и понятнее чем кажется, просто я сейчас по ходу не в состоянии нормально выражать свои мысли =/

var staggerVal = 65; // стартовое значение отклонения соседних частей
var staggerStep = 4; // значение, которое с каждым последующим шагом будет понижать высоту ступеньки

var changeAT = 0.5; // время анимации в секундах

function moveParts(y, index) { // y = deltaY; index - индекс активного элемента
  var leftMax = index - 1; // максимальный индекс части слева от активной
  var rightMin = index + 1; // и минимальный индекс части справа

  // переменные для аккумулированных значений отступа частей от активной
  var stagLeft = 0;
  var stagRight = 0;

  // переменные для суммарного значения шагов уменьшения отклонения
  var stagStepL = 0;
  var stagStepR = 0;
  var sign = (y > 0) ? -1 : 1; // определяем направление движения

  movePart(".ws-bg__part-" + index, y); // двигаем активную часть

  for (var i = leftMax; i > 0; i--) { // стартуем цикл "справа налево" по частям слева от активной
    var step = index - i; // то, насколько часть далека от активной

    // вычитаем из стартового значения отклонения переменную суммарного значения шагов уменьшения отклонения
    var sVal = staggerVal - stagStepL;

    // для первых 15 ступенек работает обычный шаг, затем он понижается до 1
    // магические цифры это плохо. Не делайте так.
    stagStepL += (step <= 15) ? staggerStep : 1;

    // в том месте где заканчивается лесенка пусть лучше будет ровный пол, а не начало другой лестницы в обратном направлении
    if (sVal < 0) sVal = 0;
    stagLeft += sVal; // шаг текущей ступеньки прибавляем ко всей суммарной высоте предыдущих ступенек
    var nextY = y + stagLeft * sign; // Y значение для ступеньки

    // если отклонение текущей части от активной больше deltaY активной, то текущую часть фиксируем на обычном месте
    if (Math.abs(y) < Math.abs(stagLeft)) nextY = 0;
    movePart(".ws-bg__part-" + i, nextY); // двигаем часть
  }

  // тут происходит все тоже самое что и в прошлом цикле, но только слева направо для частей справа от активной
  for (var j = rightMin; j <= bgParts; j++) {
    var step = j - index;
    var sVal = staggerVal - stagStepR;
    stagStepR += (step <= 15) ? staggerStep : 1;
    if (sVal < 0) sVal = 0;
    stagRight += sVal;
    var nextY = y + stagRight * sign;
    if (Math.abs(y) < Math.abs(stagRight)) nextY = 0;
    movePart(".ws-bg__part-" + j, nextY);
  }
};

function movePart($part, y) {
  var y = y - (curPage - 1) * winH; // корректируем Y значение относительно текущей страницы

  // используем гсап для анимации (о нем чуть ниже)
  // использование простое:
  // TweenMax.to(%селектор%, %время анимации в секундах%, {%свойства для анимации и дополнительные настройки%}
  // используем Back easing для подобия эффекта bounce. Сам bounce при быстром движении смотрится жутко
  TweenLite.to($part, changeAT, {y: y, ease: Back.easeOut.config(4)});
};

Как вы уже заметили, использую я GSAP (greensock) для анимации. Я привык делать большинство демок без специальных библиотек для анимации, потому-что это весьма весело и отлично прокачивает, но в этот раз я решил пожалеть себя, когда понял что реализация велосипеда на requestAnimationFrame займет немало времени, ибо на использовании css transition's тут никуда не уедешь. Связано это с тем, что нам необходима реалтайм анимация, которая будет автоматом паузить прошлую анимацию и запускать следующую при движении, сохраняя при этом плавность.

Теперь необходимо написать функцию смены страниц. Тут все просто, мы по сути дела используем сильно облегченный вариант функции moveParts:

var waveStagger = 0.013; // мы не хотим чтобы все части двигались одновременно при смене страниц и добавляем шаг задержки в 13 мс
// но при этом мы будем вычитать накопленный шаг из времени анимации, дабы юзер не ждал времени сверх стандартного из-за того, что он потянул бэкграунд на одном из краев

function changePages() {
  var y = (curPage - 1) * winH * -1; // положение с учетом текущей страницы
  var leftMax = index - 1;
  var rightMin = index + 1;

  TweenLite.to(".ws-bg__part-" + index, changeAT, {y: y});

  for (var i = leftMax; i > 0; i--) {
    var d = (index - i) * waveStagger; // чем дальше от активной части, тем больше задержка и меньше время анимации
    TweenLite.to(".ws-bg__part-" + i, changeAT - d, {y: y, delay: d});
  }

  for (var j = rightMin; j <= bgParts; j++) {
    var d = (j - index) * waveStagger;
    TweenLite.to(".ws-bg__part-" + j, changeAT - d, {y: y, delay: d});
  }
};

// и заодно добавляем вызов этой функции в обработчик ресайза. Ведь значения у нас выставляются в пикселях
// при увеличении количества действий внутри обработчика ресайза его безусловно надо будет обернуть в debounce.
$(window).on("resize", function() {
  winW = $(window).width();
  winH = $(window).height();
  changePages();
});

Альтернатива
Если вам не нужна анимация с шагом задержки, то функцию можно сократить до 2х строчек кода. Но вариант с задержкой мне понравился больше в плане эстетики.

Теперь мы умеем двигать бэкграунд свайпом мышкой. С промежуточным вариантом демки можно ознакомиться вот тут.

WaveSCROLL


Далее, навешиваем обработчики на колесо мыши и стрелки, дабы превратить демку в полноценную реализацию one page scroll. Ну и заодно станет понятно, почему выбрано именно такое название (хотя придумал его не я, мне помогли).

// создаем переменную, ибо мы не хотим чтобы юзер за долю секунды мог проскроллить все страницы одним быстрым прокручиванием колеса
var waveBlocked = false;

var waveStartDelay = 0.2; // стартовая задержка анимации

// вешаем обработчик на колесо. DOMMouseScroll необходим для FireFox
$(document).on("mousewheel DOMMouseScroll", function(e) {
  if (waveBlocked) return;
  if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
    navigateWaveUp();
  } else { 
    navigateWaveDown();
  }
});

$(document).on("keydown", function(e) {
  if (waveBlocked) return;
  if (e.which === 38) {
    navigateWaveUp();
  } else if (e.which === 40) {
    navigateWaveDown();
  }
});

function navigateWaveUp() {
  // в отличии от обычных navigate функций, при попытке скролла за пределы наших страниц мы не делаем ничего
  // иначе у нас будет стартовать блокировка скролла и затем убираться с задержкой, а юзер все это время будет смотреть на статичную страницу, без возможности скроллить куда-либо
  if (curPage === 1) return;
  curPage--;
  waveChange();
};

function navigateWaveDown() {
  if (curPage === numOfPages) return;
  curPage++;
  waveChange();
};

function waveChange() {
  waveBlocked = true; // блокируем возможность скролла
  var y = (curPage - 1) * winH * -1;

  for (var i = 1; i <= bgParts; i++) {
    // стартуем анимацию для каждой группы частей с увеличенной задержкой, помимо статичной
    var d = (i - 1) * waveStagger + waveStartDelay;
    TweenLite.to(".ws-bg__part-" + i, changeAT, {y: y, delay: d});
  }

  var delay = (changeAT + waveStagger * (bgParts - 1)) * 1000; // считаем общее время анимации в милисекундах
  setTimeout(function() {
    waveBlocked = false; // убираем блокировку скролла по окончании анимации
    // прошу заметить что мы не можем использовать onComplete в gsap'e, ибо он сработает на каждом шаге цикла
  }, delay);
};

Весь основной функционал реализован.

Небольшая оптимизация


После того как я полез смотреть это демо на телефоне, я столкнулся с существенными проблемами по части производительности во время свайпа. Тогда я сразу вспомнил о том, что обработчик движения (будь то mousemove или touchmove) надо слегка оптимизировать. Для этого я буду использовать requestAnimationFrame, он же rAF.

rAF? WTF?
requestAnimationFrame это специальное браузерное API созданное для анимации и подобных вещей. Подробнее тут.

// скажу честно, я не помню как много существует браузеров, которые дружат с трансформами и не умеют при этом в rAF без префиксов, так что перестраховываемся
window.requestAnimFrame = (function() {
  return window.requestAnimationFrame ||
    window.webkitRequestAnimationFrame ||
    window.mozRequestAnimationFrame ||
    function(callback){
    window.setTimeout(callback, 1000 / 60);
  };
})();

// пишем простенький throttle велосипед, который будет ограничивать количество вызовов функции
function rafThrottle(fn) { // принимаем функцию в качестве параметра
  var busy = false; // создаем переменную для определения того, можно ли выполнять функцию или еще нет
  return function() { // возвращаем функцию, образуя при этом замыкание
    if (busy) return; // если занято, то разворачиваемся и уходим
    busy = true; // вешаем табличку что занято
    fn.apply(this, arguments); // вызываем функцию

    // используем rAF, для того чтобы снять табличку, когда браузеру полегчает
    requestAnimFrame(function() {
      busy = false;
    });
  };
};

//оборачиваем наш обработчик mousemove в rafThrottle
var mousemoveHandler = rafThrottle(function(e) {
  // тот же код функции
});

Эта оптимизация дала заметный прирост производительности на моем Nexus 5, так как она снизила количество вычислений и перезапусков анимации при постоянном движении мыши/пальца. Да и на десктопе тоже по виду все стало получше работать (хотя и до этого не было проблем, при учете что браузер работает со встроенной видеокарты).

Как реализованы текстовые заголовки я расписывать не буду, там все довольно-таки просто, чтение кода вам в помощь.

Вот в общем то и все! Ссылки на демо:

Версия с редактором.
Фуллскрин версия без айфрейма и кусков кодпена — если хотите проверять на телефоне, то открывайте эту версию, другие багают на тач-устройствах.

Так, стоп, а код для тач-устройств?
В оригинальном демо реализована поддержка тач устройств. Там по сути дела добавляется лишь дополнительный обработчик на touchmove, который делает те же вещи и еще несколько строчек кода, которые легко найти. Но я решил не описывать эту часть в статье, потому-что даже после оптимизации мое демо существенно проигрывает по производительности на телефонах (как минимум на моем Nexus 5) оригинальному сайту, который работает на канвасе. Хотя это все равно неплохой результат для такого эффекта.
И к тому же на мобильных устройствах имеются проблемы с черными полосами по краям частей фона во время движения. Связано это с шириной в % и сочетанием 3д трансформаций во время движения, из-за чего слегка изменяются размеры частей и просвечивают куски черного фона body.

Напоследок
Это моя первая статья, так что качество сильно хромает. Да и демо явно не одно из лучших. Но писать решил об этой демке, ибо сделал я её всего 10 дней назад и неплохо помню ход своих мыслей.
Tags:
Hubs:
+3
Comments8

Articles