company_banner

Кастомный скроллбар в 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.


    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'))
          );
        })
      );

    Обновлённый пример тут. Там же можно увидеть методы, в которые убрана вся математика из кода выше.

    Tinkoff
    it’s Tinkoff — просто о сложном

    Comments 12

      –8

      Обожаю современный веб: пишешь не на Angular? Проходи мимо, код для тебя бесполезен. Отсюда и ценность статьи — чуть выше, чем «как сложить 2+2 на jQuery».

        +6

        Для тех кто меняет скролбар и выделение приготовлен отдельный котёл...

          +3

          И поведение при скролле (все эти лендинги с плавной прокруткой и прочее).

          +2
          Если речь не о каких-то неюзерфрендли веб-приложениях, то менять скрол-бар по мне это зло. К машине тоже можно приделать пропеллер на крышу, но зачем?
            0

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

              0
              Честно говоря, не очень понимаю, в чём преимущество тянуть ползунок, по сравнению с тянуть контент на телефоне, но поскольку это кастомный скроллбар, тут, как раз, такое поведение вполне можно реализовать, просто задачи такой не стояло. Возможно достаточно будет заменить события мышки на тачи и, соответственно, координаты будут браться по другому. Возможно как-то иначе. В любом случае, это просто DIV`ы, на которых слушаются события и сделать можно всё, что хочется.
                0

                Когда контента очень много и принято его полностью загружать (например тред на борде в 500+ постов), хочется тянуть за скроллбар. Кнопки быстрого перемещения обычно скроллят строго к первому либо последнему посту, а мне вот вдруг в середину захотелось.

              +1

              Смешанные чувства, однако.
              С одной стороны — прикольно.
              С другой — заявлена декларативность, но механика скроллинга все равно через прямую работу с nativeElement. А раз так, то зачем это все? Не будет ли проще в директиве напрямую повесить nativeElement.addEventListener / document.addEventListener и сразу автоматом избавиться от нежелательного change detection? Тогда и .silent не надо с обвеской из декораторов.

                0
                Ну тут без работы со свойствами nativeElement никак. Мне кажется довольно прозрачно и наглядно получилось, в целом немного императива. Вообще я думал, что через директиву будет запутаннее для статьи, поэтому не стал её делать, но сейчас переписал на директиву и стало совсем классно (через один аутпут из fromEvent):
                stackblitz.com/edit/angular-scrollbar-component-directive?file=src/app/scrollbar/draggable.directive.ts
                Спасибо за коммент :) Добавлю в статью.
                  +1

                  Годно! Как по мне, observable сам по себе достаточно декларативен.

                0

                Иногда тебе просто говорят, что это нужно сделать и деваться некуда. Такой вопрос: будет ли продолжение с реализацией такого скроллбара, которых бы делал свою магию при динамическом создании и обёртывании элементов? Помог бы при работе со всякими материаловскими таблицами или wijmo

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

                Only users with full accounts can post comments. Log in, please.