Кроссбраузерная кастомизация системного скроллбара

в 5:57, , рубрики: html, javascript, Блог компании ДубльГИС (2ГИС), метки: ,

Кроссбраузерная кастомизация системного скроллбара

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

Под катом можно узнать, как в ближайшее время будет работать скролл в 2ГИС-онлайн.

Механизм системного скролла реализуется на уровне базовых возможностей операционной системы, поэтому с уверенностью можно сказать, что он всегда лучше js-эмуляции: он производительнее, работает независимо от JavaScript и реализует все необходимые «фичи» системы для разного типа устройств.

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

Сейчас в 2ГИС Онлайн мы используем FleXcroll: он эмулирует механизм скролла и не подходит нам по ряду причин:

  • Не является кроссплатформенным решением (в частности, плохо работает на Mac)
  • Как и любой эмулятор в принципе, имеет проблемы с производительностью
  • Не имеет встроенного механизма фиксации заголовков.

Все эти факторы заставили нас задуматься над двумя вопросами:

  • существует ли нужное нам готовое решение, или необходимо создавать собственное?
  • возможно ли в принципе сохранить системный механизм скролла, но полностью заменить его дизайн?

Нами были сформированы основные требования к решению, которое должно менять визуальное представление скроллбара:

  1. Системный механизм скролла должен быть сохранён: коррекции подвергается только его дизайн.
  2. Общий размер всех зависимостей должен быть минимизирован. В идеальном случае их не должно быть вообще. Должен быть минимизирован размер и самого решения.
  3. Должен присутствовать механизм фиксации заголовков контента при выходе их за поле зрения, либо простой интерфейс для добавления такого механизма (подробнее об этом пункте см. ниже).

Ограничения

На момент написания статьи, более или менее гибко кастомизировать скроллбар средствами CSS можно только в браузерах на движке webkit. Цвета скроллбара можно поменять в браузере Internet Explorer. В остальных браузерах поддержка кастомизации скроллбара через CSS полностью отсутствует. Отчасти это связано с жёсткой позицией w3c:

There are just some things that CSS should not do, full stop.

Существующие решения

Кроссбраузерная кастомизация системного скроллбара

Из того большого числа js-библиотек более половигы подменяют нативный механизм скролла. Это значит, что для враппера свойство overflow выставляется в значение hidden, а вложенный контейнер с нужным нам контентом меняет свою абсолютную позицию при генерации событий, связанных со скроллом (например, mousewheel). К таким решениям можно отнести: jScrollPane, Scrollbar Paper, jQuery Custom Scrollbar plugin, FleXcroll, Tiny Scrollbar и многие другие.

При таком подходе возникает сразу два фундаментальных недостатка: отсутствие кроссбраузерности и отсутствие же кроссплатформенности. Дело в том, что интерфейс событий, генерируемых действиями пользователя, при помощи которых пользователь что-то скроллит, существенно отличается от браузера к браузеру: с точки зрения стандартов тут творится настоящий бардак. Более того, последовательность и логика «бросания» событий серьёзно отличается и между платформами. Например, трекпады на платформе Mac при скролле генерируют события типа Wheel с большей частотой, чем колесо обычной мыши, что приводит к чересчур быстрому скроллу в ряде подобных решений.

Именно эти недостатки эмуляции скролла привели нас к формулированию первого пункта требований.

Многие решения изначально позиционируются как плагины к jQuery. В ситуации, когда мы используем jQuery по частям, возникает проблема экономии трафика. Проблема существенно растёт при наличии у плагина зависимости от куда более тяжеловесного jQuery UI. Это касается, например, ShortScroll и Vertical Scroll. А также от ряда других библиотек: например, один из немногих jQuery плагинов, сохраняющих нативный механизм скролла, Scrollbars, зависит от 4 плагинов: event.drag, resize, mousehold и mousewheel общим весом более 10 кб.

Третьему пункту требований не удовлетворяет ни одно из найденных нами решений.

Собственное решение

У решения есть две основных задачи: 1) скрыть системный скроллбар и 2) отобразить кастомный скроллбар.

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

Для начала, построим html структуру:

<div class='wrapper' id='wrapper'>
	<div class='scroller' id='scroller'>
		<article class='container' id='container'>
		</article>
	</div>
</div>

где container — собственно, то, что мы хотим скроллировать; scroller — блок, в который по высоте не помещается container, но у него выставлено свойство overflow-y: scroll, что приводит к появлению системного скроллбара у его правой границы; wrapper — окно с шириной чуть меньшей, чем у scroller и свойством overflow: hidden. Ширина меньше ровно на ширину скроллбара scroller.

К сожалению, средствами CSS невозможно точно узнать ширину системного скроллбара. Например, не работает вариант с выставлением ширины 125% для scroller и 80% для container, при котором, казалось бы, ширины container и wrapper должны точно совпасть. Можно сделать scroller заведомо шире, а wrapper и container выставить одинаковую ширину, но такой способ не подходит для резиновой вёрстки и порождает баг в webkit браузерах (см. ниже).

Введём js-переменные:

var wrapper = document.getElementById('wrapper'),
scroller = document.getElementById('scroller'),
container = document.getElementById('container');

Теперь мы можем вычислить ширину системного скроллбара: scroller.offsetWidth — это ширина scroller, включающая в себя border, padding, а также системный скроллбар. Если мы обнулим border и padding при помощи CSS, и вычтем scroller.clientWidth, мы получим искомую ширину скроллбара в пикселях.

В webkit-браузерах существует особенность, заставляющая скроллиться элементы при выделении в них текста в горизонтальном направлении, даже при overflow-x: hidden. То есть scroller начинает двигаться по горизонтали внутри меньшего по ширине wrapper, в результате чего обнажается скрытый нами системный скроллбар. К счастью, в webkit-браузерах, и только в них, мы можем обнулить ширину скроллбара средствами CSS, после чего ширины всех трёх блоков в точности совпадут и места для горизонтального скролла просто не будет:

.scroller::-webkit-scrollbar {
    width: 0;
}

Теперь нарисуем и спозиционируем кастомный скроллбар. Для этого минимально усложним html структуру на 1 элемент, который и будет полностью отвечать за визуальное представление скроллбара:

<div class='wrapper' id='wrapper'>
	<div class='scroller' id='scroller'>
		<article class='container' id='container'>
		</article>
		<div class='scroller__bar'></div>
	</div>
</div>

Здесь важно отметить, что для нашей задачи не требовалась прорисовка кнопок «вверх» и «вниз», а также подстилающего «трека». Впрочем, никаких ограничений для их реализации нет.

При реализации драга нарисованного кастомного скроллбара, главное — запретить выделение текста внутри скроллируемого контента. Для этого достаточно сделать вот такой бинд:

function dontStartSelect() {
	return false;
}

function selection(on) {
	if (on) {
		$(document).on('selectstart', dontStartSelect);
	} else {
		$(document).off('selectstart', dontStartSelect);
	}
}

event(bar, 'mousedown', function(e) {
	e.preventDefault();
	selection(false); // Disable text selection in IE8
});

event(document, 'mouseup blur', function() {
	selection(true); // Enable text selection in IE8
});

Как видите, большая часть кода нужна для браузера IE8, который пока, к сожалению, нельзя сбрасывать со счетов. Обратите внимание, что сброс «нажатого» состояния мыши должен происходить не только при отпускании кнопки мыши, но и при потере страницей фокуса (blur).

Прилипающие заголовки

Некоторые элементы внутри container всегда должны быть видны пользователю. Например, это могут быть заголовки разделов какой-то статьи, при клике на которые (это дополнительный функционал, который выходит за рамки решения) контент «перематывался» бы к кликнутому заголовку.

Ни абсолютное (относительно scroller), ни относительное (относительно своих начальных позиций) позиционирование заголовков в чистом виде, в данном случае не подходит. Первое — по причине схлапывания контента, окружающего заголовок и соответствующих им рывков при скролле; второе — по причине нативных неустранимых transition'ов у браузера Internet Explorer для скролла, которые приводят к дрожанию всех зафиксированных заголовков.

В связи с этим, html структуру пришлось ещё немного усложнить, обернув все заголовки в врапперы, задача которых — сохранять место под заголовками при их вырывании из потока во время фиксации абсолютным позиционированием. В принципе, аналогичного результата можно добиться подбором отрицательных margin'ов для заголовков и секций, к которым они относятся — тогда можно обойтись и без врапперов заголовков.

При каждом скролле для каждого заголовка необходимо проверять условие:

scroller.scrollTop - H[i].offsetTop > Sum(H[j].offsetHeight, j=0..i-1)
scroller.scrollTop - H[i].offsetTop < scroller.clientHeight - Sum(H[j].offsetHeight, j=i..n-1)

где H — массив элементов заголовков; i — номер заголовка, меняется в диапазоне от 0 до n-1; scroller.scrollTop — виртуальное расстояние от верхней границы container до верхней границы видимой части container; H[i].offsetTop — расстояние от верхней границы container до верхней границы заголовка H[i].

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

Пробрасывание события mousewheel

В webkit браузерах мы столкнулись с неприятным багом: событие mousewheel не пробрасывалось с фиксированных заголовков вверх к scroller. Это создавало эффект поломки (внезапного прекращения) скролла при попадании под курсор фиксированного заголовка (например, в результате всё того же скролла). То есть, пользователь крутит колесо мыши, под курсор попадает заголовок, фиксируется, и, внезапно, скролл перестаёт работать (точнее, скроллиться начинает вся страница).

К счастью, в webkit браузерах (а нам требовалось обратиться только к ним) есть такая возможность:

$(headers).on('mousewheel', function(e) {
	var evt = document.createEvent('WheelEvent');
	evt.initWebKitWheelEvent(e.originalEvent.wheelDeltaX, e.originalEvent.wheelDeltaY);
	scroller.dispatchEvent(evt); // Пробрасываем событие на scroller
	e.preventDefault(); // Останавливаем скролл страницы
});

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

Пример

Для минимизации объёма js-кода и зависимостей был использован подход, при котором библиотека не является плагином jQuery, хотя по-умолчанию использует его.

Например, в самом простом случае (без фиксации заголовков), инициализация выглядит так:

acBar($('.wrapper'), {
	scroller: '.scroller',
	container: '.container',
	bar: '.scroller__bar'
});

Причём $('.wrapper') может быть массивом html объектов — проинициализируется каждый из них.

Если вы хотите фиксации заголовков, ограничения верхнего положения скроллбара, и использования альтернатив jQuery, инициализация немного усложняется:

baron($('.test_advanced'), {
	scroller: '.scroller',
	container: '.container',
	bar: '.scroller__bar',
	barOnCls: '.scroller__bar_state_on', // Класс, навешиваемый скроллбару, когда он должен быть видимым
	barTop: 40, // Ограничитель позиции скроллбара сверху
	header: '.header__title',
	hFixCls: 'header__title_state_fixed', // Класс, навешиваемый зафиксированным заголовкам
	selector: qwery, // Селектор
	event: function(elem, event, func, off) { // Менеджер событий
		if (off) {
			bean.off(elem, event, func);
		} else {
			bean.on(elem, event, func);
		}
	},
	dom: bonzo // Библиотека для работы с DOM
});

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

Тестирование

В тестировании принимали участие актуальные версии браузеров Chrome, Firefox, Opera, Safari и Internet Explorer (IE) на платформах Windows, Mac и iOs (тестировались, конечно, только существующие версии браузеров :)). Кроме этого, тестировался IE9 и IE8. Все тесты на всех браузерах проходят нормально.

Особенность предлагаемого в данной статье решения заключается в том, что даже в случае каких-то ошибок в JavaScript, скролл всё равно будет работать, поскольку он системный, поэтому категорию риска попадают лишь браузеры устаревших версий Android и Opera Mini, в которых системный скролл на элементах не реализован, или реализован плохо.

Итог

Предложенное решение позволяет сохранить механизм системного скролла, заменить его дизайн, полностью отказаться от жестких зависимостей от других библиотек, и уложиться в 998 байт сжатого кода (минифицированного и сжатого gzip). В довесок к этому, есть механизм фиксации каких-либо элементов скроллируемого контента (например, заголовков).

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

Код решения можно скачать с Github.
Демо.

Внедрение данного решения запланировано на начало марта.

Автор: Diokuz

Источник

Поделиться

* - обязательные к заполнению поля