Привет!
В Angular CDK в седьмой версии появился виртуальный скролл.
Он отлично работает, когда размер каждого элемента одинаков, — причем прямо «из коробки». Мы просто задаем размер в пикселях и указываем, к какому элементу нужно прокрутить контейнер, сделать ли это плавно, а также можем подписаться на индекс текущего элемента. Однако что делать, если размер элементов меняется? Для этого в CDK предусмотрен интерфейс VirtualScrollStrategy
, реализовав который мы научим скролл работать с нашим списком.
В моем случае требовалось сделать календарь для мобильного представления, который можно прокручивать непрерывно, а количество недель в месяце всегда разное. Попробуем разобраться, что представляет собой стратегия виртуального скролла и напишем свою.
Расчет размеров
Как известно, календари повторяются каждые 28 лет.
Это выполняется, если не учитывать, что год не является високосным, если делится на 100, но не на 400. В нашем случае не нужны годы до 1900 и после 2100. Чтобы 1 января пришлось на понедельник, для ровного счета начнем с 1900 и будем выводить 196 лет. Таким образом, в нашем календаре будет 7 повторяющихся циклов. Отсутствие 29 февраля в 1900 году не помешает, так как это был бы четверг.
Расчеты будут выполняться во время скролла, поэтому чем проще будут вычисления, тем выше производительность. Для этого заведем константу цикла, которая будет состоять из 28 массивов по 12 чисел, отвечающих за высоту каждого месяца:
function getCycle(label: number, week: number): ReadonlyArray<ReadonlyArray<number>> {
return Array.from({length: 28}, (_, 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,
);
}
Вызвав эту функцию без аргументов, получим размер одного цикла, а в дальнейшем сможем находить отступ от верха контейнера для любого месяца любого года из нашего диапазона.
VirtualScrollStrategy
Свою стратегию можно предоставить в виртуальный скролл с помощью токена
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;
}
Функции attach
и detach
отвечают за инициализацию и завершение работы. Самый важный для нас метод onContentScrolled
вызывается каждый раз, когда пользователь прокручивает контейнер (внутри используется debounce через requestAnimationFrame
, чтобы избежать лишних вызовов).
onDataLengthChanged
вызывается, когда изменилось количество элементов в переборе — в нашем случае это никогда не случится. Как правило, в такой ситуации потребовалось бы пересчитать общую высоту и отображаемые сейчас элементы, примерно то же, что необходимо сделать в функции attach
.
onContentRendered
и onRenderedOffsetChanged
вызываются при изменении отображаемой части элементов и изменении отступа до первого элемента. К этим методам обращается CdkVirtualScrollViewport
, когда ему задают новый диапазон отображаемых элементов и задают отступ до первого из них соответственно. Нам это не нужно, так как нет необходимости вызывать методы CdkVirtualScrollViewport
руками. Если же вам это понадобится, то внутри onContentRendered
можно рассчитать новый отступ, а в onRenderedOffsetChanged
— наоборот, диапазон видимых элементов для полученного отступа.
Второй важный для нас метод — 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;
}
Когда мы получим общую высоту полных 28-летних циклов, мы будем перебирать массив, собирая суммарную высоту всех месяцев до тех пор, пока она не превысит искомый отступ. При этом проверять на превышение будем половину высоты каждого месяца (CYCLE[year][month] / 2
), чтобы найти не просто самый верхний видимый месяц, а ближайший к верхней границе. Это понадобится в будущем для плавного подкручивания на начало месяца после завершения скролла.
Остается написать самую главную функцию, отвечающую за отрисовку элементов видимой области:
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>
Осталось реализовать плавную подкрутку к ближайшему месяцу по окончании скролла. Тут есть нюансы.
Дело в том, что скролл на мобильных устройствах продолжается после того, как палец отпустил поверхность. Поэтому нам будет непросто понять момент, когда необходимо выполнить выравнивание текущего месяца. Для этого воспользуемся RxJs. Подпишемся на событие touchstart
и будем ждать последующего touchend
. После его наступления применим оператор race, чтобы узнать, продолжается ли скролл, или палец отпустили без ускорения. Если в течение промежутка времени SCROLL_DEBOUNCE_TIME
не возникло события скролла, то мы выравниваем текущий месяц. Иначе мы ждем, пока остаточный скролл прекратится. При этом нужно добавить 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');
});
Тут надо заметить, что для плавной прокрутки scrollToIndex
в Angular CDK используется нативная реализация, а она не работает в Safari. Поправить это можно, написав свой плавный скролл через requestAnimationFrame
внутри написанной нами стратегии в методе scrollToIndex
.
Вывод
Благодаря DI и предусмотрительности команды Angular мы смогли гибко настроить виртуальный скролл под себя. На первый взгляд, реализация виртуального скролла для элементов с изменяющейся высотой кажется сложной задачей.
Однако, когда есть возможность рассчитать высоту каждого элемента, написать свою стратегию оказалось довольно просто. Главное, чтобы этот расчет выполнялся быстро, ведь он будет вызываться часто. Если вам понадобится выводить большое количество карточек, на которых могут присутствовать или отсутствовать элементы, влияющие на их высоту, — продумайте эффективный алгоритм получения высоты и не бойтесь написать свою стратегию.
Автор: Александр Инкин