Created
July 30, 2024 10:20
-
-
Save kaplan81/3ec04a925c000ec9850f62f270fe8ac0 to your computer and use it in GitHub Desktop.
InfiniteScrollDirective with Angular Signals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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