После вступления Edge в доблестные ряды Chromium-браузеров кастомизация скроллбаров через CSS отсутствует только в Firefox. Это здорово, но кроме Firefox у CSS-решения есть масса ограничений. Посмотрите, какую черную магию приходится применять для плавного исчезновения. Чтобы получить полный контроль над внешним видом, по-прежнему нужно прибегать к JavaScript. Давайте разберемся, как это по-хорошему сделать через компонент Angular.
Магия CSS
Без нее, конечно, не обойтись. Во-первых, нам нужно скрыть нативные скроллбары. Это уже можно сделать во всех браузерах. Для этого есть стандартное правило scrollbar-width
, которое на данный момент работает как раз только в Firefox. Для старых версий Edge и IE есть его аналог -ms-overflow-style
. Однако IE мы поддерживать не будем, так как нам понадобится position: sticky
. В Chrome и Safari уже давно можно использовать псевдоселектор ::-webkit-scrollbar
.
Во-вторых, чтобы не нарушать нативную работу и скроллить сам контейнер, нам нужно обойтись без вложенной скроллящейся обертки. А значит, необходимо реализовать что-то вроде локального position: fixed
для ползунков. Абсолютное позиционирование не поможет: при прокрутке ползунки будут исчезать из виду вместе с содержимым.
Этого мы сможем добиться с помощью хитрой комбинации position: sticky
у ползунков и display: flex
у самого компонента. Внутри нам понадобятся два контейнера:
<div class="bars">...</div>
<div class="content"><ng-content></ng-content></div>
Для начала мы изолируем контекст наложения. Не все про это знают, из-за чего на проектах часто можно встретить z-index: 1000, 1001, 9999
. Чтобы ничто из контента не смогло перекрыть ползунки, мы повесим на него position: relative, z-index: 0
. Это создаст в его рамках новый контекст наложения и не позволит внутренним элементам перекрыть что-то снаружи.
Ползункам зададим z-index: 1
, чтобы поднять их выше контента, а также минимальную ширину 100%. Получим следующую картину:
Ползунки заняли все место, сдвинув содержимое вправо. При этом прокрутка работает и ползунки никуда не деваются. Остается добавить им margin-right: -100%
, чтобы они «подвинулись» и освободили место под содержимое компонента.
В принципе, этого можно добиться и без флекса, используяfloat
, но высоту обертки для ползунков не удастся сделать на 100%, если высота самого скроллбара задана неявно (max-height, flex: 1
и так далее).
Angular
Если вы читали другие мои статьи про Angular, то знаете, что я большой любитель декларативного подхода. Этот случай не станет исключением. Постараемся написать код максимально аккуратно. В качестве примера возьмем вертикальную прокрутку, для горизонтальной все будет идентично. Добавим в шаблон ползунок:
<div class="bars">
<div *ngIf="hasVerticalBar" class="bar">
<div
class="thumb"
[class.thumb_active]="verticalThumbActive"
[style.height.%]="verticalView"
[style.top.%]="verticalThumb"
></div>
</div>
</div>
Для задания его внешнего вида используются геттеры:
// На сколько процентов компонент проскроллили
get verticalScrolled(): number {
const {
scrollTop,
scrollHeight,
clientHeight
} = this.elementRef.nativeElement;
return scrollTop / (scrollHeight - clientHeight);
}
// Какой процент содержимого виден
get verticalSize(): number {
const { clientHeight, scrollHeight } = this.elementRef.nativeElement;
return Math.ceil(clientHeight / scrollHeight * 100);
}
// На сколько процентов сдвинут ползунок
get verticalPosition(): number {
return this.verticalScrolled * (100 - this.verticalSize);
}
// Содержимое не уместилось, виден ползунок
get hasVerticalBar(): boolean {
return this.verticalSize < 100;
}
Теперь нам нужно просто слушать скролл, чтобы запускать проверку изменений, — и наш компонент начнет работать с колесиком или трекпадом. Осталось научиться двигать ползунки мышкой. Эту логику стоит вынести в отдельную директиву, но для прозрачности сейчас мы это делать не будем.
Перемещение ползунка начинается с событияmousedown
на нем, выполняется с событиемmousemove
на документе и завершается событиемmouseup
на документе.
Добавим обработчики на ползунок:
<div
#vertical
class="thumb"
[class.thumb_active]="verticalThumbActive"
[style.height.%]="verticalSize"
[style.top.%]="verticalPosition"
(mousedown)="onVerticalStart($event)"
(document:mousemove)="onVerticalMove($event, vertical)"
></div>
А в коде компонента будем обрабатывать эти события и слушать mouseup
:
@HostListener('document:mouseup)
onDragEnd() {
this.verticalThumbActive = false;
}
onVerticalStart(event: MouseEvent) {
event.preventDefault();
const { target, clientY } = event;
const { top, height } = target.getBoundingClientRect();
this.verticalThumbDragOffset = (clientY - top) / height;
this.verticalThumbActive = true;
}
onVerticalMove(
{ clientY }: MouseEvent,
{ offsetHeight }: HTMLElement
) {
if (!this.verticalThumbActive) {
return;
}
const { nativeElement } = this.elementRef;
const { top, height } = nativeElement.getBoundingClientRect();
const maxScrollTop = nativeElement.scrollHeight - height;
const scrolled =
(clientY - top - offsetHeight * this.verticalThumbDragOffset) /
(height - offsetHeight);
nativeElement.scrollTop = maxScrollTop * scrolled;
}
Теперь ползунки тоже работают.
Магия Angular
Бывалый ангулярщик может заметить, что у этого решения крайне низкая производительность. На каждый экземпляр скроллбара мы слушаем все события mousemove
на документе и каждый раз запускаем проверку изменений. Так дело не пойдет. К счастью, Angular позволяет работать с событиями иначе, о чем я писал ранее. Воспользовавшись библиотекой @tinkoff/ng-event-plugins, мы избавимся от лишних вызовов проверки изменений. Для этого добавим модификатор .silent
к подписке и декоратор @shouldCall
к обработчику:
(document:mousemove.silent)="onVerticalMove($event, vertical)"
@shouldCall(isActive)
@HostListener('init.end', ['$event'])
@HostListener('document:mouseup.silent')
onDragEnd() {
this.verticalThumbActive = false;
}
@shouldCall(isActive)
@HostListener('init.move', ['$event'])
onVerticalMove(
{ clientY }: MouseEvent,
{ offsetHeight }: HTMLElement
) {
const { nativeElement } = this.elementRef;
const { top, height } = nativeElement.getBoundingClientRect();
const maxScrollTop = nativeElement.scrollHeight - height;
const scrolled =
(clientY - top - offsetHeight * this.verticalThumbDragOffset) /
(height - offsetHeight);
nativeElement.scrollTop = maxScrollTop * scrolled;
}
Примечание: до выхода Angular 10 с методомmarkDirty(this)
вместе с@shouldCall
приходится использовать специальный декоратор@HostListener(‘init.xxx’, [‘$event’])
, чтобы запустить проверку изменений, подробнее — в упомянутой статье.
Теперь проверка изменений и обработчики событий будут запускаться, только когда мы действительно тянем ползунок. Наш скроллбар готов к использованию. В качестве доработки еще можно следить за изменениями размера самого контейнера, чтобы пересчитать размер ползунков. Для этого отлично подойдет ResizeObserver
и наша библиотека @ng-web-apis/resize-observer, о которой вы можете почитать тут. Полноценное демо скроллбара доступно на Stackblitz.
Edit: Директива
Как справедливо заметили в комментариях, получилось не так декларативно, как хотелось бы. Попробуем уложить логику ползунков в директиву. Для этого будет достаточно одного Output объявленного через fromEvent, что избавляет код от состояний и лишней императивности:
@Output()
dragged = fromEvent(
this.elementRef.nativeElement,
'mousedown'
).pipe(
switchMap(event => {
event.preventDefault();
const clientRect = event.target.getBoundingClientRect();
const offsetVertical = getOffsetVertical(event, clientRect);
const offsetHorizontal = getOffsetHorizontal(event, clientRect);
return fromEvent(this.documentRef, 'mousemove').pipe(
map(event => this.getScrolled(event, offsetVertical, offsetHorizontal)),
takeUntil(fromEvent(this.documentRef, 'mouseup'))
);
})
);
Обновлённый пример тут. Там же можно увидеть методы, в которые убрана вся математика из кода выше.
pepelsbey
Обожаю современный веб: пишешь не на Angular? Проходи мимо, код для тебя бесполезен. Отсюда и ценность статьи — чуть выше, чем «как сложить 2+2 на jQuery».