Pull to refresh

Анимированные баннеры на Javascript — это просто*

jQuery *
*) На самом деле все равно сложно, но зато проще, чем было раньше.

История началась с постановки задачи: нужно сделать анимированный баннер с примерно тридцатью объектами средствами HTML+javascript за один день. За день, конечно, баннер сделан не был, а был сделан за два усилиями трех человекодней. После выполнения задания осталась библиотека пакетной анимации, которую я назвал Scenario. О её доработанной версии я и хочу рассказать.

Основная идея библиотеки — собрать информацию обо всех анимируемых объектах в один сценарий и отправить его на выполнение. Один сценарий может быть исполнен сколько угодно раз или может быть модифицирован, поскольку представляет из себя обычную яваскриптовскую структуру.

Запуск сценария из любого места предельно прост:

var newScenario = [...];
$.scenario(newScenario, {
    complete: function(time) {
        alert('Готово!');
    }
});

Осталось только разобраться, что писать вместо трех точек в примере :)

Описание сценариев


Любой сценарий является массивом анимируемых объектов либо один анимируемым объектом. Каждый анимируемый объект может содержать три основных свойства:
  • element — указатель на HTML-элемент или несколько элементов, представляющих данный анимируемый объект. Указать на HTML-элемент можно тысячей и одним способом, поэтому подробное описание этого свойства я пока опущу.
  • scene — массив сцен или одна сцена. Сценой в данном случае называются какие-то действия над анимируемым объектом, имеющие время начала и время окончания.
  • child — массив дочерних объектов или один дочерний объект.
Таким образом, весь сценарий представляет из себя дерево анимируемых объектов. Причем структура этого дерева не обязана совпадать со структурой HTML-элементов, анимацию которых описывает сценарий. Мне представляется, что начинать сценарий удобнее всего с построения этого дерева. В конце должно получиться что-то такое:

var newScenario = {
    element: '#scenario',
    child: [{
        element: '.rocket_smile'
        child: [{
            element: '.fire'
        }, {
            element: '.eyes'
        }, {
            element: '.ears'
        }]
    }, {
        element: '.rocket_atack'
        child: {
            element: '.fire'
        }
    }, {
        element: '.cloud',
        child: [{
            element: ['eq', 0]
        }, {
            element: ['eq', 1]
        }
    }]
};


А теперь магия — описание объекта сцены.
  • time — единственное обязательное свойство. Массив из двух или трех элементов. Первый элемент — время начала данной сцены в миллисекундах, отсчитываемое от старта сценария, т.е. от момента вызова функции $.scenario(). Второй — время окончания, отсчитываемое от того же события. Общая продолжительность всего сценария равна самому большому времени окончания сцены.
    Третий опциональный элемент — название функции изинга (easing), которая будет применяться для данной сцены. Изинг берется из jQuery, поэтому сторонние функции изинга должны работать нормально.
  • before — объект с css-свойствами, которые нужно применить к элементу до старта анимации, т.е. в момент time[0].
  • after — объект с css-свойствами, которые нужно применить к элементу после завершения анимации, т.е. в момент time[1].
  • animate — объект, в котором перечислены анимируемые css-свойства и их конечные значения. В качестве начальных значений берутся текущие css-свойства анимируемого элемента. Пока поддерживаются только числовые значения.
  • start — функция, которая будет вызвана при старте сцены, непосредственно после применения css-свойств из свойства before.
  • end — функция, которая будет вызвана после завершения сцены, непосредственно после применения css-свойств из свойства after.
  • step — функция, которая будет вызвана на каждом шаге анимации, пока текущее время находится между time[0] и time[1].
Параметры before и after позволяют в большинстве случаев обойтись без функций обратного вызова start и end. Помимо обычных css-свойств, в них можно указать некоторые дополнительные манипуляции над анимируемыми элементами: поддерживаются функции show, hide, attr, removeAttr, addClass, removeClass, toggleClass. Небольшой пример, из которого все станет понятно:

scene: [{
    time: [2500, 4000],
    before: { opacity: 0, show: '' },
    animate: { opacity: 1 }
    after: { opacity: '' }
}, {
    time: [5500, 6500],
    before: { addClass: 'rocket_wink' },
    after: { removeClass: 'rocket_wink' }
}]


Ну и сферический код облаков в вакууме:

{
    element: '.cloud',
    child: [{
        element: ['eq', 0],
        time: [0, 7500, 'linear'],
        before: { top: -80, display: 'block', left: 300 },
        animate: { top: 374 }
    }, {
        element: ['eq', 1],
        scene: [{
            time: [2500, 5500, 'linear'],
            before: { top: -80, display: 'block', left: 500 },
            animate: { top: 374 }
        }, {
            time: [5500, 9000, 'linear'],
            before: { top: -80, display: 'block', left: 150 },
            animate: { top: 374 }
        }]
    }]
}


Обратите внимание, что элемент .cloud вообще не имеет анимации, а элемент eq(0) не содержит свойства scene, но содержит свойства time, before и animate — это возможность еще немного сократить запись сценария.

Для усвоения основных принципов пока хватит.

Почему бы не сделать все на jQuery.animate. Живой пример


На первую часть подзаголовка я отвечу второй. Я подготовил пример анимации, сделанной с помошью Scenario. Пример очень похож на баннер, о котором шла речь в самом начале, правда я вырезал из него всю айдентику и заменил главных персонажей на ниндзя. Кроме того, ради интереса я сделал такой-же баннер на jQuery.animate (тот, что снизу).

Живой пример

И вот почему jQuery.animate не слишком подходит для подобных задач:
  1. Animate вызывает функцию complete после окончания анимации. Но она ничего не вызывает перед стартом. Из-за этой особенности приходится пользоваться очередями:
    .delay(2500)
    .queue(function(next) {
        $(this).css({top: 82, left: 74});
        next();
    })
    .animate({top: 85}, 1500)
    

  2. Animate анимирует все HTML-элементы в текущем jQuery-объекте независимо друг от друга. Для каждого из элементов после окончания анимации вызывается функция complete. Функция step же вызывается для каждого свойства каждого элемента на каждом шаге анимации. Это еще более неудобно.

  3. Через animate нельзя просто прогнать функцию step в течении определенного времени. При пустом наборе анимируемых свойств animate немедленно возвращает результат. Приходится использовать грязный трюк: анимировать какое-либо значение из 0 в 0.

  4. Множество точек выхода из анимации. Если 10 разных объектов одновременно заканчивают анимацию, на событие complete какого из них поставить завершающий код баннера? В каком событии complete искать этот код через 10 дней?
    Scenario, напротив, имеет одну точку выхода, это complete функции $.scenario()
Из-за этих проблем получается такой код:

// сначала сохраним исходный селектор, чтобы иметь к нему доступ
var $rocket_smile_fire = $rocket_smile.find('.fire');
$rocket_smile_fire
  // Первый хак: нам нужно, чтобы калбэки вызывались только один раз
  .eq(0)
  // Второй хак: marginTop и так 0, но без него анимация вовсе не запустится 
  .animate({marginTop: 0}, {
    duration: 9000,
    step: function(x, opt) {
      // Вместо обращения к this, обращаемся к внешней переменной
      $rocket_smile_fire
        .hide()
        // Из информации о времени предоставляется только время начала анимации
        .eq(((new Date() - opt.startTime) / 100) % $rocket_smile_fire.length)
        .show();
    },
    complete: function() {
      $rocket_smile_fire.removeAttr('style');
    }
  });


Это, конечно, печально, но жить можно, если бы не…

Киллер-фича Scenario


Представьте: анимация, которую вы делаете, длится 20 секунд. Вы делаете финальную сцену, которая длится секунды две. Сколько времени тратится впустую, пока вы просматриваете баннер до последних секунд? Как решить проблему в jQuery? Закомментировать кусок кода, делающий большую часть работы. При этом не факт, что все объекты будут на своих местах, ведь код, который их двигал, закомментирован.

Решение от Scenario — просто укажите с какого времени начать сцену и в какое закончить. Ну и скорость воспроизведения можно задать. И частоту кадров до кучи.

Пример с возможностью настраивать время

Отладка — одно удовольствие.

Еще немного о сценариях


Без описания осталось свойство анимируемого объекта element и функции обратного вызова для сцен. Восполняю пробел. Element может принимать следующие значения:
  • Может отсутствовать. В этом случае элемент берется из родительского анимируемого объекта. Это может быть использовано для группировки свойств нескольких сцен:
    {
        element: '.rocket_smile',
        // своя анимация элемента
        scene: {
            time: [0, 2500, 'easeInOutSine'],
            animate: { top: 69 }
        },
        // дочерний объект без элемента
        child: {
            // Эти свойства будут применены ко всем сценам
            before: { addClass: 'rocket_wink' },
            after: { removeClass: 'rocket_wink' }
            scene: [{
                time: [0, 1000]
            }, {
                time: [2000, 3000]
            }, {
                time: [4000, 5000]
            }]
        }
    }

  • Объект jQuery. Например, $('#scene .element');

  • Объект DOM. Например, document.

  • Строка, css-селектор. Передается в метод find() родительского элемента, либо в функцию $(), если родительский элемент отсутствует.

  • Массив. Первый элемент — название метода родительского элемента: find, eq, parent и т.д. Второй и последующие элементы — аргументы для этого метода.
    element: ['eq', 0] эквивалентно $parent.eq(0).
    Если первый элемент false — родительский элемент игнорируется и аргументы передаются в функцию $().
    element: [false, 'input[name="name"]'] эквивалентно $('input[name="name"]').

  • Функция, возвращающая элемент. Через параметры функции передается родительский элемент.

Функции обратного вызова start, end и step выполняются в контексте найденного элемента. Первым аргументом все три принимают время, прошедшее с начала сценария. Последним — объект текущей сцены, расширенный объектом анимации:

{
    element: '.rocket_smile',
    button: $('#start-button'),
    scene: {
        time: [0, 2500],
        fadeDuration: 400,
        start: function(time, scene) {
            // scene.fadeDuration доступно из текущей сцены
            // scene.button наследуется из объекта анимации
            scene.button.fadeOut(scene.fadeDuration);
        },
        animate: { top: 69 }
    },
}


Функция обратного вызова step, помимо этих двух параметров, принимает еще один параметр с подробной информацией о текущем шаге анимации:
time, время с начала сценария;
passed, время с начала сцены;
progress, время с начала сцены, нормированное по отрезку 0..1;
position, это progress после применения функции изинга.

{
    element: '.rocket_atack',
    scene: {
        before: { top: 20 },
        // пользовательское свойство, доступное в функции step
        lastTop: 300,
        step: function(time, opt, scene) {
            // начальное значение параметра
            var startTop = scene.before.top;
            // на сколько изменится параметр
            var changeTop = scene.lastTop - startTop;
            // this — текущий элемент 
            this.css({top: startTop + changeTop * opt.position});
        }
    }
}


Код Scenario на Гитхабе


Планы на будущее

  • Придумать способ безболезненной модификации сценариев. Сейчас, чтобы сделать разные сцены для браузеров, поддерживающих css-трансформации и нет, придется помучиться, доставая объекты и сцены по индексам в массивах.

  • Реализовать более гибкий способ указания анимируемых свойств. Как минимум значения со знаком и единицами измерения, отличными от пикселей:
    var scene = {
        before: { top: '20%' },
        animate: { top: '40%', left: '+=200px' }
    }

  • Разработать визуальный редактор с временной шкалой, слоями, фильтрами и вытеснить Флеш из веба.
Многие не поняли, что про визуальный редактор — это шутка. Пишу прямым текстом — это шутка.
Tags:
Hubs:
Total votes 163: ↑158 and ↓5 +153
Views 18K
Comments Comments 65