Wir schreiben unsere Strategie für das virtuelle Scrollen von Angular CDK

Hallo!


Im Angular CDK erschien in der siebten Version eine virtuelle Schriftrolle .


Es funktioniert hervorragend, wenn die Größe der einzelnen Elemente gleich ist - und das direkt nach dem Auspacken. Wir stellen einfach die Größe in Pixel ein und geben an, zu welchem ​​Element der Container scrollen soll, ob dies reibungslos erfolgen soll, und wir können auch den Index des aktuellen Elements abonnieren. Was ist jedoch, wenn sich die Größe der Elemente ändert? Zu diesem VirtualScrollStrategy wird im CDK die VirtualScrollStrategy Schnittstelle bereitgestellt, durch deren Implementierung wir dem Bildlauf beibringen, mit unserer Liste zu arbeiten.


In meinem Fall musste ein Kalender für eine mobile Präsentation erstellt werden, der kontinuierlich gescrollt werden kann, und die Anzahl der Wochen in einem Monat ist immer unterschiedlich. Versuchen wir herauszufinden, was eine virtuelle Bildlaufstrategie ist, und schreiben wir Ihre eigene.


Bild


Größenberechnung


Wie Sie wissen, werden Kalender alle 28 Jahre wiederholt.


Dies geschieht, wenn Sie nicht berücksichtigen, dass das Jahr kein Schaltjahr ist, wenn es durch 100 teilbar ist, aber nicht durch 400. In unserem Fall benötigen wir keine Jahre vor 1900 und nach 2100. Damit der Januar auf den Montag fällt, beginnen wir ab 1900 mit einem geraden Konto, und wir werden es tun 196 Jahre zurückziehen. Somit gibt es in unserem Kalender 7 Wiederholungszyklen. Die Abwesenheit des 29. Februar 1900 wird nicht schaden, da es Donnerstag wäre.



Berechnungen werden während des Bildlaufs durchgeführt. Je einfacher die Berechnungen sind, desto höher ist die Leistung. Dazu erstellen wir eine Schleifenkonstante, die aus 28 Arrays mit 12 Zahlen besteht, die für die Höhe jedes Monats verantwortlich sind:


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

Bei der Eingabe erhält diese Funktion die Höhe des Monats-Titels und die Höhe einer Woche (64 bzw. 48 Pixel für das obige GIF). Die Anzahl der Wochen pro Monat hilft uns dabei, eine so einfache Funktion zu berechnen:


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

Das Ergebnis wird in der Konstanten const CYCLE = getCycle(64, 48); gespeichert const CYCLE = getCycle(64, 48); .


Wir werden eine Funktion schreiben, mit der Sie die Höhe nach Jahr und Monat innerhalb des Zyklus berechnen können:


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

Wenn wir diese Funktion ohne Argumente aufrufen, erhalten wir die Größe eines Zyklus und können in Zukunft den Einzug für jeden Monat eines Jahres aus unserem Sortiment oben im Container finden.


VirtualScrollStrategy


Sie können Ihre Strategie mit einem Token an einen virtuellen Bildlauf übergeben
VIRTUAL_SCROLL_STRATEGY :


 { provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, }, 

Unsere Klasse sollte die VirtualScrollStrategy Schnittstelle implementieren:


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

Die Funktionen zum attach und detach sind für das Initialisieren und Herunterfahren verantwortlich. Die für uns wichtigste onContentScrolled Methode wird jedes Mal aufgerufen, wenn der Benutzer einen Bildlauf durch den Container durchführt (Entprellen über requestAnimationFrame intern verwendet, um unnötige Aufrufe zu vermeiden).


onDataLengthChanged wird aufgerufen, wenn sich die Anzahl der Elemente in der onDataLengthChanged geändert hat - in unserem Fall wird dies niemals passieren. In einer solchen Situation ist es in der Regel erforderlich, die Gesamthöhe und die aktuell angezeigten Elemente erneut zu zählen, und zwar ungefähr so, wie dies in der Funktion " attach erforderlich ist.


onContentRendered und onRenderedOffsetChanged werden aufgerufen, wenn sich der angezeigte Teil der Elemente ändert und sich der Einzug zum ersten Element ändert. CdkVirtualScrollViewport greift auf diese Methoden zu, wenn ihm ein neuer Bereich von angezeigten Elementen CdkVirtualScrollViewport und der erste eingerückt wird. Dies ist nicht erforderlich, da CdkVirtualScrollViewport Methoden nicht CdkVirtualScrollViewport werden müssen. Wenn Sie es benötigen, onContentRendered Sie in onRenderedOffsetChanged einen neuen Einzug und in onRenderedOffsetChanged den Bereich der sichtbaren Elemente für den resultierenden Einzug berechnen.


Die zweite wichtige Methode für uns - scrollToIndex - ermöglicht es Ihnen, den Container zum gewünschten Element zu scrollen, und die gegenüberliegende - scrolledIndexChange - ermöglicht es, das aktuell sichtbare Element zu verfolgen.


Zunächst erstellen wir alle einfachen Methoden und betrachten dann den Hauptcode:


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

Um mit der Schriftrolle arbeiten zu können, müssen wir in der Lage sein, den Index eines Elements durch Einrücken abzurufen und umgekehrt durch Einrücken durch einen Index. Die von reduceCycle Funktion reduceCycle ist für die erste Aufgabe geeignet:


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

Das heißt, um die Höhe nach Index zu ermitteln, ermitteln wir, wie viele vollständige 28-Jahres-Zyklen zum aktuellen Datum passen, und addieren dann unser Array zum angegebenen Monat. Der umgekehrte Vorgang ist etwas komplizierter:


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

Wenn wir die Gesamthöhe der gesamten 28-Jahres-Zyklen erhalten, iterieren wir über das Array und erfassen die Gesamthöhe aller Monate, bis sie den gewünschten Einzug überschreitet. Gleichzeitig überprüfen wir, ob die Hälfte des Monats ( CYCLE[year][month] / 2 ) überschritten wurde, um nicht nur den höchsten sichtbaren, sondern auch den am nächsten am oberen Rand liegenden Monat zu ermitteln. Dies wird in Zukunft für eine reibungslose Verdrehung zu Beginn des Monats nach Fertigstellung der Schriftrolle benötigt.


Es bleibt die wichtigste Funktion zu schreiben, die für das Rendern der Elemente des sichtbaren Bereichs verantwortlich ist:


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

Betrachten wir alles in der richtigen Reihenfolge.


Wir fragen CdkVirtualScrollViewport aktuellen Einzug, der Containergröße, dem angezeigten aktuellen Bereich und der Gesamtzahl der Elemente. Dann finden wir das erste sichtbare Element und den Einzug beim allerersten gerenderten Element.


Danach müssen wir verstehen, wie der Bereich der aktuellen Elemente geändert und der erste eingerückt wird, damit der virtuelle Bildlauf die Elemente reibungslos lädt und beim Neuberechnen der Höhe nicht zuckt. Dazu haben wir die BUFFER Konstante, die BUFFER wie viele Pixel vom sichtbaren Bereich aus wir weiterhin Elemente zeichnen. In meinem Fall verwende ich 500px. Wenn der obere Einzug kleiner als der Puffer ist und mehr Elemente darüber vorhanden sind, ändern wir den Bereich, indem Sie oben genügend Elemente hinzufügen, um den Puffer doppelt abzudecken. Wir passen auch das Ende des Bereichs an. Da wir nach oben scrollen, reicht ein Puffer unten. Das gleiche, aber in der entgegengesetzten Richtung führen wir das Scrollen nach unten durch.


Dann weisen wir CdkVirtualScrollViewport neuen Bereich zu und betrachten den Einzug für das erste Element. Den aktuell sichtbaren Index verteilen.


Verwenden Sie


Unsere Strategie ist fertig. Fügen Sie es wie oben gezeigt zu den Komponentenanbietern hinzu und verwenden Sie CdkVirtualScrollViewport in der Vorlage:


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

Es bleibt eine sanfte Drehung bis zum nächsten Monat am Ende der Schriftrolle zu realisieren. Es gibt Nuancen.


Tatsache ist, dass das Scrollen auf Mobilgeräten fortgesetzt wird, nachdem der Finger die Oberfläche freigegeben hat. Daher ist es für uns schwierig, den Zeitpunkt zu verstehen, an dem der aktuelle Monat abgeglichen werden muss. Dafür verwenden wir RxJs. touchstart Event und warten auf das nächste touchend . Nach dem Einsetzen verwenden wir den Rennoperator, um herauszufinden, ob das Scrollen fortgesetzt wird oder ob der Finger ohne Beschleunigung losgelassen wurde. Wenn während des Zeitraums SCROLL_DEBOUNCE_TIME keine SCROLL_DEBOUNCE_TIME , richten wir den aktuellen Monat aus. Andernfalls warten wir, bis die Restrolle stoppt. In diesem Fall müssen Sie takeUntil(touchstart$) hinzufügen, da der Inertial-Scroll mit einer neuen Berührung angehalten werden kann und der gesamte Stream dann zum Anfang zurückkehren sollte:


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

Hierbei ist zu beachten, dass für ein reibungsloses Scrollen von scrollToIndex im Angular CDK eine native Implementierung verwendet wird und diese in Safari nicht funktioniert. Sie können dieses requestAnimationFrame beheben, indem Sie in der Strategie, die wir in der scrollToIndex Methode geschrieben haben, einen glatten requestAnimationFrame durch requestAnimationFrame scrollToIndex .


Fazit


Dank des DI und der Umsicht des Angular-Teams konnten wir eine virtuelle Schriftrolle flexibel für uns konfigurieren. Auf den ersten Blick erscheint die Implementierung einer virtuellen Schriftrolle für Elemente mit unterschiedlichen Höhen als eine entmutigende Aufgabe.


Wenn es jedoch möglich ist, die Höhe jedes Elements zu berechnen, hat sich das Schreiben Ihrer Strategie als recht einfach erwiesen. Die Hauptsache ist, dass diese Berechnung schnell durchgeführt wird, da sie oft aufgerufen wird. Wenn Sie eine große Anzahl von Karten anzeigen müssen, auf denen sich möglicherweise Elemente befinden, die sich auf ihre Höhe auswirken, sollten Sie einen effektiven Algorithmus zum Ermitteln der Höhe in Betracht ziehen und sich nicht scheuen, Ihre Strategie zu schreiben.

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


All Articles