Nous écrivons notre stratégie de défilement virtuel depuis Angular CDK

Salut


Dans le CDK angulaire, un parchemin virtuel est apparu dans la septième version.


Cela fonctionne très bien lorsque la taille de chaque élément est la même - et dès la sortie de la boîte. Nous définissons simplement la taille en pixels et indiquons à quel élément le conteneur doit défiler, pour le faire en douceur, et nous pouvons également vous abonner à l'index de l'élément actuel. Mais que se passe-t-il si la taille des éléments change? Pour cela, l'interface VirtualScrollStrategy est fournie dans le CDK, en implémentant ce que nous apprendrons au scroll pour travailler avec notre liste.


Dans mon cas, il était nécessaire de faire un calendrier pour une présentation mobile, qui peut défiler en continu, et le nombre de semaines dans un mois est toujours différent. Essayons de comprendre ce qu'est une stratégie de défilement virtuel et écrivons la vôtre.


image


Calcul de la taille


Comme vous le savez, les calendriers sont répétés tous les 28 ans.


Cela se fait si vous ne tenez pas compte du fait que l'année n'est pas une année bissextile si elle est divisible par 100, mais pas par 400. Dans notre cas, nous n'avons pas besoin d'années avant 1900 et après 2100. Pour que janvier tombe le lundi, nous partirons de 1900 pour un compte pair et nous le ferons retirer 196 ans. Ainsi, dans notre calendrier, il y aura 7 cycles répétitifs. L'absence du 29 février 1900 ne fera pas de mal, puisque ce serait jeudi.



Les calculs seront effectués pendant le défilement, donc plus les calculs sont simples, plus les performances sont élevées. Pour ce faire, nous allons créer une constante de boucle, qui consistera en 28 tableaux de 12 nombres, qui sont responsables de la hauteur de chaque mois:


 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 entrée, cette fonction reçoit la hauteur du titre du mois et la hauteur d'une semaine (respectivement 64 et 48 pixels pour le gif ci-dessus). Le nombre de semaines dans un mois nous aidera à calculer une fonction aussi 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); } 

Le résultat est stocké dans la constante const CYCLE = getCycle(64, 48); .


Nous allons écrire une fonction qui vous permet de calculer la hauteur par année et mois à l'intérieur du cycle:


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

En appelant cette fonction sans arguments, nous obtenons la taille d'un cycle, et à l'avenir, nous pouvons trouver le retrait du haut du conteneur pour n'importe quel mois de n'importe quelle année de notre plage.


VirtualScrollStrategy


Vous pouvez soumettre votre stratégie à un défilement virtuel à l'aide d'un jeton
VIRTUAL_SCROLL_STRATEGY :


 { provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, }, 

Notre classe devrait implémenter l'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; } 

Les fonctions d' attach et de detach sont responsables de l'initialisation et de l'arrêt. La méthode onContentScrolled plus importante pour nous est appelée à chaque fois que l'utilisateur fait défiler le conteneur (le requestAnimationFrame via requestAnimationFrame utilisé en interne pour éviter les appels inutiles).


onDataLengthChanged est appelé lorsque le nombre d'éléments dans l' onDataLengthChanged a changé - dans notre cas, cela ne se produira jamais. En règle générale, dans une telle situation, il serait nécessaire de recompter la hauteur totale et les éléments actuellement affichés, à peu près les mêmes que ce qui doit être fait dans la fonction d' attach .


onContentRendered et onRenderedOffsetChanged sont appelés lorsque la partie affichée des éléments change et que l'indentation du premier élément change. CdkVirtualScrollViewport accède à ces méthodes lorsqu'il reçoit une nouvelle plage d'éléments affichés et est mis en retrait pour le premier d'entre eux, respectivement. Nous n'avons pas besoin de cela, car il n'est pas nécessaire d'appeler les méthodes CdkVirtualScrollViewport main. Si vous en avez besoin, à l'intérieur de onContentRendered vous pouvez calculer un nouveau retrait, et dans onRenderedOffsetChanged - au contraire, la plage d'éléments visibles pour le retrait résultant.


La deuxième méthode importante pour nous - scrollToIndex - vous permet de faire défiler le conteneur jusqu'à l'élément souhaité, et son opposé - scrolledIndexChange - permettra de suivre l'élément visible actuel.


Pour commencer, nous allons créer toutes les méthodes simples, puis considérer le code 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); } } // ... } 

Pour travailler avec le défilement, nous devons être en mesure d'obtenir l'index d'un élément par indentation et vice versa - indentation par index. La fonction de reduceCycle nous avons écrite convient à la première tâche:


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

Autrement dit, pour obtenir la hauteur par indice, nous trouvons combien de cycles complets de 28 ans correspondent à la date actuelle, puis nous additionnons notre tableau au mois donné. L'opération inverse est un peu plus compliquée:


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

Lorsque nous obtenons la hauteur totale des cycles complets de 28 ans, nous parcourrons le tableau, en collectant la hauteur totale de tous les mois jusqu'à ce qu'elle dépasse le retrait souhaité. Dans le même temps, nous vérifierons si la moitié de la hauteur de chaque mois est CYCLE[year][month] / 2 ( CYCLE[year][month] / 2 ) pour trouver non seulement le mois visible le plus élevé, mais celui le plus proche de la bordure supérieure. Cela sera nécessaire à l'avenir pour une torsion en douceur au début du mois après la fin du défilement.


Il reste à écrire la fonction la plus importante qui est responsable du rendu des éléments de la région 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); } 

Considérons tout dans l'ordre.


Nous demandons à CdkVirtualScrollViewport indentation actuelle, la taille du conteneur, la plage actuelle affichée et le nombre total d'éléments. Ensuite, nous trouvons le premier élément visible et le retrait au tout premier élément rendu.


Après cela, nous devons comprendre comment modifier la plage des éléments actuels et mettre en retrait le premier d'entre eux, afin que le défilement virtuel charge les éléments en douceur et ne se contracte pas lors du recalcul de la hauteur. Pour ce faire, nous avons la constante BUFFER , qui BUFFER le nombre de pixels de haut en bas de la zone visible, nous continuons à dessiner des éléments. Dans mon cas, j'utilise 500px. Si le retrait supérieur est plus petit que le tampon et qu'il y a plus d'éléments ci-dessus, nous changerons la plage en ajoutant suffisamment d'éléments au-dessus pour couvrir deux fois le tampon. Nous ajustons également la fin de la plage. Puisque nous faisons défiler vers le haut - un tampon suffit en bas. La même chose, mais dans la direction opposée, nous effectuons lors du défilement vers le bas.


Ensuite, nous CdkVirtualScrollViewport nouvelle plage et considérons le retrait pour son premier élément. Distribuez l'index visible actuel.


Utiliser


Notre stratégie est prête. Ajoutez-le aux fournisseurs de composants, comme indiqué ci-dessus, et utilisez CdkVirtualScrollViewport dans le modèle:


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

Il reste à réaliser une torsion en douceur vers le mois suivant à la fin du parchemin. Il y a des nuances.


Le fait est que le défilement sur les appareils mobiles se poursuit après que le doigt a relâché la surface. Par conséquent, il nous sera difficile de comprendre le moment où il est nécessaire d'aligner le mois en cours. Pour cela, nous utilisons RxJs. touchstart événement touchstart et attendons le prochain touchend . Après son apparition, nous utilisons l'opérateur de course pour savoir si le défilement continue ou si le doigt a été relâché sans accélération. Si aucun événement de défilement ne se produit pendant la période SCROLL_DEBOUNCE_TIME , nous SCROLL_DEBOUNCE_TIME le mois en cours. Sinon, nous attendons que le défilement résiduel s'arrête. Dans ce cas, vous devez ajouter takeUntil(touchstart$) , car le défilement inertiel peut être arrêté avec une nouvelle touche, puis le flux entier devrait revenir au début:


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

Ici, il convient de noter que pour un défilement fluide, scrollToIndex dans le CDK angulaire, une implémentation native est utilisée et ne fonctionne pas dans Safari. Vous pouvez résoudre ce problème en écrivant votre défilement fluide via requestAnimationFrame dans la stratégie que nous avons écrite dans la méthode scrollToIndex .


Conclusion


Grâce au DI et à la prudence de l'équipe Angular, nous avons pu personnaliser de manière flexible le parchemin virtuel. À première vue, l'implémentation d'un défilement virtuel pour des éléments de hauteurs différentes semble être une tâche intimidante.


Cependant, lorsqu'il est possible de calculer la hauteur de chaque élément, l'écriture de votre stratégie s'est avérée assez simple. L'essentiel est que ce calcul soit effectué rapidement, car il sera appelé souvent. Si vous devez afficher un grand nombre de cartes, sur lesquelles il peut y avoir ou non des éléments qui affectent leur hauteur, envisagez un algorithme efficace pour obtenir la hauteur et n'ayez pas peur d'écrire votre stratégie.

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


All Articles