Kami menulis strategi kami untuk pengguliran virtual dari Angular CDK

Hai


Di CDK Angular, gulir virtual muncul di versi ketujuh.


Ini berfungsi dengan baik ketika ukuran setiap elemen adalah sama - dan tepat di luar kotak. Kami cukup mengatur ukuran dalam piksel dan menunjukkan elemen wadah yang harus digulir, apakah akan melakukannya dengan lancar, dan kami juga dapat berlangganan indeks elemen saat ini. Namun, bagaimana jika ukuran elemen berubah? Untuk ini, antarmuka VirtualScrollStrategy disediakan dalam CDK, dengan mengimplementasikan yang akan kami ajarkan gulir untuk bekerja dengan daftar kami.


Dalam kasus saya, perlu membuat kalender untuk presentasi seluler, yang dapat digulir terus menerus, dan jumlah minggu dalam sebulan selalu berbeda. Mari kita coba mencari tahu apa strategi gulir virtual dan menulis sendiri.


gambar


Perhitungan ukuran


Seperti yang Anda tahu, kalender diulang setiap 28 tahun.


Hal ini dilakukan jika Anda tidak memperhitungkan bahwa tahun itu bukan tahun kabisat jika dibagi dengan 100, tetapi tidak oleh 400. Dalam kasus kami, kami tidak perlu bertahun-tahun sebelum 1900 dan setelah 2100. Untuk Januari jatuh pada hari Senin, kami akan mulai dari tahun 1900 untuk akun yang rata dan kami akan menarik 196 tahun. Dengan demikian, dalam kalender kita akan ada 7 siklus berulang. Tidak adanya tanggal 29 Februari 1900 tidak akan merugikan, karena itu akan menjadi hari Kamis.



Perhitungan akan dilakukan selama gulir, sehingga semakin sederhana perhitungan, semakin tinggi kinerjanya. Untuk melakukan ini, kami akan membuat loop konstan, yang akan terdiri dari 28 array dari 12 angka, yang bertanggung jawab atas ketinggian setiap bulan:


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

Pada input, fungsi ini menerima ketinggian judul bulan dan tinggi satu minggu (masing-masing 64 dan 48 piksel, untuk gif di atas). Jumlah minggu dalam sebulan akan membantu kami menghitung fungsi sederhana seperti itu:


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

Hasilnya disimpan dalam konstanta konstan const CYCLE = getCycle(64, 48); .


Kami akan menulis fungsi yang memungkinkan Anda menghitung ketinggian berdasarkan tahun dan bulan di dalam siklus:


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

Dengan memanggil fungsi ini tanpa argumen, kita mendapatkan ukuran satu siklus, dan di masa depan kita dapat menemukan indentasi dari atas wadah untuk setiap bulan atau tahun apa pun dari jangkauan kita.


Strategi VirtualScroll


Anda dapat mengirimkan strategi Anda ke gulir virtual menggunakan token
VIRTUAL_SCROLL_STRATEGY :


 { provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, }, 

Kelas kami harus mengimplementasikan antarmuka 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; } 

Fungsi attach dan detach bertanggung jawab untuk menginisialisasi dan mematikan. Metode onContentScrolled paling penting bagi kami dipanggil setiap kali pengguna menggulir wadah (debounce melalui requestAnimationFrame digunakan secara internal untuk menghindari panggilan yang tidak perlu).


onDataLengthChanged dipanggil ketika jumlah elemen dalam onDataLengthChanged telah berubah - dalam kasus kami ini tidak akan pernah terjadi. Sebagai aturan, dalam situasi seperti itu, perlu untuk menghitung tinggi total dan elemen yang saat ini ditampilkan, kira-kira sama dengan apa yang perlu dilakukan pada fungsi attach .


onContentRendered dan onRenderedOffsetChanged dipanggil saat bagian yang ditampilkan dari elemen berubah dan lekukan ke perubahan elemen pertama. CdkVirtualScrollViewport mengakses metode ini ketika diberikan serangkaian item yang ditampilkan dan diindentasikan ke yang pertama, secara berurutan. Kami tidak memerlukan ini, karena tidak perlu memanggil metode CdkVirtualScrollViewport tangan. Jika Anda membutuhkannya, maka di dalam onContentRendered Anda dapat menghitung indentasi baru, dan di onRenderedOffsetChanged - sebaliknya, rentang elemen yang terlihat untuk indentasi yang dihasilkan.


Metode penting kedua bagi kami - scrollToIndex - memungkinkan Anda untuk menggulir wadah ke elemen yang diinginkan, dan sebaliknya - scrolledIndexChange - akan memungkinkan untuk melacak elemen yang terlihat saat ini.


Untuk memulainya, kita akan membuat semua metode sederhana, dan kemudian mempertimbangkan kode utama:


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

Untuk bekerja dengan scroll, kita harus bisa mendapatkan indeks suatu elemen dengan indentasi dan sebaliknya indentasi dengan index. Fungsi reduceCycle kami tulis cocok untuk tugas pertama:


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

Artinya, untuk mendapatkan ketinggian berdasarkan indeks, kami menemukan berapa banyak siklus 28 tahun penuh yang sesuai dengan tanggal saat ini, dan kemudian kami menjumlahkan array kami hingga bulan yang ditentukan. Operasi sebaliknya agak lebih rumit:


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

Saat kami mendapatkan tinggi total dari siklus 28 tahun penuh, kami akan mengulangi susunan, mengumpulkan tinggi total semua bulan hingga melebihi indent yang diinginkan. Pada saat yang sama, kami akan memeriksa untuk melebihi setengah tinggi setiap bulan ( CYCLE[year][month] / 2 ) untuk menemukan bukan hanya bulan yang terlihat tertinggi, tetapi yang paling dekat dengan batas atas. Ini akan diperlukan di masa depan untuk memuntir yang lancar di awal bulan setelah selesainya gulungan.


Tetap menulis fungsi paling penting yang bertanggung jawab untuk merender elemen-elemen wilayah yang terlihat:


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

Mari kita pertimbangkan segalanya secara berurutan.


Kami meminta CdkVirtualScrollViewport indentasi saat ini, ukuran wadah, kisaran saat ini ditampilkan, dan jumlah item. Kemudian kita menemukan elemen yang terlihat pertama dan indentasi pada elemen yang diberikan pertama kali.


Setelah itu, kita perlu memahami cara mengubah rentang elemen saat ini dan membuat inden ke yang pertama, sehingga gulir virtual memuat elemen dengan lancar dan tidak bergerak ketika menghitung ulang ketinggian. Untuk melakukan ini, kita memiliki konstanta BUFFER , yang BUFFER berapa banyak piksel naik dan turun dari area yang terlihat kita terus menggambar elemen. Dalam kasus saya, saya menggunakan 500px. Jika indentasi atas lebih kecil dari buffer dan ada lebih banyak elemen di atas, kami akan mengubah rentang dengan menambahkan elemen yang cukup di atas untuk menggandakan buffer. Kami juga menyesuaikan akhir kisaran. Karena kita gulir ke atas - satu buffer sudah cukup di bagian bawah. Hal yang sama, tetapi dalam arah yang berlawanan kami lakukan saat menggulir ke bawah.


Kemudian kami menetapkan CdkVirtualScrollViewport rentang baru dan mempertimbangkan indentasi untuk elemen pertama. Bagikan indeks yang terlihat saat ini.


Gunakan


Strategi kami siap. Tambahkan ke penyedia komponen, seperti yang ditunjukkan di atas, dan gunakan CdkVirtualScrollViewport dalam templat:


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

Tetap menyadari perputaran yang lancar ke bulan berikutnya di akhir gulungan. Ada nuansa.


Faktanya adalah bahwa bergulir di perangkat seluler berlanjut setelah jari melepaskan permukaan. Oleh karena itu, akan sulit bagi kita untuk memahami saat ketika perlu untuk menyelaraskan bulan saat ini. Untuk ini kami menggunakan RxJs. touchstart acara touchstart dan menunggu touchend berikutnya. Setelah onsetnya, kami menggunakan operator balapan untuk mengetahui apakah gulir berlanjut, atau jika jari dilepaskan tanpa akselerasi. Jika tidak ada peristiwa gulir yang terjadi selama periode SCROLL_DEBOUNCE_TIME , maka kami menyelaraskan bulan ini. Kalau tidak, kita tunggu sampai sisa gulir berhenti. Dalam hal ini, Anda perlu menambahkan takeUntil(touchstart$) , karena gulir inersia dapat dihentikan dengan sentuhan baru dan kemudian seluruh aliran akan kembali ke awal:


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

Di sini perlu dicatat bahwa untuk menggulir dengan mulus scrollToIndex di CDK Angular implementasi asli digunakan, dan itu tidak berfungsi di Safari. Anda dapat memperbaikinya dengan menulis gulir halus Anda melalui requestAnimationFrame di dalam strategi yang kami tulis dalam metode scrollToIndex .


Kesimpulan


Berkat DI dan kehati-hatian dari tim Angular, kami dapat secara fleksibel mengkonfigurasi gulir virtual untuk diri kami sendiri. Pada pandangan pertama, menerapkan scroll virtual untuk elemen dengan ketinggian yang bervariasi sepertinya tugas yang menakutkan.


Namun, ketika dimungkinkan untuk menghitung ketinggian setiap elemen, menulis strategi Anda ternyata cukup sederhana. Yang terpenting adalah perhitungan ini dilakukan dengan cepat, karena akan sering dipanggil. Jika Anda perlu menampilkan sejumlah besar kartu, yang mungkin ada atau tidaknya elemen yang memengaruhi ketinggiannya, pertimbangkan algoritma yang efektif untuk mendapatkan tinggi dan jangan takut untuk menulis strategi Anda.

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


All Articles