Stephen Woods, фронтэнд-инженер в Flickr, объясняет, как создать простой лайтбокс с поддержкой жестов и дает советы для улучшения восприятия и производительности сенсорных интерфейсов.
Необходимые знания: средний уровень CSS, средний-продвинутый уровень JavaScript
Требования: Android или IOS устройство
Временные затраты: 2-3 часа
Скачать исходники
Просмотр демонстрации
Лайтбокс-виджеты стали стандартными в сети, с тех пор, как первая версия lightbox.js была выпущена в 2005 году. Лайтбокс создает модальное диалоговое окно для того, чтобы просмотреть увеличенные изображения, обычно с кнопками «Следующий» и «Предыдущий», чтобы перемещаться между слайдами.
Начиная с бума в использовании сенсорных устройств, веб-сайты обновили лайтбоксы, чтобы поддерживать взаимодействие жестов с различными степенями успеха. В этом учебном руководстве я собираюсь показать, как создать простой лайтбокс с поддержкой жестов. В процессе вы узнаете немного об улучшении воспринимаемой производительности сенсорных интерфейсов, а также несколько простых приемов для улучшения фактической производительности.
Запись кода для сенсорных устройств существенно отличается от записи кода для десктопов. Вы можете (и должны) объединять как можно больше кода с десктопным, но между ними всегда будут значительные различия.
Сравнительные тесты показывают, что наиболее распространенные сенсорные устройства сопоставимы по производительности с настольными компьютерами приблизительно 1998 года. У них обычно около 256 МБ RAM, производительности ЦП наравне с оригинальным iMac. Методы, которые мы используем, чтобы «просто работал» на десктопе, не будут корректно работать на мобильных телефонах и планшетах. К нашему счастью, эти устройства обычно хорошо оптимизированы для графики, особенно для перемещения элементов на экране. Устройства iOS и Android 3.0+ имеют аппаратное ускорение графики.
Фактически, вы можете представить эти устройства, как дрянные компьютеры с приличными видеокартами.
Мы взаимодействовали с нашими компьютерами одним и тем же способом в течение прошлых 20 лет. Мы перемещаем указатель мыши и щелкаем по кнопкам управления. Кнопки, блоки закрытия, ссылки и полосы прокрутки — вторая природа для пользователей и разработчиков. Сенсорные интерфейсы представляют совершенно другой набор соглашений. Один из наиболее распространенных — «скольжение (swipe)». При «скольжении» несколько элементов представлены, как будто они идут подряд, и пользователь может использовать «скользящий» жест, чтобы перемещаться между ними.
Скольжение является таким общим шаблоном, что мы даже не должны говорить пользователям об этом — когда пользователь видит что-то похожее на список, он инстинктивно попытается его пролистать.
Часто мы не можем заставить наш код работать немного быстрее, особенно когда мы имеем дело с медленными соединениями и медленными устройствами. Но мы можем заставить интерфейс казаться быстрее, фокусируясь на оптимизациях восприятия.
Мой любимый пример оптимизации воспринимаемой производительности — TiVo. Тринадцать лет назад, когда появились первые TiVo, они были невероятно медленными (16 МБ RAM и ЦП на 54 МГц!). Могло потребоваться мучительно много времени, пока что-то произойдет после того, как что-то нажали на пульте, особенно если начинали проигрывать или записывать что-то. Однако, никто никогда не жаловался на то, что TiVo медленный. По-моему, это из-за звука. Самая знакомая часть интерфейса TiVo — мелодия, которая звучала после того, как нажимаете любую кнопку. Тот звук играл моментально. Инженеры в TiVo сделали так, чтобы звук загружался как можно быстрее, чтобы независимо от того, что произойдет дальше, пользователь знал, что интерфейс не умер. Эта короткая мелодия говорила пользователям, что их запрос услышали.
В сети мы разработали соглашение, которое делает такую же вещь: загрузчик (spinner). После клика сразу появляется спиннер и таким образом, пользователь получает сообщение, что его услышали. В мобильных телефонах мы должны делать иначе.
Жесты — не дискретные действия, как щелчки. Однако, чтобы заставить интерфейс казаться быстрым, мы должны дать пользователям некоторую обратную связь. Поскольку они жестикулируют, мы перемещаем интерфейс в некотором роде так, чтобы они знали, что мы «слышим» их.
Инструменты
Быстро реагирующие интерфейсы требуют, чтобы элементы двигались как можно быстрее. Движением мы показываем пользователю, что интерфейс отвечает на их запрос. Использование JavaScript-анимации для этого слишком медленное. Вместо этого мы используем CSS-трансформации и переходы: трансформации для производительности, а переходы — чтобы анимация могла работать, без блокировки выполнения JavaScript.
В этом уроке, для всех движений и анимаций, я буду использовать CSS-трансформации и переходы.
Другая оптимизация, которую я хочу использовать как можно больше, является, как я называю «write-only DOM». Чтение свойств и значений из DOM сложное и обычно ненужное. Для лайтбокса я попытаюсь объединить все считывания в фазе инициализации. После этого я буду поддерживать состояние в JavaScript и при необходимости делать простую арифметику.
Создание лайтбокса
Для этого урока мы создадим страницу с несколькими миниатюрами. Щелчок (или касание пальцем) на миниатюрах запустят лайтбокс. После этого в лайтбоксе, пользователь сможет пролистывать пальцем изображения, а потом коснуться изображения, чтобы выйти из лайтбокса.
При создании интерфейса для жестов, осознавайте важность воспринимаемой производительности. В лайтбоксе это означает что слайды должны двигаться из-за скольжения пальца по экрану. Когда пользователь прекращает жестикулировать, слайды должны перейти в следующую позицию, или вернуться в предыдущую, если не хватило длины скольжения.
Анимация возврата критически важна. Благодаря ей пользователь никогда не подумает, что интерфейс мертв.
Начало
Создадим следующие файлы:
lightbox/
reset.css
slides.css
slides.html
slides.js
Шаблон
HTML будет простым. Это не только ради демонстрационного примера. Сложное дерево DOM по определению медленнее. Стили, извлечение DOM элемента и визульные эффекты все более тяжелый с более сложным деревом DOM. Поскольку мы нацелены на «дрянные компьютеры», каждый бит на счету, поэтому важно делать все простым с самого начала.
Я использую reset.css от Eric Meyer, чтобы начать обнулить CSS. Также я настраиваю область просмотра так, чтобы она не масштабировалась.
Я отключил родной «клик для изменения масштаба», чтобы он не вмешивался в жесты. Корректная реакция на клик будет реализована в JavaScript. «Клик для изменения масштаба» заслуживает отдельного урока, поэтому мы опустим его сейчас.
На стороне JS я использую zepto.js, очень легкую платформу с jQuery-синтаксисом. На самом деле нет необходимости ни в какой платформе, но эта немного ускорит работу над некоторыми задачами. Для фактических жестовых взаимодействий мы будем использовать встроенные API.
<div class="main">
<div class="welcome">
<h1>Welcome to an amazing carousel!</h1>
<p>This is an example of a nice touch interface</p>
</div>
<div class="carousel">
<ul>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664939239/in/photostream/">
<img data-full-height="427" data-full-width="640" src="http://farm8.staticflickr.com/7142/6664939239_7a6c846ec9_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664957519/in/photostream">
<img data-full-height="424" data-full-width="640" src="http://farm8.staticflickr.com/7001/6664957519_582f716e38_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664955215/in/photostream">
<img data-full-height="640" data-full-width="427" src="http://farm8.staticflickr.com/7019/6664955215_d49f2a0b18_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664952047/in/photostream">
<img data-full-height="426" data-full-width="640" src="http://farm8.staticflickr.com/7017/6664952047_6955870ecb_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664948305/in/photostream">
<img data-full-height="428" data-full-width="640" src="http://farm8.staticflickr.com/7149/6664948305_fb5a6276e5_s.jpg">
</a>
</li>
</ul>
</div>
</div>
</body>
<script src="zepto.min.js" type="text/javascript" charset="utf-8"></script>
<script src="slides.js" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript" charset="utf-8">
//this code initializes the lightbox and shows it when the user
//clicks on a slide
$(document).ready(function(){
var lightbox = new saw.Lightbox('.carousel');
$(document).on('click', 'a', function(e){
e.preventDefault();
lightbox.show(this.href);
});
});
</script>
</html>
Стилизация миниатюр
Теперь добавим маленькие симпатичные миниатюры и некоторые другие визуальные эффекты:
html {
background: #f1eee4;
font-family: georgia;
color: #7d7f94;
}
h1 {
color: #ba4a00;
}
.welcome {
text-align: center;
text-shadow: 1px 1px 1px #fff;
}
.welcome h1 {
font-size: 20px;
font-weight: bold;
}
.welcome {
-webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
-moz-box-sizing: border-box; /* Firefox, other Gecko */
box-sizing: border-box; /* Opera/IE 8+ */
margin:5px;
padding:10px;
box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
border-radius: 5px;
}
.carousel {
margin:5px;
}
.carousel ul li {
height: 70px;
width: 70px;
margin: 5px;
overflow: hidden;
display: block;
float: left;
border-radius: 5px;
box-shadow: 1px 1px 2px rgba(0,0,0,0.5), -1px -1px 2px rgba(255,255,255,1);
}
Основной лайтбокс
JavaScript для лайтбокса должен делать несколько разных вещей:
- Собирать данные о лайтбоксе и инициализировать
- Прятать и показывать лайтбокс
- Создавать HTML для оболчки лайтбокса
- Создавать слайды
- Обрабатывать события для жестов
Служебные функции
Вместо того, чтобы прописывать снова и снова "-webkit-transform" и «translate3d», я создам несколько служебных функций, чтобы они выполняли эту работу за меня.
function prefixify(str) {
var ua = window.navigator.userAgent;
if(ua.indexOf('WebKit') !== -1) {
return '-webkit-' + str;
}
if(ua.indexOf('Opera') !== -1) {
return '-o-' + str;
}
if(ua.indexOf('Gecko') !== -1) {
return '-moz-' + str;
}
return str;
}
function setPosition(node, left) {
// node.css('left', left +'px');
node.css(prefixify('transform'), "translate3d("+left+"px, 0, 0)");
}
function addTransitions(node){
node.css(prefixify('transition'), prefixify('transform') + ' .25s ease-in-out');
node[0].addEventListener('webkitTransitionEnd', function(e){
window.setTimeout(function(){
$(e.target).css('-webkit-transition', 'none');
}, 0)
})
}
function cleanTransitions(node){
node.css(prefixify('transition'), 'none');
}
Наш виджет лайтбокса будет инициализироваться при загрузке страницы, чтобы ускорить процессы. Инициализация заключается в нахождении всех миниатюр на странице и создании модели данных. Мы будем ждать, когда лайтбокс будет предоставлен для создания HTML и присоединим обработчики событий.
Инициализация
Для лайтбокса я использую конструктор, который берет селектор блока в качестве его единственного параметра.
//clean namespacing
window.saw = (function($){
//the lightbox constructor
function Lightbox (selector) {
var container_node = $(selector),
wrapper,
chromeBuilt,
currentSlide = 0,
slideData =[],
boundingBox = [0,0],
slideMap = {};
function init(){
//init function
}
return {
show: show,
hide: hide
};
}
return {
Lightbox:Lightbox
};
}($));
Функция «init» захватывает все теги «li», находит миниатюры и записывает информацию в массив «slideData». В то же время я создаю объект «slideMap», который отображает «href» миниатюры в массиве «slideData». Это позволяет мне быстро искать данные по щелчку, без необходимости циклично пропускать все данные в массиве или украшать DOM дополнительной информацией.
function init(){
var slides = container_node.find('li');
slides.each(function(i, el){
var thisSlide = {}, thisImg = $(el).find('img');
thisSlide.url = thisImg.attr('src');
thisSlide.height = thisImg.attr('data-full-height');
thisSlide.width = thisImg.attr('data-full-width');
thisSlide.link = $(el).find('a').attr('href');
//push the slide info into the slideData array while recording the array index in the slideMap object.
slideMap[thisSlide.link] = slideData.push(thisSlide) - 1;
});
}
Остальная инициализация происходит в методе «show».
//this is the function called from the inline script
function show(startSlide){
if(!chromeBuilt){
buildChrome();
attachEvents();
}
wrapper.show();
//keep track of the viewport size
boundingBox = [ window.innerWidth, window.innerHeight ];
goTo(slideMap[startSlide]);
}
Создание оболочки
Функция «buildChrome» создает HTML-оболочку для лайтбокса и затем устанавливает семафор так, чтобы оболочка не перестраивалась каждый раз, когда пользователь скрывает или показывает лайтбокс. Для простоты использования, я создал отдельную шаблонную функцию для самого HTML:
var wrapperTemplate = function(){
return '<div class="slidewrap">'+
'<div class="controls"><a class="prev" href="#">prev</a> | <a class="next" href="#">next</a></div>'+
'</div>';
}
function buildChrome(){
wrapper = $(wrapperTemplate()).addClass('slidewrap');
$('body').append(wrapper);
chromeBuilt = true;
}
Последний шаг в создании оболочки — добавление обработчика событий для ссылок «Следующая» и «Предыдущая»:
function handleClicks(e){
e.preventDefault();
var targ = $(e.target);
if(targ.hasClass('next')) {
goTo(currentSlide + 1);
} else if(targ.hasClass('prev')){
goTo(currentSlide - 1);
} else {
hide();
}
}
function attachEvents(){
wrapper.on('click', handleClicks, false);
}
Теперь оболочка лайтбокса готова к работе с несколькими слайдами. В моей функции «show» я вызываю «goTo()», для загрузки первого слайда. Эта функция показывает слайд, идентифицированный параметрами, но она также неспеша создает слайды, на случай когда они мне понадобятся. (Важно: не задавайте функцию «goTo» в нижнем регистре, т.к. «goto» — зарезервированное слово в JavaScript).
Создание слайдов
Теперь слайд, на который я смотрю, находится в области просмотра, а предыдущий и следующий слайды — соответственно слева и справа от видимой области экрана. Когда пользователь касается кнопки «Следующий», текущий слайд уходит влево, и замещается следующим слайдом.
//for the slides, takes a "slide" object
function slideTemplate(slide){
return '<div class="slide"><span>'+slide.id+'</span><div style="background-image:url('+slide.url.replace(/_s|_q/, '_z')+')"></div></div>';
}
Я использую вместо , потому что (по крайней мере, сейчас) мобильные браузеры намного медленнее прорисовывают , чем с фоновым изображением. При работе с мобильными устройствами, обычно предпочтительна быстрота работы. Проблемы доступности могут легко быть решены при помощи "ARIA Role".
Сама по себе, функция «buildSlide» более сложна. Помимо движения данных через шаблон слайдов, код должен сделать так, чтобы слайды помещались в область просмотра. Это простая задача выяснения, насколько масштабировать изображение, если оно не соответствует. Мы можем позволить браузеру обрабатывать изменение размеров.
function buildSlide (slideNum) {
var thisSlide, s, img, scaleFactor = 1, w, h;
if(!slideData[slideNum] || slideData[slideNum].node){
return false;
}
var thisSlide = slideData[slideNum];
var s = $(slideTemplate(thisSlide));
var img = s.children('div');
//image is too big! scale it!
if(thisSlide.width > boundingBox[0] || thisSlide.height > boundingBox[1]){
if(thisSlide.width > thisSlide.height) {
scaleFactor = boundingBox[0]/thisSlide.width;
} else {
scaleFactor = boundingBox[1]/thisSlide.height;
}
w = Math.round(thisSlide.width * scaleFactor);
h = Math.round(thisSlide.height * scaleFactor);
img.css('height', h + 'px');
img.css('width', w + 'px');
}else{
img.css('height', thisSlide.height + 'px');
img.css('width', thisSlide.width + 'px');
}
thisSlide.node = s;
wrapper.append(s);
//put the new slide into the start poisition
setPosition(s, boundingBox[0]);
return s;
}
goTo
«goTo» перемещает запрашиваемый и смежные слайды в область просмотра.
function goTo(slideNum){
var thisSlide;
//if the slide we are looking for doesn't exist, lets just go
//back to the current slide. This has the handy effect of providing
//"snap back" feedback when gesturing, the slide will just animate
//back into position
if(!slideData[slideNum]){
return;
}
thisSlide = slideData[slideNum];
//build adjacent slides
buildSlide(slideNum);
buildSlide(slideNum + 1);
buildSlide(slideNum - 1);
//make it fancy
addTransitions(thisSlide.node);
//put the current slide into position
setPosition(thisSlide.node, 0);
//slide the adjacent slides away
if(slideData[slideNum - 1] && slideData[slideNum-1].node){
addTransitions(slideData[slideNum - 1 ].node);
setPosition( slideData[slideNum - 1 ].node , (0 - boundingBox[0]) );
}
if(slideData[slideNum + 1] && slideData[slideNum + 1].node){
addTransitions(slideData[slideNum + 1 ].node);
setPosition(slideData[slideNum + 1 ].node, boundingBox[0] );
}
//update the state
currentSlide = slideNum;
}
На данный момент лайтбокс более или менее функционален. Мы можем перейти к следующему и предыдущему слайду, мы можем скрыть и показать его. Было бы идеально узнать, когда мы достигнем первого или последнего слайда: можно, например, отображать средства управления серым цветом. Это применимо как для десктопов, так и для сенсорных устройств.
Добавление поддержки жестов
Большинство сенсорных устройств имеют встроенные программы просмотра фотографий. Эти различные приложения, следуя оригинальному приложению просмотра фотографий для iPhone, создали соглашение для интерфейсов: скольжение пальца влево показывает следующий слайд. Я видел несколько реализаций такого взаимодействия, которые вообще не дают обратной связи — слайды просто заменяются, когда жест завершен. Правильный подход — дать живую обратную связь. Поскольку пользователь скользит пальцем, слайды должны двигаться вместе с ним, и, в зависимости от направления, должен появляться следующий или предыдущий слайд. Это создает иллюзию, что пользователь вытягивает полосу фотографий.
Обработка сенсорных событий
Много библиотек, в том числе и «Zepto», включают поддержку сенсорных событий. В целом я не рекомендую использовать их. При обработке сенсорных событий, вы обновляете элементы вместе с пользовательскими жестами. Когда задержка заметна для пользователя, это вызывает чувство медленного интерфейса. Одна из главных причин, по которой мы использовали библиотеки для событий, состоит в том, чтобы обеспечить нормализацию в браузерах. У всех мобильных браузеров, которые поддерживают сенсорные события, есть такие же API.
Есть три сенсорных события, которые мы рассмотрим в этом примере: «touchstart», «touchmove» и «touchend». Есть также событие «touchcancel», когда жест прерван по некоторым причинам (например, push-сообщение). При разработке вы должны корректно их обрабатывать.
function attachTouchEvents() {
var bd = document.querySelector('html');
bd.addEventListener('touchmove', handleTouchEvents);
bd.addEventListener('touchstart', handleTouchEvents);
bd.addEventListener('touchend', handleTouchEvents);
}
Обработчик событий получает объект «TouchEvent». События «touchstart» и «touchmove» содержат свойство «touches», которое является объектом массива «Touch». Для скольжения пальцем необходимо только одно свойство: «clientX». Оно содержит значение позиции касания относительно верхнего левого угла страницы.
iOS устройства поддерживают до одиннадцати одновременных касаний. Android (до Ice Cream Sandwich) поддерживает только одно. Большинство взаимодействий требуют только одного касания. Более сложные жесты заставляют беспокоиться о множественных касаниях.
Функция «handleTouchEvents»
Сначала определим несколько переменных вне этой функции для поддержки состояний:
var startPos, endPos, lastPos;
Следующее ответвление основано на свойстве типа объекта события:
function handleTouchEvents(e){
var direction = 0;
//you could also use a switch statement
if(e.type == 'touchstart') {
} else if(e.type == 'touchmove' ) {
} else if(e.type == 'touchend) {
}
Событие «touchstart» запускается в начале любого сенсорного события, поэтому используйте его, чтобы записать, где жест начался, что пригодится позже. Избавьтесь от любых переходов, которые все еще могут быть в тегах.
if(e.type == 'touchstart') {
//record the start clientX
startPos = e.touches[0].clientX;
//lastPos is startPos at the beginning
lastPos = startPos;
//we'll keep track of direction as a signed integer.
// -1 is left, 1 is right and 0 is staying still
direction = 0;
//now we clean off the transtions
if(slideData[currentSlide] && slideData[currentSlide].node){
cleanTransitions(slideData[currentSlide].node);
}
if(slideData[currentSlide + 1] && slideData[currentSlide + 1].node){
cleanTransitions(slideData[currentSlide + 1].node);
}
if(slideData[currentSlide - 1] && slideData[currentSlide -1].node){
cleanTransitions(slideData[currentSlide -1].node);
}
} else if(e.type == 'touchmove' ) {
В «touchmove» определите, сколько касание прошло вдоль «clientX», а затем переместите текущий слайд на такое же расстояние. Если слайд перемещается влево, также переместите следующий слайд, если вправо — соответственно переместите предыдущий слайд. Таким образом вы двигаете только два блока, но это создает иллюзию, что двигается целая полоса.
}else if(e.type == 'touchmove'){
e.preventDefault();
//figure out the direction
if(lastPos > startPos){
direction = -1;
}else{
direction = 1;
}
//make sure the slide exists
if(slideData[currentSlide]){
//move the current slide into position
setPosition(slideData[currentSlide].node, e.touches[0].clientX - startPos);
//make sure the next or previous slide exits
if(direction !== 0 && slideData[currentSlide + direction]){
//move the next or previous slide.
if(direction < 0){
//I want to move the next slide into the right position, which is the same as the
//current slide, minus the width of the viewport (each slide is as wide as the viewport)
setPosition(slideData[currentSlide + direction].node, (e.touches[0].clientX - startPos) - boundingBox[0]);
}else if(direction > 0){
setPosition(slideData[currentSlide + direction].node, (e.touches[0].clientX - startPos) + boundingBox[0]);
}
}
}
//save the last position, we need it for touch end
lastPos = e.touches[0].clientX;
}else if(e.type == 'touchend'){
В конце слайдов необходимо определить дальнейшее поведение: идти по кругу или остановиться на последнем слайде. Если оставлять пользователя на последнем слайде, то при попытке пролистать слайд, нужно возвращать его обратно на свою позицию, таким образом давая пользователю обратную связь относительно того, почему слайд не изменился.
}else if(e.type == 'touchend'){
//figure out if we have moved left or right beyond a threshold
//(50 pixels in this case)
if(lastPos - startPos > 50){
goTo(currentSlide-1);
} else if(lastPos - startPos < -50){
goTo(currentSlide+1);
}else{
//we are not advancing, so we need to "snap back" to the previous position
addTransitions(slideData[currentSlide].node);
setPosition(slideData[currentSlide].node, 0);
if(slideData[currentSlide + 1] && slideData[currentSlide + 1].node){
addTransitions(slideData[currentSlide + 1]);
setPosition(slideData[currentSlide + 1].node, boundingBox[0]);
}
if(slideData[currentSlide - 1] && slideData[currentSlide - 1].node){
addTransitions(slideData[currentSlide - 1]);
setPosition(slideData[currentSlide - 1].node, 0 - boundingBox[0]);
}
}
}
Теперь все основные элементы на месте. У вас есть простой сенсорный лайтбокс!
От переводчика:
Если вы нашли какие-то ошибки перевода, прошу отписаться в личных сообщениях. Спасибо.
Автор: twixxer