Skip to content

Commit

Permalink
fix(ripple): not fading out on touch devices (angular#12488)
Browse files Browse the repository at this point in the history
* 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 angular#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
  • Loading branch information
devversion authored Feb 23, 2022
1 parent 3e1de9d commit 65fb5f4
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 210 deletions.
24 changes: 14 additions & 10 deletions src/material-experimental/mdc-list/list.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import {waitForAsync, TestBed, fakeAsync, tick} from '@angular/core/testing';
import {fakeAsync, TestBed, waitForAsync} from '@angular/core/testing';
import {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing/private';
import {Component, QueryList, ViewChildren} from '@angular/core';
import {defaultRippleAnimationConfig} from '@angular/material-experimental/mdc-core';
import {dispatchMouseEvent} from '../../cdk/testing/private';
import {By} from '@angular/platform-browser';
import {MatListItem, MatListModule} from './index';

describe('MDC-based MatList', () => {
// Default ripple durations used for testing.
const {enterDuration, exitDuration} = defaultRippleAnimationConfig;

beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
Expand Down Expand Up @@ -243,12 +239,16 @@ describe('MDC-based MatList', () => {
dispatchMouseEvent(rippleTarget, 'mousedown');
dispatchMouseEvent(rippleTarget, 'mouseup');

// Flush the ripple enter animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to be enabled by default.')
.toBe(1);

// Wait for the ripples to go away.
tick(enterDuration + exitDuration);
// Flush the ripple exit animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to go away.')
.toBe(0);
Expand All @@ -273,12 +273,16 @@ describe('MDC-based MatList', () => {
dispatchMouseEvent(rippleTarget, 'mousedown');
dispatchMouseEvent(rippleTarget, 'mouseup');

// Flush the ripple enter animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to be enabled by default.')
.toBe(1);

// Wait for the ripples to go away.
tick(enterDuration + exitDuration);
// Flush the ripple exit animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to go away.')
.toBe(0);
Expand Down
12 changes: 7 additions & 5 deletions src/material-experimental/mdc-list/selection-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
waitForAsync,
} from '@angular/core/testing';
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
import {defaultRippleAnimationConfig, ThemePalette} from '@angular/material-experimental/mdc-core';
import {ThemePalette} from '@angular/material-experimental/mdc-core';
import {By} from '@angular/platform-browser';
import {numbers} from '@material/list';
import {
Expand Down Expand Up @@ -612,17 +612,19 @@ describe('MDC-based MatSelectionList without forms', () => {
const rippleTarget = fixture.nativeElement.querySelector(
'.mat-mdc-list-option:not(.mdc-list-item--disabled)',
);
const {enterDuration, exitDuration} = defaultRippleAnimationConfig;

dispatchMouseEvent(rippleTarget, 'mousedown');
dispatchMouseEvent(rippleTarget, 'mouseup');

// Flush the ripple enter animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to be enabled by default.')
.toBe(1);

// Wait for the ripples to go away.
tick(enterDuration + exitDuration);
// Flush the ripple exit animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to go away.')
.toBe(0);
Expand Down
20 changes: 10 additions & 10 deletions src/material-experimental/mdc-slider/slider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,13 @@
import {BidiModule, Directionality} from '@angular/cdk/bidi';
import {Platform} from '@angular/cdk/platform';
import {
dispatchFakeEvent,
dispatchMouseEvent,
dispatchPointerEvent,
dispatchTouchEvent,
} from '../../cdk/testing/private';
import {Component, Provider, QueryList, Type, ViewChild, ViewChildren} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
flush,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {By} from '@angular/platform-browser';
import {Thumb} from '@material/slider';
Expand Down Expand Up @@ -297,8 +291,14 @@ describe('MDC-based MatSlider', () => {
);

function isRippleVisible(selector: string) {
tick(500);
return !!document.querySelector(`.mat-mdc-slider-${selector}-ripple`);
flushRippleTransitions();
return thumbElement.querySelector(`.mat-mdc-slider-${selector}-ripple`) !== null;
}

function flushRippleTransitions() {
thumbElement.querySelectorAll('.mat-ripple-element').forEach(el => {
dispatchFakeEvent(el, 'transitionend');
});
}

function blur() {
Expand Down
1 change: 0 additions & 1 deletion src/material-experimental/mdc-tabs/tab-group.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,6 @@ describe('MDC-based MatTabGroup', () => {
.toBe(0);

dispatchFakeEvent(tabLabel.nativeElement, 'mousedown');
dispatchFakeEvent(tabLabel.nativeElement, 'mouseup');

expect(testElement.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected one ripple to show up on label mousedown.')
Expand Down
2 changes: 2 additions & 0 deletions src/material/core/ripple/ripple-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export class RippleRef {
public element: HTMLElement,
/** Ripple configuration used for the ripple. */
public config: RippleConfig,
/* Whether animations are forcibly disabled for ripples through CSS. */
public _animationForciblyDisabledThroughCss = false,
) {}

/** Fades out the ripple element. */
Expand Down
118 changes: 78 additions & 40 deletions src/material/core/ripple/ripple-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export class RippleRenderer implements EventListenerObject {
const radius = config.radius || distanceToFurthestCorner(x, y, containerRect);
const offsetX = x - containerRect.left;
const offsetY = y - containerRect.top;
const duration = animationConfig.enterDuration;
const enterDuration = animationConfig.enterDuration;

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

ripple.style.transitionDuration = `${duration}ms`;
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 because then the `scale` would not animate properly.
enforceStyleRecalculation(ripple);
// 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 by the use of
// `transition: 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';

// We use a 3d transform here in order to avoid an issue in Safari where
// 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)';

// Exposed reference to the ripple that will be returned.
const rippleRef = new RippleRef(this, ripple, config);

rippleRef.state = RippleState.FADING_IN;

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

// Wait for the ripple element to be completely faded in.
// Once it's faded in, the ripple can be hidden immediately if the mouse is released.
this._runTimeoutOutsideZone(() => {
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;

rippleRef.state = RippleState.VISIBLE;
// 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(() => {
ripple.addEventListener('transitionend', () => this._finishRippleTransition(rippleRef));
});
}

// 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 (!config.persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
rippleRef.fadeOut();
}
}, duration);
// 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;
}
Expand All @@ -194,15 +209,17 @@ export class RippleRenderer implements EventListenerObject {
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;

// Once the ripple faded out, the ripple can be safely removed from the DOM.
this._runTimeoutOutsideZone(() => {
rippleRef.state = RippleState.HIDDEN;
rippleEl.remove();
}, animationConfig.exitDuration);
// 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. */
Expand Down Expand Up @@ -256,6 +273,40 @@ export class RippleRenderer implements EventListenerObject {
}
}

/** 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) {
rippleRef.state = RippleState.HIDDEN;
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
Expand Down Expand Up @@ -312,11 +363,6 @@ export class RippleRenderer implements EventListenerObject {
});
}

/** Runs a timeout outside of the Angular zone to avoid triggering the change detection. */
private _runTimeoutOutsideZone(fn: Function, delay = 0) {
this._ngZone.runOutsideAngular(() => setTimeout(fn, delay));
}

/** Registers event listeners for a given list of events. */
private _registerEvents(eventTypes: string[]) {
this._ngZone.runOutsideAngular(() => {
Expand All @@ -342,14 +388,6 @@ export class RippleRenderer implements EventListenerObject {
}
}

/** Enforces a style recalculation of a DOM element by computing its styles. */
function enforceStyleRecalculation(element: HTMLElement) {
// Enforce a style recalculation by calling `getComputedStyle` and accessing any property.
// Calling `getPropertyValue` is important to let optimizers know that this is not a noop.
// See: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
window.getComputedStyle(element).getPropertyValue('opacity');
}

/**
* Returns the distance from the point (x, y) to the furthest corner of a rectangle.
*/
Expand Down
Loading

0 comments on commit 65fb5f4

Please sign in to comment.