Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ All notable changes for each version of this project will be documented in this
- The `ng add` schematic no longer prompts for or installs `hammerjs`.
- If your application imported `hammerjs` solely for Ignite UI components, you can safely remove it from your `package.json` dependencies, `angular.json` scripts/polyfills, and any `import 'hammerjs'` statements.

- **igxList**
- `igx-list-item` no longer tracks touch gestures when neither `allowLeftPanning` nor `allowRightPanning` is enabled, so pressing an item no longer captures the pointer or suppresses the touch scrolling of the list. When panning is enabled, the gestures are processed outside of the Angular zone, which keeps continuous touch dragging of large (virtualized) lists smooth.

## 22.0.0

### New Features
Expand Down
104 changes: 79 additions & 25 deletions projects/igniteui-angular/core/src/core/touch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { NgZone } from '@angular/core';

/**
* Normalized gesture event emitted by {@link IgxTouchManager}.
*
Expand Down Expand Up @@ -78,15 +80,29 @@ export interface IgxTouchManagerOptions {
tapThreshold?: number;
/** Minimum velocity (in px/ms) for a primarily horizontal gesture to be recognized as a swipe. Defaults to `0.3`. */
swipeVelocityThreshold?: number;
/**
* Predicate evaluated on pointer down. When it returns `false` the gesture is not tracked at all,
* so no pointer capture is taken and the native scrolling of the page is not prevented.
* Defaults to always tracking.
*/
canStart?: (event: PointerEvent) => boolean;
/**
* When provided, the listeners are attached outside of the Angular zone so that the high frequency
* `panMove` callback does not trigger change detection. The remaining callbacks are still invoked
* inside the Angular zone.
*/
ngZone?: NgZone;
}

/**
* Lightweight, zoneless pointer-based gesture manager.
*
* Consolidates the pan/swipe/tap recognition logic shared across components
* (carousel, navigation drawer, list item, time picker) on top of native
* Pointer Events. It does not depend on `NgZone`; consumers update their own
* state inside the provided callbacks.
* Pointer Events. It does not require `NgZone`; consumers update their own
* state inside the provided callbacks. An `NgZone` can optionally be supplied so
* that the listeners are attached outside of Angular and only the discrete
* callbacks re-enter it, keeping continuous dragging free of change detection.
*
* Outside of a browser environment (e.g. during server-side rendering) it is a
* noop: no listeners are attached and no callbacks are invoked, so consumers can
Expand Down Expand Up @@ -119,6 +135,8 @@ export class IgxTouchManager {
private readonly _setPointerCapture: boolean;
private readonly _tapThreshold: number;
private readonly _swipeVelocityThreshold: number;
private readonly _canStart: ((event: PointerEvent) => boolean) | null;
private readonly _ngZone: NgZone | null;

constructor(
private target: EventTarget,
Expand All @@ -129,6 +147,8 @@ export class IgxTouchManager {
this._setPointerCapture = options.setPointerCapture ?? true;
this._tapThreshold = options.tapThreshold ?? 0;
this._swipeVelocityThreshold = options.swipeVelocityThreshold ?? 0.3;
this._canStart = options.canStart ?? null;
this._ngZone = options.ngZone ?? null;

// Behave as a noop outside of a browser (e.g. during server-side rendering),
// mirroring the previous Hammer.js-based manager. Angular's platform-server
Expand All @@ -141,14 +161,16 @@ export class IgxTouchManager {
return;
}

this.target.addEventListener('pointerdown', this._onPointerDown);
this.target.addEventListener('pointermove', this._onPointerMove);
this.target.addEventListener('pointerup', this._onPointerUp);
this.target.addEventListener('pointercancel', this._onPointerCancel);
// prevents the default scrolling behavior on touch devices while a gesture is tracked
// necessary on edge pans detected on the body as the browsers cancel the pointer events
// and trigger a scroll / rubber band effect instead of the pan
this.target.addEventListener('touchmove', this._onTouchMove, { passive: false });
this._runOutsideAngular(() => {
this.target.addEventListener('pointerdown', this._onPointerDown);
this.target.addEventListener('pointermove', this._onPointerMove);
this.target.addEventListener('pointerup', this._onPointerUp);
this.target.addEventListener('pointercancel', this._onPointerCancel);
// prevents the default scrolling behavior on touch devices while a gesture is tracked
// necessary on edge pans detected on the body as the browsers cancel the pointer events
// and trigger a scroll / rubber band effect instead of the pan
this.target.addEventListener('touchmove', this._onTouchMove, { passive: false });
});
}

/** Detaches all listeners and stops tracking. */
Expand All @@ -168,6 +190,23 @@ export class IgxTouchManager {
return this._pointerTypes.includes(pointerType);
}

private _runOutsideAngular(fn: () => void): void {
if (this._ngZone) {
this._ngZone.runOutsideAngular(fn);
} else {
fn();
}
}

/** Invokes a discrete (low frequency) callback back inside the Angular zone, when one is provided. */
private _runInAngular(fn: () => void): void {
if (this._ngZone) {
this._ngZone.run(fn);
} else {
fn();
}
}

private _createEvent(event: PointerEvent): IgxGestureEvent {
const deltaX = event.clientX - this._startX;
const deltaY = event.clientY - this._startY;
Expand Down Expand Up @@ -197,7 +236,7 @@ export class IgxTouchManager {
}

private _onPointerDown = (event: PointerEvent) => {
if (this._tracking || !this._accepts(event.pointerType)) {
if (this._tracking || !this._accepts(event.pointerType) || this._canStart?.(event) === false) {
return;
}
this._startX = event.clientX;
Expand All @@ -220,8 +259,13 @@ export class IgxTouchManager {
// Let the consumer veto the gesture (e.g. the touch did not start in an active
// zone). Returning `false` stops tracking immediately so the `touchmove` listener
// does not block normal scrolling and other gestures are not interfered with.
if (this.callbacks.pointerDown?.(this._createEvent(event)) === false) {
this._stopTracking(event.pointerId);
if (this.callbacks.pointerDown) {
const gesture = this._createEvent(event);
this._runInAngular(() => {
if (this.callbacks.pointerDown?.(gesture) === false) {
this._stopTracking(event.pointerId);
}
});
}
};

Expand All @@ -234,8 +278,13 @@ export class IgxTouchManager {
// A press with no movement (a tap) therefore never raises `panStart`.
if (!this._panStarted) {
this._panStarted = true;
this.callbacks.panStart?.(gesture);
if (this.callbacks.panStart) {
this._runInAngular(() => this.callbacks.panStart(gesture));
}
}
// `panMove` is intentionally invoked outside of the Angular zone (when one is provided)
// as it fires for every pointer move and running change detection for each of them
// makes continuous dragging lag behind the pointer.
this.callbacks.panMove?.(gesture);
};

Expand All @@ -247,18 +296,20 @@ export class IgxTouchManager {
this._pointerId = null;
const gesture = this._createEvent(event);

if (this.callbacks.tap && gesture.distance < this._tapThreshold) {
this.callbacks.tap(gesture);
return;
}
this._runInAngular(() => {
if (this.callbacks.tap && gesture.distance < this._tapThreshold) {
this.callbacks.tap(gesture);
return;
}

if (this.callbacks.swipe &&
gesture.velocity > this._swipeVelocityThreshold &&
Math.abs(gesture.deltaX) > Math.abs(gesture.deltaY)) {
this.callbacks.swipe(gesture);
}
if (this.callbacks.swipe &&
gesture.velocity > this._swipeVelocityThreshold &&
Math.abs(gesture.deltaX) > Math.abs(gesture.deltaY)) {
this.callbacks.swipe(gesture);
}

this.callbacks.panEnd?.(gesture);
this.callbacks.panEnd?.(gesture);
});
};

private _onPointerCancel = (event: PointerEvent) => {
Expand All @@ -267,7 +318,10 @@ export class IgxTouchManager {
}
this._tracking = false;
this._pointerId = null;
this.callbacks.panCancel?.(this._createEvent(event));
if (this.callbacks.panCancel) {
const gesture = this._createEvent(event);
this._runInAngular(() => this.callbacks.panCancel(gesture));
}
};

private _onTouchMove = (event: TouchEvent) => {
Expand Down
16 changes: 15 additions & 1 deletion projects/igniteui-angular/list/src/list/list-item.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, ElementRef, HostBinding, HostListener, Input, OnDestroy, OnInit, Renderer2, ViewChild, booleanAttribute, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, HostBinding, HostListener, Input, NgZone, OnDestroy, OnInit, Renderer2, ViewChild, booleanAttribute, inject } from '@angular/core';

import {
IgxListPanState,
Expand Down Expand Up @@ -33,6 +33,7 @@ export class IgxListItemComponent implements IListChild, OnInit, OnDestroy {
public list = inject(IgxListBaseDirective);
private elementRef = inject(ElementRef);
private _renderer = inject(Renderer2);
private _zone = inject(NgZone);

/**
* @hidden
Expand Down Expand Up @@ -345,6 +346,11 @@ export class IgxListItemComponent implements IListChild, OnInit, OnDestroy {
panMove: (event) => this.panMove(event),
panEnd: () => this.panEnd(),
panCancel: () => this.panCancel()
}, {
ngZone: this._zone,
// Do not track the gesture at all when the item cannot be panned. Otherwise every
// pressed item captures the pointer and suppresses the touch scrolling of the list.
canStart: () => this.panningAllowed
});
}

Expand All @@ -355,6 +361,14 @@ export class IgxListItemComponent implements IListChild, OnInit, OnDestroy {
this._gestures?.destroy();
}

/**
* @hidden
*/
private get panningAllowed(): boolean {
return !this.isTrue(this.isHeader) &&
(this.isTrue(this.list.allowLeftPanning) || this.isTrue(this.list.allowRightPanning));
}

/**
* @hidden
*/
Expand Down
40 changes: 39 additions & 1 deletion projects/igniteui-angular/list/src/list/list.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { QueryList } from '@angular/core';
import { NgZone, QueryList } from '@angular/core';
import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { IgxListItemComponent } from './list-item.component';
Expand Down Expand Up @@ -170,6 +170,44 @@ describe('List', () => {
expect(list.endPan.emit).toHaveBeenCalledTimes(2);
});

it('should not track touch gestures when panning is disabled', () => {
const fixture = TestBed.createComponent(TwoHeadersListNoPanningComponent);
fixture.detectChanges();

const nativeElement = fixture.debugElement.queryAll(By.css('igx-list-item'))[1].nativeElement;
const captureSpy = spyOn(nativeElement, 'setPointerCapture');
const touchMove = new Event('touchmove', { bubbles: true, cancelable: true });

dispatchPointer(nativeElement, 'pointerdown', 0);
dispatchPointer(nativeElement, 'pointermove', 50);
nativeElement.dispatchEvent(touchMove);

/* The item should not interfere with the touch scrolling of the list */
expect(captureSpy).not.toHaveBeenCalled();
expect(touchMove.defaultPrevented).toBeFalse();
});

it('should handle continuous panning outside the Angular zone', () => {
const fixture = TestBed.createComponent(ListWithPanningComponent);
const list: IgxListComponent = fixture.componentInstance.list;

fixture.detectChanges();

const listItem = list.items[0] as IgxListItemComponent;
const zoneStates: { panStart?: boolean; panMove?: boolean; panEnd?: boolean } = {};

spyOn(listItem, 'panStart').and.callFake(() => zoneStates.panStart = NgZone.isInAngularZone());
spyOn(listItem, 'panMove').and.callFake(() => zoneStates.panMove = NgZone.isInAngularZone());
spyOn(listItem, 'panEnd').and.callFake(() => zoneStates.panEnd = NgZone.isInAngularZone());

panItem(fixture.debugElement.queryAll(By.css('igx-list-item'))[0], 0.6);

/* panMove fires for every pointer move, so it should not trigger change detection */
expect(zoneStates.panMove).toBeFalse();
expect(zoneStates.panStart).toBeTrue();
expect(zoneStates.panEnd).toBeTrue();
});

it('should not emit startPan on a tap without horizontal movement', () => {
const fixture = TestBed.createComponent(ListWithPanningComponent);
const list: IgxListComponent = fixture.componentInstance.list;
Expand Down
Loading