Skip to content

Commit 47448cb

Browse files
dozingcathansl
authored andcommitted
feat(ripple): initial mdInkRipple implementation (#681)
* Initial mdInkRipple implementation. * Add missing files. * Remove unused code. * Fix stylelint errors. * In-progress updates for PR comments. * More PR comments. * Fix tests, use @internal. * Restore original body margin after tests. * Add "unbounded" and "max-radius" bindings. * Tweaking ripple color and speed. * Fix ripple scaling. * In-progress updates for PR comments. * PR comments * Fix maxRadius binding in tests. * Simplify ripple demo @ViewChild. * Switch to attribute directive (<div md-ink-ripple> instead of <md-ink-ripple>) and move to core. * Change MdInkRipple identifiers to MdRipple, remove duplicate CSS file.
1 parent 54c6158 commit 47448cb

File tree

13 files changed

+920
-0
lines changed

13 files changed

+920
-0
lines changed

src/core/core.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export {
3030
// Gestures
3131
export {MdGestureConfig} from './gestures/MdGestureConfig';
3232

33+
// Ripple
34+
export {MD_RIPPLE_DIRECTIVES, MdRipple} from './ripple/ripple';
35+
3336
// a11y
3437
export {
3538
AriaLivePoliteness,

src/core/ripple/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# md-ripple
2+
3+
`md-ripple` defines an area in which a ripple animates, usually in response to user action. It is used as an attribute directive, for example `<div md-ripple [md-ripple-color]="rippleColor">...</div>`.
4+
5+
By default, a ripple is activated when the host element of the `md-ripple` directive receives mouse or touch events. On a mousedown or touch start, the ripple background fades in. When the click event completes, a circular foreground ripple fades in and expands from the event location to cover the host element bounds.
6+
7+
Ripples can also be triggered programmatically by getting a reference to the MdRipple directive and calling its `start` and `end` methods.
8+
9+
10+
### Upcoming work
11+
12+
Ripples will be added to the `md-button`, `md-radio-button`, `md-checkbox`, and `md-nav-list` components.
13+
14+
### API Summary
15+
16+
Properties:
17+
18+
| Name | Type | Description |
19+
| --- | --- | --- |
20+
| `md-ripple-trigger` | Element | The DOM element that triggers the ripple when clicked. Defaults to the parent of the `md-ripple`.
21+
| `md-ripple-color` | string | Custom color for foreground ripples
22+
| `md-ripple-background-color` | string | Custom color for the ripple background
23+
| `md-ripple-centered` | boolean | If true, the ripple animation originates from the center of the `md-ripple` bounds rather than from the location of the click event.
24+
| `md-ripple-max-radius` | number | Optional fixed radius of foreground ripples when fully expanded. Mainly used in conjunction with `unbounded` attribute. If not set, ripples will expand from their origin to the most distant corner of the component's bounding rectangle.
25+
| `md-ripple-unbounded` | boolean | If true, foreground ripples will be visible outside the component's bounds.
26+
| `md-ripple-focused` | boolean | If true, the background ripple is shown using the current theme's accent color to indicate focus.
27+
| `md-ripple-disabled` | boolean | If true, click events on the trigger element will not activate ripples. The `start` and `end` methods can still be called to programmatically create ripples.

src/core/ripple/ripple-renderer.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import {
2+
ElementRef,
3+
} from '@angular/core';
4+
5+
/** TODO: internal */
6+
export enum ForegroundRippleState {
7+
NEW,
8+
EXPANDING,
9+
FADING_OUT,
10+
}
11+
12+
/**
13+
* Wrapper for a foreground ripple DOM element and its animation state.
14+
* TODO: internal
15+
*/
16+
export class ForegroundRipple {
17+
state = ForegroundRippleState.NEW;
18+
constructor(public rippleElement: Element) {}
19+
}
20+
21+
const RIPPLE_SPEED_PX_PER_SECOND = 1000;
22+
const MIN_RIPPLE_FILL_TIME_SECONDS = 0.1;
23+
const MAX_RIPPLE_FILL_TIME_SECONDS = 0.3;
24+
25+
/**
26+
* Returns the distance from the point (x, y) to the furthest corner of a rectangle.
27+
*/
28+
const distanceToFurthestCorner = (x: number, y: number, rect: ClientRect) => {
29+
const distX = Math.max(Math.abs(x - rect.left), Math.abs(x - rect.right));
30+
const distY = Math.max(Math.abs(y - rect.top), Math.abs(y - rect.bottom));
31+
return Math.sqrt(distX * distX + distY * distY);
32+
};
33+
34+
/**
35+
* Helper service that performs DOM manipulations. Not intended to be used outside this module.
36+
* The constructor takes a reference to the ripple directive's host element and a map of DOM
37+
* event handlers to be installed on the element that triggers ripple animations.
38+
* This will eventually become a custom renderer once Angular support exists.
39+
* TODO: internal
40+
*/
41+
export class RippleRenderer {
42+
private _backgroundDiv: HTMLElement;
43+
private _rippleElement: HTMLElement;
44+
private _triggerElement: HTMLElement;
45+
46+
constructor(_elementRef: ElementRef, private _eventHandlers: Map<string, (e: Event) => void>) {
47+
this._rippleElement = _elementRef.nativeElement;
48+
// It might be nice to delay creating the background until it's needed, but doing this in
49+
// fadeInRippleBackground causes the first click event to not be handled reliably.
50+
this._backgroundDiv = document.createElement('div');
51+
this._backgroundDiv.classList.add('md-ripple-background');
52+
this._rippleElement.appendChild(this._backgroundDiv);
53+
}
54+
55+
/**
56+
* Installs event handlers on the given trigger element, and removes event handlers from the
57+
* previous trigger if needed.
58+
*/
59+
setTriggerElement(newTrigger: HTMLElement) {
60+
if (this._triggerElement !== newTrigger) {
61+
if (this._triggerElement) {
62+
this._eventHandlers.forEach((eventHandler, eventName) => {
63+
this._triggerElement.removeEventListener(eventName, eventHandler);
64+
});
65+
}
66+
this._triggerElement = newTrigger;
67+
if (this._triggerElement) {
68+
this._eventHandlers.forEach((eventHandler, eventName) => {
69+
this._triggerElement.addEventListener(eventName, eventHandler);
70+
});
71+
}
72+
}
73+
}
74+
75+
/**
76+
* Installs event handlers on the host element of the md-ripple directive.
77+
*/
78+
setTriggerElementToHost() {
79+
this.setTriggerElement(this._rippleElement);
80+
}
81+
82+
/**
83+
* Removes event handlers from the current trigger element if needed.
84+
*/
85+
clearTriggerElement() {
86+
this.setTriggerElement(null);
87+
}
88+
89+
/**
90+
* Creates a foreground ripple and sets its animation to expand and fade in from the position
91+
* given by rippleOriginLeft and rippleOriginTop (or from the center of the <md-ripple>
92+
* bounding rect if centered is true).
93+
*/
94+
createForegroundRipple(
95+
rippleOriginLeft: number,
96+
rippleOriginTop: number,
97+
color: string,
98+
centered: boolean,
99+
radius: number,
100+
speedFactor: number,
101+
transitionEndCallback: (r: ForegroundRipple, e: TransitionEvent) => void) {
102+
const parentRect = this._rippleElement.getBoundingClientRect();
103+
// Create a foreground ripple div with the size and position of the fully expanded ripple.
104+
// When the div is created, it's given a transform style that causes the ripple to be displayed
105+
// small and centered on the event location (or the center of the bounding rect if the centered
106+
// argument is true). Removing that transform causes the ripple to animate to its natural size.
107+
const startX = centered ? (parentRect.left + parentRect.width / 2) : rippleOriginLeft;
108+
const startY = centered ? (parentRect.top + parentRect.height / 2) : rippleOriginTop;
109+
const offsetX = startX - parentRect.left;
110+
const offsetY = startY - parentRect.top;
111+
const maxRadius = radius > 0 ? radius : distanceToFurthestCorner(startX, startY, parentRect);
112+
113+
const rippleDiv = document.createElement('div');
114+
this._rippleElement.appendChild(rippleDiv);
115+
rippleDiv.classList.add('md-ripple-foreground');
116+
rippleDiv.style.left = `${offsetX - maxRadius}px`;
117+
rippleDiv.style.top = `${offsetY - maxRadius}px`;
118+
rippleDiv.style.width = `${2 * maxRadius}px`;
119+
rippleDiv.style.height = rippleDiv.style.width;
120+
// If color input is not set, this will default to the background color defined in CSS.
121+
rippleDiv.style.backgroundColor = color;
122+
// Start the ripple tiny.
123+
rippleDiv.style.transform = `scale(0.001)`;
124+
125+
const fadeInSeconds = (1 / (speedFactor || 1)) * Math.max(
126+
MIN_RIPPLE_FILL_TIME_SECONDS,
127+
Math.min(MAX_RIPPLE_FILL_TIME_SECONDS, maxRadius / RIPPLE_SPEED_PX_PER_SECOND));
128+
rippleDiv.style.transitionDuration = `${fadeInSeconds}s`;
129+
130+
// https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/
131+
window.getComputedStyle(rippleDiv).opacity;
132+
133+
rippleDiv.classList.add('md-ripple-fade-in');
134+
// Clearing the transform property causes the ripple to animate to its full size.
135+
rippleDiv.style.transform = '';
136+
const ripple = new ForegroundRipple(rippleDiv);
137+
ripple.state = ForegroundRippleState.EXPANDING;
138+
139+
rippleDiv.addEventListener('transitionend',
140+
(event: TransitionEvent) => transitionEndCallback(ripple, event));
141+
}
142+
143+
/**
144+
* Fades out a foreground ripple after it has fully expanded and faded in.
145+
*/
146+
fadeOutForegroundRipple(ripple: Element) {
147+
ripple.classList.remove('md-ripple-fade-in');
148+
ripple.classList.add('md-ripple-fade-out');
149+
}
150+
151+
/**
152+
* Removes a foreground ripple from the DOM after it has faded out.
153+
*/
154+
removeRippleFromDom(ripple: Element) {
155+
ripple.parentElement.removeChild(ripple);
156+
}
157+
158+
/**
159+
* Fades in the ripple background.
160+
*/
161+
fadeInRippleBackground(color: string) {
162+
this._backgroundDiv.classList.add('md-ripple-active');
163+
// If color is not set, this will default to the background color defined in CSS.
164+
this._backgroundDiv.style.backgroundColor = color;
165+
}
166+
167+
/**
168+
* Fades out the ripple background.
169+
*/
170+
fadeOutRippleBackground() {
171+
if (this._backgroundDiv) {
172+
this._backgroundDiv.classList.remove('md-ripple-active');
173+
}
174+
}
175+
}

0 commit comments

Comments
 (0)