Repository: callstack/react-native-paper
Pull request #793 (2019-01-31 20:10:31)

feat: add ripple effect on the web
![kapture 2019-01-31 at 21 00 58](https://user-images.githubusercontent.com/1174278/52082231-8b144c80-259c-11e9-8753-2df5f98e9ef4.gif)


callstack-bot: Hey @satya164, thank you for your pull request 🤗. The documentation from this branch can be viewed [here](https://7539-71323749-gh.circle-artifacts.com/0/docs/index.html). Please remember to update Typescript types if you changed API.
jaulz: Looks very good! 😊 

Patch:
diff --git a/example/src/RootNavigator.native.js b/example/src/RootNavigator.native.js
index bf486b4d7..82da01afe 100644
--- a/example/src/RootNavigator.native.js
+++ b/example/src/RootNavigator.native.js
@@ -37,6 +37,9 @@ export default createStackNavigator(
   },
   {
     navigationOptions: ({ navigation }) => ({
+      gestureResponseDistance: {
+        horizontal: 45,
+      },
       header: (
         <Appbar.Header>
           <Appbar.Action icon="menu" onPress={() => navigation.openDrawer()} />
diff --git a/src/components/BottomNavigation.js b/src/components/BottomNavigation.js
index 6bf55ac33..cc907b890 100644
--- a/src/components/BottomNavigation.js
+++ b/src/components/BottomNavigation.js
@@ -252,7 +252,7 @@ const FAR_FAR_AWAY = 9999;
 
 const Touchable = TouchableRipple.supported
   ? TouchableRipple
-  : ({ style, children, borderless, rippleColor, ...rest }) => (
+  : ({ style, children, borderless, centered, rippleColor, ...rest }) => (
       <TouchableWithoutFeedback {...rest}>
         <View style={style}>{children}</View>
       </TouchableWithoutFeedback>
@@ -749,6 +749,7 @@ class BottomNavigation<T: *> extends React.Component<Props<T>, State> {
                   <Touchable
                     key={route.key}
                     borderless
+                    centered
                     rippleColor={touchColor}
                     onPress={() => this._handleTabPress(index)}
                     testID={getTestID({ route })}
diff --git a/src/components/IconButton.js b/src/components/IconButton.js
index 37900d1b1..12f9739ab 100644
--- a/src/components/IconButton.js
+++ b/src/components/IconButton.js
@@ -101,6 +101,7 @@ const IconButton = ({
   return (
     <TouchableRipple
       borderless
+      centered
       onPress={onPress}
       rippleColor={rippleColor}
       style={[styles.container, disabled && styles.disabled, style]}
@@ -141,6 +142,7 @@ const styles = StyleSheet.create({
         margin: 6,
         alignItems: 'center',
         justifyContent: 'center',
+        overflow: 'visible',
       },
   disabled: {
     opacity: 0.32,
diff --git a/src/components/TouchableRipple/index.js b/src/components/TouchableRipple/index.js
new file mode 100644
index 000000000..3dc0e3cc2
--- /dev/null
+++ b/src/components/TouchableRipple/index.js
@@ -0,0 +1,256 @@
+/* @flow */
+
+import * as React from 'react';
+import { TouchableWithoutFeedback, View, StyleSheet } from 'react-native';
+import color from 'color';
+import { withTheme } from '../../core/theming';
+import type { Theme } from '../../types';
+
+type Props = React.ElementConfig<typeof TouchableWithoutFeedback> & {|
+  /**
+   * Whether to render the ripple outside the view bounds.
+   */
+  borderless?: boolean,
+  /**
+   * Type of background drawabale to display the feedback (Android).
+   * https://facebook.github.io/react-native/docs/touchablenativefeedback.html#background
+   */
+  background?: Object,
+  /**
+   * Whether to start the ripple at the center (Web).
+   */
+  centered?: boolean,
+  /**
+   * Whether to prevent interaction with the touchable.
+   */
+  disabled?: boolean,
+  /**
+   * Function to execute on press. If not set, will cause the touchable to be disabled.
+   */
+  onPress?: ?() => mixed,
+  /**
+   * Color of the ripple effect (Android >= 5.0 and Web).
+   */
+  rippleColor?: string,
+  /**
+   * Color of the underlay for the highlight effect (Android < 5.0 and iOS).
+   */
+  underlayColor?: string,
+  /**
+   * Content of the `TouchableRipple`.
+   */
+  children: React.Node,
+  style?: any,
+  /**
+   * @optional
+   */
+  theme: Theme,
+|};
+
+/**
+ * A wrapper for views that should respond to touches.
+ * Provides a material "ink ripple" interaction effect for supported platforms (>= Android Lollipop).
+ * On unsupported platforms, it falls back to a highlight effect.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { View } from 'react-native';
+ * import { Text, TouchableRipple } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ *   <TouchableRipple
+ *     onPress={() => console.log('Pressed')}
+ *     rippleColor="rgba(0, 0, 0, .32)"
+ *   >
+ *     <Text>Press me</Text>
+ *   </TouchableRipple>
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+class TouchableRipple extends React.Component<Props, void> {
+  static defaultProps = {
+    borderless: false,
+  };
+
+  /**
+   * Whether ripple effect is supported.
+   */
+  static supported = true;
+
+  _handlePressIn = e => {
+    const { centered, rippleColor, onPressIn, theme } = this.props;
+
+    onPressIn && onPressIn(e);
+
+    const { dark, colors } = theme;
+    const calculatedRippleColor =
+      rippleColor ||
+      color(colors.text)
+        .alpha(dark ? 0.32 : 0.2)
+        .rgb()
+        .string();
+
+    const button = e.currentTarget;
+    const style = window.getComputedStyle(button);
+    const dimensions = button.getBoundingClientRect();
+
+    let touchX;
+    let touchY;
+
+    if (centered) {
+      touchX = dimensions.width / 2;
+      touchY = dimensions.height / 2;
+    } else {
+      const startX = e.nativeEvent.touches
+        ? e.nativeEvent.touches[0].pageX
+        : e.pageX;
+      const startY = e.nativeEvent.touches
+        ? e.nativeEvent.touches[0].pageY
+        : e.pageY;
+
+      touchX = startX - dimensions.left;
+      touchY = startY - dimensions.top;
+    }
+
+    // Get the size of the button to determine how big the ripple should be
+    const size = centered
+      ? // If ripple is always centered, we don't need to make it too big
+        Math.min(dimensions.width, dimensions.height) * 1.25
+      : // Otherwise make it twice as big so clicking on one end spreads ripple to other
+        Math.max(dimensions.width, dimensions.height) * 2;
+
+    // Create a container for our ripple effect so we don't need to change the parent's style
+    const container = document.createElement('span');
+
+    container.setAttribute('data-paper-ripple', '');
+
+    Object.assign(container.style, {
+      position: 'absolute',
+      pointerEvents: 'none',
+      top: '0',
+      left: '0',
+      right: '0',
+      bottom: '0',
+      borderTopLeftRadius: style.borderTopLeftRadius,
+      borderTopRightRadius: style.borderTopRightRadius,
+      borderBottomRightRadius: style.borderBottomRightRadius,
+      borderBottomLeftRadius: style.borderBottomLeftRadius,
+      overflow: centered ? 'visible' : 'hidden',
+    });
+
+    // Create span to show the ripple effect
+    const ripple = document.createElement('span');
+
+    Object.assign(ripple.style, {
+      position: 'absolute',
+      pointerEvents: 'none',
+      backgroundColor: calculatedRippleColor,
+      borderRadius: '50%',
+
+      /* Transition configuration */
+      transitionProperty: 'transform opacity',
+      transitionDuration: `${Math.min(size * 1.5, 350)}ms`,
+      transitionTimingFunction: 'linear',
+      transformOrigin: 'center',
+
+      /* We'll animate these properties */
+      transform: 'translate3d(-50%, -50%, 0) scale3d(0.1, 0.1, 0.1)',
+      opacity: '0.5',
+
+      // Position the ripple where cursor was
+      left: `${touchX}px`,
+      top: `${touchY}px`,
+      width: `${size}px`,
+      height: `${size}px`,
+    });
+
+    // Finally, append it to DOM
+    container.appendChild(ripple);
+    button.appendChild(container);
+
+    // rAF runs in the same frame as the event handler
+    // Use double rAF to ensure the transition class is added in next frame
+    // This will make sure that the transition animation is triggered
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        Object.assign(ripple.style, {
+          transform: 'translate3d(-50%, -50%, 0) scale3d(1, 1, 1)',
+          opacity: '1',
+        });
+      });
+    });
+  };
+
+  _handlePressOut = e => {
+    this.props.onPressOut && this.props.onPressOut(e);
+
+    const containers = e.currentTarget.querySelectorAll('[data-paper-ripple]');
+
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        containers.forEach(container => {
+          const ripple = container.firstChild;
+
+          Object.assign(ripple.style, {
+            transitionDuration: '250ms',
+            opacity: 0,
+          });
+
+          // Finally remove the span after the transition
+          setTimeout(() => {
+            const { parentNode } = container;
+
+            if (parentNode) {
+              parentNode.removeChild(container);
+            }
+          }, 500);
+        });
+      });
+    });
+  };
+
+  render() {
+    const {
+      style,
+      background,
+      borderless,
+      disabled: disabledProp,
+      rippleColor,
+      underlayColor,
+      children,
+      theme,
+      ...rest
+    } = this.props;
+
+    const disabled = disabledProp || !this.props.onPress;
+
+    return (
+      <TouchableWithoutFeedback
+        {...rest}
+        onPressIn={this._handlePressIn}
+        onPressOut={this._handlePressOut}
+        disabled={disabled}
+      >
+        <View
+          style={[styles.touchable, borderless && styles.borderless, style]}
+        >
+          {React.Children.only(children)}
+        </View>
+      </TouchableWithoutFeedback>
+    );
+  }
+}
+
+const styles = StyleSheet.create({
+  touchable: {
+    position: 'relative',
+  },
+  borderless: {
+    overflow: 'hidden',
+  },
+});
+
+export default withTheme(TouchableRipple);
diff --git a/src/components/TouchableRipple.js b/src/components/TouchableRipple/index.native.js
similarity index 64%
rename from src/components/TouchableRipple.js
rename to src/components/TouchableRipple/index.native.js
index 9789aac71..47540e482 100644
--- a/src/components/TouchableRipple.js
+++ b/src/components/TouchableRipple/index.native.js
@@ -9,80 +9,29 @@ import {
   View,
 } from 'react-native';
 import color from 'color';
-import { withTheme } from '../core/theming';
-import type { Theme } from '../types';
+import { withTheme } from '../../core/theming';
+import type { Theme } from '../../types';
 
 const ANDROID_VERSION_LOLLIPOP = 21;
 const ANDROID_VERSION_PIE = 28;
 
 type Props = React.ElementConfig<typeof TouchableWithoutFeedback> & {|
-  /**
-   * Whether to render the ripple outside the view bounds.
-   */
   borderless?: boolean,
-  /**
-   * Type of background drawabale to display the feedback.
-   * https://facebook.github.io/react-native/docs/touchablenativefeedback.html#background
-   */
   background?: Object,
-  /**
-   * Whether to prevent interaction with the touchable.
-   */
   disabled?: boolean,
-  /**
-   * Function to execute on press. If not set, will cause the touchable to be disabled.
-   */
-  onPress?: ?Function,
-  /**
-   * Color of the ripple effect.
-   */
+  onPress?: ?() => mixed,
   rippleColor?: string,
-  /**
-   * Color of the underlay for the highlight effect.
-   */
   underlayColor?: string,
-  /**
-   * Content of the `TouchableRipple`.
-   */
   children: React.Node,
   style?: any,
-  /**
-   * @optional
-   */
   theme: Theme,
 |};
 
-/**
- * A wrapper for views that should respond to touches.
- * Provides a material "ink ripple" interaction effect for supported platforms (>= Android Lollipop).
- * On unsupported platforms, it falls back to a highlight effect.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { View } from 'react-native';
- * import { Text, TouchableRipple } from 'react-native-paper';
- *
- * const MyComponent = () => (
- *   <TouchableRipple
- *     onPress={() => console.log('Pressed')}
- *     rippleColor="rgba(0, 0, 0, .32)"
- *   >
- *     <Text>Press me</Text>
- *   </TouchableRipple>
- * );
- *
- * export default MyComponent;
- * ```
- */
 class TouchableRipple extends React.Component<Props, void> {
   static defaultProps = {
     borderless: false,
   };
 
-  /**
-   * Whether ripple effect is supported.
-   */
   static supported =
     Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_LOLLIPOP;
 


Test patch:
diff --git a/src/components/__tests__/__snapshots__/DataTable.test.js.snap b/src/components/__tests__/__snapshots__/DataTable.test.js.snap
index 713203802..8244955e7 100644
--- a/src/components/__tests__/__snapshots__/DataTable.test.js.snap
+++ b/src/components/__tests__/__snapshots__/DataTable.test.js.snap
@@ -228,6 +228,7 @@ exports[`renders data table pagination 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           false,
@@ -305,6 +306,7 @@ exports[`renders data table pagination 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           false,
@@ -423,6 +425,7 @@ exports[`renders data table pagination with label 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           false,
@@ -500,6 +503,7 @@ exports[`renders data table pagination with label 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           false,
diff --git a/src/components/__tests__/__snapshots__/IconButton.test.js.snap b/src/components/__tests__/__snapshots__/IconButton.test.js.snap
index 86b78bbd7..1b891e962 100644
--- a/src/components/__tests__/__snapshots__/IconButton.test.js.snap
+++ b/src/components/__tests__/__snapshots__/IconButton.test.js.snap
@@ -36,6 +36,7 @@ exports[`renders disabled icon button 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         Object {
@@ -118,6 +119,7 @@ exports[`renders icon button by default 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         undefined,
@@ -198,6 +200,7 @@ exports[`renders icon button with color 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         undefined,
@@ -278,6 +281,7 @@ exports[`renders icon button with size 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         undefined,
@@ -358,6 +362,7 @@ exports[`renders icon change animated 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         undefined,
diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.js.snap b/src/components/__tests__/__snapshots__/Searchbar.test.js.snap
index 35ac802c3..fd016c3a2 100644
--- a/src/components/__tests__/__snapshots__/Searchbar.test.js.snap
+++ b/src/components/__tests__/__snapshots__/Searchbar.test.js.snap
@@ -49,6 +49,7 @@ exports[`renders with placeholder 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           undefined,
@@ -158,6 +159,7 @@ exports[`renders with placeholder 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           Object {
@@ -259,6 +261,7 @@ exports[`renders with text 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           undefined,
@@ -364,6 +367,7 @@ exports[`renders with text 1`] = `
             "height": 36,
             "justifyContent": "center",
             "margin": 6,
+            "overflow": "visible",
             "width": 36,
           },
           false,
diff --git a/src/components/__tests__/__snapshots__/ToggleButton.test.js.snap b/src/components/__tests__/__snapshots__/ToggleButton.test.js.snap
index 0eaeeb510..1caa86d55 100644
--- a/src/components/__tests__/__snapshots__/ToggleButton.test.js.snap
+++ b/src/components/__tests__/__snapshots__/ToggleButton.test.js.snap
@@ -34,6 +34,7 @@ exports[`renders disabled toggle button 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         Object {
@@ -126,6 +127,7 @@ exports[`renders toggle button 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         undefined,
@@ -221,6 +223,7 @@ exports[`renders unchecked toggle button 1`] = `
           "height": 36,
           "justifyContent": "center",
           "margin": 6,
+          "overflow": "visible",
           "width": 36,
         },
         Object {