Skip to content

Commit

Permalink
feat(ripple): initial mdInkRipple implementation (#681)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
dozingcat authored and hansl committed Jul 25, 2016
1 parent 54c6158 commit 47448cb
Show file tree
Hide file tree
Showing 13 changed files with 920 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export {
// Gestures
export {MdGestureConfig} from './gestures/MdGestureConfig';

// Ripple
export {MD_RIPPLE_DIRECTIVES, MdRipple} from './ripple/ripple';

// a11y
export {
AriaLivePoliteness,
Expand Down
27 changes: 27 additions & 0 deletions src/core/ripple/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# md-ripple

`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>`.

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.

Ripples can also be triggered programmatically by getting a reference to the MdRipple directive and calling its `start` and `end` methods.


### Upcoming work

Ripples will be added to the `md-button`, `md-radio-button`, `md-checkbox`, and `md-nav-list` components.

### API Summary

Properties:

| Name | Type | Description |
| --- | --- | --- |
| `md-ripple-trigger` | Element | The DOM element that triggers the ripple when clicked. Defaults to the parent of the `md-ripple`.
| `md-ripple-color` | string | Custom color for foreground ripples
| `md-ripple-background-color` | string | Custom color for the ripple background
| `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.
| `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.
| `md-ripple-unbounded` | boolean | If true, foreground ripples will be visible outside the component's bounds.
| `md-ripple-focused` | boolean | If true, the background ripple is shown using the current theme's accent color to indicate focus.
| `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.
175 changes: 175 additions & 0 deletions src/core/ripple/ripple-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import {
ElementRef,
} from '@angular/core';

/** TODO: internal */
export enum ForegroundRippleState {
NEW,
EXPANDING,
FADING_OUT,
}

/**
* Wrapper for a foreground ripple DOM element and its animation state.
* TODO: internal
*/
export class ForegroundRipple {
state = ForegroundRippleState.NEW;
constructor(public rippleElement: Element) {}
}

const RIPPLE_SPEED_PX_PER_SECOND = 1000;
const MIN_RIPPLE_FILL_TIME_SECONDS = 0.1;
const MAX_RIPPLE_FILL_TIME_SECONDS = 0.3;

/**
* Returns the distance from the point (x, y) to the furthest corner of a rectangle.
*/
const distanceToFurthestCorner = (x: number, y: number, rect: ClientRect) => {
const distX = Math.max(Math.abs(x - rect.left), Math.abs(x - rect.right));
const distY = Math.max(Math.abs(y - rect.top), Math.abs(y - rect.bottom));
return Math.sqrt(distX * distX + distY * distY);
};

/**
* Helper service that performs DOM manipulations. Not intended to be used outside this module.
* The constructor takes a reference to the ripple directive's host element and a map of DOM
* event handlers to be installed on the element that triggers ripple animations.
* This will eventually become a custom renderer once Angular support exists.
* TODO: internal
*/
export class RippleRenderer {
private _backgroundDiv: HTMLElement;
private _rippleElement: HTMLElement;
private _triggerElement: HTMLElement;

constructor(_elementRef: ElementRef, private _eventHandlers: Map<string, (e: Event) => void>) {
this._rippleElement = _elementRef.nativeElement;
// It might be nice to delay creating the background until it's needed, but doing this in
// fadeInRippleBackground causes the first click event to not be handled reliably.
this._backgroundDiv = document.createElement('div');
this._backgroundDiv.classList.add('md-ripple-background');
this._rippleElement.appendChild(this._backgroundDiv);
}

/**
* Installs event handlers on the given trigger element, and removes event handlers from the
* previous trigger if needed.
*/
setTriggerElement(newTrigger: HTMLElement) {
if (this._triggerElement !== newTrigger) {
if (this._triggerElement) {
this._eventHandlers.forEach((eventHandler, eventName) => {
this._triggerElement.removeEventListener(eventName, eventHandler);
});
}
this._triggerElement = newTrigger;
if (this._triggerElement) {
this._eventHandlers.forEach((eventHandler, eventName) => {
this._triggerElement.addEventListener(eventName, eventHandler);
});
}
}
}

/**
* Installs event handlers on the host element of the md-ripple directive.
*/
setTriggerElementToHost() {
this.setTriggerElement(this._rippleElement);
}

/**
* Removes event handlers from the current trigger element if needed.
*/
clearTriggerElement() {
this.setTriggerElement(null);
}

/**
* Creates a foreground ripple and sets its animation to expand and fade in from the position
* given by rippleOriginLeft and rippleOriginTop (or from the center of the <md-ripple>
* bounding rect if centered is true).
*/
createForegroundRipple(
rippleOriginLeft: number,
rippleOriginTop: number,
color: string,
centered: boolean,
radius: number,
speedFactor: number,
transitionEndCallback: (r: ForegroundRipple, e: TransitionEvent) => void) {
const parentRect = this._rippleElement.getBoundingClientRect();
// Create a foreground ripple div with the size and position of the fully expanded ripple.
// When the div is created, it's given a transform style that causes the ripple to be displayed
// small and centered on the event location (or the center of the bounding rect if the centered
// argument is true). Removing that transform causes the ripple to animate to its natural size.
const startX = centered ? (parentRect.left + parentRect.width / 2) : rippleOriginLeft;
const startY = centered ? (parentRect.top + parentRect.height / 2) : rippleOriginTop;
const offsetX = startX - parentRect.left;
const offsetY = startY - parentRect.top;
const maxRadius = radius > 0 ? radius : distanceToFurthestCorner(startX, startY, parentRect);

const rippleDiv = document.createElement('div');
this._rippleElement.appendChild(rippleDiv);
rippleDiv.classList.add('md-ripple-foreground');
rippleDiv.style.left = `${offsetX - maxRadius}px`;
rippleDiv.style.top = `${offsetY - maxRadius}px`;
rippleDiv.style.width = `${2 * maxRadius}px`;
rippleDiv.style.height = rippleDiv.style.width;
// If color input is not set, this will default to the background color defined in CSS.
rippleDiv.style.backgroundColor = color;
// Start the ripple tiny.
rippleDiv.style.transform = `scale(0.001)`;

const fadeInSeconds = (1 / (speedFactor || 1)) * Math.max(
MIN_RIPPLE_FILL_TIME_SECONDS,
Math.min(MAX_RIPPLE_FILL_TIME_SECONDS, maxRadius / RIPPLE_SPEED_PX_PER_SECOND));
rippleDiv.style.transitionDuration = `${fadeInSeconds}s`;

// https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/
window.getComputedStyle(rippleDiv).opacity;

rippleDiv.classList.add('md-ripple-fade-in');
// Clearing the transform property causes the ripple to animate to its full size.
rippleDiv.style.transform = '';
const ripple = new ForegroundRipple(rippleDiv);
ripple.state = ForegroundRippleState.EXPANDING;

rippleDiv.addEventListener('transitionend',
(event: TransitionEvent) => transitionEndCallback(ripple, event));
}

/**
* Fades out a foreground ripple after it has fully expanded and faded in.
*/
fadeOutForegroundRipple(ripple: Element) {
ripple.classList.remove('md-ripple-fade-in');
ripple.classList.add('md-ripple-fade-out');
}

/**
* Removes a foreground ripple from the DOM after it has faded out.
*/
removeRippleFromDom(ripple: Element) {
ripple.parentElement.removeChild(ripple);
}

/**
* Fades in the ripple background.
*/
fadeInRippleBackground(color: string) {
this._backgroundDiv.classList.add('md-ripple-active');
// If color is not set, this will default to the background color defined in CSS.
this._backgroundDiv.style.backgroundColor = color;
}

/**
* Fades out the ripple background.
*/
fadeOutRippleBackground() {
if (this._backgroundDiv) {
this._backgroundDiv.classList.remove('md-ripple-active');
}
}
}
Loading

0 comments on commit 47448cb

Please sign in to comment.