Repository: callstack/react-native-paper
Pull request #976 (2019-04-04 12:08:36)

fix: rewrite of menu component
### Motivation

Menu was broken on iOS, implementation was really complex and animations didn't use native driver.

### Test plan

![Example](https://user-images.githubusercontent.com/18584155/55554442-1320ea80-56e3-11e9-9c26-34f9fe3be4f8.gif)

callstack-bot: Hey @Trancever, thank you for your pull request 🤗. The documentation from this branch can be viewed [here](https://9461-71323749-gh.circle-artifacts.com/0/docs/index.html). Please remember to update Typescript types if you changed API.
cltsang: Thanks so much for the quick fix.
I'd like to make a [dropdown menu](https://material.io/design/components/menus.html#dropdown-menu) like the one in the material design doc. Normally the menu should be anchored to the overflow button right?
By default, the menu is rendered covering the anchor, like in your gif. So I added marginTop 40 to the menu component's style. However, the margin part does not respond to touch to dismiss the menu.
It seems [the absolute fill](https://github.com/callstack/react-native-paper/pull/976/files#diff-c46d8ddb9464108a35b17099c084ea07R307) under it does not receive the touch event. Maybe because the menu's margin is wrapped by [another view,](https://github.com/callstack/react-native-paper/pull/976/files#diff-c46d8ddb9464108a35b17099c084ea07R310) and it captures the touch event?

So what should I do to do it properly? Thanks.
zerobytes: I'm facing this too. How long so we get the merge?
mihaidaviddev: @Trancever If you have the time, #977 might be related. Thank you for your work fixing this bug!
Trancever: @mihaidaviddev I am aware of this issue and yes, this PR will fix it.
Trancever: @cltsang With this PR, passing padding in style will be supported.
cltsang: Thank you so much!

Patch:
diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js
index 3887327db..92a21bbb3 100644
--- a/src/components/Menu/Menu.js
+++ b/src/components/Menu/Menu.js
@@ -10,7 +10,7 @@ import {
   TouchableWithoutFeedback,
   I18nManager,
   BackHandler,
-  StatusBar,
+  Platform,
 } from 'react-native';
 import { withTheme } from '../../core/theming';
 import type { Theme } from '../../types';
@@ -48,13 +48,11 @@ type State = {
   menuLayout: { height: number, width: number },
   anchorLayout: { height: number, width: number },
   opacityAnimation: Animated.Value,
-  menuSizeAnimation: Animated.ValueXY,
-  menuState: 'hidden' | 'animating' | 'shown',
+  scaleAnimation: Animated.ValueXY,
 };
 
 // Minimum padding between the edge of the screen and the menu
 const SCREEN_INDENT = 8;
-
 // From https://material.io/design/motion/speed.html#duration
 const ANIMATION_DURATION = 250;
 // From the 'Standard easing' section of https://material.io/design/motion/speed.html#easing
@@ -72,7 +70,7 @@ const EASING = Easing.bezier(0.4, 0, 0.2, 1);
  * ```js
  * import * as React from 'react';
  * import { View } from 'react-native';
- * import { Button, Paragraph, Menu, Divider } from 'react-native-paper';
+ * import { Button, Paragraph, Menu, Divider, Provider } from 'react-native-paper';
  *
  * export default class MyComponent extends React.Component {
  *   state = {
@@ -85,20 +83,27 @@ const EASING = Easing.bezier(0.4, 0, 0.2, 1);
  *
  *   render() {
  *     return (
- *       <View>
- *         <Menu
- *           visible={this.state.visible}
- *           onDismiss={this._closeMenu}
- *           anchor={
- *             <Button onPress={this._openMenu}>Show menu</Button>
- *           }
- *         >
- *           <Menu.Item onPress={() => {}} title="Item 1" />
- *           <Menu.Item onPress={() => {}} title="Item 2" />
- *           <Divider />
- *           <Menu.Item onPress={() => {}} title="Item 3" />
- *         </Menu>
- *       </View>
+ *       <Provider>
+ *         <View
+ *           style={{
+ *             paddingTop: 50,
+ *             flexDirection: 'row',
+ *             justifyContent: 'center'
+ *           }}>
+ *           <Menu
+ *             visible={this.state.visible}
+ *             onDismiss={this._closeMenu}
+ *             anchor={
+ *               <Button onPress={this._openMenu}>Show menu</Button>
+ *             }
+ *           >
+ *             <Menu.Item onPress={() => {}} title="Item 1" />
+ *             <Menu.Item onPress={() => {}} title="Item 2" />
+ *             <Divider />
+ *             <Menu.Item onPress={() => {}} title="Item 3" />
+ *           </Menu>
+ *         </View>
+ *       </Provider>
  *     );
  *   }
  * }
@@ -109,13 +114,12 @@ class Menu extends React.Component<Props, State> {
   static Item = MenuItem;
 
   state = {
-    menuState: 'hidden',
     top: 0,
     left: 0,
     menuLayout: { width: 0, height: 0 },
     anchorLayout: { width: 0, height: 0 },
     opacityAnimation: new Animated.Value(0),
-    menuSizeAnimation: new Animated.ValueXY({ x: 0, y: 0 }),
+    scaleAnimation: new Animated.ValueXY({ x: 0, y: 0 }),
   };
 
   componentDidUpdate(prevProps) {
@@ -124,188 +128,285 @@ class Menu extends React.Component<Props, State> {
     }
   }
 
-  _container: ?View;
+  componentWillUnmount() {
+    BackHandler.removeEventListener('hardwareBackPress', this.props.onDismiss);
+  }
 
-  // Start menu animation
-  _onMenuLayout = e => {
-    if (this.state.menuState === 'animating') {
-      return;
+  _anchor: ?View;
+  _menu: ?View;
+
+  _measureMenuLayout = () =>
+    new Promise(resolve => {
+      if (this._menu) {
+        this._menu.measureInWindow((x, y, width, height) => {
+          resolve({ x, y, width, height });
+        });
+      }
+    });
+
+  _measureAnchorLayout = () =>
+    new Promise(resolve => {
+      if (this._anchor) {
+        this._anchor.measureInWindow((x, y, width, height) => {
+          resolve({ x, y, width, height });
+        });
+      }
+    });
+
+  _updateVisibility = () => {
+    if (this.props.visible) {
+      this._show();
+    } else {
+      this._hide();
     }
+  };
 
-    const { width, height } = e.nativeEvent.layout;
+  _show = async () => {
+    BackHandler.addEventListener('hardwareBackPress', this.props.onDismiss);
+
+    const [menuLayout, anchorLayout] = await Promise.all([
+      this._measureMenuLayout(),
+      this._measureAnchorLayout(),
+    ]);
+
+    // When visible is true for first render
+    // native views can be still not rendered and
+    // measureMenuLayout/measureAnchorLayout functions
+    // return wrong values e.g { x:0, y: 0, width: 0, height: 0 }
+    // so we have to wait until views are ready
+    // and rerun this function to show menu
+    if (
+      !menuLayout.width ||
+      !menuLayout.height ||
+      !anchorLayout.width ||
+      !anchorLayout.height
+    ) {
+      BackHandler.removeEventListener(
+        'hardwareBackPress',
+        this.props.onDismiss
+      );
+      setTimeout(() => {
+        this._show();
+      }, ANIMATION_DURATION);
+      return;
+    }
 
     this.setState(
       {
-        menuState: 'animating',
-        menuLayout: { width, height },
+        left: anchorLayout.x,
+        top: anchorLayout.y,
+        anchorLayout: {
+          height: anchorLayout.height,
+          width: anchorLayout.width,
+        },
+        menuLayout: {
+          width: menuLayout.width,
+          height: menuLayout.height,
+        },
       },
       () => {
         Animated.parallel([
-          Animated.timing(this.state.menuSizeAnimation, {
-            toValue: { x: width, y: height },
+          Animated.timing(this.state.scaleAnimation, {
+            toValue: { x: menuLayout.width, y: menuLayout.height },
             duration: ANIMATION_DURATION,
             easing: EASING,
+            useNativeDriver: true,
           }),
           Animated.timing(this.state.opacityAnimation, {
             toValue: 1,
             duration: ANIMATION_DURATION,
             easing: EASING,
+            useNativeDriver: true,
           }),
         ]).start();
       }
     );
   };
 
-  // Save anchor width and height for menu layout
-  _onAnchorLayout = e => {
-    const { width, height } = e.nativeEvent.layout;
-
-    this.setState({ anchorLayout: { width, height } });
-  };
-
-  _updateVisibility = () => {
-    if (this.props.visible) {
-      this._show();
-    } else {
-      this._hide();
-    }
-  };
-
-  _show = () => {
-    BackHandler.addEventListener('hardwareBackPress', this._hide);
-
-    if (this._container) {
-      this._container.measureInWindow((x, y) => {
-        const top = Math.max(SCREEN_INDENT, y) + StatusBar.currentHeight;
-        const left = Math.max(SCREEN_INDENT, x);
-
-        this.setState({ menuState: 'shown', top, left });
-      });
-    }
-  };
-
   _hide = () => {
-    BackHandler.removeEventListener('hardwareBackPress', this._hide);
+    BackHandler.removeEventListener('hardwareBackPress', this.props.onDismiss);
 
     Animated.timing(this.state.opacityAnimation, {
       toValue: 0,
       duration: ANIMATION_DURATION,
       easing: EASING,
-    }).start(() => {
-      if (this.props.visible && this.props.onDismiss) {
-        this.props.onDismiss();
-      }
-      if (this.props.visible) {
-        this._show();
-      } else {
-        this.setState({
-          menuState: 'hidden',
-          menuSizeAnimation: new Animated.ValueXY({ x: 0, y: 0 }),
-          opacityAnimation: new Animated.Value(0),
-        });
+      useNativeDriver: true,
+    }).start(finished => {
+      if (finished) {
+        this.state.scaleAnimation.setValue({ x: 0, y: 0 });
       }
     });
   };
 
   render() {
-    const { visible, anchor, style, children, theme } = this.props;
+    const { visible, anchor, style, children, theme, onDismiss } = this.props;
 
     const {
-      menuState,
       menuLayout,
       anchorLayout,
       opacityAnimation,
-      menuSizeAnimation,
+      scaleAnimation,
     } = this.state;
 
-    // Adjust position of menu
+    // I don't know why but on Android measure function is wrong by 24
+    const additionalVerticalValue = Platform.select({
+      android: 24,
+      default: 0,
+    });
+
     let { left, top } = this.state;
-    const transforms = [];
+
+    const scaleTransforms = [
+      {
+        scaleX: scaleAnimation.x.interpolate({
+          inputRange: [0, menuLayout.width],
+          outputRange: [0, 1],
+        }),
+      },
+      {
+        scaleY: scaleAnimation.y.interpolate({
+          inputRange: [0, menuLayout.height],
+          outputRange: [0, 1],
+        }),
+      },
+    ];
+
+    // We need to translate menu while animating scale to imitate transform origin for scale animation
+    const positionTransforms = [];
 
     const { width: screenWidth, height: screenHeight } = Dimensions.get(
       'screen'
     );
 
-    // Flip by X axis if menu hits right screen border
-    if (left > screenWidth - menuLayout.width - SCREEN_INDENT) {
-      transforms.push({
-        translateX: Animated.multiply(menuSizeAnimation.x, -1),
+    // Check if menu fits horizontally and if not align it to right.
+    if (left <= screenWidth - menuLayout.width - SCREEN_INDENT) {
+      positionTransforms.push({
+        translateX: scaleAnimation.x.interpolate({
+          inputRange: [0, menuLayout.width],
+          outputRange: [-(menuLayout.width / 2), 0],
+        }),
+      });
+
+      // Check if menu position has enough space from left side
+      if (left >= 0 && left < SCREEN_INDENT) {
+        left = SCREEN_INDENT;
+      }
+    } else {
+      positionTransforms.push({
+        translateX: scaleAnimation.x.interpolate({
+          inputRange: [0, menuLayout.width],
+          outputRange: [menuLayout.width / 2, 0],
+        }),
       });
 
-      left = Math.min(screenWidth - SCREEN_INDENT, left + anchorLayout.width);
+      left += anchorLayout.width - menuLayout.width;
+
+      const right = left + menuLayout.width;
+      // Check if menu position has enough space from right side
+      if (right <= screenWidth && right > screenWidth - SCREEN_INDENT) {
+        left = screenWidth - SCREEN_INDENT - menuLayout.width;
+      }
     }
 
-    // Flip by Y axis if menu hits bottom screen border
-    if (top > screenHeight - menuLayout.height - SCREEN_INDENT) {
-      transforms.push({
-        translateY: Animated.multiply(menuSizeAnimation.y, -1),
+    // Check if menu fits vertically and if not align it to bottom.
+    if (top <= screenHeight - menuLayout.height - SCREEN_INDENT) {
+      positionTransforms.push({
+        translateY: scaleAnimation.y.interpolate({
+          inputRange: [0, menuLayout.height],
+          outputRange: [-(menuLayout.height / 2), 0],
+        }),
       });
 
-      top = Math.min(screenHeight - SCREEN_INDENT, top + anchorLayout.height);
+      // Check if menu position has enough space from top side
+      if (top >= 0 && top < SCREEN_INDENT) {
+        top = SCREEN_INDENT;
+      }
+    } else {
+      positionTransforms.push({
+        translateY: scaleAnimation.y.interpolate({
+          inputRange: [0, menuLayout.height],
+          outputRange: [menuLayout.height / 2, 0],
+        }),
+      });
+
+      top += anchorLayout.height - menuLayout.height;
+
+      const bottom = top + menuLayout.height + additionalVerticalValue;
+      // Check if menu position has enough space from bottom side
+      if (bottom <= screenHeight && bottom > screenHeight - SCREEN_INDENT) {
+        top =
+          screenHeight -
+          SCREEN_INDENT -
+          menuLayout.height -
+          additionalVerticalValue;
+      }
     }
 
     const shadowMenuContainerStyle = {
       opacity: opacityAnimation,
-      transform: transforms,
+      transform: scaleTransforms,
       borderRadius: theme.roundness,
-      top,
-      ...(I18nManager.isRTL ? { right: left } : { left }),
     };
 
-    const animationStarted = menuState === 'animating';
-    const menuVisible = menuState === 'shown' || animationStarted || visible;
+    const positionStyle = {
+      top: top + additionalVerticalValue,
+      ...(I18nManager.isRTL ? { right: left } : { left }),
+    };
 
     return (
       <View
-        ref={c => {
-          this._container = c;
+        ref={ref => {
+          this._anchor = ref;
         }}
         collapsable={false}
-        onLayout={this._onAnchorLayout}
       >
         {anchor}
-        {menuVisible ? (
-          <Portal>
-            <TouchableWithoutFeedback onPress={this._hide}>
+        <Portal>
+          {visible ? (
+            <TouchableWithoutFeedback onPress={onDismiss}>
               <View style={StyleSheet.absoluteFill} />
             </TouchableWithoutFeedback>
-            <Surface
-              onLayout={this._onMenuLayout}
-              style={[
-                styles.shadowMenuContainer,
-                shadowMenuContainerStyle,
-                style,
-              ]}
-            >
-              <Animated.View
-                style={[
-                  styles.menuContainer,
-                  animationStarted && {
-                    width: menuSizeAnimation.x,
-                    height: menuSizeAnimation.y,
-                  },
-                ]}
+          ) : null}
+          <View
+            ref={ref => {
+              // This hack is needed to properly show menu
+              // when visible is `true` initially
+              // because in componentDidMount _menu ref is undefined
+              // because it's rendered in portal
+              if (!this._menu) {
+                this._menu = ref;
+                if (visible) {
+                  this._show();
+                }
+              }
+            }}
+            collapsable={false}
+            pointerEvents={visible ? 'auto' : 'none'}
+            style={[styles.wrapper, positionStyle, style]}
+          >
+            <Animated.View style={{ transform: positionTransforms }}>
+              <Surface
+                style={[styles.shadowMenuContainer, shadowMenuContainerStyle]}
               >
                 {children}
-              </Animated.View>
-            </Surface>
-          </Portal>
-        ) : null}
+              </Surface>
+            </Animated.View>
+          </View>
+        </Portal>
       </View>
     );
   }
 }
 
 const styles = StyleSheet.create({
-  shadowMenuContainer: {
+  wrapper: {
     position: 'absolute',
+  },
+  shadowMenuContainer: {
     opacity: 0,
-    paddingTop: 8,
+    paddingVertical: 8,
     elevation: 8,
   },
-  menuContainer: {
-    overflow: 'hidden',
-  },
 });
 
 export default withTheme(Menu);


Test patch:
diff --git a/src/components/__tests__/Menu.test.js b/src/components/__tests__/Menu.test.js
new file mode 100644
index 000000000..6591f8ce9
--- /dev/null
+++ b/src/components/__tests__/Menu.test.js
@@ -0,0 +1,40 @@
+/* @flow */
+
+import * as React from 'react';
+import renderer from 'react-test-renderer';
+import Menu from '../Menu/Menu';
+import Button from '../Button';
+
+it('renders visible menu', () => {
+  const tree = renderer
+    .create(
+      <Menu
+        visible
+        onDismiss={jest.fn()}
+        anchor={<Button mode="outlined">Open menu</Button>}
+      >
+        <Menu.Item onPress={jest.fn()} title="Undo" />
+        <Menu.Item onPress={jest.fn()} title="Redo" />
+      </Menu>
+    )
+    .toJSON();
+
+  expect(tree).toMatchSnapshot();
+});
+
+it('renders not visible menu', () => {
+  const tree = renderer
+    .create(
+      <Menu
+        visible={false}
+        onDismiss={jest.fn()}
+        anchor={<Button mode="outlined">Open menu</Button>}
+      >
+        <Menu.Item onPress={jest.fn()} title="Undo" />
+        <Menu.Item onPress={jest.fn()} title="Redo" />
+      </Menu>
+    )
+    .toJSON();
+
+  expect(tree).toMatchSnapshot();
+});
diff --git a/src/components/__tests__/__snapshots__/Menu.test.js.snap b/src/components/__tests__/__snapshots__/Menu.test.js.snap
new file mode 100644
index 000000000..57226cbcd
--- /dev/null
+++ b/src/components/__tests__/__snapshots__/Menu.test.js.snap
@@ -0,0 +1,189 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders not visible menu 1`] = `
+<View
+  collapsable={false}
+>
+  <View
+    style={
+      Object {
+        "backgroundColor": "transparent",
+        "borderColor": "rgba(0, 0, 0, 0.29)",
+        "borderRadius": 4,
+        "borderStyle": "solid",
+        "borderWidth": 0.5,
+        "elevation": 0,
+        "minWidth": 64,
+        "shadowColor": "#000000",
+        "shadowOffset": Object {
+          "height": 0,
+          "width": 0,
+        },
+        "shadowOpacity": 0.24,
+        "shadowRadius": 0,
+      }
+    }
+  >
+    <View
+      accessibilityRole="button"
+      accessible={true}
+      isTVSelectable={true}
+      onResponderGrant={[Function]}
+      onResponderMove={[Function]}
+      onResponderRelease={[Function]}
+      onResponderTerminate={[Function]}
+      onResponderTerminationRequest={[Function]}
+      onStartShouldSetResponder={[Function]}
+      style={
+        Array [
+          Object {
+            "overflow": "hidden",
+          },
+          Object {
+            "borderRadius": 4,
+          },
+        ]
+      }
+    >
+      <View
+        style={
+          Array [
+            Object {
+              "alignItems": "center",
+              "flexDirection": "row",
+              "justifyContent": "center",
+            },
+            undefined,
+          ]
+        }
+      >
+        <Text
+          numberOfLines={1}
+          style={
+            Array [
+              Object {
+                "color": "#000000",
+                "fontFamily": "Helvetica Neue",
+                "textAlign": "left",
+                "writingDirection": "ltr",
+              },
+              Array [
+                Object {
+                  "letterSpacing": 1,
+                  "marginHorizontal": 16,
+                  "marginVertical": 9,
+                  "textAlign": "center",
+                },
+                undefined,
+                Object {
+                  "color": "#6200ee",
+                  "fontFamily": "HelveticaNeue-Medium",
+                },
+                Object {
+                  "fontFamily": "HelveticaNeue-Medium",
+                },
+              ],
+            ]
+          }
+        >
+          OPEN MENU
+        </Text>
+      </View>
+    </View>
+  </View>
+</View>
+`;
+
+exports[`renders visible menu 1`] = `
+<View
+  collapsable={false}
+>
+  <View
+    style={
+      Object {
+        "backgroundColor": "transparent",
+        "borderColor": "rgba(0, 0, 0, 0.29)",
+        "borderRadius": 4,
+        "borderStyle": "solid",
+        "borderWidth": 0.5,
+        "elevation": 0,
+        "minWidth": 64,
+        "shadowColor": "#000000",
+        "shadowOffset": Object {
+          "height": 0,
+          "width": 0,
+        },
+        "shadowOpacity": 0.24,
+        "shadowRadius": 0,
+      }
+    }
+  >
+    <View
+      accessibilityRole="button"
+      accessible={true}
+      isTVSelectable={true}
+      onResponderGrant={[Function]}
+      onResponderMove={[Function]}
+      onResponderRelease={[Function]}
+      onResponderTerminate={[Function]}
+      onResponderTerminationRequest={[Function]}
+      onStartShouldSetResponder={[Function]}
+      style={
+        Array [
+          Object {
+            "overflow": "hidden",
+          },
+          Object {
+            "borderRadius": 4,
+          },
+        ]
+      }
+    >
+      <View
+        style={
+          Array [
+            Object {
+              "alignItems": "center",
+              "flexDirection": "row",
+              "justifyContent": "center",
+            },
+            undefined,
+          ]
+        }
+      >
+        <Text
+          numberOfLines={1}
+          style={
+            Array [
+              Object {
+                "color": "#000000",
+                "fontFamily": "Helvetica Neue",
+                "textAlign": "left",
+                "writingDirection": "ltr",
+              },
+              Array [
+                Object {
+                  "letterSpacing": 1,
+                  "marginHorizontal": 16,
+                  "marginVertical": 9,
+                  "textAlign": "center",
+                },
+                undefined,
+                Object {
+                  "color": "#6200ee",
+                  "fontFamily": "HelveticaNeue-Medium",
+                },
+                Object {
+                  "fontFamily": "HelveticaNeue-Medium",
+                },
+              ],
+            ]
+          }
+        >
+          OPEN MENU
+        </Text>
+      </View>
+    </View>
+  </View>
+</View>
+`;