Escribimos nuestra estrategia para el desplazamiento virtual desde Angular CDK

Hola


En Angular CDK, apareció un pergamino virtual en la séptima versión.


Funciona muy bien cuando el tamaño de cada elemento es el mismo, y listo para usar. Simplemente establecemos el tamaño en píxeles e indicamos a qué elemento debe desplazarse el contenedor, si debe hacerlo sin problemas, y también podemos suscribirnos al índice del elemento actual. Sin embargo, ¿qué pasa si cambia el tamaño de los elementos? Para esto, la interfaz VirtualScrollStrategy se proporciona en el CDK, implementando la cual le enseñaremos el desplazamiento para que funcione con nuestra lista.


En mi caso, era necesario hacer un calendario para una presentación móvil, que se puede desplazar continuamente, y el número de semanas en un mes siempre es diferente. Intentemos descubrir qué es una estrategia de desplazamiento virtual y escribir la suya.


imagen


Cálculo de tamaño


Como saben, los calendarios se repiten cada 28 años.


Esto se hace si no tiene en cuenta que el año no es bisiesto, si es divisible por 100, pero no por 400. En nuestro caso, no necesitamos años antes de 1900 y después de 2100. Para que enero caiga el lunes, comenzaremos desde 1900 para una cuenta pareja y lo haremos retirar 196 años. Por lo tanto, en nuestro calendario habrá 7 ciclos repetitivos. La ausencia del 29 de febrero de 1900 no afectará, ya que sería el jueves.



Los cálculos se realizarán durante el desplazamiento, por lo que cuanto más simples sean los cálculos, mayor será el rendimiento. Para hacer esto, crearemos una constante de bucle, que consistirá en 28 matrices de 12 números, que son responsables de la altura de cada mes:


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

En la entrada, esta función recibe la altura del título del mes y la altura de una semana (64 y 48 píxeles, respectivamente, para el gif de arriba). El número de semanas en un mes nos ayudará a calcular una función tan simple:


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

El resultado se almacena en la constante const CYCLE = getCycle(64, 48); .


Escribiremos una función que le permita calcular la altura por año y mes dentro del 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, ); } 

Al llamar a esta función sin argumentos, obtenemos el tamaño de un ciclo, y en el futuro podemos encontrar la sangría desde la parte superior del contenedor para cualquier mes de cualquier año de nuestro rango.


VirtualScrollStrategy


Puede enviar su estrategia a un desplazamiento virtual utilizando un token
VIRTUAL_SCROLL_STRATEGY :


 { provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, }, 

Nuestra clase debe implementar la interfaz 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; } 

Las funciones de detach y detach son responsables de inicializar y apagar. El método onContentScrolled más importante para nosotros se llama cada vez que el usuario desplaza el contenedor (el rebote a través de requestAnimationFrame usa internamente para evitar llamadas innecesarias).


onDataLengthChanged se llama cuando el número de elementos en la onDataLengthChanged ha cambiado; en nuestro caso, esto nunca sucederá. Como regla general, en tal situación, sería necesario contar la altura total y los elementos que se muestran actualmente, aproximadamente lo mismo que lo que se debe hacer en la función attach .


Se llama a onContentRendered y onRenderedOffsetChanged cuando cambia la parte visualizada de los elementos y cambia la sangría del primer elemento. CdkVirtualScrollViewport accede a estos métodos cuando se le asigna un nuevo rango de elementos mostrados y se sangra al primero de ellos, respectivamente. No necesitamos esto, ya que no es necesario llamar a CdkVirtualScrollViewport métodos CdkVirtualScrollViewport . Si lo necesita, dentro de onContentRendered puede calcular una nueva sangría y en onRenderedOffsetChanged , por el contrario, el rango de elementos visibles para la sangría resultante.


El segundo método importante para nosotros, scrollToIndex , le permite desplazar el contenedor al elemento deseado, y su opuesto, scrolledIndexChange , permitirá rastrear el elemento visible actual.


Para comenzar, crearemos todos los métodos simples y luego consideraremos el 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 trabajar con el desplazamiento, necesitamos poder obtener el índice de un elemento por sangría y viceversa - sangría por índice. La función reduceCycle que escribimos es adecuada para la primera tarea:


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

Es decir, para obtener la altura por índice, encontramos cuántos ciclos completos de 28 años se ajustan a la fecha actual, y luego sumamos nuestra matriz hasta el mes dado. La operación inversa es algo más 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; } 

Cuando obtengamos la altura total de los ciclos completos de 28 años, iteraremos sobre la matriz, recolectando la altura total de todos los meses hasta que exceda la sangría deseada. Al mismo tiempo, verificaremos que exceda la mitad de la altura de cada mes ( CYCLE[year][month] / 2 ) para encontrar no solo el mes visible más alto, sino el más cercano al borde superior. Esto será necesario en el futuro para una torsión suave a principios de mes después de completar el desplazamiento.


Queda por escribir la función más importante que se encarga de representar los elementos de la región visible:


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

Consideremos todo en orden.


Le pedimos a CdkVirtualScrollViewport sangría actual, el tamaño del contenedor, el rango actual que se muestra y el número total de artículos. Luego encontramos el primer elemento visible y la sangría en el primer elemento renderizado.


Después de eso, debemos entender cómo cambiar el rango de elementos actuales y aplicar sangría al primero de ellos, para que el desplazamiento virtual cargue suavemente los elementos y no se contraiga al recalcular la altura. Para hacer esto, tenemos la constante BUFFER , que BUFFER cuántos píxeles arriba y abajo del área visible continuamos dibujando elementos. En mi caso, uso 500px. Si la sangría superior es más pequeña que el búfer y hay más elementos arriba, cambiaremos el rango agregando suficientes elementos en la parte superior para cubrir dos veces el búfer. También ajustamos el final del rango. Como nos desplazamos hacia arriba, un búfer es suficiente en la parte inferior. Lo mismo, pero en la dirección opuesta que realizamos al desplazarnos hacia abajo.


Luego asignamos a CdkVirtualScrollViewport nuevo rango y consideramos la sangría para su primer elemento. Pase el índice visible actual.


Uso


Nuestra estrategia está lista. CdkVirtualScrollViewport a los proveedores de componentes, como se muestra arriba, y use CdkVirtualScrollViewport en la plantilla:


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

Queda por darse un giro suave al próximo mes al final del pergamino. Hay matices


El hecho es que el desplazamiento en dispositivos móviles continúa después de que el dedo suelta la superficie. Por lo tanto, será difícil para nosotros entender el momento en que es necesario alinear el mes actual. Para esto usamos RxJs. touchstart evento de touchstart y esperamos el próximo touchend . Después de su inicio, usamos el operador de carrera para averiguar si el desplazamiento continúa o si el dedo se soltó sin aceleración. Si no se producen eventos de desplazamiento durante el período SCROLL_DEBOUNCE_TIME , alineamos el mes actual. De lo contrario, esperamos hasta que se detenga el desplazamiento residual. En este caso, debe agregar takeUntil(touchstart$) , ya que el desplazamiento inercial se puede detener con un nuevo toque y luego toda la secuencia debe volver al principio:


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

Aquí debe tenerse en cuenta que para un desplazamiento suave scrollToIndex en Angular CDK se utiliza una implementación nativa, y no funciona en Safari. Puede solucionar esto escribiendo su desplazamiento suave a través de requestAnimationFrame dentro de la estrategia que escribimos en el método scrollToIndex .


Conclusión


Gracias a la DI y la prudencia del equipo de Angular, pudimos personalizar con flexibilidad el rollo virtual para nosotros. A primera vista, implementar un desplazamiento virtual para elementos con diferentes alturas parece una tarea desalentadora.


Sin embargo, cuando es posible calcular la altura de cada elemento, escribir su estrategia resultó ser bastante simple. Lo principal es que este cálculo se realiza rápidamente, porque se llamará con frecuencia. Si necesita mostrar una gran cantidad de tarjetas, en las que puede haber o no elementos que afecten su altura, considere un algoritmo efectivo para obtener la altura y no tenga miedo de escribir su estrategia.

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


All Articles