Кастомный скроллбар в Angular

—

от автора

После вступления 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.

ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/501610/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *