//
// Reusable Ripple layout
//
// - [Props](#props)
// - [Defaults](#defaults)
//
// Created by ywu on 15/8/2.
//
import React, {
Component,
createRef,
} from 'react'
import {
Animated,
findNodeHandle,
LayoutChangeEvent,
LayoutRectangle,
MeasureOnSuccessCallback,
NativeModules,
Platform, StyleProp, ViewStyle,
} from 'react-native'
import MKTouchable, {
MKTouchableProps,
TouchEvent,
} from '../internal/MKTouchable'
import {RippleLocation} from '../MKPropTypes'
const UIManager = NativeModules.UIManager;
// ##
export type RippleProps = {
// Color of the `Ripple` layer
rippleColor?: string,
// Duration of the ripple effect, in milliseconds
rippleDuration?: number,
// Hot-spot position of the ripple effect, [available values](../MKPropTypes.html#RippleLocation)
rippleLocation?: RippleLocation,
// Whether a `Mask` layer should be used, to clip the ripple to the container’s bounds,
// default is `true`
maskEnabled?: boolean,
// Color of the `Mask` layer
maskColor?: string,
// Border width TODO move to `style`?
borderWidth?: number;
// Border radius of the `Mask` layer
maskBorderRadius?: number,
// Border radius of the `Mask` layer, in percentage (of min(width, height))
maskBorderRadiusInPercent?: number,
// Duration of the mask effect (alpha), in milliseconds
maskDuration?: number,
// Animating the shadow (on pressed/released) or not
shadowAniEnabled?: boolean,
disabled?: boolean,
} & MKTouchableProps
interface RippleState {
width: number,
height: number,
maskBorderRadius: number,
shadowOffsetY: number,
ripple: {
radii: number,
dia: number,
offset: {
top: number,
left: number,
},
},
}
//
// ##
// Reusable `Ripple` effect.
//
export default class Ripple extends Component {
// ##
static defaultProps: RippleProps = {
borderWidth: 0,
disabled: false,
maskBorderRadius: 2,
maskBorderRadiusInPercent: 0,
maskColor: 'rgba(255,255,255,0.15)',
maskDuration: 200,
maskEnabled: true,
rippleColor: 'rgba(255,255,255,0.2)',
rippleDuration: 200,
rippleLocation: 'tapLocation',
shadowAniEnabled: true,
};
private containerRef = createRef();
private maskRef = createRef();
private rippleRef = createRef();
private _animatedAlpha = new Animated.Value(0);
private _animatedRippleScale = new Animated.Value(0);
private _rippleAni?: Animated.CompositeAnimation;
private _pendingRippleAni?: () => void;
constructor(props: RippleProps) {
super(props);
// [Android] set initial size > 0 to avoid NPE
// at `ReactViewBackgroundDrawable.drawRoundedBackgroundWithBorders`
// @see https://github.com/facebook/react-native/issues/3069
this.state = {
height: 1,
width: 1,
maskBorderRadius: 0,
ripple: { radii: 0, dia: 0, offset: { top: 0, left: 0 } },
shadowOffsetY: 1,
};
}
measure(cb: MeasureOnSuccessCallback) {
return this.containerRef.current &&
UIManager.measure(findNodeHandle(this.containerRef.current), cb);
}
// Start the ripple effect
showRipple() {
this._animatedAlpha.setValue(1);
this._animatedRippleScale.setValue(0.3);
// scaling up the ripple layer
this._rippleAni = Animated.timing(this._animatedRippleScale, {
duration: this.props.rippleDuration || 200,
toValue: 1,
useNativeDriver: true,
});
// enlarge the shadow, if enabled
if (this.props.shadowAniEnabled) {
this.setState({ shadowOffsetY: 1.5 });
}
this._rippleAni.start(() => {
this._rippleAni = undefined;
// if any pending animation, do it
if (this._pendingRippleAni) {
this._pendingRippleAni();
}
});
}
// Stop the ripple effect
hideRipple() {
this._pendingRippleAni = () => {
// hide the ripple layer
Animated.timing(this._animatedAlpha, {
duration: this.props.maskDuration || 200,
toValue: 0,
useNativeDriver: true,
}).start();
// scale down the shadow
if (this.props.shadowAniEnabled) {
this.setState({ shadowOffsetY: 1 });
}
this._pendingRippleAni = undefined;
};
if (!this._rippleAni) {
// previous ripple animation is done, good to go
this._pendingRippleAni();
}
}
render() {
const shadowStyle: StyleProp = {};
if (this.props.shadowAniEnabled) {
shadowStyle.shadowOffset = {
height: this.state.shadowOffsetY,
width: 0,
};
}
return (
{this.props.children}
);
}
private _onLayout = (evt: LayoutChangeEvent) => {
this._onLayoutChange(evt.nativeEvent.layout);
this.props.onLayout && this.props.onLayout(evt);
};
private _onLayoutChange({ width, height }: LayoutRectangle) {
if (width === this.state.width && height === this.state.height) {
return;
}
this.setState({
...this._calcMaskLayer(width, height),
height,
width,
});
}
// update Mask layer's dimen
private _calcMaskLayer(width: number, height: number): { maskBorderRadius: number } {
const maskRadiiPercent = this.props.maskBorderRadiusInPercent;
let maskBorderRadius = this.props.maskBorderRadius || 0;
if (maskRadiiPercent) {
maskBorderRadius = Math.min(width, height) * maskRadiiPercent / 100;
}
return { maskBorderRadius };
}
// update Ripple layer's dimen
private _calcRippleLayer(x0: number, y0: number) {
const { width, height, maskBorderRadius } = this.state;
const maskRadiusPercent = this.props.maskBorderRadiusInPercent || 0;
let radii;
let hotSpotX = x0;
let hotSpotY = y0;
if (this.props.rippleLocation === 'center') {
hotSpotX = width / 2;
hotSpotY = height / 2;
}
const offsetX = Math.max(hotSpotX, (width - hotSpotX));
const offsetY = Math.max(hotSpotY, (height - hotSpotY));
// FIXME Workaround for Android not respect `overflow`
// @see https://github.com/facebook/react-native/issues/3198
if (Platform.OS === 'android'
&& this.props.rippleLocation === 'center'
&& this.props.maskEnabled && maskRadiusPercent > 0) {
// limit ripple to the bounds of mask
radii = maskBorderRadius;
} else {
radii = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
}
return {
ripple: {
dia: radii * 2,
offset: {
left: hotSpotX - radii,
top: hotSpotY - radii,
},
radii,
},
};
}
// Touch events handling
private _onTouchEvent = (evt: TouchEvent) => {
if (this.props.disabled) return;
switch (evt.type) {
case 'TOUCH_DOWN':
this._onPointerDown(evt);
break;
case 'TOUCH_UP':
case 'TOUCH_CANCEL':
this._onPointerUp();
break;
default:
break;
}
if (this.props.onTouch) {
this.props.onTouch(evt);
}
};
private _onPointerDown(evt: TouchEvent) {
this.setState({
...this._calcRippleLayer(evt.x, evt.y),
});
this.showRipple();
}
private _onPointerUp() {
this.hideRipple();
}
}