Skip to content

Commit 9428647

Browse files
authored
fix: set up Hammer outside of Angular to reduce CDs (#443)
1 parent 044a294 commit 9428647

File tree

2 files changed

+88
-34
lines changed

2 files changed

+88
-34
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Injectable, NgZone, OnDestroy } from '@angular/core';
2+
import { Observable, Subject, defer, fromEvent, map, shareReplay, takeUntil } from 'rxjs';
3+
4+
@Injectable({ providedIn: 'root' })
5+
export class NguHammerLoader {
6+
private _hammer$ = defer(() => import('hammerjs')).pipe(
7+
shareReplay({ bufferSize: 1, refCount: true })
8+
);
9+
10+
load() {
11+
return this._hammer$;
12+
}
13+
}
14+
15+
@Injectable()
16+
export class NguCarouselHammerManager implements OnDestroy {
17+
private _destroy$ = new Subject<void>();
18+
19+
constructor(private _ngZone: NgZone, private _nguHammerLoader: NguHammerLoader) {}
20+
21+
ngOnDestroy(): void {
22+
this._destroy$.next();
23+
}
24+
25+
createHammer(element: HTMLElement): Observable<HammerManager> {
26+
return this._nguHammerLoader.load().pipe(
27+
map(() =>
28+
// Note: The Hammer manager should be created outside of the Angular zone since it sets up
29+
// `pointermove` event listener which triggers change detection every time the pointer is moved.
30+
this._ngZone.runOutsideAngular(() => new Hammer(element))
31+
),
32+
// Note: the dynamic import is always a microtask which may run after the view is destroyed.
33+
// `takeUntil` is used to prevent setting Hammer up if the view had been destroyed before
34+
// the HammerJS is loaded.
35+
takeUntil(this._destroy$)
36+
);
37+
}
38+
39+
on(hammer: HammerManager, event: string) {
40+
return fromEvent(hammer, event).pipe(
41+
// Note: We have to re-enter the Angular zone because Hammer would trigger events outside of the
42+
// Angular zone (since we set it up with `runOutsideAngular`).
43+
enterNgZone(this._ngZone),
44+
takeUntil(this._destroy$)
45+
);
46+
}
47+
}
48+
49+
function enterNgZone<T>(ngZone: NgZone) {
50+
return (source: Observable<T>) =>
51+
new Observable<T>(subscriber =>
52+
source.subscribe({
53+
next: value => ngZone.run(() => subscriber.next(value)),
54+
error: error => ngZone.run(() => subscriber.error(error)),
55+
complete: () => ngZone.run(() => subscriber.complete())
56+
})
57+
);
58+
}

libs/ngu/carousel/src/lib/ngu-carousel/ngu-carousel.component.ts

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,7 @@ import {
2424
TrackByFunction,
2525
ViewChild
2626
} from '@angular/core';
27-
import {
28-
EMPTY,
29-
from,
30-
fromEvent,
31-
interval,
32-
merge,
33-
Observable,
34-
of,
35-
Subject,
36-
Subscription,
37-
timer
38-
} from 'rxjs';
27+
import { EMPTY, fromEvent, interval, merge, Observable, of, Subject, timer } from 'rxjs';
3928
import { debounceTime, filter, map, startWith, switchMap, takeUntil } from 'rxjs/operators';
4029

4130
import {
@@ -53,6 +42,7 @@ import {
5342
NguCarouselStore
5443
} from './ngu-carousel';
5544
import { NguWindowScrollListener } from './ngu-window-scroll-listener';
45+
import { NguCarouselHammerManager } from './ngu-carousel-hammer-manager';
5646

5747
type DirectionSymbol = '' | '-';
5848

@@ -68,7 +58,8 @@ const NG_DEV_MODE = typeof ngDevMode === 'undefined' || ngDevMode;
6858
selector: 'ngu-carousel',
6959
templateUrl: 'ngu-carousel.component.html',
7060
styleUrls: ['ngu-carousel.component.scss'],
71-
changeDetection: ChangeDetectionStrategy.OnPush
61+
changeDetection: ChangeDetectionStrategy.OnPush,
62+
providers: [NguCarouselHammerManager]
7263
})
7364
// eslint-disable-next-line @angular-eslint/component-class-suffix
7465
export class NguCarousel<T>
@@ -146,7 +137,7 @@ export class NguCarousel<T>
146137

147138
private _intervalController$ = new Subject<number>();
148139

149-
private _hammertime: HammerManager | null = null;
140+
private _hammer: HammerManager | null = null;
150141

151142
private _withAnimation = true;
152143

@@ -191,7 +182,8 @@ export class NguCarousel<T>
191182
@Inject(IS_BROWSER) private _isBrowser: boolean,
192183
private _cdr: ChangeDetectorRef,
193184
private _ngZone: NgZone,
194-
private _nguWindowScrollListener: NguWindowScrollListener
185+
private _nguWindowScrollListener: NguWindowScrollListener,
186+
private _nguCarouselHammerManager: NguCarouselHammerManager
195187
) {
196188
super();
197189
this._setupButtonListeners();
@@ -347,45 +339,48 @@ export class NguCarousel<T>
347339
}
348340

349341
ngOnDestroy() {
350-
this._hammertime?.destroy();
342+
this._hammer?.destroy();
351343
this._destroy$.next();
352344
}
353345

354346
/** Get Touch input */
355347
private _setupHammer(): void {
356-
from(import('hammerjs'))
357-
// Note: the dynamic import is always a microtask which may run after the view is destroyed.
358-
// `takeUntil` is used to prevent setting Hammer up if the view had been destroyed before
359-
// the HammerJS is loaded.
360-
.pipe(takeUntil(this._destroy$))
361-
.subscribe(() => {
362-
const hammertime = (this._hammertime = new Hammer(this._touchContainer.nativeElement));
363-
hammertime.get('pan').set({ direction: Hammer.DIRECTION_HORIZONTAL });
348+
// Note: doesn't need to unsubscribe because streams are piped with `takeUntil` already.
349+
this._nguCarouselHammerManager
350+
.createHammer(this._touchContainer.nativeElement)
351+
.subscribe(hammer => {
352+
this._hammer = hammer;
364353

365-
hammertime.on('panstart', () => {
354+
hammer.get('pan').set({ direction: Hammer.DIRECTION_HORIZONTAL });
355+
356+
this._nguCarouselHammerManager.on(hammer, 'panstart').subscribe(() => {
366357
this.carouselWidth = this._nguItemsContainer.nativeElement.offsetWidth;
367358
this.touchTransform = this.transform[this.deviceType!]!;
368359
this.dexVal = 0;
369360
this._setStyle(this._nguItemsContainer.nativeElement, 'transition', '');
370361
});
362+
371363
if (this.vertical.enabled) {
372-
hammertime.on('panup', (ev: any) => {
364+
this._nguCarouselHammerManager.on(hammer, 'panup').subscribe((ev: any) => {
373365
this._touchHandling('panleft', ev);
374366
});
375-
hammertime.on('pandown', (ev: any) => {
367+
368+
this._nguCarouselHammerManager.on(hammer, 'pandown').subscribe((ev: any) => {
376369
this._touchHandling('panright', ev);
377370
});
378371
} else {
379-
hammertime.on('panleft', (ev: any) => {
372+
this._nguCarouselHammerManager.on(hammer, 'panleft').subscribe((ev: any) => {
380373
this._touchHandling('panleft', ev);
381374
});
382-
hammertime.on('panright', (ev: any) => {
375+
376+
this._nguCarouselHammerManager.on(hammer, 'panright').subscribe((ev: any) => {
383377
this._touchHandling('panright', ev);
384378
});
385379
}
386-
hammertime.on('panend pancancel', (ev: any) => {
387-
if (Math.abs(ev.velocity) >= this.velocity) {
388-
this.touch.velocity = ev.velocity;
380+
381+
this._nguCarouselHammerManager.on(hammer, 'panend pancancel').subscribe(({ velocity }) => {
382+
if (Math.abs(velocity) >= this.velocity) {
383+
this.touch.velocity = velocity;
389384
let direc = 0;
390385
if (!this.RTL) {
391386
direc = this.touch.swipe === 'panright' ? 0 : 1;
@@ -403,10 +398,11 @@ export class NguCarousel<T>
403398
this._setStyle(this._nguItemsContainer.nativeElement, 'transform', '');
404399
}
405400
});
406-
hammertime.on('hammer.input', ev => {
401+
402+
this._nguCarouselHammerManager.on(hammer, 'hammer.input').subscribe(({ srcEvent }) => {
407403
// allow nested touch events to no propagate, this may have other side affects but works for now.
408404
// TODO: It is probably better to check the source element of the event and only apply the handle to the correct carousel
409-
ev.srcEvent.stopPropagation();
405+
srcEvent.stopPropagation();
410406
});
411407
});
412408
}

0 commit comments

Comments
 (0)