diff --git a/src/material/core/ripple/ripple-event-manager.ts b/src/material/core/ripple/ripple-event-manager.ts new file mode 100644 index 000000000000..5cfb1cd5fdb8 --- /dev/null +++ b/src/material/core/ripple/ripple-event-manager.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {normalizePassiveListenerOptions, _getEventTarget} from '@angular/cdk/platform'; +import {NgZone} from '@angular/core'; + +/** Options used to bind a passive capturing event. */ +const passiveCapturingEventOptions = normalizePassiveListenerOptions({ + passive: true, + capture: true, +}); + +/** Manages events through delegation so that as few event handlers as possible are bound. */ +export class RippleEventManager { + private _events = new Map>>(); + + /** Adds an event handler. */ + addHandler(ngZone: NgZone, name: string, element: HTMLElement, handler: EventListenerObject) { + const handlersForEvent = this._events.get(name); + + if (handlersForEvent) { + const handlersForElement = handlersForEvent.get(element); + + if (handlersForElement) { + handlersForElement.add(handler); + } else { + handlersForEvent.set(element, new Set([handler])); + } + } else { + this._events.set(name, new Map([[element, new Set([handler])]])); + + ngZone.runOutsideAngular(() => { + document.addEventListener(name, this._delegateEventHandler, passiveCapturingEventOptions); + }); + } + } + + /** Removes an event handler. */ + removeHandler(name: string, element: HTMLElement, handler: EventListenerObject) { + const handlersForEvent = this._events.get(name); + + if (!handlersForEvent) { + return; + } + + const handlersForElement = handlersForEvent.get(element); + + if (!handlersForElement) { + return; + } + + handlersForElement.delete(handler); + + if (handlersForElement.size === 0) { + handlersForEvent.delete(element); + } + + if (handlersForEvent.size === 0) { + this._events.delete(name); + document.removeEventListener(name, this._delegateEventHandler, passiveCapturingEventOptions); + } + } + + /** Event handler that is bound and which dispatches the events to the different targets. */ + private _delegateEventHandler = (event: Event) => { + const target = _getEventTarget(event); + + if (target) { + this._events.get(event.type)?.forEach((handlers, element) => { + if (element === target || element.contains(target as Node)) { + handlers.forEach(handler => handler.handleEvent(event)); + } + }); + } + }; +} diff --git a/src/material/core/ripple/ripple-renderer.ts b/src/material/core/ripple/ripple-renderer.ts index 8561b6654c1a..29147bee072b 100644 --- a/src/material/core/ripple/ripple-renderer.ts +++ b/src/material/core/ripple/ripple-renderer.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ import {ElementRef, NgZone} from '@angular/core'; -import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {Platform, normalizePassiveListenerOptions, _getEventTarget} from '@angular/cdk/platform'; import {isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader} from '@angular/cdk/a11y'; import {coerceElement} from '@angular/cdk/coercion'; import {RippleRef, RippleState, RippleConfig} from './ripple-ref'; +import {RippleEventManager} from './ripple-event-manager'; /** * Interface that describes the target for launching ripples. @@ -45,8 +46,11 @@ export const defaultRippleAnimationConfig = { */ const ignoreMouseEventsTimeout = 800; -/** Options that apply to all the event listeners that are bound by the ripple renderer. */ -const passiveEventOptions = normalizePassiveListenerOptions({passive: true}); +/** Options used to bind a passive capturing event. */ +const passiveCapturingEventOptions = normalizePassiveListenerOptions({ + passive: true, + capture: true, +}); /** Events that signal that the pointer is down. */ const pointerDownEvents = ['mousedown', 'touchstart']; @@ -94,14 +98,16 @@ export class RippleRenderer implements EventListenerObject { */ private _containerRect: ClientRect | null; + private static _eventManager = new RippleEventManager(); + constructor( private _target: RippleTarget, private _ngZone: NgZone, elementOrElementRef: HTMLElement | ElementRef, - platform: Platform, + private _platform: Platform, ) { // Only do anything if we're on the browser. - if (platform.isBrowser) { + if (_platform.isBrowser) { this._containerElement = coerceElement(elementOrElementRef); } } @@ -252,15 +258,19 @@ export class RippleRenderer implements EventListenerObject { setupTriggerEvents(elementOrElementRef: HTMLElement | ElementRef) { const element = coerceElement(elementOrElementRef); - if (!element || element === this._triggerElement) { + if (!this._platform.isBrowser || !element || element === this._triggerElement) { return; } // Remove all previously registered event listeners from the trigger element. this._removeTriggerEvents(); - this._triggerElement = element; - this._registerEvents(pointerDownEvents); + + // Use event delegation for the trigger events since they're + // set up during creation and are performance-sensitive. + pointerDownEvents.forEach(type => { + RippleRenderer._eventManager.addHandler(this._ngZone, type, element, this); + }); } /** @@ -280,7 +290,17 @@ export class RippleRenderer implements EventListenerObject { // We do this on-demand in order to reduce the total number of event listeners // registered by the ripples, which speeds up the rendering time for large UIs. if (!this._pointerUpEventsRegistered) { - this._registerEvents(pointerUpEvents); + // The events for hiding the ripple are bound directly on the trigger, because: + // 1. Some of them occur frequently (e.g. `mouseleave`) and any advantage we get from + // delegation will be diminished by having to look through all the data structures often. + // 2. They aren't as performance-sensitive, because they're bound only after the user + // has interacted with an element. + this._ngZone.runOutsideAngular(() => { + pointerUpEvents.forEach(type => { + this._triggerElement!.addEventListener(type, this, passiveCapturingEventOptions); + }); + }); + this._pointerUpEventsRegistered = true; } } @@ -393,30 +413,23 @@ export class RippleRenderer implements EventListenerObject { }); } - /** Registers event listeners for a given list of events. */ - private _registerEvents(eventTypes: string[]) { - this._ngZone.runOutsideAngular(() => { - eventTypes.forEach(type => { - this._triggerElement!.addEventListener(type, this, passiveEventOptions); - }); - }); - } - private _getActiveRipples(): RippleRef[] { return Array.from(this._activeRipples.keys()); } /** Removes previously registered event listeners from the trigger element. */ _removeTriggerEvents() { - if (this._triggerElement) { - pointerDownEvents.forEach(type => { - this._triggerElement!.removeEventListener(type, this, passiveEventOptions); - }); + const trigger = this._triggerElement; + + if (trigger) { + pointerDownEvents.forEach(type => + RippleRenderer._eventManager.removeHandler(type, trigger, this), + ); if (this._pointerUpEventsRegistered) { - pointerUpEvents.forEach(type => { - this._triggerElement!.removeEventListener(type, this, passiveEventOptions); - }); + pointerUpEvents.forEach(type => + trigger.removeEventListener(type, this, passiveCapturingEventOptions), + ); } } } diff --git a/tools/public_api_guard/material/core.md b/tools/public_api_guard/material/core.md index d593d47b9ea8..4c68e948abf6 100644 --- a/tools/public_api_guard/material/core.md +++ b/tools/public_api_guard/material/core.md @@ -524,7 +524,7 @@ export class RippleRef { // @public export class RippleRenderer implements EventListenerObject { - constructor(_target: RippleTarget, _ngZone: NgZone, elementOrElementRef: HTMLElement | ElementRef, platform: Platform); + constructor(_target: RippleTarget, _ngZone: NgZone, elementOrElementRef: HTMLElement | ElementRef, _platform: Platform); fadeInRipple(x: number, y: number, config?: RippleConfig): RippleRef; fadeOutAll(): void; fadeOutAllNonPersistent(): void;