From 718dd0affb73d61834a4effbf2a314ca2d42def3 Mon Sep 17 00:00:00 2001 From: Nikolay Evtikhovich <33560032+evtkhvch@users.noreply.github.com> Date: Wed, 1 Dec 2021 22:15:00 +0300 Subject: [PATCH] feat(infinite list): threshold emissions throttling (#2948) BREAKING CHANGE: `NbScrollableContainerDimentions` class renamed to `NbScrollableContainerDimensions`. --- .../list/infinite-list.directive.spec.ts | 239 ++++++++++++++---- .../list/infinite-list.directive.ts | 94 +++++-- 2 files changed, 253 insertions(+), 80 deletions(-) diff --git a/src/framework/theme/components/list/infinite-list.directive.spec.ts b/src/framework/theme/components/list/infinite-list.directive.spec.ts index 54121e4b31..9f3babde14 100644 --- a/src/framework/theme/components/list/infinite-list.directive.spec.ts +++ b/src/framework/theme/components/list/infinite-list.directive.spec.ts @@ -24,6 +24,20 @@ let listElementRef: DebugElement; let layoutComponent: NbLayoutComponent; let infiniteListDirective: NbInfiniteListDirective; +// First change detection run must take place inside a `fakeAsync` zone, +// so rxjs interval scheduled in the `throttle` (by `interval` observable) +// use patched `setInterval`. Then we are able to control this interval via +// `tick`. +function setup() { + fixture.detectChanges(); + tick(); + + listElementRef = fixture.debugElement.query(By.directive(NbListComponent)); + layoutComponent = fixture.debugElement.query(By.directive(NbLayoutComponent)).componentInstance; + infiniteListDirective = listElementRef.injector.get(NbInfiniteListDirective); + testComponent = fixture.componentInstance; +} + @Component({ template: ` @@ -33,6 +47,7 @@ let infiniteListDirective: NbInfiniteListDirective; class="scroller" [class.element-scroll]="!listenWindowScroll" nbInfiniteList + [throttleTime]="throttleTime" [threshold]="threshold" [listenWindowScroll]="listenWindowScroll" (bottomThreshold)="bottomThreshold()" @@ -68,6 +83,7 @@ class ScrollTestComponent { listenWindowScroll = false; threshold = THRESHOLD; withScroll = false; + throttleTime = 0; bottomThreshold() {} topThreshold() {} @@ -85,13 +101,6 @@ describe('Directive: NbScrollDirective', () => { providers: [NbLayoutScrollService, { provide: APP_BASE_HREF, useValue: '/' }], declarations: [ScrollTestComponent], }).createComponent(ScrollTestComponent); - - fixture.detectChanges(); - - listElementRef = fixture.debugElement.query(By.directive(NbListComponent)); - layoutComponent = fixture.debugElement.query(By.directive(NbLayoutComponent)).componentInstance; - infiniteListDirective = listElementRef.injector.get(NbInfiniteListDirective); - testComponent = fixture.componentInstance; }); afterEach(fakeAsync(() => { @@ -100,16 +109,18 @@ describe('Directive: NbScrollDirective', () => { fixture.nativeElement.remove(); })); - it('should listen to window scroll', () => { + it('should listen to window scroll', fakeAsync(() => { + setup(); const checkPositionSpy = spyOn(infiniteListDirective, 'checkPosition'); testComponent.listenWindowScroll = true; fixture.detectChanges(); window.dispatchEvent(new Event('scroll')); expect(checkPositionSpy).toHaveBeenCalledTimes(1); - }); + })); - it('should listen to layout scroll', () => { + it('should listen to layout scroll', fakeAsync(() => { + setup(); const checkPositionSpy = spyOn(infiniteListDirective, 'checkPosition'); testComponent.listenWindowScroll = true; testComponent.withScroll = true; @@ -118,15 +129,17 @@ describe('Directive: NbScrollDirective', () => { layoutComponent.scrollableContainerRef.nativeElement.dispatchEvent(new Event('scroll')); expect(checkPositionSpy).toHaveBeenCalledTimes(1); - }); + })); - it('should listen to element scroll', () => { + it('should listen to element scroll', fakeAsync(() => { + setup(); const elementScrollHandlerSpy = spyOn(infiniteListDirective, 'onElementScroll'); listElementRef.nativeElement.dispatchEvent(new Event('scroll')); expect(elementScrollHandlerSpy).toHaveBeenCalledTimes(1); - }); + })); - it('should ignore window and layout scroll when listening to element scroll', () => { + it('should ignore window and layout scroll when listening to element scroll', fakeAsync(() => { + setup(); const checkPositionSpy = spyOn(infiniteListDirective, 'checkPosition'); window.dispatchEvent(new Event('scroll')); @@ -138,141 +151,263 @@ describe('Directive: NbScrollDirective', () => { listElementRef.nativeElement.dispatchEvent(new Event('scroll')); expect(checkPositionSpy).toHaveBeenCalledTimes(1); - }); + })); - it('should ignore element scroll when listening to window or layout scroll', () => { + it('should ignore element scroll when listening to window or layout scroll', fakeAsync(() => { + setup(); testComponent.listenWindowScroll = true; fixture.detectChanges(); const checkPositionSpy = spyOn(infiniteListDirective, 'checkPosition'); listElementRef.nativeElement.dispatchEvent(new Event('scroll')); + tick(infiniteListDirective.throttleTime); expect(checkPositionSpy).toHaveBeenCalledTimes(0); window.dispatchEvent(new Event('scroll')); + tick(infiniteListDirective.throttleTime); expect(checkPositionSpy).toHaveBeenCalledTimes(1); testComponent.withScroll = true; fixture.detectChanges(); listElementRef.nativeElement.dispatchEvent(new Event('scroll')); + tick(infiniteListDirective.throttleTime); expect(checkPositionSpy).toHaveBeenCalledTimes(1); const layoutScrollContainer = layoutComponent.scrollableContainerRef.nativeElement; layoutScrollContainer.dispatchEvent(new Event('scroll')); + tick(infiniteListDirective.throttleTime); expect(checkPositionSpy).toHaveBeenCalledTimes(2); - }); + })); - it('should trigger bottomThreshold only when treshold reached (element scroll)', fakeAsync(() => { + it('should trigger bottomThreshold only when threshold reached (element scroll)', fakeAsync(() => { + setup(); const scrollingNativeElement = listElementRef.nativeElement; - const tresholdSpy = spyOn(testComponent, 'bottomThreshold'); + const thresholdSpy = spyOn(testComponent, 'bottomThreshold'); const positionUnderThreshold = CONTENT_HEIGHT - THRESHOLD - ELEMENT_HEIGHT - 1; scrollingNativeElement.scrollTop = positionUnderThreshold; scrollingNativeElement.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(0); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(0); const positionBelowThreshold = CONTENT_HEIGHT - THRESHOLD / 2; scrollingNativeElement.scrollTop = positionBelowThreshold; scrollingNativeElement.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(1); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(1); })); - it('should trigger bottomThreshold only when treshold reached (window scroll)', fakeAsync(() => { + it('should trigger bottomThreshold only when threshold reached (window scroll)', fakeAsync(() => { + setup(); const { documentElement } = document; testComponent.listenWindowScroll = true; fixture.detectChanges(); - const tresholdSpy = spyOn(testComponent, 'bottomThreshold'); + const thresholdSpy = spyOn(testComponent, 'bottomThreshold'); const reporterHeight = 1000; const positionUnderThreshold = CONTENT_HEIGHT - THRESHOLD - reporterHeight; documentElement.scrollTop = positionUnderThreshold; window.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(0); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(0); const positionBelowThreshold = CONTENT_HEIGHT - THRESHOLD / 2; documentElement.scrollTop = positionBelowThreshold; window.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(1); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(1); })); - it('should trigger bottomThreshold only when treshold reached (layout scroll)', fakeAsync(() => { + it('should trigger bottomThreshold only when threshold reached (layout scroll)', fakeAsync(() => { + setup(); const scroller: Element = layoutComponent.scrollableContainerRef.nativeElement; testComponent.listenWindowScroll = true; testComponent.withScroll = true; fixture.detectChanges(); - const tresholdSpy = spyOn(testComponent, 'bottomThreshold'); + const thresholdSpy = spyOn(testComponent, 'bottomThreshold'); const positionUnderThreshold = CONTENT_HEIGHT - THRESHOLD - scroller.clientHeight - 1; scroller.scrollTop = positionUnderThreshold; scroller.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(0); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(0); const positionBelowThreshold = CONTENT_HEIGHT - THRESHOLD / 2; scroller.scrollTop = positionBelowThreshold; scroller.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(1); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(1); })); - it('should trigger topThreshold when treshold reached (element)', fakeAsync(() => { + it('should trigger topThreshold when threshold reached (element)', fakeAsync(() => { + setup(); const scrollingElement = listElementRef.nativeElement; - const tresholdSpy = spyOn(testComponent, 'topThreshold'); + const thresholdSpy = spyOn(testComponent, 'topThreshold'); scrollingElement.scrollTop = THRESHOLD + 1; scrollingElement.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(0); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(0); scrollingElement.scrollTop = THRESHOLD - 1; scrollingElement.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(1); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(1); })); - it('should trigger topThreshold when treshold reached (window)', fakeAsync(() => { + it('should trigger topThreshold when threshold reached (window)', fakeAsync(() => { + setup(); testComponent.listenWindowScroll = true; fixture.detectChanges(); const { documentElement } = document; - const tresholdSpy = spyOn(testComponent, 'topThreshold'); + const thresholdSpy = spyOn(testComponent, 'topThreshold'); documentElement.scrollTop = THRESHOLD + 1; window.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(0); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(0); documentElement.scrollTop = THRESHOLD - 1; window.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(1); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(1); })); - it('should trigger topThreshold when treshold reached (layout scroll)', fakeAsync(() => { + it('should trigger topThreshold when threshold reached (layout scroll)', fakeAsync(() => { + setup(); testComponent.listenWindowScroll = true; testComponent.withScroll = true; fixture.detectChanges(); const layoutElement = layoutComponent.scrollableContainerRef.nativeElement; - const tresholdSpy = spyOn(testComponent, 'topThreshold'); + const thresholdSpy = spyOn(testComponent, 'topThreshold'); layoutElement.scrollTop = THRESHOLD + 1; layoutElement.dispatchEvent(new Event('scroll')); - tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(0); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(0); layoutElement.scrollTop = THRESHOLD - 1; layoutElement.dispatchEvent(new Event('scroll')); + tick(infiniteListDirective.throttleTime); + expect(thresholdSpy).toHaveBeenCalledTimes(1); + })); + + it('should prevent subsequent bottomThreshold emissions for throttleTime duration (window scroll)', fakeAsync(() => { + setup(); + const { documentElement } = document; + const THROTTLE = 200; + + testComponent.listenWindowScroll = true; + testComponent.throttleTime = THROTTLE; + fixture.detectChanges(); + + const thresholdSpy = spyOn(testComponent, 'bottomThreshold'); + + documentElement.scrollTop = CONTENT_HEIGHT - THRESHOLD / 2; + + window.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2); // 100ms passed + window.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2 - 1); // 199ms passed, resent scroll event should be throttled + expect(thresholdSpy).toHaveBeenCalledTimes(1); + tick(1); // 200ms passed, throttling has stopped + + window.dispatchEvent(new Event('scroll')); + tick(); + expect(thresholdSpy).toHaveBeenCalledTimes(2); + tick(THROTTLE); // waiting for the end of the throttle interval + })); + + it('should prevent subsequent topThreshold emissions for throttleTime duration (window scroll)', fakeAsync(() => { + setup(); + const { documentElement } = document; + const THROTTLE = 200; + + testComponent.listenWindowScroll = true; + testComponent.throttleTime = THROTTLE; + + fixture.detectChanges(); + + documentElement.scrollTop = THRESHOLD + 1; + window.dispatchEvent(new Event('scroll')); + + const thresholdSpy = spyOn(testComponent, 'topThreshold'); + + documentElement.scrollTop -= 1; + window.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2); // 100ms passed + documentElement.scrollTop -= 1; + window.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2 - 1); // 199ms passed, resent scroll event should be throttled + expect(thresholdSpy).toHaveBeenCalledTimes(1); + tick(1); // 200ms passed, throttling has stopped + + documentElement.scrollTop -= 1; + window.dispatchEvent(new Event('scroll')); + tick(); + expect(thresholdSpy).toHaveBeenCalledTimes(2); + tick(THROTTLE); // waiting for the end of the throttle interval + })); + + it('should prevent subsequent bottomThreshold emissions for throttleTime duration (element scroll)', fakeAsync(() => { + setup(); + const scrollingNativeElement = listElementRef.nativeElement; + const THROTTLE = 200; + + testComponent.throttleTime = THROTTLE; + fixture.detectChanges(); + + const thresholdSpy = spyOn(testComponent, 'bottomThreshold'); + + scrollingNativeElement.scrollTop = CONTENT_HEIGHT - THRESHOLD / 2; + + scrollingNativeElement.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2); // 100ms passed + scrollingNativeElement.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2 - 1); // 199ms passed, resent scroll event should be throttled + expect(thresholdSpy).toHaveBeenCalledTimes(1); + tick(1); // 200ms passed, throttling has stopped + + scrollingNativeElement.dispatchEvent(new Event('scroll')); + tick(); + expect(thresholdSpy).toHaveBeenCalledTimes(2); + tick(THROTTLE); // waiting for the end of the throttle interval + })); + + it('should prevent subsequent topThreshold emissions for throttleTime duration (element scroll)', fakeAsync(() => { + setup(); + const scrollingElement = listElementRef.nativeElement; + const THROTTLE = 200; + + testComponent.throttleTime = THROTTLE; + fixture.detectChanges(); + + scrollingElement.scrollTop = THRESHOLD + 1; + scrollingElement.dispatchEvent(new Event('scroll')); + + const thresholdSpy = spyOn(testComponent, 'topThreshold'); + + scrollingElement.scrollTop -= 1; + scrollingElement.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2); // 100ms passed + scrollingElement.scrollTop -= 1; + scrollingElement.dispatchEvent(new Event('scroll')); + tick(THROTTLE / 2 - 1); // 199ms passed, resent scroll event should be throttled + expect(thresholdSpy).toHaveBeenCalledTimes(1); + tick(1); // 200ms passed, throttling has stopped + + scrollingElement.scrollTop -= 1; + scrollingElement.dispatchEvent(new Event('scroll')); tick(); - expect(tresholdSpy).toHaveBeenCalledTimes(1); + expect(thresholdSpy).toHaveBeenCalledTimes(2); + tick(THROTTLE); // waiting for the end of the throttle interval })); }); diff --git a/src/framework/theme/components/list/infinite-list.directive.ts b/src/framework/theme/components/list/infinite-list.directive.ts index 97d4f3a168..2d56126cb7 100644 --- a/src/framework/theme/components/list/infinite-list.directive.ts +++ b/src/framework/theme/components/list/infinite-list.directive.ts @@ -10,14 +10,14 @@ import { ContentChildren, QueryList, } from '@angular/core'; -import { Observable, forkJoin, of as observableOf, interval, timer, Subject } from 'rxjs'; -import { filter, switchMap, map, takeUntil, take } from 'rxjs/operators'; +import { Observable, forkJoin, of as observableOf, interval, timer, Subject, merge, BehaviorSubject } from 'rxjs'; +import { filter, switchMap, map, takeUntil, take, throttle } from 'rxjs/operators'; import { convertToBoolProperty, NbBooleanInput } from '../helpers'; import { NbLayoutScrollService } from '../../services/scroll.service'; import { NbLayoutRulerService } from '../../services/ruler.service'; import { NbListItemComponent } from './list.component'; -export class NbScrollableContainerDimentions { +export class NbScrollableContainerDimensions { scrollTop: number; scrollHeight: number; clientHeight: number; @@ -55,13 +55,17 @@ export class NbScrollableContainerDimentions { selector: '[nbInfiniteList]', }) export class NbInfiniteListDirective implements AfterViewInit, OnDestroy { - private destroy$ = new Subject(); private lastScrollPosition; windowScroll = false; private get elementScroll() { return !this.windowScroll; } + private elementScroll$ = new Subject(); + private windowScroll$ = this.scrollService.onScroll().pipe(filter(() => this.windowScroll)); + private bottomThreshold$ = new Subject(); + private topThreshold$ = new Subject(); + private throttleTime$ = new BehaviorSubject(0); /** * Threshold after which event load more event will be emited. @@ -70,6 +74,18 @@ export class NbInfiniteListDirective implements AfterViewInit, OnDestroy { @Input() threshold: number; + /** + * Prevent subsequent bottom/topThreshold emissions for specified duration after emitting once. + * In milliseconds. + */ + @Input() + set throttleTime(value: number) { + this.throttleTime$.next(value); + } + get throttleTime() { + return this.throttleTime$.value; + } + /** * By default component observes list scroll position. * If set to `true`, component will observe position of page scroll instead. @@ -95,7 +111,7 @@ export class NbInfiniteListDirective implements AfterViewInit, OnDestroy { @HostListener('scroll') onElementScroll() { if (this.elementScroll) { - this.checkPosition(this.elementRef.nativeElement); + this.elementScroll$.next(); } } @@ -108,13 +124,30 @@ export class NbInfiniteListDirective implements AfterViewInit, OnDestroy { ) {} ngAfterViewInit() { - this.scrollService.onScroll() + merge(this.windowScroll$, this.elementScroll$) .pipe( - filter(() => this.windowScroll), switchMap(() => this.getContainerDimensions()), takeUntil(this.destroy$), ) - .subscribe(dimentions => this.checkPosition(dimentions)); + .subscribe((dimensions) => this.checkPosition(dimensions)); + + this.throttleTime$ + .pipe( + switchMap(() => this.topThreshold$.pipe(throttle(() => interval(this.throttleTime)))), + takeUntil(this.destroy$), + ) + .subscribe(() => { + this.topThreshold.emit(); + }); + + this.throttleTime$ + .pipe( + switchMap(() => this.bottomThreshold$.pipe(throttle(() => interval(this.throttleTime)))), + takeUntil(this.destroy$), + ) + .subscribe(() => { + this.bottomThreshold.emit(); + }); this.listItems.changes .pipe( @@ -122,55 +155,60 @@ export class NbInfiniteListDirective implements AfterViewInit, OnDestroy { // so dimensions will be incorrect. // Check every 50ms for a second if dom and query are in sync. // Once they synchronized, we can get proper dimensions. - switchMap(() => interval(50).pipe( - filter(() => this.inSyncWithDom()), - take(1), - takeUntil(timer(1000)), - )), + switchMap(() => + interval(50).pipe( + filter(() => this.inSyncWithDom()), + take(1), + takeUntil(timer(1000)), + ), + ), switchMap(() => this.getContainerDimensions()), takeUntil(this.destroy$), ) - .subscribe(dimentions => this.checkPosition(dimentions)); + .subscribe((dimensions) => this.checkPosition(dimensions)); - this.getContainerDimensions().subscribe(dimentions => this.checkPosition(dimentions)); + this.getContainerDimensions().subscribe((dimensions) => this.checkPosition(dimensions)); } ngOnDestroy() { + this.topThreshold$.complete(); + this.bottomThreshold$.complete(); + this.elementScroll$.complete(); this.destroy$.next(); this.destroy$.complete(); } - checkPosition({ scrollHeight, scrollTop, clientHeight }: NbScrollableContainerDimentions) { + checkPosition({ scrollHeight, scrollTop, clientHeight }: NbScrollableContainerDimensions) { const initialCheck = this.lastScrollPosition == null; const manualCheck = this.lastScrollPosition === scrollTop; const scrollUp = scrollTop < this.lastScrollPosition; const scrollDown = scrollTop > this.lastScrollPosition; const distanceToBottom = scrollHeight - scrollTop - clientHeight; - if ((initialCheck || manualCheck || scrollDown) && distanceToBottom <= this.threshold) { - this.bottomThreshold.emit(); + if ((initialCheck || manualCheck || scrollDown) && distanceToBottom <= this.threshold) { + this.bottomThreshold$.next(); } + if ((initialCheck || scrollUp) && scrollTop <= this.threshold) { - this.topThreshold.emit(); + this.topThreshold$.next(); } this.lastScrollPosition = scrollTop; } - private getContainerDimensions(): Observable { + private getContainerDimensions(): Observable { if (this.elementScroll) { const { scrollTop, scrollHeight, clientHeight } = this.elementRef.nativeElement; return observableOf({ scrollTop, scrollHeight, clientHeight }); } - return forkJoin([this.scrollService.getPosition(), this.dimensionsService.getDimensions()]) - .pipe( - map(([scrollPosition, dimensions]) => ({ - scrollTop: scrollPosition.y, - scrollHeight: dimensions.scrollHeight, - clientHeight: dimensions.clientHeight, - })), - ); + return forkJoin([this.scrollService.getPosition(), this.dimensionsService.getDimensions()]).pipe( + map(([scrollPosition, dimensions]) => ({ + scrollTop: scrollPosition.y, + scrollHeight: dimensions.scrollHeight, + clientHeight: dimensions.clientHeight, + })), + ); } private inSyncWithDom(): boolean {