Хабрахабр

Пишем свою стратегию для виртуального скролла из Angular CDK

Привет!

В Angular CDK в седьмой версии появился виртуальный скролл.

Мы просто задаем размер в пикселях и указываем, к какому элементу нужно прокрутить контейнер, сделать ли это плавно, а также можем подписаться на индекс текущего элемента. Он отлично работает, когда размер каждого элемента одинаков, — причем прямо «из коробки». Для этого в CDK предусмотрен интерфейс VirtualScrollStrategy, реализовав который мы научим скролл работать с нашим списком. Однако что делать, если размер элементов меняется?

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

image

Расчет размеров

Как известно, календари повторяются каждые 28 лет.

В нашем случае не нужны годы до 1900 и после 2100. Это выполняется, если не учитывать, что год не является високосным, если делится на 100, но не на 400. Таким образом, в нашем календаре будет 7 повторяющихся циклов. Чтобы 1 января пришлось на понедельник, для ровного счета начнем с 1900 и будем выводить 196 лет. Отсутствие 29 февраля в 1900 году не помешает, так как это был бы четверг.

Для этого заведем константу цикла, которая будет состоять из 28 массивов по 12 чисел, отвечающих за высоту каждого месяца: Расчеты будут выполняться во время скролла, поэтому чем проще будут вычисления, тем выше производительность.

function getCycle(label: number, week: number): ReadonlyArray<ReadonlyArray<number>> , (_, i) => Array.from( {length: 12}, (_, month) => label + weekCount(i, month) * week, ), );
}

Количество недель в месяце нам поможет посчитать такая нехитрая функция: На вход эта функция получает высоту заголовка месяца и высоту одной недели (64 и 48 пикселей соответственно, для гифки выше).

function weekCount(year: number, month: number): number { const firstOfMonth = new Date(year + STARTING_YEAR, month, 1); const lastOfMonth = new Date(year + STARTING_YEAR, month + 1, 0); const days = lastOfMonth.getDate() + (firstOfMonth.getDay() || 7) - 1; return Math.ceil(days / 7);
}

Результат сохраним в константу const CYCLE = getCycle(64, 48);.

Напишем функцию, которая позволит рассчитать высоту по году и месяцу внутри цикла:

function reduceCycle(lastYear: number = 28, lastMonth: number = 12): number { return CYCLE.reduce( (total, year, yearIndex) => yearIndex <= lastYear ? total + year.reduce( (sum, month, monthIndex) => yearIndex < lastYear || (yearIndex === lastYear && monthIndex < lastMonth) ? sum + month : sum, 0, ) : total, 0, );
}

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

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

{ provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy,
},

Наш класс должен реализовывать интерфейс 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;
}

Самый важный для нас метод onContentScrolled вызывается каждый раз, когда пользователь прокручивает контейнер (внутри используется debounce через requestAnimationFrame, чтобы избежать лишних вызовов). Функции attach и detach отвечают за инициализацию и завершение работы.

Как правило, в такой ситуации потребовалось бы пересчитать общую высоту и отображаемые сейчас элементы, примерно то же, что необходимо сделать в функции attach. onDataLengthChanged вызывается, когда изменилось количество элементов в переборе — в нашем случае это никогда не случится.

К этим методам обращается CdkVirtualScrollViewport, когда ему задают новый диапазон отображаемых элементов и задают отступ до первого из них соответственно. onContentRendered и onRenderedOffsetChanged вызываются при изменении отображаемой части элементов и изменении отступа до первого элемента. Если же вам это понадобится, то внутри onContentRendered можно рассчитать новый отступ, а в onRenderedOffsetChanged — наоборот, диапазон видимых элементов для полученного отступа. Нам это не нужно, так как нет необходимости вызывать методы CdkVirtualScrollViewport руками.

Второй важный для нас метод — scrollToIndex — позволяет прокрутить контейнер до нужного элемента, а его противоположность — scrolledIndexChange — даст возможность отслеживать текущий видимый элемент.

Для начала создадим все простые методы, а затем рассмотрим основной код:

export class MobileCalendarStrategy implements VirtualScrollStrategy { private index$ = new Subject<number>(); private viewport: CdkVirtualScrollViewport | null = null; scrolledIndexChange = this.index$.pipe(distinctUntilChanged()); attach(viewport: CdkVirtualScrollViewport) { this.viewport = viewport; this.viewport.setTotalContentSize(CYCLE_HEIGHT * 7); this.updateRenderedRange(this.viewport); } detach() { this.index$.complete(); this.viewport = null; } onContentScrolled() { if (this.viewport) { this.updateRenderedRange(this.viewport); } } scrollToIndex(index: number, behavior: ScrollBehavior): void { if (this.viewport) { this.viewport.scrollToOffset(this.getOffsetForIndex(index), behavior); } } // ...
}

Для первой задачи подойдет написанная нами функция reduceCycle: Для работы со скроллом нам нужно уметь получать индекс элемента по отступу и наоборот — отступ по индексу.

private getOffsetForIndex(index: number): number { const month = index % 12; const year = (index - month) / 12; return this.computeHeight(year, month);
} private computeHeight(year: number, month: number): number { const remainder = year % 28; const remainderHeight = reduceCycle(remainder, month); const fullCycles = (year - remainder) / 28; const fullCyclesHeight = fullCycles * CYCLE_HEIGHT; return fullCyclesHeight + remainderHeight;
}

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

private getIndexForOffset(offset: number): number { const remainder = offset % CYCLE_HEIGHT; const years = ((offset - remainder) / CYCLE_HEIGHT) * 28; let accumulator = 0; for (let year = 0; year < CYCLE.length; year++) { for (let month = 0; month < CYCLE[year].length; month++) { accumulator += CYCLE[year][month]; if (accumulator - CYCLE[year][month] / 2 > remainder) { return Math.max((years + year) * MONTHS_IN_YEAR + month, 0); } } } return 196;
}

При этом проверять на превышение будем половину высоты каждого месяца (CYCLE[year][month] / 2), чтобы найти не просто самый верхний видимый месяц, а ближайший к верхней границе. Когда мы получим общую высоту полных 28-летних циклов, мы будем перебирать массив, собирая суммарную высоту всех месяцев до тех пор, пока она не превысит искомый отступ. Это понадобится в будущем для плавного подкручивания на начало месяца после завершения скролла.

Остается написать самую главную функцию, отвечающую за отрисовку элементов видимой области:

private updateRenderedRange(viewport: CdkVirtualScrollViewport) { const offset = viewport.measureScrollOffset(); const viewportSize = viewport.getViewportSize(); const {start, end} = viewport.getRenderedRange(); const dataLength = viewport.getDataLength(); const newRange = {start, end}; const firstVisibleIndex = this.getIndexForOffset(offset); const startBuffer = offset - this.getOffsetForIndex(start); if (startBuffer < BUFFER && start !== 0) { newRange.start = Math.max(0, this.getIndexForOffset(offset - BUFFER * 2)); newRange.end = Math.min( dataLength, this.getIndexForOffset(offset + viewportSize + BUFFER), ); } else { const endBuffer = this.getOffsetForIndex(end) - offset - viewportSize; if (endBuffer < BUFFER && end !== dataLength) { newRange.start = Math.max(0, this.getIndexForOffset(offset - BUFFER)); newRange.end = Math.min( dataLength, this.getIndexForOffset(offset + viewportSize + BUFFER * 2), ); } } viewport.setRenderedRange(newRange); viewport.setRenderedContentOffset(this.getOffsetForIndex(newRange.start)); this.index$.next(firstVisibleIndex);
}

Рассмотрим всё по порядку.

Затем найдем первый видимый элемент и отступ у самого первого отрендеренного элемента. Мы запросим у CdkVirtualScrollViewport текущий отступ, размер контейнера, текущий показанный диапазон и общее число элементов.

Для этого у нас есть константа BUFFER, отвечающая за то, на сколько пикселей вверх и вниз от видимой области мы продолжаем отрисовывать элементы. После этого нам нужно понять, как изменить диапазон текущих элементов и отступ до первого из них, чтобы виртуальный скролл плавно подгружал элементы и не дергался при пересчете высоты. Если верхний отступ стал меньше буфера и при этом выше есть еще элементы, мы изменим диапазон, добавив сверху достаточно элементов для двукратного покрытия буфера. В моем случае я использую 500px. Так как мы скроллим вверх — внизу достаточно одного буфера. Скорректируем так же конец диапазона. То же самое, но в другую сторону выполняем при прокрутке вниз.

Передаем наружу текущий видимый индекс. Затем назначаем CdkVirtualScrollViewport новый диапазон и считаем отступ для его первого элемента.

Использование

Добавляем ее в провайдеры компонента, как было показано выше, и используем CdkVirtualScrollViewport в шаблоне: Наша стратегия готова.

<cdk-virtual-scroll-viewport (scrolledIndexChange)="activeMonth = $event"
> <section *cdkVirtualFor="let month of months; templateCacheSize: 10" > <h1>{{month.name}}</h2> <our-calendar [month]="month"></our-calendar> </section>
</cdk-virtual-scroll-viewport>

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

Поэтому нам будет непросто понять момент, когда необходимо выполнить выравнивание текущего месяца. Дело в том, что скролл на мобильных устройствах продолжается после того, как палец отпустил поверхность. Подпишемся на событие touchstart и будем ждать последующего touchend. Для этого воспользуемся RxJs. Если в течение промежутка времени SCROLL_DEBOUNCE_TIME не возникло события скролла, то мы выравниваем текущий месяц. После его наступления применим оператор race, чтобы узнать, продолжается ли скролл, или палец отпустили без ускорения. При этом нужно добавить takeUntil(touchstart$), так как инерционный скролл может быть остановлен новым касанием и тогда весь стрим должен вернуться к началу: Иначе мы ждем, пока остаточный скролл прекратится.

const touchstart$ = touchStartFrom(monthsScrollRef.elementRef.nativeElement);
const touchend$ = touchEndFrom(monthsScrollRef.elementRef.nativeElement);
// Smooth scroll to closest month after scrolling is done
touchstart$ .pipe( switchMap(() => touchend$), switchMap(() => race( monthsScrollRef.elementScrolled(), timer(SCROLL_DEBOUNCE_TIME), ).pipe( debounceTime(SCROLL_DEBOUNCE_TIME * 2), take(1), takeUntil(touchstart$), ), ), ) .subscribe(() => { monthsScrollRef.scrollToIndex(this.activeMonth, 'smooth'); });

Поправить это можно, написав свой плавный скролл через requestAnimationFrame внутри написанной нами стратегии в методе scrollToIndex. Тут надо заметить, что для плавной прокрутки scrollToIndex в Angular CDK используется нативная реализация, а она не работает в Safari.

Вывод

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

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

Показать больше

Похожие публикации

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

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

Кнопка «Наверх»