import React, {useCallback, useMemo, useRef, useState} from 'react'; import { Animated, Easing, GestureResponderEvent, I18nManager, LayoutChangeEvent, StyleSheet, TouchableWithoutFeedback, TouchableWithoutFeedbackProps, View, } from 'react-native'; interface Props { rippleColor?: string; rippleCentered?: boolean; rippleOpacity?: number; rippleFades?: boolean; rippleExpandDuration?: number; rippleFadeDuration?: number; rippleSize?: number; rippleContainerBorderRadius?: number; children?: React.ReactNode; } interface AnimatedRipple { id: number; locationX: number; locationY: number; radius: number; expandAnimatedValue: Animated.Value; fadeAnimatedValue: Animated.Value; timestamp: number; } export type RippleProps = Props & TouchableWithoutFeedbackProps; export const Ripple = ({ children, disabled, rippleColor = 'rgb(0, 0, 0)', rippleCentered = false, rippleOpacity = 0.3, rippleExpandDuration = 500, rippleFadeDuration = 200, rippleContainerBorderRadius = 0, rippleSize = 0, ...touchableWithoutFeedbackProps }: RippleProps) => { const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const uuid = useRef(0); const [ripples, setRipples] = useState<AnimatedRipple[]>([]); const [fadings, setFadings] = useState<number[]>([]); const startFade = useCallback( (ripple: AnimatedRipple, duration: number) => { if (fadings.indexOf(ripple.id) >= 0) { return; } setFadings([...fadings, ripple.id]); Animated.timing(ripple.fadeAnimatedValue, { toValue: 1, easing: Easing.out(Easing.ease), duration, useNativeDriver: true, }).start(() => { setRipples(ripples.filter(item => item !== ripple)); }); }, [fadings, ripples], ); const startExpand = useCallback( (event: GestureResponderEvent) => { if (!width || !height) { return; } const timestamp = Date.now(); if (ripples.length > 0 && timestamp < ripples[ripples.length - 1].timestamp + DEBOUNCE) { return; } const w2 = 0.5 * width; const h2 = 0.5 * height; const {locationX, locationY} = rippleCentered ? {locationX: w2, locationY: h2} : event.nativeEvent; const offsetX = Math.abs(w2 - locationX); const offsetY = Math.abs(h2 - locationY); const radius = rippleSize > 0 ? 0.5 * rippleSize : Math.sqrt((w2 + offsetX) ** 2 + (h2 + offsetY) ** 2); const id = uuid.current; uuid.current += 1; const ripple: AnimatedRipple = { id, locationX, locationY, radius, timestamp, expandAnimatedValue: new Animated.Value(0), fadeAnimatedValue: new Animated.Value(0), }; Animated.timing(ripple.expandAnimatedValue, { toValue: 1, easing: Easing.out(Easing.ease), duration: rippleExpandDuration, useNativeDriver: true, }).start(); setRipples(ripples.concat(ripple)); }, [height, rippleCentered, rippleExpandDuration, rippleSize, ripples, width], ); const onLayout = useCallback( (event: LayoutChangeEvent) => { const { nativeEvent: { layout: {height, width}, }, } = event; setWidth(width); setHeight(height); touchableWithoutFeedbackProps.onLayout?.(event); }, [touchableWithoutFeedbackProps.onLayout], ); const onPressIn = useCallback( (event: GestureResponderEvent) => { startExpand(event); touchableWithoutFeedbackProps.onPressIn?.(event); }, [startExpand, touchableWithoutFeedbackProps.onPressIn], ); const onPressOut = useCallback( (event: GestureResponderEvent) => { ripples.forEach(ripple => startFade(ripple, rippleFadeDuration + rippleExpandDuration / 2)); touchableWithoutFeedbackProps.onPressOut?.(event); }, [rippleExpandDuration, rippleFadeDuration, ripples, startFade, touchableWithoutFeedbackProps.onPressOut], ); const onPress = useCallback( (event: GestureResponderEvent) => { requestAnimationFrame(() => { touchableWithoutFeedbackProps.onPress?.(event); }); }, [touchableWithoutFeedbackProps.onPress], ); const renderRipple = useCallback( ({locationX, locationY, radius, id, expandAnimatedValue, fadeAnimatedValue}: AnimatedRipple) => { const rippleStyle = { top: locationY - RADIUS, [I18nManager.isRTL ? 'right' : 'left']: locationX - RADIUS, backgroundColor: rippleColor, transform: [ { scale: expandAnimatedValue.interpolate({ inputRange: [0, 1], outputRange: [0.5 / RADIUS, radius / RADIUS], }), }, ], opacity: fadeAnimatedValue.interpolate({ inputRange: [0, 1], outputRange: [rippleOpacity, 0], }), }; return <Animated.View style={[styles.ripple, rippleStyle]} key={id} />; }, [rippleColor, rippleOpacity], ); const style = useMemo( () => [ styles.container, { borderRadius: rippleContainerBorderRadius, }, ], [rippleContainerBorderRadius], ); return ( <TouchableWithoutFeedback {...touchableWithoutFeedbackProps} disabled={disabled} onPressIn={onPressIn} onPressOut={onPressOut} onPress={onPress} onLayout={onLayout} > <Animated.View pointerEvents="box-only"> <View style={style}>{ripples.map(renderRipple)}</View> {children} </Animated.View> </TouchableWithoutFeedback> ); }; const RADIUS = 8; const DEBOUNCE = 200; const styles = StyleSheet.create({ container: { ...StyleSheet.absoluteFillObject, backgroundColor: 'transparent', overflow: 'hidden', }, ripple: { width: RADIUS * 2, height: RADIUS * 2, borderRadius: RADIUS, overflow: 'hidden', position: 'absolute', }, });