/
ripple-renderer.ts
445 lines (374 loc) · 17.6 KB
/
ripple-renderer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
/**
* @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 {ElementRef, NgZone} from '@angular/core';
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.
* It defines the ripple configuration and disabled state for interaction ripples.
* @docs-private
*/
export interface RippleTarget {
/** Configuration for ripples that are launched on pointer down. */
rippleConfig: RippleConfig;
/** Whether ripples on pointer down should be disabled. */
rippleDisabled: boolean;
}
/** Interfaces the defines ripple element transition event listeners. */
interface RippleEventListeners {
onTransitionEnd: EventListener;
onTransitionCancel: EventListener;
}
// TODO: import these values from `@material/ripple` eventually.
/**
* Default ripple animation configuration for ripples without an explicit
* animation config specified.
*/
export const defaultRippleAnimationConfig = {
enterDuration: 225,
exitDuration: 150,
};
/**
* Timeout for ignoring mouse events. Mouse events will be temporary ignored after touch
* events to avoid synthetic mouse events.
*/
const ignoreMouseEventsTimeout = 800;
/** 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'];
/** Events that signal that the pointer is up. */
const pointerUpEvents = ['mouseup', 'mouseleave', 'touchend', 'touchcancel'];
/**
* Helper service that performs DOM manipulations. Not intended to be used outside this module.
* The constructor takes a reference to the ripple directive's host element and a map of DOM
* event handlers to be installed on the element that triggers ripple animations.
* This will eventually become a custom renderer once Angular support exists.
* @docs-private
*/
export class RippleRenderer implements EventListenerObject {
/** Element where the ripples are being added to. */
private _containerElement: HTMLElement;
/** Element which triggers the ripple elements on mouse events. */
private _triggerElement: HTMLElement | null;
/** Whether the pointer is currently down or not. */
private _isPointerDown = false;
/**
* Map of currently active ripple references.
* The ripple reference is mapped to its element event listeners.
* The reason why `| null` is used is that event listeners are added only
* when the condition is truthy (see the `_startFadeOutTransition` method).
*/
private _activeRipples = new Map<RippleRef, RippleEventListeners | null>();
/** Latest non-persistent ripple that was triggered. */
private _mostRecentTransientRipple: RippleRef | null;
/** Time in milliseconds when the last touchstart event happened. */
private _lastTouchStartEvent: number;
/** Whether pointer-up event listeners have been registered. */
private _pointerUpEventsRegistered = false;
/**
* Cached dimensions of the ripple container. Set when the first
* ripple is shown and cleared once no more ripples are visible.
*/
private _containerRect: ClientRect | null;
private static _eventManager = new RippleEventManager();
constructor(
private _target: RippleTarget,
private _ngZone: NgZone,
elementOrElementRef: HTMLElement | ElementRef<HTMLElement>,
private _platform: Platform,
) {
// Only do anything if we're on the browser.
if (_platform.isBrowser) {
this._containerElement = coerceElement(elementOrElementRef);
}
}
/**
* Fades in a ripple at the given coordinates.
* @param x Coordinate within the element, along the X axis at which to start the ripple.
* @param y Coordinate within the element, along the Y axis at which to start the ripple.
* @param config Extra ripple options.
*/
fadeInRipple(x: number, y: number, config: RippleConfig = {}): RippleRef {
const containerRect = (this._containerRect =
this._containerRect || this._containerElement.getBoundingClientRect());
const animationConfig = {...defaultRippleAnimationConfig, ...config.animation};
if (config.centered) {
x = containerRect.left + containerRect.width / 2;
y = containerRect.top + containerRect.height / 2;
}
const radius = config.radius || distanceToFurthestCorner(x, y, containerRect);
const offsetX = x - containerRect.left;
const offsetY = y - containerRect.top;
const enterDuration = animationConfig.enterDuration;
const ripple = document.createElement('div');
ripple.classList.add('mat-ripple-element');
ripple.style.left = `${offsetX - radius}px`;
ripple.style.top = `${offsetY - radius}px`;
ripple.style.height = `${radius * 2}px`;
ripple.style.width = `${radius * 2}px`;
// If a custom color has been specified, set it as inline style. If no color is
// set, the default color will be applied through the ripple theme styles.
if (config.color != null) {
ripple.style.backgroundColor = config.color;
}
ripple.style.transitionDuration = `${enterDuration}ms`;
this._containerElement.appendChild(ripple);
// By default the browser does not recalculate the styles of dynamically created
// ripple elements. This is critical to ensure that the `scale` animates properly.
// We enforce a style recalculation by calling `getComputedStyle` and *accessing* a property.
// See: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
const computedStyles = window.getComputedStyle(ripple);
const userTransitionProperty = computedStyles.transitionProperty;
const userTransitionDuration = computedStyles.transitionDuration;
// Note: We detect whether animation is forcibly disabled through CSS (e.g. through
// `transition: none` or `display: none`). This is technically unexpected since animations are
// controlled through the animation config, but this exists for backwards compatibility. This
// logic does not need to be super accurate since it covers some edge cases which can be easily
// avoided by users.
const animationForciblyDisabledThroughCss =
userTransitionProperty === 'none' ||
// Note: The canonical unit for serialized CSS `<time>` properties is seconds. Additionally
// some browsers expand the duration for every property (in our case `opacity` and `transform`).
userTransitionDuration === '0s' ||
userTransitionDuration === '0s, 0s' ||
// If the container is 0x0, it's likely `display: none`.
(containerRect.width === 0 && containerRect.height === 0);
// Exposed reference to the ripple that will be returned.
const rippleRef = new RippleRef(this, ripple, config, animationForciblyDisabledThroughCss);
// Start the enter animation by setting the transform/scale to 100%. The animation will
// execute as part of this statement because we forced a style recalculation before.
// Note: We use a 3d transform here in order to avoid an issue in Safari where
// the ripples aren't clipped when inside the shadow DOM (see #24028).
ripple.style.transform = 'scale3d(1, 1, 1)';
rippleRef.state = RippleState.FADING_IN;
if (!config.persistent) {
this._mostRecentTransientRipple = rippleRef;
}
let eventListeners: RippleEventListeners | null = null;
// Do not register the `transition` event listener if fade-in and fade-out duration
// are set to zero. The events won't fire anyway and we can save resources here.
if (!animationForciblyDisabledThroughCss && (enterDuration || animationConfig.exitDuration)) {
this._ngZone.runOutsideAngular(() => {
const onTransitionEnd = () => this._finishRippleTransition(rippleRef);
const onTransitionCancel = () => this._destroyRipple(rippleRef);
ripple.addEventListener('transitionend', onTransitionEnd);
// If the transition is cancelled (e.g. due to DOM removal), we destroy the ripple
// directly as otherwise we would keep it part of the ripple container forever.
// https://www.w3.org/TR/css-transitions-1/#:~:text=no%20longer%20in%20the%20document.
ripple.addEventListener('transitioncancel', onTransitionCancel);
eventListeners = {onTransitionEnd, onTransitionCancel};
});
}
// Add the ripple reference to the list of all active ripples.
this._activeRipples.set(rippleRef, eventListeners);
// In case there is no fade-in transition duration, we need to manually call the transition
// end listener because `transitionend` doesn't fire if there is no transition.
if (animationForciblyDisabledThroughCss || !enterDuration) {
this._finishRippleTransition(rippleRef);
}
return rippleRef;
}
/** Fades out a ripple reference. */
fadeOutRipple(rippleRef: RippleRef) {
// For ripples already fading out or hidden, this should be a noop.
if (rippleRef.state === RippleState.FADING_OUT || rippleRef.state === RippleState.HIDDEN) {
return;
}
const rippleEl = rippleRef.element;
const animationConfig = {...defaultRippleAnimationConfig, ...rippleRef.config.animation};
// This starts the fade-out transition and will fire the transition end listener that
// removes the ripple element from the DOM.
rippleEl.style.transitionDuration = `${animationConfig.exitDuration}ms`;
rippleEl.style.opacity = '0';
rippleRef.state = RippleState.FADING_OUT;
// In case there is no fade-out transition duration, we need to manually call the
// transition end listener because `transitionend` doesn't fire if there is no transition.
if (rippleRef._animationForciblyDisabledThroughCss || !animationConfig.exitDuration) {
this._finishRippleTransition(rippleRef);
}
}
/** Fades out all currently active ripples. */
fadeOutAll() {
this._getActiveRipples().forEach(ripple => ripple.fadeOut());
}
/** Fades out all currently active non-persistent ripples. */
fadeOutAllNonPersistent() {
this._getActiveRipples().forEach(ripple => {
if (!ripple.config.persistent) {
ripple.fadeOut();
}
});
}
/** Sets up the trigger event listeners */
setupTriggerEvents(elementOrElementRef: HTMLElement | ElementRef<HTMLElement>) {
const element = coerceElement(elementOrElementRef);
if (!this._platform.isBrowser || !element || element === this._triggerElement) {
return;
}
// Remove all previously registered event listeners from the trigger element.
this._removeTriggerEvents();
this._triggerElement = element;
// 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);
});
}
/**
* Handles all registered events.
* @docs-private
*/
handleEvent(event: Event) {
if (event.type === 'mousedown') {
this._onMousedown(event as MouseEvent);
} else if (event.type === 'touchstart') {
this._onTouchStart(event as TouchEvent);
} else {
this._onPointerUp();
}
// If pointer-up events haven't been registered yet, do so now.
// 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) {
// 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;
}
}
/** Method that will be called if the fade-in or fade-in transition completed. */
private _finishRippleTransition(rippleRef: RippleRef) {
if (rippleRef.state === RippleState.FADING_IN) {
this._startFadeOutTransition(rippleRef);
} else if (rippleRef.state === RippleState.FADING_OUT) {
this._destroyRipple(rippleRef);
}
}
/**
* Starts the fade-out transition of the given ripple if it's not persistent and the pointer
* is not held down anymore.
*/
private _startFadeOutTransition(rippleRef: RippleRef) {
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
const {persistent} = rippleRef.config;
rippleRef.state = RippleState.VISIBLE;
// When the timer runs out while the user has kept their pointer down, we want to
// keep only the persistent ripples and the latest transient ripple. We do this,
// because we don't want stacked transient ripples to appear after their enter
// animation has finished.
if (!persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
rippleRef.fadeOut();
}
}
/** Destroys the given ripple by removing it from the DOM and updating its state. */
private _destroyRipple(rippleRef: RippleRef) {
const eventListeners = this._activeRipples.get(rippleRef) ?? null;
this._activeRipples.delete(rippleRef);
// Clear out the cached bounding rect if we have no more ripples.
if (!this._activeRipples.size) {
this._containerRect = null;
}
// If the current ref is the most recent transient ripple, unset it
// avoid memory leaks.
if (rippleRef === this._mostRecentTransientRipple) {
this._mostRecentTransientRipple = null;
}
rippleRef.state = RippleState.HIDDEN;
if (eventListeners !== null) {
rippleRef.element.removeEventListener('transitionend', eventListeners.onTransitionEnd);
rippleRef.element.removeEventListener('transitioncancel', eventListeners.onTransitionCancel);
}
rippleRef.element.remove();
}
/** Function being called whenever the trigger is being pressed using mouse. */
private _onMousedown(event: MouseEvent) {
// Screen readers will fire fake mouse events for space/enter. Skip launching a
// ripple in this case for consistency with the non-screen-reader experience.
const isFakeMousedown = isFakeMousedownFromScreenReader(event);
const isSyntheticEvent =
this._lastTouchStartEvent &&
Date.now() < this._lastTouchStartEvent + ignoreMouseEventsTimeout;
if (!this._target.rippleDisabled && !isFakeMousedown && !isSyntheticEvent) {
this._isPointerDown = true;
this.fadeInRipple(event.clientX, event.clientY, this._target.rippleConfig);
}
}
/** Function being called whenever the trigger is being pressed using touch. */
private _onTouchStart(event: TouchEvent) {
if (!this._target.rippleDisabled && !isFakeTouchstartFromScreenReader(event)) {
// Some browsers fire mouse events after a `touchstart` event. Those synthetic mouse
// events will launch a second ripple if we don't ignore mouse events for a specific
// time after a touchstart event.
this._lastTouchStartEvent = Date.now();
this._isPointerDown = true;
// Use `changedTouches` so we skip any touches where the user put
// their finger down, but used another finger to tap the element again.
const touches = event.changedTouches;
for (let i = 0; i < touches.length; i++) {
this.fadeInRipple(touches[i].clientX, touches[i].clientY, this._target.rippleConfig);
}
}
}
/** Function being called whenever the trigger is being released. */
private _onPointerUp() {
if (!this._isPointerDown) {
return;
}
this._isPointerDown = false;
// Fade-out all ripples that are visible and not persistent.
this._getActiveRipples().forEach(ripple => {
// By default, only ripples that are completely visible will fade out on pointer release.
// If the `terminateOnPointerUp` option is set, ripples that still fade in will also fade out.
const isVisible =
ripple.state === RippleState.VISIBLE ||
(ripple.config.terminateOnPointerUp && ripple.state === RippleState.FADING_IN);
if (!ripple.config.persistent && isVisible) {
ripple.fadeOut();
}
});
}
private _getActiveRipples(): RippleRef[] {
return Array.from(this._activeRipples.keys());
}
/** Removes previously registered event listeners from the trigger element. */
_removeTriggerEvents() {
const trigger = this._triggerElement;
if (trigger) {
pointerDownEvents.forEach(type =>
RippleRenderer._eventManager.removeHandler(type, trigger, this),
);
if (this._pointerUpEventsRegistered) {
pointerUpEvents.forEach(type =>
trigger.removeEventListener(type, this, passiveCapturingEventOptions),
);
}
}
}
}
/**
* Returns the distance from the point (x, y) to the furthest corner of a rectangle.
*/
function distanceToFurthestCorner(x: number, y: number, rect: ClientRect) {
const distX = Math.max(Math.abs(x - rect.left), Math.abs(x - rect.right));
const distY = Math.max(Math.abs(y - rect.top), Math.abs(y - rect.bottom));
return Math.sqrt(distX * distX + distY * distY);
}