Сайт Игоря Кононученко   Статьи

Анимированный баннер средствами CSS3

3 декабря 2010

Введение

Некоторое время назад меня пригласили провести тренинг по фронтенд-разработке (можно поклацать) в Циклуме. Требовалось обучить команду флешеров каким-то азам и показать как можно создавать анимированные баннера, которые будут работать на iOS-устройствах. Я предложение принял и решил, раз такая возможность, углубится в тему css3-аннимаций.

Задача

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

Ниже про пройденный к этой реализации путь.

Попытка один. Транзишны посредством применения CSS-классов на контейнере

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

Предстояло решить как оно должно работать. Придумал так — в css создаю классы, внутри которых транзишны. Классы последовательно из джсов прописываю в контейнере и магия происходит. Основное, что требуется — подвязаться как-то завершению транзишна. Для этого есть событие onTransitionEnd. Сразу скажу, не самое удобное событие. Приведу пример.

Есть такой css (чтобы не удивлялись, я использую LESS-синтаксис):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.banner-top,
.banner-bottom {
        height:50%;
        width:100%;
        position:absolute;
        background-color:#00a1de;
        z-index:10;
        .transition(height, 0.3s, linear);        
}
.first-stage {
        .banner-top {
            height:10%;
            
        }
        .banner-bottom {
            bottom:0px;
            height:20%;
            
        }
        .main-msg {
            top:50px;
             .transition(top, 0.3s, ease-in, 0.6s);
             
        }
        .shape {
            height:91px;
            .transition(height, 0.3s, ease-in, 0.3s);
        }
        
}

Где .transition:

1
2
3
4
5
6
.transition (@prop, @time, @type: linear, @delay: 0s) {
    -webkit-transition:@prop @time @type @delay;
    -moz-transition:@prop @time @type @delay;
    -o-transition:@prop @time @type @delay;
    transition:@prop @time @type @delay;    
}

Итак, устанавливаем контейнеру класс .first-stage. И добавляем обработчик:

1
2
el.className +=".first-stage";
el.addEventListener("webkitTransitionEnd", function(e){ console.log(e)}, true);

Как вы думаете сколько раз отработает обработчик? - правильный ответ - 4 раза, но не для всех браузеров. В Опере сработает 2 раза. А если css-правило такое: .transition2(top, left, 0.3s, ease-in, 0.6s);?

где

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
.transition2(@p1, @p2, @time, @type: linear, @delay: 0s){
        -webkit-transition-property: @p1, @p2;  
        -webkit-transition-duration: @time, @time;  
        -webkit-transition-timing-function:@type, @type;
        -webkit-transition-delay:@delay;
        
        -moz-transition-property: @p1, @p2;  
        -moz-transition-duration: @time, @time;  
        -moz-transition-timing-function:@type, @type;
        -moz-transition-delay:@delay;
        
        -o-transition-property: @p1, @p2;  
        -o-transition-duration: @time, @time;  
        -o-transition-timing-function:@type, @type;
        -o-transition-delay:@delay; 
        
        transition-property: @p1, @p2;  
        transition-duration: @time, @time;  
        transition-timing-function:@type, @type; 
        transition-delay:@delay;
}

В Вебките и ФФ (говорим про 4-ю бетку) обработчик сработает 5 раз, по разу на каждое transition-свойство.
И как узнать когда закончился этап анимации? Возможен такой костыль:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var count = 0, //количество вызовов
stageEnd = 0,//сколько раз событие должно отработать прежде чем вызвать обработчик
callback = function(){};//обработчик

container[0].addEventListener(vendor_prefix+"TransitionEnd", function(e){ onAnimationEnd(e)}, true);        

function setCallback(fn, stage) {
    callback = fn;
    count = 0;
    stageEnd = stage || 0;
}

function onAnimationEnd(e) {
    if( (count++) == stageEnd ) {
        count = 0;
        stageEnd = -1;
        callback();
    }
}

container.addClass("first-stage")
setCallback(showCities, 4);

function showCities() {
    //тут добавляем следующий обработчик и запускаем следующую анимацию
    _contentEl.addClass("main-msg-to-right");
    setCallback(nextHandler, 3);
    
}

Очевидный недостаток в том, что нужно постоянно высчитывать и выверять с CSS количество срабатываний события. Из-за того, что Опера по своему обрабатывает конец анимации, этот способ не может претендовать на универсальность.

Про vendor_prefix и то как правильно добавлять событие поговорим ниже. А вот, собственно, результат, который работает (не очень стабильно) только в Вебкитах:

Второй способ. -webkit-animation посредством применения CSS-классов на контейнере

Почему бы не попробовать сделать тоже самое с помощью -webkit-animation? Ну и интересно выяснить, что лучше использовать для подобных задач. Потенциально -webkit-animation более мощное оружие и позволяет задавать шаги анимации и давать анимациям имена, к которым в обработчике можно привязаться. Недостаток в том, что -webkit-animation существуют пока только в Вебките.

Итак, пишем стили. Сразу внимание привлекает концептуальное отличие от транзишнов. Допустим, нам надо плавно изменить высоту обьекта:

- c помощью анимаций:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.animation(@name:"", @time:0s, @type:linear, @delay:0) {
    -webkit-animation-name: @name;
    -webkit-animation-duration: @time;
    -webkit-animation-delay:@delay;
    -webkit-animation-iteration-count: 1;
    -webkit-animation-direction: alternate;
    -webkit-animation-timing-function: @type;
}
.animate {
    .banner-top {
        .animation(slide-top, 0.3s, linear);
        height: 10%;/*ставим чтобы размер не прыгнул назад*/
            
    }
}
@-webkit-keyframes slide-top {
        from {height:50%;}
        to {height: 10%;}
}

- транзишны:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.banner-top {
        height:50%;
        .transition(height, 0.3s, linear);
                
}
.animate {
    .banner-top {
        height:10%;
            
    }
        
}

Как видим, транзишны лаконичнее. Еще важный момент в том, что если мы уберем класс .animate в случае -webkit-animation, то высота элемента прыгнет. В случае с транзишном — плавно вернется в исходное состояние. Если плавный возврат то что нужно, в случае с транзишнами придется добавить класс, который вернет все на место:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.animate-back {
    .banner-top {
        .animation(slide-top-back, 0.3s, linear);
        height: 50%;        
    }
    @-webkit-keyframes slide-top-back {
        to {height:50%;}
        from {height: 10%;}
    }
}

Теперь поговорим про обработку событий в джсе.

Архитектура упрощается по сравнению первым подхом и становится независимой от количества срабатываний обработчика.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
el.addEventListener("webkitAnimationEnd", function(e){ onAnimationEnd(e)}, true);

var callback = function(){},
    animationName = "";
        
        
function setCallback(fn, name) {
    callback = fn;
    animationName = name;
}


function onAnimationEnd(e) {
    if( animationName == e.animationName) {
        animationName = "";
        callback();
    }
}

Получилось приблизительно то что и было в первом шаге, правда без наметок на поддержку других браузеров. И с более грамоздким CSS. К тому времени у меня уже было видение как сделать более простое в использовании решение.

Третья попытка. Библиотека и CSS-классы

Предыдущие шаги были реализованы весьма громоздко. Должно быть более-менее шаблонное решение, которое позволит создавать подобные баннеры с меньшим количеством усилий, кода и времени. За основу я взял концепцию шагов, и написал конфиг:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
var config = {
     el: "#klmBanner",
     steps: [
         {
             cls:"openGate",/*добавляем класс openGate*/
             el: ".gate" /*элементу .gate*/
         }, 
         {
             cls:"drawShape",
             el: ".shape"
         },
         
         {
             cls: "toBottom",
             el: ".main-msg",
         /*в обработчике меняем отображаемые данные*/
             onStart:function(e){
                 setMsg(data.main.text1);
             }
         },
         
         {
             cls: "toRight",
             el: ".main-msg",
             reset: {el: ".main-msg", cls: "toBottom toRight"}
         },
         /*момент показа предложений по городам*/
         {
             /*этот шаг состоит из нескольких шагов*/
             steps:[
                     {
                         cls:"showCity",
                         el: ".destination",
                         onStart: function(e) {
                             var dest = data.destinations[e.parentIteration-1];
                             _value.html(dest.destinationPrice);
                             _name.html(dest.destinationName);
                         }
                     },
                     {
                         /*для паралельной аннимации*/
                         parallelSteps: [
                             {
                                 cls:"showPrice",
                                 el: ".price",
                                 onStart: function(e) {
                                     e.el.show();
                                 }
                             },
                             {
                                 cls:"showBtn",
                                 el: ".btn-order",
                                 onStart: function(e) {
                                     e.el.show();
                                 }
                             },
                         ]
                     },
                     
                     {
                         /*в данном убираем класс и запускаем аннимацию*/
                         removeCls:"showCity", 
                         el: ".destination",
                         delay: 3000,/*этот шаг запустится с отстрочкой в 3 секунды*/
                         onStart: function(e){ 
                             if(e.parentIteration==3) {
                             $("#klmBanner .btn-order")
                             .hide()
                             .removeClass("showBtn");
                         }
                     }
                }
             ],
             /*прежде чем пойти дальше, шаг выполнится 3 раза*/
             iterations: 3,
             onStart: function(e) {
                 $("#klmBanner .price")
                 .hide()
                 .removeClass("showPrice");
             }
         
         },
         
         /* показали города, идем дальше*/
         {
             cls: "toBottom",
             el: ".main-msg",
             onStart: function(e) {
                 setMsg(data.main.text2);
             }
         },
         {
             cls:"showBtn",
             el: ".btn-order",
             onStart: function(e) {
                 e.el.show();
             }
         },
         
         {
             removeCls:"openGate", 
             el: ".gate",
             delay:2000,
             reset: [
                 {el:".btn-order", cls: "showBtn"}, 
                 {el:".main-msg", cls: "toBottom"},
                 {el: ".shape", cls: "drawShape"}
             ],
             nextStep: 0,//зацикливаем
             onEnd: function(){
                 _btn.hide();
             }
         } 
     ]
 
};
new Banny(config);

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

По поводу того как добавлять и удалять обработчик:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function getVendorPrefix() {
    var result = null, div = document.createElement('div');
    ["webkit", "Moz", "O", ""]
    .forEach(
        function(prefix, i) {
            var transition = prefix==""?"transition": "Transition";
            if(typeof(div.style[prefix+transition]) != 'undefined') result = prefix.toLowerCase();
        }
    );
    return result;
}
var vendorPrefix = getVendorPrefix();

addListener: function(el, cb) {
    el.addEventListener(vendorPrefix+"TransitionEnd", cb, true);
    //http://dev.w3.org/csswg/css3-transitions/#transition-events-
    el.addEventListener("transitionend", cb, true);
},
removeListener: function(el, cb) {
    el.removeEventListener(vendorPrefix+"TransitionEnd", cb, true);
    el.removeEventListener("transitionend", cb, true);
}

По стандарту событие называется transitionend, Фаерфокс 4 реализует именно такое именование. Поэтому я 2 раза привязываюсь к событию, и получу только один обработчик.

Уже работает в ФФ, но там видно артефакты, которые остаются от масштабирования кнопки:

А вот в Опере не работает. Потому что в Опере транзишн запускается только если его заранее описали и только после этого изменили анимируемое свойство:

1
2
3
.class{ transition(...); height:10px;}
	
.run .class {height:100px;}

Поэтому такой вариант не работает:

1
2
3
.class{ height:10px;}

.run .class {transition(...); height:100px;}

Но и это еще не все.

Реализация четыре. Задаем анимации инлайном в конфиге

Логичным продолжениям стало добавление поддержки транзишнов прямо внутри библиотеки.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
var config = {
    el: "#klmBanner",
    steps: [
        {
            parallelSteps: [
                {
                    el: ".banner-top",
                    transition: {height: "10%", time: 0.3}
                },
                {
                    el: ".banner-bottom",
                    transition: {height: "20%", time: 0.3}
                }
            ]
        },
    
        {
            el: ".shape",
            transition: {height: 91, time: 0.3, delay: 0.3, func: "ease-in"}
        },
        {
            el: ".main-msg",
            transition: {top: 50, time: 0.3, func: "ease-in"},
            onStart: function (e) {
                setMsg(data.main.text1);
            }
        },
        {
            el: ".main-msg",
            transition: {marginLeft: 200, top: 50, opacity: 0.1, time: 0.3, func: "ease-in", delay: 3},
            resetStyle: [".main-msg"] //для каждого элемента массива el.style.cssText = "";
        },
        {
            steps: [
                {
                    el: ".destination",
                    transition: {right: 10, opacity: 1, time: 0.2},
                    onStart: function (e) {
                        var dest = data.destinations[e.parentIteration - 1];
                        _value.html(dest.destinationPrice);
                        _name.html(dest.destinationName);
                    }
                },
                {
                    parallelSteps: [
                    {
                        el: ".price",
                        transition: {opacity: 1, transform: "scale(1,1)", time: 0.1, func: "ease-out"},
                        onStart: function (e) {
                            e.el.show();
                        }
                    },
                    {
                        el: ".btn-order",
                        transition: {transform: "scale(1,1)", time: 0.1, func: "ease-out"},
                        onStart: function (e) {
                            e.el.show();
                        }
                    }
                    ]
                },
                {
                    el: ".destination",
                    delay: 3000,
                    transition: {right: -150, opacity: 0.1, time: 0.2},
                    resetStyle: [".price"],
                    onStart: function (e) {
                        if (e.parentIteration == 3) {
                            $("#klmBanner .btn-order").hide()
                        }
                    }
                }
            ],
            iterations: 3,
            onStart: function (e) {
                $("#klmBanner .price").hide();
    
            },
            resetStyle: [".btn-order"]
        },
        {
            el: ".main-msg",
            transition: {top: 50, time: 0.3, func: "ease-in"},
            onStart: function (e) {
                setMsg(data.main.text2);
            }
        },
        {
    
            el: ".btn-order",
            transition: {transform: "scale(1,1)", time: 0.1, func: "ease-out"},
            onStart: function (e) {
                e.el.show();
            }
        },
        {
            parallelSteps: [
                {
                    el: ".banner-top",
                    transition: {height: "50%", time: 0.3}
                },
                {
                    el: ".banner-bottom",
                    transition: {height: "50%", time: 0.3}
                }
            ],
            nextStep: 0,
            delay: 2000,
            resetStyle: [".btn-order", ".main-msg", ".shape"]
        }
    ]
};
new Banny(config);

Такой вид еще более лаконичен и в перспективе более масштабируем. Для преобразования описаний транзишнов cоздал утилитный класс, который работает приблизительно так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var config = {marginLeft: 200, top:50, opacity:0.1, time:0.3, func: "ease-in", delay:3},
    cssObj = toCss(config);
/*
cssObj = {
    cssProps: {
        margin-left: "200px",
        opacity: 0.1,
        top: "50px"
    },
    //vendor-prefix проставляется в зависимости от браузера
    tProps: {
        "-webkit-transition-delay": "3s,3s,3s"
        "-webkit-transition-duration": "0.3s,0.3s,0.3s"
        "-webkit-transition-property": "margin-left,top,opacity"
        "-webkit-transition-timing-function": "ease-in,ease-in,ease-in"
    }
}    
*/
function _transition(el, config) {
        var cssObj = toCss(config);
        //сначала применяем транзишны
        el.css(cssObj.tProps);
        //потом делаем минимально возможный таймаут, чтобы в опере заработало
        setTimeout(function(){el.css(cssObj.cssProps)}, 1);
        
}
_transition(el, config);

Тут видно, что сначала применяем транзишн к элементу, и потом только обычные CSS-свойства, поэтому на этот раз в Опере работает:

Пятым шагом, может стать поддержка менее продвинутых браузеров посредством подключения джсной анимационной библиотеки. Еще я почти сделал независимость от jQuery (но работает она пока только в Вебките).

Итоговый код получился полноценной библиотекой, с которой, впрочем, еще стоит поиграть, посмотреть как она будет себя вести в разных ситуациях и можно будет выложить на ГитХаб.

Еще немного

В баннере стоит нестандартный шрифт (попросили его убрать). Есть такой замечательный сайт fontsquirrel, он преобразует шрифт для показа в вебе. Для того, чтобы шрифт отображался в мобильном Сафари на версиях ниже iOS 4.2, его надо подключать в виде SVG.

Аннимированные сдвиги, я делал с помощью изменения top-, left- или margin-свойств. Выдержка из статьи Чикуенка:

В Webkit-браузерах для анимации перемещения объектов лучше использовать связку CSS Transition/Animation и translate (x, y) из CSS Transforms. Трансформации получают аппаратное ускорение (по крайней мере на Маке), а анимации включают субпиксельное сглаживание, что даёт полее плавное и естественное движение.

Я этого не проверял, надо будет сравнить.

Для верстки, как можно было заметить, я использую less.js. Для маков есть симпатичный LESS.app, также можно компилировать с помощью node.js, тоже очень удобно. Еще говорят, что scss мощнее и по докам это видно. Надо будет попробовать.

В процессе гугления нашел эту статью и она меня надоумила на концепцию шагов для моей реализации. Еще в этой статье была ссылка на очень симпатичную библиотеку для работы с трансформациями

Про светлое будущее. Есть подозрение, что все же проще будет использовать специализированные визуальные инструменты для создания аннимаций. Есть вот такой инструмент от авторов Экст джса. Он в довольно-таки сыром виде, генерит грязный код основанный на webkit-animations. Для простых ситуаций может и пойти. И его улучшают не глупые люди. Можете скачать, поклацать.
А Адобы тоже что-то делают в этом направлении.
И вот еще http://radiapp.com/ — новый интересный инструмент для аннимирования

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

Все вопросы и пожелания рад буду получить на почту или в Твиттер.

И главное

То, ради чего вы читали этот длинный рекламный текст. В эту субботу 4 декабря 2010 года, иначе говоря завтра, я буду проводить мастер-класс по JavaScript. Приглашаю. Поделюсь опытом и пообщаемся. Еще есть немного времени, чтобы успеть зарегистрироваться.

Про создание этого сайта
Движок презентации в браузере
SmartInterval — класс для эффективной обработки повторяющихся событий
Перетаскивание файлов в браузер (Drag and Drop, XMLHttpRequest)
Ctrl