Кроссбраузерная кастомизация системного скроллбара
Проблема размещения непрерывного контента произвольного объёма в экран, или окно, фиксированных размеров, существует несколько десятков лет. Примерно столько же существует и лучшее решение этой проблемы: элемент графического интерфейса — скроллбар. Ниже можно узнать, как в ближайшее время будет работать скролл в 2ГИС Онлайн. Механизм системного скролла реализуется на уровне базовых возможностей операционной системы, поэтому с уверенностью можно сказать, что он всегда лучше js-эмуляции: он производительнее, работает независимо от JavaScript и реализует все необходимые «фичи» системы для разного типа устройств. Дизайн же системного скроллбара, особенно Windows младше 8 версии, способен изуродовать значительную часть сайтов интернета. То, что не все согласны с этим мириться, подтверждается фактом наличия большого числа решений, программно меняющих системный скроллбар на кастомный. Сейчас в 2ГИС Онлайн (и, соответственно, в API 2ГИС) мы используем FleXcroll: он эмулирует механизм скролла и не подходит нам по ряду причин:
Все эти факторы заставили нас задуматься над двумя вопросами:
Нами были сформированы основные требования к решению, которое должно менять визуальное представление скроллбара:
ОграниченияНа момент написания статьи, более или менее гибко кастомизировать скроллбар средствами CSS можно только в браузерах на движке webkit. Цвета скроллбара можно поменять в браузере Internet Explorer. В остальных браузерах поддержка кастомизации скроллбара через CSS полностью отсутствует. Отчасти это связано с жёсткой позицией w3c:
Существующие решенияИз того большого числа 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, хотя по-умолчанию использует его. Например, в самом простом случае (без фиксации заголовков), инициализация выглядит так: baron($('.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. Внедрение данного решения запланировано на начало марта. |