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 ( - * - * Show menu - * } - * > - * {}} title="Item 1" /> - * {}} title="Item 2" /> - * - * {}} title="Item 3" /> - * - * + * + * + * Show menu + * } + * > + * {}} title="Item 1" /> + * {}} title="Item 2" /> + * + * {}} title="Item 3" /> + * + * + * * ); * } * } @@ -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( + Open menu} + > + + + + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders not visible menu', () => { + const tree = renderer + .create( + Open 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 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 + + + + + +`;