Skip to content

Commit 7d9ef8b

Browse files
committed
fix(ripple): not fading out on touch devices
* 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
1 parent f951bf4 commit 7d9ef8b

File tree

7 files changed

+254
-199
lines changed

7 files changed

+254
-199
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 {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing/private';
22
import {Component, QueryList, ViewChildren} from '@angular/core';
3-
import {defaultRippleAnimationConfig} from '@angular/material/core';
4-
import {dispatchMouseEvent} from '@angular/cdk/testing/private';
3+
import {fakeAsync, TestBed, waitForAsync} from '@angular/core/testing';
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(waitForAsync(() => {
139
TestBed.configureTestingModule({
1410
imports: [MatListModule],
@@ -230,11 +226,15 @@ describe('MDC-based MatList', () => {
230226
dispatchMouseEvent(rippleTarget, 'mousedown');
231227
dispatchMouseEvent(rippleTarget, 'mouseup');
232228

229+
// Flush the ripple enter animation.
230+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
231+
233232
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
234233
.toBe(1, 'Expected ripples to be enabled by default.');
235234

236-
// Wait for the ripples to go away.
237-
tick(enterDuration + exitDuration);
235+
// Flush the ripple exit animation.
236+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
237+
238238
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
239239
.toBe(0, 'Expected ripples to go away.');
240240

@@ -258,11 +258,15 @@ describe('MDC-based MatList', () => {
258258
dispatchMouseEvent(rippleTarget, 'mousedown');
259259
dispatchMouseEvent(rippleTarget, 'mouseup');
260260

261+
// Flush the ripple enter animation.
262+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
263+
261264
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
262265
.toBe(1, 'Expected ripples to be enabled by default.');
263266

264-
// Wait for the ripples to go away.
265-
tick(enterDuration + exitDuration);
267+
// Flush the ripple exit animation.
268+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
269+
266270
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
267271
.toBe(0, 'Expected ripples to go away.');
268272

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
tick,
2323
} from '@angular/core/testing';
2424
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
25-
import {defaultRippleAnimationConfig, ThemePalette} from '@angular/material/core';
25+
import {ThemePalette} from '@angular/material/core';
2626
import {By} from '@angular/platform-browser';
2727
import {numbers} from '@material/list';
2828
import {MatListModule, MatListOption, MatSelectionList, MatSelectionListChange} from './index';
@@ -695,16 +695,18 @@ describe('MDC-based MatSelectionList without forms', () => {
695695
fakeAsync(() => {
696696
const rippleTarget = fixture.nativeElement
697697
.querySelector('.mat-mdc-list-option:not(.mdc-list-item--disabled)');
698-
const {enterDuration, exitDuration} = defaultRippleAnimationConfig;
699-
700698
dispatchMouseEvent(rippleTarget, 'mousedown');
701699
dispatchMouseEvent(rippleTarget, 'mouseup');
702700

701+
// Flush the ripple enter animation.
702+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
703+
703704
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
704705
.toBe(1, 'Expected ripples to be enabled by default.');
705706

706-
// Wait for the ripples to go away.
707-
tick(enterDuration + exitDuration);
707+
// Flush the ripple exit animation.
708+
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
709+
708710
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
709711
.toBe(0, 'Expected ripples to go away.');
710712

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

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -150,21 +150,19 @@ export class RippleRenderer implements EventListenerObject {
150150
this._mostRecentTransientRipple = rippleRef;
151151
}
152152

153-
// Wait for the ripple element to be completely faded in.
154-
// Once it's faded in, the ripple can be hidden immediately if the mouse is released.
155-
this._runTimeoutOutsideZone(() => {
156-
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
157-
158-
rippleRef.state = RippleState.VISIBLE;
159-
160-
// When the timer runs out while the user has kept their pointer down, we want to
161-
// keep only the persistent ripples and the latest transient ripple. We do this,
162-
// because we don't want stacked transient ripples to appear after their enter
163-
// animation has finished.
164-
if (!config.persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
165-
rippleRef.fadeOut();
166-
}
167-
}, duration);
153+
// Do not register the `transition` event listener if fade-in and fade-out duration
154+
// are set to zero. The events won't fire anyway and we can save resources here.
155+
if (duration || animationConfig.exitDuration) {
156+
this._ngZone.runOutsideAngular(() => {
157+
ripple.addEventListener('transitionend', () => this._finishRippleTransition(rippleRef));
158+
});
159+
}
160+
161+
// In case there is no fade-in transition duration, we need to manually call the transition
162+
// end listener because `transitionend` doesn't fire if there is no transition.
163+
if (!duration) {
164+
this._finishRippleTransition(rippleRef);
165+
}
168166

169167
return rippleRef;
170168
}
@@ -190,15 +188,17 @@ export class RippleRenderer implements EventListenerObject {
190188
const rippleEl = rippleRef.element;
191189
const animationConfig = {...defaultRippleAnimationConfig, ...rippleRef.config.animation};
192190

191+
// This starts the fade-out transition and will fire the transition end listener that
192+
// removes the ripple element from the DOM.
193193
rippleEl.style.transitionDuration = `${animationConfig.exitDuration}ms`;
194194
rippleEl.style.opacity = '0';
195195
rippleRef.state = RippleState.FADING_OUT;
196196

197-
// Once the ripple faded out, the ripple can be safely removed from the DOM.
198-
this._runTimeoutOutsideZone(() => {
199-
rippleRef.state = RippleState.HIDDEN;
200-
rippleEl.parentNode!.removeChild(rippleEl);
201-
}, animationConfig.exitDuration);
197+
// In case there is no fade-out transition duration, we need to manually call the
198+
// transition end listener because `transitionend` doesn't fire if there is no transition.
199+
if (!animationConfig.exitDuration) {
200+
this._finishRippleTransition(rippleRef);
201+
}
202202
}
203203

204204
/** Fades out all currently active ripples. */
@@ -243,6 +243,40 @@ export class RippleRenderer implements EventListenerObject {
243243
}
244244
}
245245

246+
/** Method that will be called if the fade-in or fade-in transition completed. */
247+
private _finishRippleTransition(rippleRef: RippleRef) {
248+
if (rippleRef.state === RippleState.FADING_IN) {
249+
this._startFadeOutTransition(rippleRef);
250+
} else if (rippleRef.state === RippleState.FADING_OUT) {
251+
this._destroyRipple(rippleRef);
252+
}
253+
}
254+
255+
/**
256+
* Starts the fade-out transition of the given ripple if it's not persistent and the pointer
257+
* is not held down anymore.
258+
*/
259+
private _startFadeOutTransition(rippleRef: RippleRef) {
260+
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
261+
const {persistent} = rippleRef.config;
262+
263+
rippleRef.state = RippleState.VISIBLE;
264+
265+
// When the timer runs out while the user has kept their pointer down, we want to
266+
// keep only the persistent ripples and the latest transient ripple. We do this,
267+
// because we don't want stacked transient ripples to appear after their enter
268+
// animation has finished.
269+
if (!persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
270+
rippleRef.fadeOut();
271+
}
272+
}
273+
274+
/** Destroys the given ripple by removing it from the DOM and updating its state. */
275+
private _destroyRipple(rippleRef: RippleRef) {
276+
rippleRef.state = RippleState.HIDDEN;
277+
rippleRef.element.parentNode!.removeChild(rippleRef.element);
278+
}
279+
246280
/** Function being called whenever the trigger is being pressed using mouse. */
247281
private _onMousedown(event: MouseEvent) {
248282
// Screen readers will fire fake mouse events for space/enter. Skip launching a
@@ -297,11 +331,6 @@ export class RippleRenderer implements EventListenerObject {
297331
});
298332
}
299333

300-
/** Runs a timeout outside of the Angular zone to avoid triggering the change detection. */
301-
private _runTimeoutOutsideZone(fn: Function, delay = 0) {
302-
this._ngZone.runOutsideAngular(() => setTimeout(fn, delay));
303-
}
304-
305334
/** Registers event listeners for a given list of events. */
306335
private _registerEvents(eventTypes: string[]) {
307336
this._ngZone.runOutsideAngular(() => {

0 commit comments

Comments
 (0)