نكتب استراتيجيتنا للتمرير الظاهري من Angular CDK

تحية!


في الزاوي CDK ، ظهر التمرير الظاهري في الإصدار السابع.


إنه يعمل بشكل رائع عندما يكون حجم كل عنصر هو نفسه - وخرجًا من الصندوق. نحن ببساطة نضبط الحجم بالبكسل ونشير إلى العنصر الذي يجب أن تنتقل إليه الحاوية ، وما إذا كان يجب القيام بذلك بسلاسة ، ويمكننا أيضًا الاشتراك في فهرس العنصر الحالي. ومع ذلك ، ماذا لو تغير حجم العناصر؟ لهذا ، يتم توفير واجهة VirtualScrollStrategy في CDK ، من خلال تنفيذ والتي سوف نعلم التمرير للعمل مع قائمتنا.


في حالتي ، كان من الضروري عمل تقويم للعرض التقديمي على الأجهزة المحمولة ، والذي يمكن التمرير عليه باستمرار ، ويكون عدد الأسابيع في شهر مختلفًا دائمًا. دعونا نحاول معرفة ماهية استراتيجية التمرير الافتراضية وكتابة استراتيجيتك الخاصة.


صورة


حساب الحجم


كما تعلمون ، يتم تكرار التقويمات كل 28 عامًا.


يتم ذلك إذا لم تأخذ في الاعتبار أن السنة ليست سنة كبيسة ، إذا كانت قابلة للقسمة على 100 ، ولكن ليس ب 400. في حالتنا ، نحن لسنا بحاجة إلى سنوات قبل عام 1900 وبعد 2100. لشهر يناير يوم الاثنين ، سنبدأ من عام 1900 لحساب زوجي وسنقوم سحب 196 سنة. وبالتالي ، في التقويم لدينا سيكون هناك 7 دورات متكررة. لن يضر غياب 29 فبراير عام 1900 ، لأنه سيكون يوم الخميس.



سيتم إجراء العمليات الحسابية أثناء التمرير ، وبالتالي كلما كانت العمليات أبسط ، زاد الأداء. للقيام بذلك ، سنقوم بإنشاء حلقة ثابتة ، والتي ستتألف من 28 صفيفًا من 12 رقمًا ، تكون مسؤولة عن ارتفاع كل شهر:


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

عند الإدخال ، تتلقى هذه الوظيفة ارتفاع عنوان الشهر وارتفاع أسبوع واحد (64 و 48 بكسل ، على التوالي ، لـ gif أعلاه). سيساعدنا عدد الأسابيع في الشهر على حساب هذه الوظيفة البسيطة:


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

يتم تخزين النتيجة في const CYCLE = getCycle(64, 48); ثابت const CYCLE = getCycle(64, 48); .


سنكتب وظيفة تسمح لك بحساب الارتفاع حسب السنة والشهر داخل الدورة:


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

من خلال استدعاء هذه الوظيفة بدون وسيطات ، نحصل على حجم دورة واحدة ، وفي المستقبل يمكننا أن نجد المسافة البادئة من أعلى الحاوية لأي شهر من أي عام من مجموعتنا.


VirtualScrollStrategy


يمكنك إرسال استراتيجيتك إلى تمرير افتراضي باستخدام رمز مميز
VIRTUAL_SCROLL_STRATEGY :


 { provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, }, 

يجب أن يقوم VirtualScrollStrategy بتنفيذ واجهة 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; } 

وظائف attach detach هي المسؤولة عن التهيئة والإغلاق. يتم onContentScrolled الأسلوب onContentScrolled الأكثر أهمية بالنسبة لنا في كل مرة يقوم المستخدم بالتمرير الحاوية ( requestAnimationFrame استخدام debounce عبر requestAnimationFrame داخليًا لتجنب المكالمات غير الضرورية).


يتم استدعاء onDataLengthChanged عندما onDataLengthChanged عدد العناصر في onDataLengthChanged - ولن يحدث هذا في حالتنا. كقاعدة عامة ، في مثل هذه الحالة ، سيكون من الضروري إعادة حساب إجمالي الطول والعناصر المعروضة حاليًا ، تقريبًا مثل ما يجب القيام به في وظيفة attach .


يتم استدعاء onContentRendered و onRenderedOffsetChanged عندما يتغير الجزء المعروض من العناصر ويتغير المسافة البادئة إلى العنصر الأول. يصل CdkVirtualScrollViewport إلى هذه الطرق عندما يتم إعطاؤه نطاقًا جديدًا من العناصر المعروضة ويتم وضع مسافة بادئة لأول منها ، على التوالي. لا نحتاج إلى ذلك ، حيث لا توجد حاجة إلى استدعاء أساليب CdkVirtualScrollViewport باليد. إذا كنت في حاجة إليها ، يمكنك داخل onContentRendered حساب مسافة بادئة جديدة وفي onRenderedOffsetChanged - على العكس ، نطاق العناصر المرئية للمسافة البادئة الناتجة.


الطريقة الهامة الثانية بالنسبة لنا - scrollToIndex - تسمح لك بتمرير الحاوية إلى العنصر المطلوب ، وسيتيح عكسها - scrolledIndexChange - تتبع العنصر المرئي الحالي.


للبدء ، فلننشئ جميع الطرق البسيطة ، ثم ضع في اعتبارك الرمز الرئيسي:


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

للعمل مع التمرير ، نحتاج إلى أن نكون قادرين على الحصول على فهرس عنصر من خلال المسافة البادئة والعكس بالعكس - المسافة البادئة حسب الفهرس. وظيفة reduceCycle مناسبة للمهمة الأولى:


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

أي للحصول على الارتفاع حسب الفهرس ، نجد عدد الدورات الكاملة التي تبلغ 28 عامًا والتي تناسب التاريخ الحالي ، ثم نلخص مجموعتنا حتى الشهر المحدد. العملية العكسية أكثر تعقيدًا إلى حد ما:


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

عندما نحصل على الارتفاع الكلي لدورات 28 عامًا كاملة ، سنقوم بالتكرار على المصفوفة ، ونجمع الارتفاع الكلي لجميع الأشهر حتى يتجاوز المسافة البادئة المطلوبة. في الوقت نفسه ، سوف نتحقق من تجاوز نصف ارتفاع كل شهر ( CYCLE[year][month] / 2 ) للعثور على ليس فقط أعلى شهر مرئي ، ولكن الشهر الأقرب إلى الحد العلوي. ستكون هناك حاجة لذلك في المستقبل للالتواء السلس في بداية الشهر بعد الانتهاء من التمرير.


يبقى أن نكتب أهم وظيفة مسؤولة عن تقديم عناصر المنطقة المرئية:


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

لننظر في كل شيء بالترتيب.


نسأل CdkVirtualScrollViewport المسافة البادئة الحالية وحجم الحاوية والنطاق الحالي المعروض وإجمالي عدد العناصر. ثم نجد أول عنصر مرئي ومسافة بادئة في أول عنصر مقدم.


بعد ذلك ، نحتاج إلى فهم كيفية تغيير نطاق العناصر الحالية والمسافة البادئة إلى الأولى منها ، بحيث يقوم التمرير الظاهري بتحميل العناصر بسلاسة وعدم ارتعاشها عند إعادة حساب الارتفاع. للقيام بذلك ، لدينا ثابت BUFFER ، الذي BUFFER عدد البكسل لأعلى ولأسفل من المنطقة المرئية التي نواصل رسم العناصر. في حالتي ، استخدم 500 بكسل. إذا كانت المسافة البادئة العلوية أصغر من المخزن المؤقت وكان هناك المزيد من العناصر أعلاه ، فسنغير النطاق بإضافة عناصر كافية في الأعلى لمضاعفة تغطية المخزن المؤقت. نحن أيضا ضبط نهاية النطاق. نظرًا لأننا ننتقل لأعلى - يكفي وجود مخزن مؤقت واحد في الأسفل. نفس الشيء ، لكن في الاتجاه المعاكس ، نقوم بالأداء عند التمرير لأسفل.


ثم نقوم بتعيين CdkVirtualScrollViewport نطاقًا جديدًا CdkVirtualScrollViewport في المسافة البادئة للعنصر الأول. مرر الفهرس المرئي الحالي.


استخدام


استراتيجيتنا جاهزة قم CdkVirtualScrollViewport إلى موفري المكونات ، كما هو موضح أعلاه ، واستخدم CdkVirtualScrollViewport في القالب:


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

يبقى تحقيق تطور سلس إلى الشهر المقبل في نهاية التمرير. هناك فروق دقيقة.


والحقيقة هي أن التمرير على الأجهزة المحمولة لا يزال مستمرا بعد إصبعه على السطح. لذلك ، سيكون من الصعب علينا أن نفهم اللحظة التي يكون فيها من الضروري محاذاة الشهر الحالي. لهذا نستخدم RxJs. touchstart حدث touchstart وننتظر touchend التالي. بعد بدايتها ، نستخدم مشغل السباق لمعرفة ما إذا كان التمرير يستمر ، أو إذا كان قد تم إصدار الإصبع دون تسارع. إذا لم تحدث أحداث التمرير خلال فترة SCROLL_DEBOUNCE_TIME ، SCROLL_DEBOUNCE_TIME الشهر الحالي. وإلا ، فإننا ننتظر حتى يتوقف التمرير المتبقي. في هذه الحالة ، تحتاج إلى إضافة takeUntil(touchstart$) ، نظرًا لأن التمرير بالقصور الذاتي يمكن إيقافه بلمسة جديدة ومن ثم يجب أن يعود الدفق بالكامل إلى البداية:


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

هنا تجدر الإشارة إلى أنه للتمرير السلس scrollToIndex في Angular CDK ، يتم استخدام تطبيق أصلي ، ولا يعمل في Safari. يمكنك إصلاح هذا عن طريق كتابة التمرير السلس الخاص بك من خلال requestAnimationFrame داخل الاستراتيجية التي scrollToIndex طريقة scrollToIndex .


استنتاج


بفضل DI وحكمة فريق Angular ، تمكنا من تخصيص التمرير الظاهري لأنفسنا بمرونة. للوهلة الأولى ، يبدو تنفيذ التمرير الافتراضي للعناصر ذات ارتفاعات متفاوتة مهمة شاقة.


ومع ذلك ، عندما يكون من الممكن حساب ارتفاع كل عنصر ، فإن كتابة استراتيجيتك تبين أنها بسيطة للغاية. الشيء الرئيسي هو أن يتم إجراء هذا الحساب بسرعة ، لأنه سيتم استدعاء في كثير من الأحيان. إذا كنت بحاجة إلى عرض عدد كبير من البطاقات ، والتي قد تكون أو لا تكون هناك عناصر تؤثر على طولها ، ففكر في خوارزمية فعالة للحصول على الطول ولا تخاف من كتابة استراتيجيتك.

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


All Articles