CSS Deep-Dive — матрица3d() для идеальной пользовательской полосы прокрутки

Пользовательские полосы прокрутки встречаются крайне редко, и это в основном связано с тем, что полосы прокрутки — это один из оставшихся элементов в Интернете, которые практически невозможно стилизовать (я смотрю на тебя, выбор даты). Вы можете использовать JavaScript для создания своего собственного, но это дорого, с низкой точностью и может оказаться медленным. В этой статье мы воспользуемся некоторыми нетрадиционными CSS-матрицами для создания собственного скроллера, который не требует никакого JavaScript при прокрутке, а только некоторый код настройки.

ТЛ;ДР

Вас не волнуют мелочи? Вы просто хотите посмотреть демо-версию Nyan Cat и получить библиотеку? Код демо-версии вы можете найти в нашем репозитории на GitHub .

LAM;WRA (длинно и математически; все равно прочитаю)

Некоторое время назад мы создали скроллер с параллаксом (вы читали эту статью ? Она действительно хороша и стоит потраченного времени!). Отодвигая элементы назад с помощью 3D-преобразований CSS, элементы перемещались медленнее , чем наша фактическая скорость прокрутки.

Резюме

Давайте начнем с краткого обзора того, как работает скроллер параллакса.

Как показано в анимации, мы добились эффекта параллакса, сдвигая элементы «назад» в трехмерном пространстве вдоль оси Z. Прокрутка документа по сути является перемещением по оси Y. Таким образом, если мы прокрутим вниз , скажем, на 100 пикселей, каждый элемент будет переведен вверх на 100 пикселей. Это относится ко всем элементам, даже к тем, которые находятся «дальше назад». Но поскольку они находятся дальше от камеры, их наблюдаемое движение на экране будет меньше 100 пикселей, что обеспечит желаемый эффект параллакса.

Конечно, перемещение элемента обратно в пространстве также сделает его меньше, что мы исправим, увеличив масштаб элемента. Мы выяснили точную математику, когда создавали скроллер параллакса , поэтому я не буду повторять все детали.

Шаг 0: Что мы хотим сделать?

Полосы прокрутки. Это то, что мы собираемся построить. Но задумывались ли вы когда-нибудь о том, что они делают? Я, конечно, нет. Полосы прокрутки — это индикатор того, какая часть доступного контента в данный момент видна и какого прогресса вы достигли как читатель. Если вы прокрутите вниз, то же самое сделает и полоса прокрутки, указывающая, что вы приближаетесь к концу. Если весь контент помещается в область просмотра, полоса прокрутки обычно скрыта. Если высота содержимого в два раза превышает высоту области просмотра, полоса прокрутки заполняет половину высоты области просмотра. Содержимое, высота которого в три раза превышает высоту области просмотра, масштабирует полосу прокрутки до ⅓ области просмотра и т. д. Вы видите закономерность. Вместо прокрутки вы также можете щелкнуть и перетащить полосу прокрутки, чтобы быстрее перемещаться по сайту. Это удивительное поведение для такого незаметного элемента. Давайте сражаться по одному сражению за раз.

Шаг 1: Ставим наоборот

Хорошо, мы можем заставить элементы двигаться медленнее, чем скорость прокрутки, с помощью 3D-преобразований CSS, как описано в статье о параллаксной прокрутке. Можем ли мы также изменить направление? Оказывается, мы можем, и это наш путь к созданию идеальной, настраиваемой полосы прокрутки. Чтобы понять, как это работает, нам нужно сначала охватить несколько основ CSS 3D.

Чтобы получить какую-либо перспективную проекцию в математическом смысле, вам, скорее всего, придется использовать однородные координаты . Я не буду вдаваться в подробности, что это такое и почему они работают, но вы можете думать о них как о трехмерных координатах с дополнительной, четвертой координатой, называемой w . Эта координата должна быть равна 1, за исключением случаев, когда вы хотите получить искажение перспективы. Нам не нужно беспокоиться о деталях w , поскольку мы не собираемся использовать какое-либо другое значение, кроме 1. Поэтому с этого момента все точки являются 4-мерными векторами [x, y, z, w=1] и, следовательно, матрицами. тоже должен быть 4х4.

Один из случаев, когда вы можете увидеть, что CSS использует однородные координаты под капотом, — это когда вы определяете свои собственные матрицы 4x4 в свойстве преобразования с помощью функции matrix3d() . matrix3d ​​принимает 16 аргументов (поскольку матрица имеет размер 4x4), указывая один столбец за другим. Таким образом, мы можем использовать эту функцию, чтобы вручную указать повороты, перемещения и т. д. Но она также позволяет нам возиться с этой координатой w !

Прежде чем мы сможем использовать matrix3d() , нам нужен 3D-контекст, потому что без 3D-контекста не было бы никаких искажений перспективы и не было бы необходимости в однородных координатах. Чтобы создать 3D-контекст, нам нужен контейнер с perspective и некоторыми элементами внутри, которые мы можем трансформировать во вновь созданном 3D-пространстве. Например :

Часть кода CSS, которая искажает элемент div с помощью атрибута перспективы CSS.

Элементы внутри контейнера перспективы обрабатываются движком CSS следующим образом:

  • Превратите каждый угол (вершину) элемента в однородные координаты [x,y,z,w] относительно контейнера перспективы.
  • Примените все преобразования элемента в виде матриц справа налево .
  • Если элемент перспективы прокручивается, примените матрицу прокрутки.
  • Примените перспективную матрицу.

Матрица прокрутки представляет собой перемещение по оси Y. Если мы прокрутим вниз на 400 пикселей, все элементы нужно будет переместить на 400 пикселей вверх . Матрица перспективы — это матрица, которая «подтягивает» точки ближе к точке схода, чем дальше они находятся в трехмерном пространстве. Это позволяет достичь обоих эффектов: предметы кажутся меньше, когда они находятся дальше назад, а также заставляет их «двигаться медленнее» при переводе. Таким образом, если элемент сдвинут назад, сдвиг на 400 пикселей приведет к тому, что элемент переместится на экране только на 300 пикселей.

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

Наш блок находится внутри контейнера перспективы со значением p для атрибута perspective . Предположим, что контейнер прокручивается и прокручивается вниз на n пикселей.

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

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

Однако для нашей полосы прокрутки мы хотим обратного — мы хотим, чтобы наш элемент перемещался вниз при прокрутке вниз . Вот где мы можем использовать трюк: инвертируем координату w углов нашего прямоугольника. Если координата w равна -1, все перемещения будут действовать в противоположном направлении. Так как же нам это сделать? Движок CSS позаботится о преобразовании углов нашего прямоугольника в однородные координаты и установит w равным 1. Пришло время для matrix3d() проявить себя!

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

Эта матрица не будет делать ничего, кроме отрицания w . Поэтому, когда движок CSS превратил каждый угол в вектор формы [x,y,z,1] , матрица преобразует его в [x,y,z,-1] .

Единичная матрица четыре на четыре с минус один на p в четвертой строке, третий столбец, умноженный на четыре на четыре, единичная матрица с минус n во второй строке, четвертый столбец, умноженный на четыре на четыре, единичная матрица с минус единицей в четвертой строке, четвертый столбец, умноженный на четырехмерный вектор x, y, z, 1 соответствует единичной матрице четыре на четыре, где минус один над p в третьем столбце четвертой строки, минус n во второй строке, четвертом столбце и минус один в четвертом столбце четвертой строки равняется четырехмерному вектору x, y плюс n, z, минус z над p минус 1.

Я перечислил промежуточный шаг, чтобы показать эффект нашей матрицы преобразования элементов. Если вас не устраивает матричная математика, ничего страшного. Момент Эврики заключается в том, что в последней строке мы добавляем смещение прокрутки n к нашей координате y, а не вычитаем его. Элемент будет переведен вниз , если мы прокрутим вниз .

Однако если мы просто поместим эту матрицу в наш пример , то элемент отображаться не будет. Это связано с тем, что спецификация CSS требует, чтобы любая вершина с w < 0 блокировала визуализацию элемента. А поскольку наша координата z в данный момент равна 0, а p равна 1, w будет равно -1.

К счастью, мы можем выбрать значение z! Чтобы убедиться, что в итоге мы получим w=1, нам нужно установить z = -2.

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

И вот, наша коробочка вернулась !

Шаг 2: Заставьте его двигаться

Теперь наша коробка находится на месте и выглядит так же, как и без каких-либо преобразований. Сейчас контейнер перспективы не прокручивается, поэтому мы его не видим, но мы знаем , что наш элемент будет двигаться в другом направлении при прокрутке. Итак, давайте заставим контейнер прокручиваться, хорошо? Мы можем просто добавить разделительный элемент, который занимает место:

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

А теперь прокрутите поле ! Красное поле опускается.

Шаг 3. Укажите размер.

У нас есть элемент, который перемещается вниз при прокрутке страницы. На самом деле это трудная часть. Теперь нам нужно стилизовать его так, чтобы он выглядел как полоса прокрутки, и сделать его более интерактивным.

Полоса прокрутки обычно состоит из «ползуна» и «дорожки», при этом дорожка не всегда видна. Высота большого пальца прямо пропорциональна тому, какая часть содержимого видна.

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight — это высота прокручиваемого элемента, а scroller.scrollHeight — общая высота прокручиваемого содержимого. scrollerHeight/scroller.scrollHeight — это часть видимого содержимого. Соотношение вертикального пространства, которое закрывает большой палец, должно быть равно соотношению видимого контента:

Высота точки в стиле большого пальца по отношению к ScrollerHeight равна высоте прокрутки над высотой прокрутки точки скроллера тогда и только тогда, когда высота точки в стиле большого пальца равна высоте прокрутки, умноженной на высоту прокрутки над высотой прокрутки точки скроллера.
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

Размер большого пальца выглядит хорошо , но он движется слишком быстро. Здесь мы можем использовать нашу технику из скроллера параллакса. Если мы переместим элемент дальше назад, при прокрутке он будет двигаться медленнее. Мы можем исправить размер, увеличив его. Но насколько точно мы должны отодвинуть его назад? Давайте займемся – как вы уже догадались – математикой! Это последний раз, обещаю.

Важнейшая информация заключается в том, что мы хотим, чтобы нижний край большого пальца совпадал с нижним краем прокручиваемого элемента при прокрутке до конца вниз. Другими словами: если мы прокрутили пиксели scroller.scrollHeight - scroller.height , мы хотим, чтобы наш большой палец переводился с помощью scroller.height - thumb.height . Мы хотим, чтобы для каждого пикселя скроллера наш большой палец перемещался на долю пикселя:

Коэффициент равен высоте точки прокрутки минус высота точки большого пальца над высотой прокрутки точки прокрутки минус высота точки прокрутки.

Это наш коэффициент масштабирования. Теперь нам нужно преобразовать коэффициент масштабирования в сдвиг по оси Z, что мы уже делали в статье о параллаксной прокрутке. Согласно соответствующему разделу спецификации : коэффициент масштабирования равен p/(p - z). Мы можем решить это уравнение для z, чтобы выяснить, насколько нам нужно переместить большой палец вдоль оси Z. Но имейте в виду, что из-за наших махинаций с координатой w нам нужно сдвинуть дополнительные -2px вдоль z. Также обратите внимание, что преобразования элемента применяются справа налево, а это означает, что все переводы до нашей специальной матрицы не будут инвертированы, однако все переводы после нашей специальной матрицы будут инвертированы! Давайте это кодифицируем!

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

У нас есть полоса прокрутки ! И это всего лишь элемент DOM, который мы можем стилизовать по своему усмотрению. С точки зрения доступности важно сделать так, чтобы большой палец реагировал на нажатие и перетаскивание, поскольку многие пользователи привыкли таким образом взаимодействовать с полосой прокрутки. Чтобы не делать этот пост еще длиннее, я не буду объяснять детали этой части. Если вы хотите узнать, как это делается, посмотрите код библиотеки .

А что насчет iOS?

Ах, мой старый друг iOS Safari. Как и в случае с прокруткой параллакса, здесь мы сталкиваемся с проблемой. Поскольку мы прокручиваем элемент, нам нужно указать -webkit-overflow-scrolling: touch , но это приведет к сглаживанию 3D, и весь эффект прокрутки перестанет работать. Мы решили эту проблему в скроллере с параллаксом, обнаружив iOS Safari и полагаясь на position: sticky в качестве обходного пути, и здесь мы сделаем то же самое. Чтобы освежить память, прочтите статью о параллаксе .

А как насчет полосы прокрутки браузера?

В некоторых системах нам придется иметь дело с постоянной встроенной полосой прокрутки. Исторически полосу прокрутки нельзя скрыть (кроме нестандартного псевдоселектора ). Поэтому, чтобы скрыть это, нам придется прибегнуть к некоторому (без математических вычислений) хакерству. Мы обертываем наш элемент прокрутки в контейнер с помощью overflow-x: hidden и делаем элемент прокрутки шире, чем контейнер. Собственная полоса прокрутки браузера теперь скрыта из поля зрения.

Плавник

Объединив все это, мы теперь можем создать идеальную пользовательскую полосу прокрутки — как в нашей демонстрации Nyan Cat .

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

Вот оно. Это была большая работа. Я аплодирую вам за то, что вы прочитали все это. Это настоящая хитрость, чтобы заставить это работать, и, вероятно, редко стоит затраченных усилий, за исключением случаев, когда настраиваемая полоса прокрутки является важной частью работы. Но приятно знать, что это возможно, не так ли? Тот факт, что создать собственную полосу прокрутки так сложно, показывает, что со стороны CSS есть над чем поработать. Но не бойтесь! В будущем AnimationWorklet от Houdini сделает подобные эффекты прокрутки с идеальным кадром намного проще.