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
113 changes: 113 additions & 0 deletions projects/igniteui-angular/core/src/core/touch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { IgxTouchManager } from './touch';

describe('IgxTouchManager', () => {
let manager: IgxTouchManager;
let target: HTMLDivElement;

beforeEach(() => {
target = document.createElement('div');
document.body.appendChild(target);
});

afterEach(() => {
manager?.destroy();
target.remove();
});

it('should stop tracking when pointerDown vetoes the gesture', () => {
const panStart = jasmine.createSpy('panStart');
const panMove = jasmine.createSpy('panMove');
manager = new IgxTouchManager(target, {
pointerDown: () => false,
panStart,
panMove
});

dispatchPointerEvent(target, 'pointerdown', 10, 10);
const touchMove = dispatchTouchMove(target);
dispatchPointerEvent(target, 'pointermove', 30, 10);

expect(touchMove.defaultPrevented).toBeFalse();
expect(panStart).not.toHaveBeenCalled();
expect(panMove).not.toHaveBeenCalled();
});

it('should preserve native touch behavior until the pan threshold is exceeded', () => {
const panStart = jasmine.createSpy('panStart');
const panMove = jasmine.createSpy('panMove');
manager = new IgxTouchManager(target, {
panStart,
panMove
}, { panAxis: 'horizontal', panThreshold: 5 });

dispatchPointerEvent(target, 'pointerdown', 10, 10);
const initialTouchMove = dispatchTouchMove(target);
dispatchPointerEvent(target, 'pointermove', 13, 10);
const candidateTouchMove = dispatchTouchMove(target);

expect(initialTouchMove.defaultPrevented).toBeFalse();
expect(candidateTouchMove.defaultPrevented).toBeFalse();
expect(panStart).not.toHaveBeenCalled();
expect(panMove).not.toHaveBeenCalled();

dispatchPointerEvent(target, 'pointermove', 11, 20);
const verticalTouchMove = dispatchTouchMove(target);

expect(verticalTouchMove.defaultPrevented).toBeFalse();
expect(panStart).not.toHaveBeenCalled();
expect(panMove).not.toHaveBeenCalled();

dispatchPointerEvent(target, 'pointermove', 16, 10);
const activePanTouchMove = dispatchTouchMove(target);

expect(panStart).toHaveBeenCalledTimes(1);
expect(panMove).toHaveBeenCalledTimes(1);
expect(activePanTouchMove.defaultPrevented).toBeTrue();
});

for (const eventType of ['pointerup', 'pointercancel']) {
it(`should reset tracking state on ${eventType}`, () => {
manager = new IgxTouchManager(target, {});

dispatchPointerEvent(target, 'pointerdown', 10, 10);
dispatchPointerEvent(target, 'pointermove', 20, 10);
dispatchPointerEvent(target, eventType, 20, 10);

expectTrackingStateToBeReset(manager);
});
}

it('should reset tracking state when destroyed', () => {
manager = new IgxTouchManager(target, {});

dispatchPointerEvent(target, 'pointerdown', 10, 10);
dispatchPointerEvent(target, 'pointermove', 20, 10);
manager.destroy();

expectTrackingStateToBeReset(manager);
});
});

function expectTrackingStateToBeReset(manager: IgxTouchManager): void {
expect((manager as any)._tracking).toBeFalse();
expect((manager as any)._panStarted).toBeFalse();
expect((manager as any)._pointerId).toBeNull();
expect((manager as any)._startTarget).toBeNull();
}

function dispatchPointerEvent(target: EventTarget, type: string, clientX: number, clientY: number): void {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
pointerId: 1,
pointerType: 'touch',
clientX,
clientY
}));
}

function dispatchTouchMove(target: EventTarget): Event {
const event = new Event('touchmove', { bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event;
}
73 changes: 49 additions & 24 deletions projects/igniteui-angular/core/src/core/touch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export interface IgxTouchManagerOptions {
setPointerCapture?: boolean;
/** Maximum movement (in px) for a pointer up to be recognized as a tap. Defaults to `0` (disabled). */
tapThreshold?: number;
/** Minimum movement (in px) before a pan starts. Defaults to `0`. */
panThreshold?: number;
/** Axis on which movement can start a pan. Defaults to `'all'`. */
panAxis?: 'all' | 'horizontal' | 'vertical';
/** Minimum velocity (in px/ms) for a primarily horizontal gesture to be recognized as a swipe. Defaults to `0.3`. */
swipeVelocityThreshold?: number;
/**
Expand Down Expand Up @@ -134,6 +138,8 @@ export class IgxTouchManager {
private readonly _pointerTypes: string[];
private readonly _setPointerCapture: boolean;
private readonly _tapThreshold: number;
private readonly _panThreshold: number;
private readonly _panAxis: 'all' | 'horizontal' | 'vertical';
private readonly _swipeVelocityThreshold: number;
private readonly _canStart: ((event: PointerEvent) => boolean) | null;
private readonly _ngZone: NgZone | null;
Expand All @@ -146,6 +152,8 @@ export class IgxTouchManager {
this._pointerTypes = options.pointerTypes ?? ['touch', 'pen'];
this._setPointerCapture = options.setPointerCapture ?? true;
this._tapThreshold = options.tapThreshold ?? 0;
this._panThreshold = options.panThreshold ?? 0;
this._panAxis = options.panAxis ?? 'all';
this._swipeVelocityThreshold = options.swipeVelocityThreshold ?? 0.3;
this._canStart = options.canStart ?? null;
this._ngZone = options.ngZone ?? null;
Expand Down Expand Up @@ -175,15 +183,14 @@ export class IgxTouchManager {

/** Detaches all listeners and stops tracking. */
public destroy(): void {
if (!this._supported) {
return;
if (this._supported) {
this.target.removeEventListener('pointerdown', this._onPointerDown);
this.target.removeEventListener('pointermove', this._onPointerMove);
this.target.removeEventListener('pointerup', this._onPointerUp);
this.target.removeEventListener('pointercancel', this._onPointerCancel);
this.target.removeEventListener('touchmove', this._onTouchMove);
}
this.target.removeEventListener('pointerdown', this._onPointerDown);
this.target.removeEventListener('pointermove', this._onPointerMove);
this.target.removeEventListener('pointerup', this._onPointerUp);
this.target.removeEventListener('pointercancel', this._onPointerCancel);
this.target.removeEventListener('touchmove', this._onTouchMove);
this._tracking = false;
this._resetTracking();
}

private _accepts(pointerType: string): boolean {
Expand Down Expand Up @@ -274,9 +281,10 @@ export class IgxTouchManager {
return;
}
const gesture = this._createEvent(event);
// Defer `panStart` until movement actually begins, mirroring Hammer's `panstart`.
// A press with no movement (a tap) therefore never raises `panStart`.
if (!this._panStarted) {
if (!this._canStartPan(gesture)) {
return;
}
this._panStarted = true;
if (this.callbacks.panStart) {
this._runInAngular(() => this.callbacks.panStart(gesture));
Expand All @@ -292,9 +300,8 @@ export class IgxTouchManager {
if (!this._tracking || event.pointerId !== this._pointerId || !this._accepts(event.pointerType)) {
return;
}
this._tracking = false;
this._pointerId = null;
const gesture = this._createEvent(event);
this._resetTracking();

this._runInAngular(() => {
if (this.callbacks.tap && gesture.distance < this._tapThreshold) {
Expand All @@ -316,35 +323,53 @@ export class IgxTouchManager {
if (!this._tracking || event.pointerId !== this._pointerId) {
return;
}
this._tracking = false;
this._pointerId = null;
const gesture = this._createEvent(event);
this._resetTracking();
if (this.callbacks.panCancel) {
const gesture = this._createEvent(event);
this._runInAngular(() => this.callbacks.panCancel(gesture));
}
};

private _onTouchMove = (event: TouchEvent) => {
// Prevent scrolling only while a gesture is actively tracked.
if (this._tracking && event.cancelable) {
// Preserve native scrolling and compatibility clicks while the contact is
// only a tap candidate. Suppress scrolling after a pan is recognized.
if (this._tracking && this._panStarted && event.cancelable) {
event.preventDefault();
}
};

private _canStartPan(event: IgxGestureEvent): boolean {
if (event.distance < this._panThreshold) {
return false;
}

if (this._panAxis === 'horizontal') {
return Math.abs(event.deltaX) > Math.abs(event.deltaY);
}

if (this._panAxis === 'vertical') {
return Math.abs(event.deltaY) > Math.abs(event.deltaX);
}

return true;
}

/** Stops tracking the current gesture and best-effort releases the pointer capture. */
private _stopTracking(pointerId: number): void {
this._tracking = false;
this._panStarted = false;
this._pointerId = null;
this._startTarget = null;
this._resetTracking();

if (this._setPointerCapture && typeof (this.target as Element).releasePointerCapture === 'function') {
try {
(this.target as Element).releasePointerCapture(pointerId);
} catch {
// `releasePointerCapture` can throw when the pointer is no longer captured.
// Releasing is a best-effort cleanup, so ignore it.
// Pointer capture is best-effort and may already have been released.
}
}
}

private _resetTracking(): void {
this._tracking = false;
this._panStarted = false;
this._pointerId = null;
this._startTarget = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,26 @@ describe('Navigation Drawer', () => {
});
}, 10000);

it('should preserve a tap that starts inside the edge gesture zone', waitForAsync(() => {
TestBed.compileComponents().then(() => {
const fixture = TestBed.createComponent(TestComponentDIComponent);
fixture.detectChanges();
const navDrawer = fixture.componentInstance.navDrawer;

dispatchTouchPointerEvent(document.body, 'pointerdown', 10, 10);
dispatchTouchPointerEvent(document.body, 'pointermove', 13, 10);
const touchMove = new Event('touchmove', { bubbles: true, cancelable: true });
document.body.dispatchEvent(touchMove);

expect((navDrawer as any)._panning).toBeFalse();
expect(navDrawer.drawer.classList).not.toContain('panning');
expect(touchMove.defaultPrevented).toBeFalse();

dispatchTouchPointerEvent(document.body, 'pointerup', 13, 10);
fixture.destroy();
});
}));

it('should update edge zone with mini width', waitForAsync(() => {
const template = `<igx-nav-drawer [miniWidth]="drawerMiniWidth">
<ng-template igxDrawer></ng-template>
Expand Down Expand Up @@ -733,9 +753,20 @@ describe('Navigation Drawer', () => {
expect(navDrawer.isOpen).toBeFalse();
});

it('panStart: should set _panning flag when conditions are met', () => {
it('canStartPan: should qualify only edge touches while closed', () => {
expect((navDrawer as any).canStartPan(makeGestureInput({ center: { x: 30, y: 10 } }))).toBeTrue();
expect((navDrawer as any).canStartPan(makeGestureInput({ center: { x: 100, y: 10 } }))).toBeFalse();
});

it('canStartPan: should qualify touches anywhere while open', () => {
navDrawer.open();
fixture.detectChanges();

expect((navDrawer as any).canStartPan(makeGestureInput({ center: { x: 100, y: 10 } }))).toBeTrue();
});

it('panStart: should initialize panning after gesture recognition', () => {
expect((navDrawer as any)._panning).toBeFalse();
// simulate start from left edge (startPosition < maxEdgeZone)
(navDrawer as any).panStart(makeGestureInput({ deltaX: 0, center: { x: 30, y: 10 }, distance: 0 }));
expect((navDrawer as any)._panning).toBeTrue();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { IgxNavigationService, IToggleView } from 'igniteui-angular/core';
import { IgxNavDrawerMiniTemplateDirective, IgxNavDrawerTemplateDirective, IgxNavDrawerItemDirective } from './navigation-drawer.directives';
import { IgxGestureEvent, IgxTouchManager, PlatformUtil } from 'igniteui-angular/core';

const PAN_THRESHOLD = 5;
let NEXT_ID = 0;
/**
* **Ignite UI for Angular Navigation Drawer** -
Expand Down Expand Up @@ -674,12 +675,18 @@ export class IgxNavigationDrawerComponent implements
if (this.enableGestures && !this.pin) {
if (!this._gesturesAttached) {
this._gestures = new IgxTouchManager(this._document, {
pointerDown: (event) => this.panStart(event),
pointerDown: (event) => this.canStartPan(event),
panStart: (event) => this.panStart(event),
panMove: (event) => this.pan(event),
swipe: (event) => this.swipe(event),
panEnd: (event) => this.panEnd(event),
panCancel: () => this.panCancel()
}, { pointerTypes: ['touch'], setPointerCapture: false });
}, {
panAxis: 'horizontal',
panThreshold: PAN_THRESHOLD,
pointerTypes: ['touch'],
setPointerCapture: false
});

this._gesturesAttached = true;
}
Expand Down Expand Up @@ -755,36 +762,35 @@ export class IgxNavigationDrawerComponent implements
}
};

private panStart = (evt: IgxGestureEvent): boolean => {
private canStartPan = (evt: IgxGestureEvent): boolean => {
if (!this.enableGestures || this.pin || evt.pointerType !== 'touch') {
return false;
}
const startPosition = this.position === 'right' ? this.getWindowWidth() - (evt.center.x + evt.distance)
: evt.center.x - evt.distance;

// cache width during animation, flag to allow further handling
if (this.isOpen || (startPosition < this.maxEdgeZone)) {
this._panning = true;
this._panStartWidth = this.getExpectedWidth(!this.isOpen);
this._panLimit = this.getExpectedWidth(this.isOpen);

this.renderer.addClass(this.overlay, 'panning');
this.renderer.addClass(this.drawer, 'panning');

if (!this.hasAnimateWidth) {
// Translate-mode pan slides the panel via `transform`, but its width is
// driven by `--ig-nav-drawer-size`, which is forced to 0 while the drawer
// is closed. Pin the real width for the duration of the gesture so the
// slide reveals the full panel instead of just its padding/border.
this.renderer.setStyle(this.drawer, 'width', `${this.getExpectedWidth(false)}px`);
}
return true;
return this.isOpen || startPosition < this.maxEdgeZone;
};

private panStart = (_evt: IgxGestureEvent) => {
if (!this.enableGestures || this.pin) {
return;
}

// The touch did not start in the edge zone (and the drawer is closed), so this
// gesture should be ignored. Returning `false` lets the touch manager stop
// tracking immediately and not interfere with normal page scrolling.
return false;
this._panning = true;
this._panStartWidth = this.getExpectedWidth(!this.isOpen);
this._panLimit = this.getExpectedWidth(this.isOpen);

this.renderer.addClass(this.overlay, 'panning');
this.renderer.addClass(this.drawer, 'panning');

if (!this.hasAnimateWidth) {
// Translate-mode pan slides the panel via `transform`, but its width is
// driven by `--ig-nav-drawer-size`, which is forced to 0 while the drawer
// is closed. Pin the real width for the duration of the gesture so the
// slide reveals the full panel instead of just its padding/border.
this.renderer.setStyle(this.drawer, 'width', `${this.getExpectedWidth(false)}px`);
}
};

private pan = (evt: IgxGestureEvent) => {
Expand Down
Loading