From 65cd1a1236186033978338cea2317be5f21d7d40 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 28 Nov 2017 21:04:00 +0100 Subject: [PATCH] feat(ripple): handle touch events (#7927) * Now handles touch events properly. Fixes #7062 --- src/demo-app/ripple/ripple-demo.scss | 6 +++ src/lib/core/ripple/ripple-renderer.ts | 54 +++++++++++++++++++------- src/lib/core/ripple/ripple.spec.ts | 30 +++++++++++++- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/demo-app/ripple/ripple-demo.scss b/src/demo-app/ripple/ripple-demo.scss index b43ebe90cee3..a343e29714f8 100644 --- a/src/demo-app/ripple/ripple-demo.scss +++ b/src/demo-app/ripple/ripple-demo.scss @@ -1,3 +1,5 @@ +@import '../../lib/core/style/vendor-prefixes'; + .demo-ripple { button, a { margin: 8px; @@ -15,6 +17,10 @@ transition: all 200ms linear; width: 200px; + // Disable the blue overlay on touch. This makes it easier to see ripples fading in. + -webkit-tap-highlight-color: transparent; + @include user-select(none); + &.demo-ripple-disabled { color: rgba(32, 32, 32, 0.4); } diff --git a/src/lib/core/ripple/ripple-renderer.ts b/src/lib/core/ripple/ripple-renderer.ts index b9ad40f6883f..f5691f982a5a 100644 --- a/src/lib/core/ripple/ripple-renderer.ts +++ b/src/lib/core/ripple/ripple-renderer.ts @@ -17,6 +17,12 @@ export const RIPPLE_FADE_IN_DURATION = 450; /** Fade-out duration for the ripples in milliseconds. This can't be modified by the speedFactor. */ export const RIPPLE_FADE_OUT_DURATION = 400; +/** + * Timeout for ignoring mouse events. Mouse events will be temporary ignored after touch + * events to avoid synthetic mouse events. + */ +const IGNORE_MOUSE_EVENTS_TIMEOUT = 800; + export type RippleConfig = { color?: string; centered?: boolean; @@ -40,8 +46,8 @@ export class RippleRenderer { /** Element which triggers the ripple elements on mouse events. */ private _triggerElement: HTMLElement | null; - /** Whether the mouse is currently down or not. */ - private _isMousedown: boolean = false; + /** Whether the pointer is currently down or not. */ + private _isPointerDown = false; /** Events to be registered on the trigger element. */ private _triggerEvents = new Map(); @@ -49,6 +55,9 @@ export class RippleRenderer { /** Set of currently active ripple references. */ private _activeRipples = new Set(); + /** Time in milliseconds when the last touchstart event happened. */ + private _lastTouchStartEvent: number; + /** Ripple config for all ripples created by events. */ rippleConfig: RippleConfig = {}; @@ -62,8 +71,11 @@ export class RippleRenderer { // Specify events which need to be registered on the trigger. this._triggerEvents.set('mousedown', this.onMousedown.bind(this)); - this._triggerEvents.set('mouseup', this.onMouseup.bind(this)); - this._triggerEvents.set('mouseleave', this.onMouseup.bind(this)); + this._triggerEvents.set('mouseup', this.onPointerUp.bind(this)); + this._triggerEvents.set('mouseleave', this.onPointerUp.bind(this)); + + this._triggerEvents.set('touchstart', this.onTouchStart.bind(this)); + this._triggerEvents.set('touchend', this.onPointerUp.bind(this)); // By default use the host element as trigger element. this.setTriggerElement(this._containerElement); @@ -110,7 +122,7 @@ export class RippleRenderer { ripple.style.transform = 'scale(1)'; // Exposed reference to the ripple that will be returned. - let rippleRef = new RippleRef(this, ripple, config); + const rippleRef = new RippleRef(this, ripple, config); rippleRef.state = RippleState.FADING_IN; @@ -122,7 +134,7 @@ export class RippleRenderer { this.runTimeoutOutsideZone(() => { rippleRef.state = RippleState.VISIBLE; - if (!config.persistent && !this._isMousedown) { + if (!config.persistent && !this._isPointerDown) { rippleRef.fadeOut(); } }, duration); @@ -137,7 +149,7 @@ export class RippleRenderer { return; } - let rippleEl = rippleRef.element; + const rippleEl = rippleRef.element; rippleEl.style.transitionDuration = `${RIPPLE_FADE_OUT_DURATION}ms`; rippleEl.style.opacity = '0'; @@ -175,21 +187,37 @@ export class RippleRenderer { this._triggerElement = element; } - /** Function being called whenever the trigger is being pressed. */ + /** Function being called whenever the trigger is being pressed using mouse. */ private onMousedown(event: MouseEvent) { - if (!this.rippleDisabled) { - this._isMousedown = true; + const isSyntheticEvent = this._lastTouchStartEvent && + Date.now() < this._lastTouchStartEvent + IGNORE_MOUSE_EVENTS_TIMEOUT; + + if (!this.rippleDisabled && !isSyntheticEvent) { + this._isPointerDown = true; this.fadeInRipple(event.clientX, event.clientY, this.rippleConfig); } } + /** Function being called whenever the trigger is being pressed using touch. */ + private onTouchStart(event: TouchEvent) { + if (!this.rippleDisabled) { + // 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; + + this.fadeInRipple(event.touches[0].clientX, event.touches[0].clientY, this.rippleConfig); + } + } + /** Function being called whenever the trigger is being released. */ - private onMouseup() { - if (!this._isMousedown) { + private onPointerUp() { + if (!this._isPointerDown) { return; } - this._isMousedown = false; + this._isPointerDown = false; // Fade-out all ripples that are completely visible and not persistent. this._activeRipples.forEach(ripple => { diff --git a/src/lib/core/ripple/ripple.spec.ts b/src/lib/core/ripple/ripple.spec.ts index 709569d927b1..09517ba85ffb 100644 --- a/src/lib/core/ripple/ripple.spec.ts +++ b/src/lib/core/ripple/ripple.spec.ts @@ -1,7 +1,7 @@ import {TestBed, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing'; import {Component, ViewChild} from '@angular/core'; import {Platform} from '@angular/cdk/platform'; -import {dispatchMouseEvent} from '@angular/cdk/testing'; +import {dispatchMouseEvent, dispatchTouchEvent} from '@angular/cdk/testing'; import {RIPPLE_FADE_OUT_DURATION, RIPPLE_FADE_IN_DURATION} from './ripple-renderer'; import { MatRipple, MatRippleModule, MAT_RIPPLE_GLOBAL_OPTIONS, RippleState, RippleGlobalOptions @@ -104,6 +104,34 @@ describe('MatRipple', () => { expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(2); }); + it('should launch ripples on touchstart', fakeAsync(() => { + dispatchTouchEvent(rippleTarget, 'touchstart'); + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1); + + tick(RIPPLE_FADE_IN_DURATION); + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1); + + dispatchTouchEvent(rippleTarget, 'touchend'); + + tick(RIPPLE_FADE_OUT_DURATION); + + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0); + })); + + it('should ignore synthetic mouse events after touchstart', () => fakeAsync(() => { + dispatchTouchEvent(rippleTarget, 'touchstart'); + dispatchTouchEvent(rippleTarget, 'mousedown'); + + tick(RIPPLE_FADE_IN_DURATION); + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1); + + dispatchTouchEvent(rippleTarget, 'touchend'); + + tick(RIPPLE_FADE_OUT_DURATION); + + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0); + })); + it('removes ripple after timeout', fakeAsync(() => { dispatchMouseEvent(rippleTarget, 'mousedown'); dispatchMouseEvent(rippleTarget, 'mouseup');