import {
Directive,
Input,
HostListener,
ElementRef,
EventEmitter,
Output,
OnDestroy,
AfterViewInit,
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 { convertToBoolProperty, NbBooleanInput } from '../helpers';
import { NbLayoutScrollService } from '../../services/scroll.service';
import { NbLayoutRulerService } from '../../services/ruler.service';
import { NbListItemComponent } from './list.component';
export class NbScrollableContainerDimentions {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
/**
* Infinite List Directive
*
* ```html
*
*
*
* ```
*
* @stacked-example(Simple infinite list, infinite-list/infinite-list-showcase.component)
*
* Directive will notify when list scrolled up or down to a given threshold.
* By default it listen to scroll of list on which applied, but also can be set to listen to window scroll.
*
* @stacked-example(Scroll modes, infinite-list/infinite-list-scroll-modes.component)
*
* To improve UX of infinite lists, it's better to keep current page in url,
* so user able to return to the last viewed page or to share a link to this page.
* `nbListPageTracker` directive will help you to know, what page user currently viewing.
* Just put it on a list, set page size and it will calculate page that currently in viewport.
* You can [open the example](example/infinite-list/infinite-news-list.component)
* in a new tab to check out this feature.
*
* @stacked-example(Infinite list with pager, infinite-list/infinite-news-list.component)
*
* @stacked-example(Infinite list with placeholders at the top, infinite-list/infinite-list-placeholders.component)
*
*/
@Directive({
selector: '[nbInfiniteList]',
})
export class NbInfiniteListDirective implements AfterViewInit, OnDestroy {
private destroy$ = new Subject();
private lastScrollPosition;
windowScroll = false;
private get elementScroll() {
return !this.windowScroll;
}
/**
* Threshold after which event load more event will be emited.
* In pixels.
*/
@Input()
threshold: number;
/**
* By default component observes list scroll position.
* If set to `true`, component will observe position of page scroll instead.
*/
@Input()
set listenWindowScroll(value) {
this.windowScroll = convertToBoolProperty(value);
}
static ngAcceptInputType_listenWindowScroll: NbBooleanInput;
/**
* Emits when distance between list bottom and current scroll position is less than threshold.
*/
@Output()
bottomThreshold = new EventEmitter(true);
/**
* Emits when distance between list top and current scroll position is less than threshold.
*/
@Output()
topThreshold = new EventEmitter(true);
@HostListener('scroll')
onElementScroll() {
if (this.elementScroll) {
this.checkPosition(this.elementRef.nativeElement);
}
}
@ContentChildren(NbListItemComponent) listItems: QueryList;
constructor(
private elementRef: ElementRef,
private scrollService: NbLayoutScrollService,
private dimensionsService: NbLayoutRulerService,
) {}
ngAfterViewInit() {
this.scrollService.onScroll()
.pipe(
filter(() => this.windowScroll),
switchMap(() => this.getContainerDimensions()),
takeUntil(this.destroy$),
)
.subscribe(dimentions => this.checkPosition(dimentions));
this.listItems.changes
.pipe(
// For some reason, changes are emitted before list item removed from dom,
// 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(() => this.getContainerDimensions()),
takeUntil(this.destroy$),
)
.subscribe(dimentions => this.checkPosition(dimentions));
this.getContainerDimensions().subscribe(dimentions => this.checkPosition(dimentions));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
checkPosition({ scrollHeight, scrollTop, clientHeight }: NbScrollableContainerDimentions) {
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 || scrollUp) && scrollTop <= this.threshold) {
this.topThreshold.emit();
}
this.lastScrollPosition = scrollTop;
}
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,
})),
);
}
private inSyncWithDom(): boolean {
return this.elementRef.nativeElement.children.length === this.listItems.length;
}
}