Skip to content

Instantly share code, notes, and snippets.

@kaplan81
Created July 30, 2024 10:20
Show Gist options
  • Save kaplan81/3ec04a925c000ec9850f62f270fe8ac0 to your computer and use it in GitHub Desktop.
Save kaplan81/3ec04a925c000ec9850f62f270fe8ac0 to your computer and use it in GitHub Desktop.
InfiniteScrollDirective with Angular Signals
import { DOCUMENT } from '@angular/common';
import {
AfterViewInit,
DestroyRef,
Directive,
ElementRef,
InputSignal,
OutputEmitterRef,
WritableSignal,
effect,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Observable, filter, map, pairwise } from 'rxjs';
export interface ScrollPosition {
contentHeight: number;
scrollHeight: number;
scrollTop: number;
}
export function isWindow(element: typeof globalThis | Window | HTMLElement | null): boolean {
return element !== null
? typeof window !== 'undefined' && Object.prototype.hasOwnProperty.call(element, 'self')
: false;
}
@Directive({
selector: '[infiniteScroll]',
standalone: true,
})
export class InfiniteScrollDirective implements AfterViewInit {
static readonly defaultScrollPercent = 85;
static readonly eventType = 'scroll';
#destroyRef = inject(DestroyRef);
#document: Document = inject(DOCUMENT);
#elementRef = inject(ElementRef);
#eventTriggerer: WritableSignal<Window | HTMLElement | null> = signal(null);
#scroll: WritableSignal<Document | HTMLElement> = signal(this.#document, {
/**
* Take every change to scroll as a new value.
*/
equal: () => false,
});
#scroll$: Observable<[ScrollPosition, ScrollPosition]> = toObservable(this.#scroll).pipe(
map((scroll: Document | HTMLElement) => this.#getScrollPosition(scroll)),
pairwise(),
filter((positions: [ScrollPosition, ScrollPosition]) => this.#filterScroll(positions)),
takeUntilDestroyed(),
);
currentPage: InputSignal<number> = input.required<number>();
isLoading: InputSignal<boolean> = input.required<boolean>();
nextPage: OutputEmitterRef<number> = output<number>();
nextPageCount: WritableSignal<number | null> = signal<number | null>(null);
scrollPercent: InputSignal<number> = input<number>(InfiniteScrollDirective.defaultScrollPercent);
totalPages: InputSignal<number> = input.required<number>();
useWindow: InputSignal<boolean> = input<boolean>(false);
constructor() {
effect(() => {
this.currentPage();
untracked(() => {
if (this.currentPage() === 1) {
this.nextPageCount.set(null);
if (this.#scrollOnWindow()) {
((this.#scroll() as Document).scrollingElement as Element).scrollTop = 0;
} else {
(this.#scroll() as HTMLElement).scrollTop = 0;
}
}
});
});
}
ngAfterViewInit(): void {
this.#addScrollEvent();
this.#scroll$.subscribe(() => {
this.nextPageCount.update(() => {
if (this.nextPageCount() === null || this.nextPageCount()! < this.currentPage() + 1) {
this.nextPage.emit(this.currentPage() + 1);
return this.currentPage() + 1;
}
return this.nextPageCount();
});
});
}
#addScrollEvent(): void {
if (this.#scrollOnWindow()) {
this.#eventTriggerer.set(window);
} else {
this.#eventTriggerer.set(this.#elementRef.nativeElement as HTMLElement);
}
this.#eventTriggerer()!.addEventListener(
InfiniteScrollDirective.eventType,
(event: Event) => this.#onScroll(event),
true,
);
this.#destroyRef.onDestroy(() =>
this.#eventTriggerer()!.removeEventListener(InfiniteScrollDirective.eventType, () => {}, true),
);
}
#filterScroll(positions: [ScrollPosition, ScrollPosition]): boolean {
return (
this.#scrollingDown(positions) &&
this.#overscrolled(positions[1]) &&
this.currentPage() < this.totalPages() &&
!this.isLoading()
);
}
#getScrollPosition(scroll: Document | HTMLElement): ScrollPosition {
return this.#scrollOnWindow()
? {
contentHeight: (scroll as Document).documentElement.clientHeight,
scrollHeight: ((scroll as Document).scrollingElement as Element).scrollHeight,
scrollTop: ((scroll as Document).scrollingElement as Element).scrollTop,
}
: {
contentHeight: (scroll as HTMLElement).offsetHeight,
scrollHeight: (scroll as HTMLElement).scrollHeight,
scrollTop: (scroll as HTMLElement).scrollTop,
};
}
#onScroll(event: Event): void {
let scroll: Document | HTMLElement = event.target as HTMLElement;
if ((event as CustomEvent).detail !== undefined) {
scroll = (event as CustomEvent).detail as Document;
}
this.#scroll.set(scroll);
}
#overscrolled(position: ScrollPosition): boolean {
return (position.scrollTop + position.contentHeight) / position.scrollHeight > this.scrollPercent() / 100;
}
#scrollOnWindow(): boolean {
return this.useWindow() && isWindow(globalThis);
}
#scrollingDown(positions: [ScrollPosition, ScrollPosition]): boolean {
return positions[0].scrollTop < positions[1].scrollTop;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment