Skip to content

Commit 65cd1a1

Browse files
devversiontinayuangao
authored andcommitted
feat(ripple): handle touch events (#7927)
* Now handles touch events properly. Fixes #7062
1 parent 345a1a3 commit 65cd1a1

File tree

3 files changed

+76
-14
lines changed

3 files changed

+76
-14
lines changed

src/demo-app/ripple/ripple-demo.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import '../../lib/core/style/vendor-prefixes';
2+
13
.demo-ripple {
24
button, a {
35
margin: 8px;
@@ -15,6 +17,10 @@
1517
transition: all 200ms linear;
1618
width: 200px;
1719

20+
// Disable the blue overlay on touch. This makes it easier to see ripples fading in.
21+
-webkit-tap-highlight-color: transparent;
22+
@include user-select(none);
23+
1824
&.demo-ripple-disabled {
1925
color: rgba(32, 32, 32, 0.4);
2026
}

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

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export const RIPPLE_FADE_IN_DURATION = 450;
1717
/** Fade-out duration for the ripples in milliseconds. This can't be modified by the speedFactor. */
1818
export const RIPPLE_FADE_OUT_DURATION = 400;
1919

20+
/**
21+
* Timeout for ignoring mouse events. Mouse events will be temporary ignored after touch
22+
* events to avoid synthetic mouse events.
23+
*/
24+
const IGNORE_MOUSE_EVENTS_TIMEOUT = 800;
25+
2026
export type RippleConfig = {
2127
color?: string;
2228
centered?: boolean;
@@ -40,15 +46,18 @@ export class RippleRenderer {
4046
/** Element which triggers the ripple elements on mouse events. */
4147
private _triggerElement: HTMLElement | null;
4248

43-
/** Whether the mouse is currently down or not. */
44-
private _isMousedown: boolean = false;
49+
/** Whether the pointer is currently down or not. */
50+
private _isPointerDown = false;
4551

4652
/** Events to be registered on the trigger element. */
4753
private _triggerEvents = new Map<string, any>();
4854

4955
/** Set of currently active ripple references. */
5056
private _activeRipples = new Set<RippleRef>();
5157

58+
/** Time in milliseconds when the last touchstart event happened. */
59+
private _lastTouchStartEvent: number;
60+
5261
/** Ripple config for all ripples created by events. */
5362
rippleConfig: RippleConfig = {};
5463

@@ -62,8 +71,11 @@ export class RippleRenderer {
6271

6372
// Specify events which need to be registered on the trigger.
6473
this._triggerEvents.set('mousedown', this.onMousedown.bind(this));
65-
this._triggerEvents.set('mouseup', this.onMouseup.bind(this));
66-
this._triggerEvents.set('mouseleave', this.onMouseup.bind(this));
74+
this._triggerEvents.set('mouseup', this.onPointerUp.bind(this));
75+
this._triggerEvents.set('mouseleave', this.onPointerUp.bind(this));
76+
77+
this._triggerEvents.set('touchstart', this.onTouchStart.bind(this));
78+
this._triggerEvents.set('touchend', this.onPointerUp.bind(this));
6779

6880
// By default use the host element as trigger element.
6981
this.setTriggerElement(this._containerElement);
@@ -110,7 +122,7 @@ export class RippleRenderer {
110122
ripple.style.transform = 'scale(1)';
111123

112124
// Exposed reference to the ripple that will be returned.
113-
let rippleRef = new RippleRef(this, ripple, config);
125+
const rippleRef = new RippleRef(this, ripple, config);
114126

115127
rippleRef.state = RippleState.FADING_IN;
116128

@@ -122,7 +134,7 @@ export class RippleRenderer {
122134
this.runTimeoutOutsideZone(() => {
123135
rippleRef.state = RippleState.VISIBLE;
124136

125-
if (!config.persistent && !this._isMousedown) {
137+
if (!config.persistent && !this._isPointerDown) {
126138
rippleRef.fadeOut();
127139
}
128140
}, duration);
@@ -137,7 +149,7 @@ export class RippleRenderer {
137149
return;
138150
}
139151

140-
let rippleEl = rippleRef.element;
152+
const rippleEl = rippleRef.element;
141153

142154
rippleEl.style.transitionDuration = `${RIPPLE_FADE_OUT_DURATION}ms`;
143155
rippleEl.style.opacity = '0';
@@ -175,21 +187,37 @@ export class RippleRenderer {
175187
this._triggerElement = element;
176188
}
177189

178-
/** Function being called whenever the trigger is being pressed. */
190+
/** Function being called whenever the trigger is being pressed using mouse. */
179191
private onMousedown(event: MouseEvent) {
180-
if (!this.rippleDisabled) {
181-
this._isMousedown = true;
192+
const isSyntheticEvent = this._lastTouchStartEvent &&
193+
Date.now() < this._lastTouchStartEvent + IGNORE_MOUSE_EVENTS_TIMEOUT;
194+
195+
if (!this.rippleDisabled && !isSyntheticEvent) {
196+
this._isPointerDown = true;
182197
this.fadeInRipple(event.clientX, event.clientY, this.rippleConfig);
183198
}
184199
}
185200

201+
/** Function being called whenever the trigger is being pressed using touch. */
202+
private onTouchStart(event: TouchEvent) {
203+
if (!this.rippleDisabled) {
204+
// Some browsers fire mouse events after a `touchstart` event. Those synthetic mouse
205+
// events will launch a second ripple if we don't ignore mouse events for a specific
206+
// time after a touchstart event.
207+
this._lastTouchStartEvent = Date.now();
208+
this._isPointerDown = true;
209+
210+
this.fadeInRipple(event.touches[0].clientX, event.touches[0].clientY, this.rippleConfig);
211+
}
212+
}
213+
186214
/** Function being called whenever the trigger is being released. */
187-
private onMouseup() {
188-
if (!this._isMousedown) {
215+
private onPointerUp() {
216+
if (!this._isPointerDown) {
189217
return;
190218
}
191219

192-
this._isMousedown = false;
220+
this._isPointerDown = false;
193221

194222
// Fade-out all ripples that are completely visible and not persistent.
195223
this._activeRipples.forEach(ripple => {

src/lib/core/ripple/ripple.spec.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {TestBed, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing';
22
import {Component, ViewChild} from '@angular/core';
33
import {Platform} from '@angular/cdk/platform';
4-
import {dispatchMouseEvent} from '@angular/cdk/testing';
4+
import {dispatchMouseEvent, dispatchTouchEvent} from '@angular/cdk/testing';
55
import {RIPPLE_FADE_OUT_DURATION, RIPPLE_FADE_IN_DURATION} from './ripple-renderer';
66
import {
77
MatRipple, MatRippleModule, MAT_RIPPLE_GLOBAL_OPTIONS, RippleState, RippleGlobalOptions
@@ -104,6 +104,34 @@ describe('MatRipple', () => {
104104
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(2);
105105
});
106106

107+
it('should launch ripples on touchstart', fakeAsync(() => {
108+
dispatchTouchEvent(rippleTarget, 'touchstart');
109+
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
110+
111+
tick(RIPPLE_FADE_IN_DURATION);
112+
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
113+
114+
dispatchTouchEvent(rippleTarget, 'touchend');
115+
116+
tick(RIPPLE_FADE_OUT_DURATION);
117+
118+
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
119+
}));
120+
121+
it('should ignore synthetic mouse events after touchstart', () => fakeAsync(() => {
122+
dispatchTouchEvent(rippleTarget, 'touchstart');
123+
dispatchTouchEvent(rippleTarget, 'mousedown');
124+
125+
tick(RIPPLE_FADE_IN_DURATION);
126+
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
127+
128+
dispatchTouchEvent(rippleTarget, 'touchend');
129+
130+
tick(RIPPLE_FADE_OUT_DURATION);
131+
132+
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
133+
}));
134+
107135
it('removes ripple after timeout', fakeAsync(() => {
108136
dispatchMouseEvent(rippleTarget, 'mousedown');
109137
dispatchMouseEvent(rippleTarget, 'mouseup');

0 commit comments

Comments
 (0)