Skip to content

Commit 65fb5f4

Browse files
authored
fix(ripple): not fading out on touch devices (#12488)
* fix(material/core): ripples not fading out on touch devices when scrolling * Makes the ripple animations no longer dependent on `setTimeout` that does not always fire properly / or within the specified duration. (related chrome issue: https://bugs.chromium.org/p/chromium/issues/detail?id=567800) * Fix indentation of a few ripple tests * Fixes that the speed factor tests are basically not checking anything (even though they will be removed in the future; they need to pass right now) Fixes #12470 * fixup! fix(material/core): ripples not fading out on touch devices when scrolling Backwards compatibility change for g3 tests using just transition: none without the noopanimations module * fixup! fix(material/core): ripples not fading out on touch devices when scrolling Support transition-duration
1 parent 3e1de9d commit 65fb5f4

File tree

11 files changed

+320
-210
lines changed

11 files changed

+320
-210
lines changed

src/material-experimental/mdc-list/list.spec.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import {waitForAsync, TestBed, fakeAsync, tick} from '@angular/core/testing';
1+
import {fakeAsync, TestBed, waitForAsync} from '@angular/core/testing';
2+
import {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing/private';
23
import {Component, QueryList, ViewChildren} from '@angular/core';
3-
import {defaultRippleAnimationConfig} from '@angular/material-experimental/mdc-core';
4-
import {dispatchMouseEvent} from '../../cdk/testing/private';
54
import {By} from '@angular/platform-browser';
65
import {MatListItem, MatListModule} from './index';
76

87
describe('MDC-based MatList', () => {
9-
// Default ripple durations used for testing.
10-
const {enterDuration, exitDuration} = defaultRippleAnimationConfig;
11-
128
beforeEach(
139
waitForAsync(() => {
1410
TestBed.configureTestingModule({
@@ -243,12 +239,16 @@ describe('MDC-based MatList', () => {
243239
dispatchMouseEvent(rippleTarget, 'mousedown');
244240
dispatchMouseEvent(rippleTarget, 'mouseup');
245241

242+
// Flush the ripple enter animation.
243+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
244+
246245
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
247246
.withContext('Expected ripples to be enabled by default.')
248247
.toBe(1);
249248

250-
// Wait for the ripples to go away.
251-
tick(enterDuration + exitDuration);
249+
// Flush the ripple exit animation.
250+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
251+
252252
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
253253
.withContext('Expected ripples to go away.')
254254
.toBe(0);
@@ -273,12 +273,16 @@ describe('MDC-based MatList', () => {
273273
dispatchMouseEvent(rippleTarget, 'mousedown');
274274
dispatchMouseEvent(rippleTarget, 'mouseup');
275275

276+
// Flush the ripple enter animation.
277+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
278+
276279
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
277280
.withContext('Expected ripples to be enabled by default.')
278281
.toBe(1);
279282

280-
// Wait for the ripples to go away.
281-
tick(enterDuration + exitDuration);
283+
// Flush the ripple exit animation.
284+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
285+
282286
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
283287
.withContext('Expected ripples to go away.')
284288
.toBe(0);

src/material-experimental/mdc-list/selection-list.spec.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
waitForAsync,
2323
} from '@angular/core/testing';
2424
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
25-
import {defaultRippleAnimationConfig, ThemePalette} from '@angular/material-experimental/mdc-core';
25+
import {ThemePalette} from '@angular/material-experimental/mdc-core';
2626
import {By} from '@angular/platform-browser';
2727
import {numbers} from '@material/list';
2828
import {
@@ -612,17 +612,19 @@ describe('MDC-based MatSelectionList without forms', () => {
612612
const rippleTarget = fixture.nativeElement.querySelector(
613613
'.mat-mdc-list-option:not(.mdc-list-item--disabled)',
614614
);
615-
const {enterDuration, exitDuration} = defaultRippleAnimationConfig;
616-
617615
dispatchMouseEvent(rippleTarget, 'mousedown');
618616
dispatchMouseEvent(rippleTarget, 'mouseup');
619617

618+
// Flush the ripple enter animation.
619+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
620+
620621
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
621622
.withContext('Expected ripples to be enabled by default.')
622623
.toBe(1);
623624

624-
// Wait for the ripples to go away.
625-
tick(enterDuration + exitDuration);
625+
// Flush the ripple exit animation.
626+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
627+
626628
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
627629
.withContext('Expected ripples to go away.')
628630
.toBe(0);

src/material-experimental/mdc-slider/slider.spec.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,13 @@
99
import {BidiModule, Directionality} from '@angular/cdk/bidi';
1010
import {Platform} from '@angular/cdk/platform';
1111
import {
12+
dispatchFakeEvent,
1213
dispatchMouseEvent,
1314
dispatchPointerEvent,
1415
dispatchTouchEvent,
1516
} from '../../cdk/testing/private';
1617
import {Component, Provider, QueryList, Type, ViewChild, ViewChildren} from '@angular/core';
17-
import {
18-
ComponentFixture,
19-
fakeAsync,
20-
flush,
21-
TestBed,
22-
tick,
23-
waitForAsync,
24-
} from '@angular/core/testing';
18+
import {ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing';
2519
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
2620
import {By} from '@angular/platform-browser';
2721
import {Thumb} from '@material/slider';
@@ -297,8 +291,14 @@ describe('MDC-based MatSlider', () => {
297291
);
298292

299293
function isRippleVisible(selector: string) {
300-
tick(500);
301-
return !!document.querySelector(`.mat-mdc-slider-${selector}-ripple`);
294+
flushRippleTransitions();
295+
return thumbElement.querySelector(`.mat-mdc-slider-${selector}-ripple`) !== null;
296+
}
297+
298+
function flushRippleTransitions() {
299+
thumbElement.querySelectorAll('.mat-ripple-element').forEach(el => {
300+
dispatchFakeEvent(el, 'transitionend');
301+
});
302302
}
303303

304304
function blur() {

src/material-experimental/mdc-tabs/tab-group.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,6 @@ describe('MDC-based MatTabGroup', () => {
199199
.toBe(0);
200200

201201
dispatchFakeEvent(tabLabel.nativeElement, 'mousedown');
202-
dispatchFakeEvent(tabLabel.nativeElement, 'mouseup');
203202

204203
expect(testElement.querySelectorAll('.mat-ripple-element').length)
205204
.withContext('Expected one ripple to show up on label mousedown.')

src/material/core/ripple/ripple-ref.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export class RippleRef {
4747
public element: HTMLElement,
4848
/** Ripple configuration used for the ripple. */
4949
public config: RippleConfig,
50+
/* Whether animations are forcibly disabled for ripples through CSS. */
51+
public _animationForciblyDisabledThroughCss = false,
5052
) {}
5153

5254
/** Fades out the ripple element. */

src/material/core/ripple/ripple-renderer.ts

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export class RippleRenderer implements EventListenerObject {
114114
const radius = config.radius || distanceToFurthestCorner(x, y, containerRect);
115115
const offsetX = x - containerRect.left;
116116
const offsetY = y - containerRect.top;
117-
const duration = animationConfig.enterDuration;
117+
const enterDuration = animationConfig.enterDuration;
118118

119119
const ripple = document.createElement('div');
120120
ripple.classList.add('mat-ripple-element');
@@ -130,21 +130,38 @@ export class RippleRenderer implements EventListenerObject {
130130
ripple.style.backgroundColor = config.color;
131131
}
132132

133-
ripple.style.transitionDuration = `${duration}ms`;
133+
ripple.style.transitionDuration = `${enterDuration}ms`;
134134

135135
this._containerElement.appendChild(ripple);
136136

137137
// By default the browser does not recalculate the styles of dynamically created
138-
// ripple elements. This is critical because then the `scale` would not animate properly.
139-
enforceStyleRecalculation(ripple);
138+
// ripple elements. This is critical to ensure that the `scale` animates properly.
139+
// We enforce a style recalculation by calling `getComputedStyle` and *accessing* a property.
140+
// See: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
141+
const computedStyles = window.getComputedStyle(ripple);
142+
const userTransitionProperty = computedStyles.transitionProperty;
143+
const userTransitionDuration = computedStyles.transitionDuration;
144+
145+
// Note: We detect whether animation is forcibly disabled through CSS by the use of
146+
// `transition: none`. This is technically unexpected since animations are controlled
147+
// through the animation config, but this exists for backwards compatibility. This logic does
148+
// not need to be super accurate since it covers some edge cases which can be easily avoided by users.
149+
const animationForciblyDisabledThroughCss =
150+
userTransitionProperty === 'none' ||
151+
// Note: The canonical unit for serialized CSS `<time>` properties is seconds. Additionally
152+
// some browsers expand the duration for every property (in our case `opacity` and `transform`).
153+
userTransitionDuration === '0s' ||
154+
userTransitionDuration === '0s, 0s';
140155

141-
// We use a 3d transform here in order to avoid an issue in Safari where
156+
// Exposed reference to the ripple that will be returned.
157+
const rippleRef = new RippleRef(this, ripple, config, animationForciblyDisabledThroughCss);
158+
159+
// Start the enter animation by setting the transform/scale to 100%. The animation will
160+
// execute as part of this statement because we forced a style recalculation before.
161+
// Note: We use a 3d transform here in order to avoid an issue in Safari where
142162
// the ripples aren't clipped when inside the shadow DOM (see #24028).
143163
ripple.style.transform = 'scale3d(1, 1, 1)';
144164

145-
// Exposed reference to the ripple that will be returned.
146-
const rippleRef = new RippleRef(this, ripple, config);
147-
148165
rippleRef.state = RippleState.FADING_IN;
149166

150167
// Add the ripple reference to the list of all active ripples.
@@ -154,21 +171,19 @@ export class RippleRenderer implements EventListenerObject {
154171
this._mostRecentTransientRipple = rippleRef;
155172
}
156173

157-
// Wait for the ripple element to be completely faded in.
158-
// Once it's faded in, the ripple can be hidden immediately if the mouse is released.
159-
this._runTimeoutOutsideZone(() => {
160-
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
161-
162-
rippleRef.state = RippleState.VISIBLE;
174+
// Do not register the `transition` event listener if fade-in and fade-out duration
175+
// are set to zero. The events won't fire anyway and we can save resources here.
176+
if (!animationForciblyDisabledThroughCss && (enterDuration || animationConfig.exitDuration)) {
177+
this._ngZone.runOutsideAngular(() => {
178+
ripple.addEventListener('transitionend', () => this._finishRippleTransition(rippleRef));
179+
});
180+
}
163181

164-
// When the timer runs out while the user has kept their pointer down, we want to
165-
// keep only the persistent ripples and the latest transient ripple. We do this,
166-
// because we don't want stacked transient ripples to appear after their enter
167-
// animation has finished.
168-
if (!config.persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
169-
rippleRef.fadeOut();
170-
}
171-
}, duration);
182+
// In case there is no fade-in transition duration, we need to manually call the transition
183+
// end listener because `transitionend` doesn't fire if there is no transition.
184+
if (animationForciblyDisabledThroughCss || !enterDuration) {
185+
this._finishRippleTransition(rippleRef);
186+
}
172187

173188
return rippleRef;
174189
}
@@ -194,15 +209,17 @@ export class RippleRenderer implements EventListenerObject {
194209
const rippleEl = rippleRef.element;
195210
const animationConfig = {...defaultRippleAnimationConfig, ...rippleRef.config.animation};
196211

212+
// This starts the fade-out transition and will fire the transition end listener that
213+
// removes the ripple element from the DOM.
197214
rippleEl.style.transitionDuration = `${animationConfig.exitDuration}ms`;
198215
rippleEl.style.opacity = '0';
199216
rippleRef.state = RippleState.FADING_OUT;
200217

201-
// Once the ripple faded out, the ripple can be safely removed from the DOM.
202-
this._runTimeoutOutsideZone(() => {
203-
rippleRef.state = RippleState.HIDDEN;
204-
rippleEl.remove();
205-
}, animationConfig.exitDuration);
218+
// In case there is no fade-out transition duration, we need to manually call the
219+
// transition end listener because `transitionend` doesn't fire if there is no transition.
220+
if (rippleRef._animationForciblyDisabledThroughCss || !animationConfig.exitDuration) {
221+
this._finishRippleTransition(rippleRef);
222+
}
206223
}
207224

208225
/** Fades out all currently active ripples. */
@@ -256,6 +273,40 @@ export class RippleRenderer implements EventListenerObject {
256273
}
257274
}
258275

276+
/** Method that will be called if the fade-in or fade-in transition completed. */
277+
private _finishRippleTransition(rippleRef: RippleRef) {
278+
if (rippleRef.state === RippleState.FADING_IN) {
279+
this._startFadeOutTransition(rippleRef);
280+
} else if (rippleRef.state === RippleState.FADING_OUT) {
281+
this._destroyRipple(rippleRef);
282+
}
283+
}
284+
285+
/**
286+
* Starts the fade-out transition of the given ripple if it's not persistent and the pointer
287+
* is not held down anymore.
288+
*/
289+
private _startFadeOutTransition(rippleRef: RippleRef) {
290+
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
291+
const {persistent} = rippleRef.config;
292+
293+
rippleRef.state = RippleState.VISIBLE;
294+
295+
// When the timer runs out while the user has kept their pointer down, we want to
296+
// keep only the persistent ripples and the latest transient ripple. We do this,
297+
// because we don't want stacked transient ripples to appear after their enter
298+
// animation has finished.
299+
if (!persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
300+
rippleRef.fadeOut();
301+
}
302+
}
303+
304+
/** Destroys the given ripple by removing it from the DOM and updating its state. */
305+
private _destroyRipple(rippleRef: RippleRef) {
306+
rippleRef.state = RippleState.HIDDEN;
307+
rippleRef.element.remove();
308+
}
309+
259310
/** Function being called whenever the trigger is being pressed using mouse. */
260311
private _onMousedown(event: MouseEvent) {
261312
// Screen readers will fire fake mouse events for space/enter. Skip launching a
@@ -312,11 +363,6 @@ export class RippleRenderer implements EventListenerObject {
312363
});
313364
}
314365

315-
/** Runs a timeout outside of the Angular zone to avoid triggering the change detection. */
316-
private _runTimeoutOutsideZone(fn: Function, delay = 0) {
317-
this._ngZone.runOutsideAngular(() => setTimeout(fn, delay));
318-
}
319-
320366
/** Registers event listeners for a given list of events. */
321367
private _registerEvents(eventTypes: string[]) {
322368
this._ngZone.runOutsideAngular(() => {
@@ -342,14 +388,6 @@ export class RippleRenderer implements EventListenerObject {
342388
}
343389
}
344390

345-
/** Enforces a style recalculation of a DOM element by computing its styles. */
346-
function enforceStyleRecalculation(element: HTMLElement) {
347-
// Enforce a style recalculation by calling `getComputedStyle` and accessing any property.
348-
// Calling `getPropertyValue` is important to let optimizers know that this is not a noop.
349-
// See: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
350-
window.getComputedStyle(element).getPropertyValue('opacity');
351-
}
352-
353391
/**
354392
* Returns the distance from the point (x, y) to the furthest corner of a rectangle.
355393
*/

0 commit comments

Comments
 (0)