diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js
index 3887327db6..92a21bbb38 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 (
- *
- *
- *
+ *
+ *
+ *
+ *
+ *
* );
* }
* }
@@ -109,13 +114,12 @@ class Menu extends React.Component {
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 {
}
}
- _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 (
{
- this._container = c;
+ ref={ref => {
+ this._anchor = ref;
}}
collapsable={false}
- onLayout={this._onAnchorLayout}
>
{anchor}
- {menuVisible ? (
-
-
+
+ {visible ? (
+
-
- {
+ // 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]}
+ >
+
+
{children}
-
-
-
- ) : null}
+
+
+
+
);
}
}
const styles = StyleSheet.create({
- shadowMenuContainer: {
+ wrapper: {
position: 'absolute',
+ },
+ shadowMenuContainer: {
opacity: 0,
- paddingTop: 8,
+ paddingVertical: 8,
elevation: 8,
},
- menuContainer: {
- overflow: 'hidden',
- },
});
export default withTheme(Menu);
diff --git a/src/components/__tests__/Menu.test.js b/src/components/__tests__/Menu.test.js
new file mode 100644
index 0000000000..6591f8ce95
--- /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(
+
+ )
+ .toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
+it('renders not visible menu', () => {
+ const tree = renderer
+ .create(
+
+ )
+ .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 0000000000..57226cbcd2
--- /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`] = `
+
+
+
+
+
+ OPEN MENU
+
+
+
+
+
+`;
+
+exports[`renders visible menu 1`] = `
+
+
+
+
+
+ OPEN MENU
+
+
+
+
+
+`;