Angular CDK имеет широкие возможности для скроллинга плоского списка. Если размер каждого элемента одинаков, то можно воспользоваться FixedSizeVirtualScrollStrategy: всего лишь нужно прокинуть размер элемента в пикселях, проитерироваться по данным и виртуальный скроллинг готов. Но что делать, если размер элементов разный? Данную проблему можно решить кастомной стратегией виртуального скроллинга.

Введение

Для моей задачи требовалось реализовать просмотрщик pdf-документа. Решение pdf.js не подходило, потому что этот инструмент не справлялся с рендерингом некоторых страниц в документе и обязательно требовал наличие самого файла на клиентской части. Этого мы не могли себе позволить, ибо имели действительно большие документы и не хотелось ждать, пока они скачаются. Поэтому было необходимо грузить документ частично. Но как тогда реализовать скроллинг? Наивно предполагать, что документ состоит только из страниц А4. В нашем случае, файл мог содержать все форматы страниц от А0 до А5. Это свойственно документам эксплуатации строительства, где содержатся много чертежей и пояснительных схем. Раз страницы разной размерности, то стандартная стратегия виртуального скроллинга для фиксированного размера элемента не подходит.

Исследование формата

Если посмотреть, как устроен формат pdf, то можно выяснить, что документ состоит из изображений и метаинформации. В метаинформацию входит:

  • Текстовый слой. Разметка слов или фраз покоординатно относительно изображения. А также шрифты, ссылки, содержание и другое.

  • Размеры каждой страницы.

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

План был такой:

  • Первым запросом получить необходимую метаинформацию для построения стратегии

  • Каждый отрендеренный элемент будет дополнительным запросом грузить в себя изображение страницы.

  • Метаинформацию для текстового слоя можно было бы грузить следом: дополнительным запросом.

Я не буду детально рассматривать реализацию текстового слоя, потому что это бы нас увело от основой повествовательной нити. Этот слой можно отрисовывать поверх изображения с прозрачным цветом согласно полученным координатам. Тогда на документе работали бы все выделения, копирования и клики. Если посмотреть, как реализованы все популярные веб-просмотрщики (dropbox, google drive, acrobat cloud), то вы обнаружите именно такую реализацию.

Итак, нам с сервера пришла необходимая метаинформация с размерами страниц. Пришло время построить стратегию виртуального скроллинга.

Стратегия виртуального скроллинга

С помощью токена VIRTUAL_SCROLL_STRATEGY (и всей красоты Dependency Injection) мы можем предоставить свой класс стратегии виртуального скролинга PdfVirtualScrollStrategy:

function pdfVirtualScrollStrategyFactory({
  pdfVirtualScrollStrategy,
}: PDFViewerScrollingComponent): PdfVirtualScrollStrategy {
  return pdfVirtualScrollStrategy;
}

@Component({
  /** ... */
  providers: [
    {
      provide: VIRTUAL_SCROLL_STRATEGY,
      useFactory: pdfVirtualScrollStrategyFactory,
      deps: [forwardRef(() => PDFViewerScrollingComponent)],
    },
  ]
})
export class PDFViewerScrollingComponent {
  /** ... */

  public readonly pdfVirtualScrollStrategy: PdfVirtualScrollStrategy = new PdfVirtualScrollStrategy();
  
  /** ... */
}

В данном случае класс стратегии я предоставляю через фабрику, которая берет инстанс из текущего компонента. Для создания стратегии в классе необходимо реализовать интерфейс VirtualScrollStrategy:

export interface VirtualScrollStrategy {
   scrolledIndexChange: Observable<number>;
   attach(viewport: CdkVirtualScrollViewport): void;
   detach(): void;
   onContentScrolled(): void;
   onDataLengthChanged(): void;
   onContentRendered(): void;
   onRenderedOffsetChanged(): void;
   scrollToIndex(index: number, behavior: ScrollBehavior): void;
}

Здесь функции attach, detach отвечают за привязку и, соотвественно, отвязку стратегии к вьюпорту. Метод onContentScrolled вызывается на каждое изменение скролла. За изменением текущего индекса списка можно следить через scrolledIndexChange, а просколлить к нужному индексу можно через методscrollToIndex. В нашем случае, методы onDataLenghtChanged, onContentRendered, onRenderedOffsetChanged использоваться не будут, так как у нас не будет меняться длина списка, да мы и не будем обращаться к отступам списка через компонент CdkVirtualScrollViewport в обход стратегии.

Так как метаинформация с размерами приходит с сервера, то мы имеем следующий поток pagesData$: Observable<PdfPageMetadataModel.View[]>. Чтобы предоставить эти данные в стратегию необходимо воспользоваться заранее подготовленным методом setData:

export namespace PdfPageMetadataModel {
  export interface View {
    pageWidth: number;
    pageHeight: number;
  }
}

private updatePagesDataToScrollStrategy(): Subscription {
  return this.pagesData$.subscribe((pagesData: PdfPageMetadataModel.View[]) => {
    this.pdfVirtualScrollStrategy.setData(pagesData);
  });
}

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

Настало время показать сам класс стратегии виртуального скроллинга. Здесь будут показаны самые простые методы, а сложные мы рассмотрим отдельно.

interface PageDataOffset {
  startOffset: number;
  endOffset: number;
}

export class PdfVirtualScrollStrategy implements VirtualScrollStrategy {
  private viewport: CdkVirtualScrollViewport | null = null;

  private readonly index$: Subject<number> = new Subject<number>();
  public readonly scrolledIndexChange: Observable<number> = this.index$.pipe(distinctUntilChanged());

  private pagesData: PdfPageMetadataModel.View[] = [];
  private pagesDataOffsets: PageDataOffset[] = [];

  private totalHeight: number = 0;
  private maxPageHeight: number = 0;

  public setData(pagesData: PdfPageMetadataModel.View[], rangeStartIndex: number | undefined = undefined): void {
    this.pagesData = pagesData;
    this.rangeStartIndex = rangeStartIndex;

    this.setTotalHeight();
    this.setMaxPageHeight();
    this.setPagesDataOffsets();
    this.updateRenderedRange();
  }

  public attach(viewport: CdkVirtualScrollViewport): void {
    this.viewport = viewport;
    this.updateRenderedRange();
  }

  public detach(): void {
    this.index$.complete();
    this.viewport = null;
  }

  public onContentScrolled(): void {
    if (isNil(this.viewport)) {
      return;
    }

    this.updateRenderedRange();
  }

  public onDataLengthChanged(): void {
    // not needed
  }

  public onContentRendered(): void {
    // not needed
  }

  public onRenderedOffsetChanged(): void {
    // not needed
  }

  public scrollToIndex(index: number, behavior?: ScrollBehavior): void {
    if (isNil(this.viewport)) {
      return;
    }

    const offset: number = this.getOffsetForIndex(index);
    this.viewport.scrollToOffset(offset, behavior);
  }

  private setTotalHeight(): void {
    if (isNil(this.viewport)) {
      return;
    }

    const totalHeight: number = this.pagesData.reduce(
      (accumulatorHeight: number, { pageHeight }: PdfPageMetadataModel.View) => accumulatorHeight + pageHeight,
      0
    );
    this.totalHeight = totalHeight;
    this.viewport.setTotalContentSize(totalHeight);
  }

  private setMaxPageHeight(): void {
    this.maxPageHeight = this.pagesData.reduce(
      (accumulatedValue: number, { pageHeight }: PdfPageMetadataModel.View) =>
        pageHeight > accumulatedValue ? pageHeight : accumulatedValue,
      0
    );
  }

	private setPagesDataOffsets(): void {
    this.pagesDataOffsets = this.pagesData.reduce(
      (accumulatorHeights: PageDataOffset[], { pageHeight }: PdfPageMetadataModel.View, currentIndex: number) => {
        if (currentIndex === 0) {
          return [{ startOffset: 0, endOffset: pageHeight }];
        }

        const previousIndex: number = currentIndex - 1;
        const previousAccumulatedValue: PageDataOffset = accumulatorHeights[previousIndex];

        return [
          ...accumulatorHeights,
          {
            startOffset: previousAccumulatedValue.endOffset,
            endOffset: pageHeight + previousAccumulatedValue.endOffset,
          },
        ];
      },
      []
    );
  }

	/** ... */
}

В методе setData мы устанавливаем начальные значения данных размеров страниц, где через rangeStartIndex можно установить, с какой страницы необходимо показывать документ. Для моей задачи требовалось уметь из скроллинга переключиться в постраничный слайдер и обратно. Поэтому хотелось отрисовывать страницы сразу с предустановленной, что не вызывало бы дополнительных запросов. Если не передавать это значение или задать undefined, то по умолчанию начальной страницей будет первая. Далее в методе setTotalHeight мы считаем общую высоту всех страниц totalHeight. Аналогично находим и устанавливаем самую большую страницу по высоте в методе setMaxPageHeight. Это значение нам понадобится для вычисления буффера. Далее в методе setPagesDataOffsets вычисляем отступ каждой страницы от самого начала документа в двух значениях: стартовом и конечном. Например, для первой страницы начальный отступ от верха равен нулю, а конечный равен высоте страницы. Для последующих страниц аналогично: начальный отступ равен конечному отступу предыдущей страницы, а конечный — сумме конечного отступа предыдущей страницы с высотой текущей страницы.

Для расчетов нам понадобится уметь вычислять индекс страницы по оффсету getIndexForOffset, а также оффсет по индексу страницы getOffsetForIndex. Однако так как мы заранее посчитали начальный и конечный отступы для каждой страницы, то необходимые выкладки становятся достаточно естественными:

private getOffsetForIndex(index: number, mode: 'start' | 'end' = 'start'): number {
  if (isEmpty(this.pagesDataOffsets)) {
    return undefined;
  }

  return mode === 'start' ? this.pagesDataOffsets[index]?.startOffset : this.pagesDataOffsets[index]?.endOffset;
}

private getIndexForOffset(offset: number, mode: 'start' | 'end' = 'start'): number | undefined {
  if (isEmpty(this.pagesDataOffsets) || this.totalHeight === 0) {
    return undefined;
  }

  const possibleIndex: number = this.pagesDataOffsets.findIndex(({ startOffset, endOffset }: PageDataOffset) =>
    mode === 'start' ? Math.trunc(startOffset) > offset : Math.trunc(endOffset) > offset
  );

  if (possibleIndex < 0 && this.totalHeight <= offset * 2) {
    return this.pagesDataOffsets.length;
  }

  return Math.max(possibleIndex, 0);
}

В методе getIndexForOffset условием possibleIndex < 0 && this.totalHeight <= offset * 2 мы гарантируем, что индекс последней страницы установится, если мы проскроллили больше половины списка.

Теперь остается рассмотреть самый главный метод в классе виртуальной стратегии, отвечающий за отрисовку конкретного слайса списка.

private updateRenderedRange(): void {
  if (isNil(this.viewport) || isEmpty(this.pagesData)) {
    return;
  }

  const scrollOffset: number = isNil(this.rangeStartIndex)
      ? this.viewport.measureScrollOffset()
      : this.getOffsetForIndex(this.rangeStartIndex);
    const currentRange: ListRange = isNil(this.rangeStartIndex)
      ? this.viewport.getRenderedRange()
      : { start: this.rangeStartIndex, end: this.rangeStartIndex };

  const { start: rangeStart, end: rangeEnd }: ListRange = currentRange;
  const viewportSize: number = this.viewport.getViewportSize();
  const dataLength: number = this.viewport.getDataLength();

  const buffer: number = Math.max(2 * viewportSize, 2 * this.maxPageHeight);

  const newRange: ListRange = { start: rangeStart, end: rangeEnd };

  const firstVisibleIndex: number = this.getIndexForOffset(scrollOffset, 'end');

  const currentIndex: number = this.getIndexForOffset(
    scrollOffset + viewportSize * INDEX_CHANGE_VIEWPORT_PROPORTION,
    'end'
  );

  const startBufferOffset: number = scrollOffset - this.getOffsetForIndex(rangeStart);
  const endBufferOffset: number = this.getOffsetForIndex(rangeEnd) - (scrollOffset + viewportSize);

  const rangeStartIsFirst: boolean = rangeEnd === 0;
  const rangeEndIsLast: boolean = rangeEnd === dataLength;

  const update: VoidFunction = () => {
    this.viewport.setRenderedRange(newRange);
    const renderedContentOffset: number = this.getOffsetForIndex(newRange.start);
    this.viewport.setRenderedContentOffset(renderedContentOffset);
    this.rangeStartIndex = undefined;
    this.index$.next(currentIndex);
  };

  if (startBufferOffset < buffer && !rangeStartIsFirst) {
    const expandStartIndex: number = this.getIndexForOffset(buffer - startBufferOffset, 'end');

    const possibleStartIndex: number = rangeStart - expandStartIndex;
    const possibleEndIndex: number = firstVisibleIndex + this.getIndexForOffset(viewportSize + buffer, 'end');

    newRange.start = Math.max(0, possibleStartIndex);
    newRange.end = Math.min(dataLength, possibleEndIndex);

    update();
    return;
  }

  if (endBufferOffset < buffer && !rangeEndIsLast) {
    const expandEndIndex: number = this.getIndexForOffset(buffer - endBufferOffset, 'end');

    if (expandEndIndex === 0) {
      update();
      return;
    }

    const possibleStartIndex: number = firstVisibleIndex - this.getIndexForOffset(buffer, 'end');
    const possibleEndIndex: number = rangeEnd + expandEndIndex;
    newRange.start = Math.max(0, possibleStartIndex);
    newRange.end = Math.min(dataLength, possibleEndIndex);

    update();
    return;
  }

  update();
}

Рассмотрим все по порядку. Если индекс начальной страницы не установлен, то забираем значения scrollOffset и currentRange из вьюпорта. Однако, если установлен, то выставляем эти значения сами. Аналогично для дальнейших расчетов нам еще понадобится размер вьюпорта viewportSize и общая длина данных dataLength. Для скроллинга мы хотим грузить видимый слайс с некоторым запасом для более мягкой работы. Для меня это значение изначально было равно двум размерам видимой области. Однако, если есть потребность управлять масштабом страниц, то значение буффера должно быть коррелировано с высотой максимальной страницы в документе. Поэтому буффер у меня вычисляется как максимум двух этих значений:

const buffer: number = Math.max(2 * viewportSize, 2 * this.maxPageHeight);

Первый видимый индекс страницы firstVisibleIndex высчитывается по scrollOffset: this.getIndexForOffset(scrollOffset, 'end'). Здесь важно заметить, что мы страницу считаем видимой, если видна ее нижняя граница, поэтому мы используем режим ‘end’ в методе getIndexForOffset. Текущий индекс страницы высчитывается иначе: мы считаем страницу текущей, если ее нижняя граница ниже половины видимой области. Сама пропорция относительно вьюпорта устанавливается константой const INDEX_CHANGE_VIEWPORT_PROPORTION: number = 0.5;. Так, например, если установить это значение в 0.7, то текущая страница будет та, у которой нижняя граница меньше 70% видимой области.

Относительно видимого слайса данных, мы также имеем страницы которые находятся в буфферной зоне. Для понимания границ этой зоны сверху и снизу относительно позиции скроллбара мы вычитываем оффсеты startBufferOffset и endBufferOffset. Когда при прокрутке у нас смещаются эти границы, если нам достаточно места, то мы обновляем возвращаемый слайс. Само обновление происходит в функции update, которая забирает данные из замыкания. Здесь нам необходимо обновить отрендеренный диапазон данных, отступ сверху до контента, передать индекс текущей страницы, а также обнулить переданный стартовый индекс, чтобы он участвовал в вычислениях лишь в первый раз.

Дополнительно

Квадрирование

Если вы рассчитываете повторить мой опыт, то вы должны быть в курсе режимов отображения документа. Обычно страницы отображают в исходных размерах, однако это может быть неудобно, когда документ содержит достаточное количество страниц формата А0, ведь их неудобно смотреть из-за больших размеров. Можно автоматически вычислить такой масштаб страниц, чтобы они точно влезли в область просмотра. Чтобы посчитать фактор квадрирования, необходимо знать размер видимой области, размер страницы и ее отступы по краям:

private calculateSquaringFactor(
  { pageWidth, pageHeight }: PdfPageMetadataModel.View,
  { width: containerWidth, height: containerHeight }: DOMRectReadOnly
): number {
  const buffer: number = 2 * this.pageBoundOffsetPx;

  const squareFactorX: number = (containerWidth - buffer) / pageWidth;
  const squareFactorY: number = (containerHeight - buffer) / pageHeight;

  return Math.min(squareFactorX, squareFactorY);
}

Тогда новые размеры страницы можно вычислить, если их умножить на это значение.

Есть два варианта, как применить квадрирование:

  1. Лишь к одной странице. Остальные страницы можно отмасштабировать относительно нее. Тогда мы будем точно гарантировать, что первая страница поместилась для любого размера вьюпорта, что полезно для адаптива. Обычно такой страницей выбирают либо первую, либо самую большую по размерам.

  2. К каждой странице. Тогда мы можем гарантировать, что все страницы помещаются во вьюпорт.

Максимальная ширина документа

Так как виртуальный скроллинг отрисовывает документ по слайсам, то при определенном масштабе появляется горизонтальный скролл. Однако, если при прокрутке подгружается страница большая по ширине, чем предыдущие, то документ может прыгнуть по горизонтали в пустое пространство. И тогда пользователь не поймет, что произошло. Чтобы избежать такого поведения, необходимо знать максимальную ширину документа. Это значение необходимо выставить скроллящемуся контейнеру, тогда у документа всегда верный горизонтальный скроллбар вне зависимости от текущего слайса.

Максимальная ширина документа равна максимальной ширине страниц. Выставить это значение необходимо блоку с классом .cdk-virtual-scroll-content-wrapper, ведь именно он отвечает за скроллбары у контента. Сделать это можно через css-переменные:

<div class="scrollable-content" [style.--document-renderer-content__width.px]="maxWidthOfScrollingContainer$ | async">
  <cdk-virtual-scroll-viewport
    class="scroll-viewport document-renderer-scrolling-cdk-viewport"
  >
    <!--  -->
  </cdk-virtual-scroll-viewport>
</div>

Здесь мы контейнеру выставили значение css-переменной через [style.--document-renderer-content__width.px]="maxWidthOfScrollingContainer$ | async", а на скроллящемся вьюпорте выставили класс document-renderer-scrolling-cdk-viewport. Тогда в глобальных стилях мы можем применить это значение

.document-renderer-scrolling-cdk-viewport {
  .cdk-virtual-scroll-content-wrapper {
    width: var(--document-renderer-content__width);
  }
}

Слои

Отдельные страницы документа могут весить достаточно много. К примеру, архитектурный план здания в масштабе страницы А0 в хорошем качестве может весить около 200мб. Можете представить, насколько долго будет загружаться такая страница. Однако зачем нам загружать сразу хорошее качество? И действительно, для демонстрации скроллинга это необязательно, ведь достаточно загрузить страницу лишь в том качестве, которое требует область просмотра. К примеру, если размеры вьюпорта 1600х900, то и изображение можно запросить с сервера с шириной 1600. Больше пикселей, чем это значение, все равно уместить не удастся. Конечно, можно подумать о ретина-экранах и грузить чуть больше — но это уже детали реализации.

К тому же можно подумать о превью страниц в осознанно плохом качестве. Тогда они будут быстро отдаваться и можно будет быстро показывать контент пользователю. Реализовать это можно, если снабдить запрос параметром scale. Тогда scale: 1 будет отдавать изображение в оригинальных размерах, scale: 3 — увеличенное изображение в три раза, а scale: 0.05 — миниатюру страницы. Логика следующая: отправляем два запроса одновременно, превью отобразится быстро, а как только загрузится изображение лучше — необходимо будет переключить источник на него.

Когда изображение рендерится, оно может вызывать перекомпоновку слоев, что вызовет блокировку скролла. Для ux это может быть критично. Чтобы избежать такого поведения, необходимо вынести каждую страницу в отдельный слой через свойство will-change: transform;. Тогда браузер будет манипулировать лишь им, и это не будет вызывать рекалькуляции всего скроллящегося контейнера.

Выводы

Когда размеры элементов предсказуемы или заранее известны, то кастомную стратегию виртуального скроллинга составить не вызовет особого труда, благодаря гибкости Angular CDK. Просмотрщик pdf-документа как раз тот вариант, когда это может потребоваться. Так что если перед вами стоит задача реализовать свой веб-реадер pdf, то теперь вы знаете, как создать максимально производительный скроллинг. Однако не забудьте учесть квадрирование, максимальные размеры документа, а также работу со слоями.


Реализацию стратегии виртуального скроллинга для pdf-документов можно посмотреть здесь. В данном примере для имитации запросов к серверу используются интерсепторы. А также, чтобы не утяжелять демонстрацию, приводится работа лишь стратегии с примененным квадрированием к каждой странице без других дополнений, указанных в статье.

Комментарии (0)