From e46c976beb21e5d5d7af3207d7105d825a950421 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:57:55 +0000 Subject: [PATCH 1/2] Initial plan From eac5b942b5f6cdd311a06ae0109efb4dc09b2604 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:10:42 +0000 Subject: [PATCH 2/2] fix(list): keep touch panning off the Angular zone and idle items inert --- CHANGELOG.md | 3 + .../igniteui-angular/core/src/core/touch.ts | 100 +++++++++++++----- .../list/src/list/list-item.component.ts | 16 ++- .../list/src/list/list.component.spec.ts | 40 ++++++- 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96cd5432ced..9657b27c6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/projects/igniteui-angular/core/src/core/touch.ts b/projects/igniteui-angular/core/src/core/touch.ts index 30f019cd800..8dadbb265b8 100644 --- a/projects/igniteui-angular/core/src/core/touch.ts +++ b/projects/igniteui-angular/core/src/core/touch.ts @@ -1,3 +1,5 @@ +import { NgZone } from '@angular/core'; + /** * Normalized gesture event emitted by {@link IgxTouchManager}. * @@ -72,6 +74,18 @@ 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; } /** @@ -79,8 +93,10 @@ export interface IgxTouchManagerOptions { * * 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 @@ -113,6 +129,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, @@ -123,6 +141,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 @@ -135,14 +155,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. */ @@ -162,6 +184,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; @@ -191,7 +230,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; @@ -211,7 +250,10 @@ export class IgxTouchManager { } } - this.callbacks.pointerDown?.(this._createEvent(event)); + if (this.callbacks.pointerDown) { + const gesture = this._createEvent(event); + this._runInAngular(() => this.callbacks.pointerDown(gesture)); + } }; private _onPointerMove = (event: PointerEvent) => { @@ -223,8 +265,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); }; @@ -236,18 +283,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) => { @@ -256,7 +305,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) => { diff --git a/projects/igniteui-angular/list/src/list/list-item.component.ts b/projects/igniteui-angular/list/src/list/list-item.component.ts index 87c0b065021..ca35e2c0c71 100644 --- a/projects/igniteui-angular/list/src/list/list-item.component.ts +++ b/projects/igniteui-angular/list/src/list/list-item.component.ts @@ -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, @@ -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 @@ -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 }); } @@ -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 */ diff --git a/projects/igniteui-angular/list/src/list/list.component.spec.ts b/projects/igniteui-angular/list/src/list/list.component.spec.ts index 57b7eefdea0..2ffca19ecf6 100644 --- a/projects/igniteui-angular/list/src/list/list.component.spec.ts +++ b/projects/igniteui-angular/list/src/list/list.component.spec.ts @@ -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'; @@ -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;