Escrevemos nossa estratégia de rolagem virtual a partir do Angular CDK

Oi


No CDK Angular, um pergaminho virtual apareceu na sétima versão.


Funciona muito bem quando o tamanho de cada elemento é o mesmo - e pronto para uso. Simplesmente definimos o tamanho em pixels e indicamos para qual elemento o contêiner deve rolar, se deve fazê-lo sem problemas, e também podemos assinar o índice do elemento atual. No entanto, e se o tamanho dos elementos mudar? Para isso, a interface VirtualScrollStrategy é fornecida no CDK, implementando a qual ensinaremos o pergaminho a trabalhar com nossa lista.


No meu caso, era necessário criar um calendário para uma apresentação móvel, que pode ser rolada continuamente, e o número de semanas em um mês é sempre diferente. Vamos tentar descobrir o que é uma estratégia de rolagem virtual e escrever sua própria.


imagem


Cálculo do tamanho


Como você sabe, os calendários são repetidos a cada 28 anos.


Isso é feito se você não levar em conta que o ano não é bissexto, se é divisível por 100, mas não por 400. No nosso caso, não precisamos de anos antes de 1900 e depois de 2100. Para janeiro cair na segunda-feira, começaremos a partir de 1900 por uma conta uniforme e iremos retirar 196 anos. Assim, em nosso calendário, haverá 7 ciclos repetidos. A ausência de 29 de fevereiro de 1900 não vai doer, já que seria quinta-feira.



Os cálculos serão realizados durante a rolagem; portanto, quanto mais simples os cálculos, maior o desempenho. Para isso, criaremos uma constante de loop, que consistirá em 28 matrizes de 12 números, responsáveis ​​pela altura de cada mês:


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

Na entrada, essa função recebe a altura do título do mês e a altura de uma semana (64 e 48 pixels, respectivamente, para o gif acima). O número de semanas em um mês nos ajudará a calcular uma função tão simples:


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

O resultado é armazenado na constante const CYCLE = getCycle(64, 48); .


Escreveremos uma função que permite calcular a altura por ano e mês dentro do ciclo:


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

Ao chamar essa função sem argumentos, obtemos o tamanho de um ciclo e, no futuro, podemos encontrar o recuo da parte superior do contêiner para qualquer mês de qualquer ano do nosso intervalo.


VirtualScrollStrategy


Você pode enviar sua estratégia para um pergaminho virtual usando um token
VIRTUAL_SCROLL_STRATEGY :


 { provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, }, 

Nossa classe deve implementar a interface 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; } 

As funções attach e detach são responsáveis ​​pela inicialização e desligamento. O método onContentScrolled mais importante para nós é chamado toda vez que o usuário rolar o contêiner (a rejeição via requestAnimationFrame usada no interior para evitar chamadas desnecessárias).


onDataLengthChanged é chamado quando o número de elementos na onDataLengthChanged for alterado - no nosso caso, isso nunca acontecerá. Como regra, em tal situação, seria necessário recontar a altura total e os elementos exibidos atualmente, aproximadamente o mesmo que o que precisa ser feito na função de attach .


onContentRendered e onRenderedOffsetChanged são chamados quando a parte exibida dos elementos é alterada e o recuo no primeiro elemento é alterado. CdkVirtualScrollViewport acessa esses métodos quando recebe um novo intervalo de itens exibidos e é recuado para o primeiro deles, respectivamente. Não precisamos disso, pois não há necessidade de chamar os métodos CdkVirtualScrollViewport manualmente. Se você precisar, dentro de onContentRendered você pode calcular um novo recuo e em onRenderedOffsetChanged - pelo contrário, o intervalo de elementos visíveis para o recuo resultante.


O segundo método importante para nós - scrollToIndex - permite rolar o contêiner para o elemento desejado, e seu oposto - scrolledIndexChange - permitirá rastrear o elemento visível atual.


Para começar, vamos criar todos os métodos simples e depois considerar o código principal:


 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); } } // ... } 

Para trabalhar com o scroll, precisamos conseguir o índice de um elemento por indentação e vice-versa - indent por índice. A função reduceCycle que escrevemos é adequada para a primeira tarefa:


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

Ou seja, para obter a altura por índice, descobrimos quantos ciclos completos de 28 anos se ajustam à data atual e depois somamos nossa matriz até o mês especificado. A operação reversa é um pouco mais complicada:


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

Quando obtemos a altura total dos ciclos completos de 28 anos, iteramos sobre a matriz, coletando a altura total de todos os meses até que exceda o recuo desejado. Ao mesmo tempo, verificaremos se há mais de metade da altura de cada mês ( CYCLE[year][month] / 2 ) para encontrar não apenas o mês mais alto visível, mas o mês mais próximo da borda superior. Isso será necessário no futuro para uma torção suave no início do mês após a conclusão do pergaminho.


Resta escrever a função mais importante responsável por renderizar os elementos da região visível:


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

Vamos considerar tudo em ordem.


Solicitamos ao CdkVirtualScrollViewport recuo atual, o tamanho do contêiner, o intervalo atual mostrado e o número total de itens. Em seguida, encontramos o primeiro elemento visível e o recuo no primeiro elemento renderizado.


Depois disso, precisamos entender como alterar o intervalo de elementos atuais e recuar para o primeiro deles, para que o rolo virtual carregue suavemente os elementos e não se mova ao recalcular a altura. Para fazer isso, temos a constante BUFFER , que BUFFER quantos pixels acima e abaixo da área visível continuamos a desenhar elementos. No meu caso, eu uso 500px. Se o recuo superior for menor que o buffer e houver mais elementos acima, alteraremos o intervalo adicionando elementos suficientes no topo para cobrir o buffer duas vezes. Também ajustamos o final do intervalo. Como rolamos para cima - um buffer é suficiente na parte inferior. A mesma coisa, mas na direção oposta, executamos ao rolar para baixo.


Em seguida, atribuímos CdkVirtualScrollViewport novo intervalo ao CdkVirtualScrollViewport e consideramos o recuo do seu primeiro elemento. Distribua o índice visível atual.


Use


Nossa estratégia está pronta. Adicione-o aos provedores de componentes, como mostrado acima, e use CdkVirtualScrollViewport no modelo:


 <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> 

Resta perceber uma mudança suave para o próximo mês no final do pergaminho. Existem nuances.


O fato é que a rolagem em dispositivos móveis continua depois que o dedo libera a superfície. Portanto, será difícil entender o momento em que é necessário alinhar o mês atual. Para isso, usamos RxJs. touchstart evento touchstart e aguardamos o próximo touchend . Após o início, usamos o operador de corrida para descobrir se o pergaminho continua ou se o dedo foi liberado sem aceleração. Se nenhum evento de rolagem ocorrer durante o período SCROLL_DEBOUNCE_TIME , alinharemos o mês atual. Caso contrário, esperamos até que a rolagem residual pare. Nesse caso, você precisa adicionar takeUntil(touchstart$) , pois a rolagem inercial pode ser interrompida com um novo toque e o fluxo inteiro deve retornar ao início:


 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$), ), ), takeUntil(this.destroy$), ) .subscribe(() => { monthsScrollRef.scrollToIndex(this.activeMonth, 'smooth'); }); 

Aqui deve-se notar que, para rolagem suave scrollToIndex no CDK angular, é usada uma implementação nativa e não funciona no Safari. Você pode corrigir isso escrevendo sua rolagem suave através de requestAnimationFrame dentro da estratégia que escrevemos no método scrollToIndex .


Conclusão


Graças à DI e à prudência da equipe Angular, pudemos personalizar de forma flexível o pergaminho virtual. À primeira vista, implementar um pergaminho virtual para elementos com alturas variadas parece uma tarefa assustadora.


No entanto, quando é possível calcular a altura de cada elemento, escrever sua estratégia acabou sendo bastante simples. O principal é que esse cálculo seja realizado rapidamente, porque será chamado com frequência. Se você precisar exibir um grande número de cartões, nos quais pode ou não haver elementos que afetem sua altura, considere um algoritmo eficaz para obter altura e não tenha medo de escrever sua estratégia.

Source: https://habr.com/ru/post/pt484168/


All Articles