我们编写了从Angular CDK进行虚拟滚动的策略

你好


在Angular CDK中,第七版中出现了虚拟滚动


当每个元素的大小相同时,它非常有用-并且开箱即用。 我们仅以像素为单位设置大小,并指示容器应滚动到哪个元素,是否可以平滑地进行操作,我们还可以订阅当前元素的索引。 但是,如果元素的大小发生变化怎么办? 为此,在CDK中提供了VirtualScrollStrategy接口,通过实现该接口,我们将教滚动与列表一起使用。


就我而言,有必要为移动演示制作一个日历,该日历可以连续滚动,并且一个月中的周数总是不同的。 让我们尝试找出什么是虚拟滚动策略并编写自己的策略。


图片


尺寸计算


如您所知,日历每28年重复一次。


如果您不考虑将年份除以100,而不是被400整除的年份,则可以执行此操作。在我们的情况下,我们不需要1900年之前和2100年之后的年份。对于1月到星期一,我们将从1900年开始为一个偶数帐户,我们将退出196年。 因此,在我们的日历中将有7个重复周期。 1900年2月29日的缺席将不会受到伤害,因为那是星期四。



计算将在滚动过程中执行,因此计算越简单,性能就越高。 为此,我们将创建一个循环常数,该常数将由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, ), ); } 

在输入时,此函数接收月份标题的高度和一周的高度(上面的gif分别为64和48像素)。 一个月中的星期数将帮助我们计算出这样一个简单的函数:


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


我们将编写一个函数,使您可以按周期内的年和月计算身高:


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

通过不带参数调用此函数,我们得到一个周期的大小,将来我们可以从范围的任何一年的任何月份从容器顶部找到缩进量。


虚拟滚动策略


您可以使用令牌将策略提交到虚拟滚动
VIRTUAL_SCROLL_STRATEGY


 { provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, }, 

我们的类应实现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; } 

attachdetach功能负责初始化和关闭。 每次用户滚动容器时,对我们而言最重要的onContentScrolled方法都会被调用(内部使用requestAnimationFrame反跳操作以避免不必要的调用)。


onDataLengthChanged中元素的数量发生更改时,将调用onDataLengthChanged在我们的情况下,这将永远不会发生。 通常,在这种情况下,有必要重新计算总高度和当前显示的元素,这与attach功能中需要执行的操作大致相同。


当元素的显示部分更改并且第一个元素的缩进更改时,将调用onContentRenderedonRenderedOffsetChanged 。 当为CdkVirtualScrollViewport提供新范围的显示项目并分别缩进其中的第一个项目时,它们将访问这些方法。 我们不需要它,因为不需要手动调用CdkVirtualScrollViewport方法。 如果需要,则可以在onContentRendered内部计算一个新的缩进,而在onRenderedOffsetChanged -相反,可以计算所得缩进的可见元素范围。


对我们scrollToIndex ,第二个重要方法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着我们继续绘制元素的可见区域上下的像素数。 就我而言,我使用500px。 如果顶部缩进小于缓冲区并且上面有更多元素,我们将通过在顶部添加足够的元素以覆盖缓冲区两次来更改范围。 我们还调整范围的末端。 由于我们向上滚动-在底部一个缓冲区就足够了。 同样的事情,但是在向下滚动时我们执行相反的操作。


然后,我们为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> 

在滚动结束时,有一个平稳的转弯到下个月仍然是必须的。 有细微差别。


事实是,手指松开表面后,移动设备上的滚动会继续。 因此,对于我们来说,很难理解何时需要调整当前月份。 为此,我们使用RxJ。 touchstart事件,并等待下一个touchend 。 滚动开始后,我们使用Race运算符来查找滚动是否继续,或者手指是否在没有加速的情况下松开。 如果在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'); }); 

在这里应该注意,为使Angular CDK中的scrollToIndex平滑滚动,使用了本机实现,它在Safari中不起作用。 您可以通过在scrollToIndex方法中编写的策略内的requestAnimationFrame编写平滑滚动来解决此问题。


结论


多亏了DI和Angular团队的谨慎,我们得以为自己灵活地自定义虚拟滚动。 乍一看,为高度不同的元素实现虚拟滚动似乎是一项艰巨的任务。


但是,当可以计算每个元素的高度时,编写策略非常简单。 最主要的是,此计算将快速执行,因为它会经常调用。 如果您需要显示大量卡,其中可能有或没有影响其高度的元素,请考虑一种获取高度的有效算法,不要害怕编写您的策略。

Source: https://habr.com/ru/post/zh-CN484168/


All Articles