diff --git a/__tests__/renderers/Popover-test.js b/__tests__/renderers/Popover-test.js new file mode 100644 index 0000000..47f0b98 --- /dev/null +++ b/__tests__/renderers/Popover-test.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { View, Animated, Text } from 'react-native'; +import { render } from '../helpers'; + +jest.dontMock('../../src/renderers/Popover'); +const { default: Popover } = require('../../src/renderers/Popover'); + +describe('NotAnimatedContextMenu', () => { + + const defaultLayouts = { + windowLayout: { width: 400, height: 600 }, + triggerLayout: { width: 50, height: 50, x: 10, y: 10 }, + optionsLayout: { width: 200, height: 100 }, + }; + + describe('renderer', () => { + it('should render component', () => { + const { output } = render( + + Some text + Other text + + ); + expect(output.type).toEqual(Animated.View); + const anchor = output.props.children[0] + expect(anchor.type).toEqual(View); + const content = output.props.children[1] + expect(content.type).toEqual(View); + expect(content.props.children).toEqual([ + Some text, + Other text + ]); + }); + }); + +}); diff --git a/doc/api.md b/doc/api.md index a442549..3c2dfa8 100644 --- a/doc/api.md +++ b/doc/api.md @@ -194,6 +194,7 @@ The `renderers` module provides following renderers * `ContextMenu` (default) - opens (animated) context menu over the trigger position. The `ContextMenu.computePosition` exports function for position calculation in case you would like to implement your own renderer (without special position calculation). * `NotAnimatedContextMenu` - same as ContextMenu but without any animation. * `SlideInMenu` - slides in the menu from the bottom of the screen. +* `Popover` - display menu as a popover. It is possible to extend menu and use custom renderer (see implementation of existing renderers or [extension guide](extensions.md)). diff --git a/examples/Demo.js b/examples/Demo.js index 1b3b05a..fb05945 100644 --- a/examples/Demo.js +++ b/examples/Demo.js @@ -15,6 +15,7 @@ import TouchableExample from './TouchableExample'; import MenuMethodsExample from './MenuMethodsExample'; import CloseOnBackExample from './CloseOnBackExample'; import FlatListExample from './FlatListExample'; +import PopoverExample from './PopoverExample'; const demos = [ { Component: BasicExample, name: 'Basic example' }, @@ -30,6 +31,7 @@ const demos = [ { Component: NavigatorExample, name: 'Example with react-native-router-flux' }, { Component: CloseOnBackExample, name: 'Close on back button press example' }, { Component: FlatListExample, name: 'Using FlatList' }, + { Component: PopoverExample, name: 'Popover renderer' }, ]; // show debug messages for demos. diff --git a/examples/PopoverExample.js b/examples/PopoverExample.js new file mode 100644 index 0000000..231e551 --- /dev/null +++ b/examples/PopoverExample.js @@ -0,0 +1,73 @@ +import { + Menu, + MenuContext, + MenuOptions, + MenuTrigger, + renderers, +} from 'react-native-popup-menu'; +import { Text, View, StyleSheet } from 'react-native'; +import React from 'react'; + +const { Popover } = renderers + +const MyPopover = () => ( + + + {'\u263A'} + + + Hello world! + + +) + +const Row = () => ( + + + + + + + + +) + +const PopoverExample = () => ( + + + + + + + + + + +); + +const styles = StyleSheet.create({ + container: { + padding: 10, + flexDirection: 'column', + justifyContent: 'space-between', + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + backdrop: { + backgroundColor: 'black', + opacity: 0.3, + }, + menuOptions: { + padding: 50, + }, + menuTrigger: { + padding: 5, + }, + triggerText: { + fontSize: 20, + }, +}) + +export default PopoverExample; diff --git a/src/index.js b/src/index.js index 043d7fc..f962b61 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import MenuTrigger from './MenuTrigger'; import ContextMenu from './renderers/ContextMenu'; import NotAnimatedContextMenu from './renderers/NotAnimatedContextMenu'; import SlideInMenu from './renderers/SlideInMenu'; -const renderers = { ContextMenu, SlideInMenu, NotAnimatedContextMenu }; +import Popover from './renderers/Popover'; +const renderers = { ContextMenu, SlideInMenu, NotAnimatedContextMenu, Popover }; export { Menu as default, Menu, MenuContext, MenuOption, MenuOptions, MenuTrigger, renderers }; diff --git a/src/renderers/Popover.js b/src/renderers/Popover.js new file mode 100644 index 0000000..3b91d07 --- /dev/null +++ b/src/renderers/Popover.js @@ -0,0 +1,266 @@ +import { Animated, Easing, StyleSheet, View } from 'react-native'; +import React from 'react'; + +import { OPEN_ANIM_DURATION, CLOSE_ANIM_DURATION } from '../constants.js'; + +const popoverPadding = 7; +const anchorSize = 15; +const anchorHyp = Math.sqrt(anchorSize*anchorSize + anchorSize*anchorSize); +const anchorOffset = (anchorHyp + anchorSize) / 2 - popoverPadding; + +const POSITIVE_DIRECTION = 1; +const NEGATIVE_DIRECTION = -1; + +/** + * Computes position properties of popover when trying to align it to the triger side. + * It consideres window boundaries. + * Returns object with keys: + * - position: Absolute position - top/left, + * - direction: Positive if position is above/left, negative if position is below/right the trigger + */ +function axisSidePositionProperties({ oDim, wDim, tPos, tDim }) { + // if options are bigger than window dimension, then render at 0 + if (oDim > wDim) { + return { position: 0, direction: POSITIVE_DIRECTION }; + } + // render above trigger + if (tPos - oDim >= 0) { + return { position: tPos - oDim, direction: POSITIVE_DIRECTION }; + } + // render under trigger + if (tPos + tDim + oDim <= wDim) { + return { position: tPos + tDim, direction: NEGATIVE_DIRECTION }; + } + // compute center position + let pos = tPos + (tDim / 2) - (oDim / 2); + // check top boundary + if (pos < 0) { + return { position: 0, direction: NEGATIVE_DIRECTION }; + } + // check bottom boundary + if (pos + oDim > wDim) { + return { position: wDim - oDim, direction: POSITIVE_DIRECTION }; + } + // if everything ok, render in center position + return { position: pos, direction: POSITIVE_DIRECTION }; +} + +// computes offsets (off screen overlap) of popover when trying to align it to the center +function centeringProperties({ oDim, wDim, tPos, tDim }) { + const center = Math.round(tPos + (tDim / 2)); + const leftOffset = (oDim / 2) - center; + const rightOffset = center + (oDim / 2) - wDim; + return { center, leftOffset, rightOffset }; +} + +/** + * Computes position and offset of popover when trying to align it to the triger center. + * It consideres window boundaries. + * Returns object with keys: + * - position: Absolute position - top/left, + * - offset: window overlapping size if window boundaries were not considered + */ +function axisCenteredPositionProperties(options) { + const { oDim, wDim } = options; + const { center, leftOffset, rightOffset } = centeringProperties(options); + if (leftOffset > 0 || rightOffset > 0) { + // right/bottom position is better + if (leftOffset < rightOffset) { + return { offset: rightOffset, position: wDim - oDim }; + } + // left/top position is better + if (rightOffset < leftOffset) { + return { offset: -leftOffset, position: 0 }; + } + } + // centered position + return { offset: 0, position: center - oDim / 2 }; +} + +// picks max offset for popover +function maxCenterOffset(options) { + const { leftOffset, rightOffset } = centeringProperties(options); + return Math.max(0, leftOffset, rightOffset); +} + +/** + * Computes properties needed for drawing popover. + * Returns object with keys: + * - position: { top: Number, left: Number } - popover absolute position + * - placement: top|left|top|bottom - position to the trigger + * - offset: value by which must be anchor shifted + */ +export function computeProperties ({ windowLayout, triggerLayout, optionsLayout }) { + const { x: wX, y: wY, width: wWidth, height: wHeight } = windowLayout; + const { x: tX, y: tY, height: tHeight, width: tWidth } = triggerLayout; + const { height: oHeight, width: oWidth } = optionsLayout; + const hOptions = { + oDim: oHeight + popoverPadding * 2, + wDim: wHeight, + tPos: tY - wY, + tDim: tHeight, + } + const vOptions = { + oDim: oWidth + popoverPadding * 2, + wDim: wWidth, + tPos: tX - wX, + tDim: tWidth, + } + const vCenterOffset = maxCenterOffset(vOptions); + const hCenterOffset = maxCenterOffset(hOptions); + + const result = {}; + // prefer vertical centering + if (vCenterOffset <= hCenterOffset) { + const { position: left, offset } = axisCenteredPositionProperties(vOptions); + const { position: top, direction } = axisSidePositionProperties(hOptions); + result.position = { top, left } + result.placement = direction > 0 ? 'bottom' : 'top'; + result.offset = offset; + if (result.placement === 'top') { + // substract anchor placeholder from the beginning + result.position.top -= anchorSize; + } + } else { + const { position: top, offset } = axisCenteredPositionProperties(hOptions); + const { position: left, direction } = axisSidePositionProperties(vOptions); + result.position = { top, left }; + result.placement = direction > 0 ? 'right' : 'left'; + result.offset = offset; + if (result.placement === 'left') { + // substract anchor placeholder from the beginning + result.position.left -= anchorSize; + } + } + return result; +} + +export default class Popover extends React.Component { + + constructor(props) { + super(props); + this.state = { + scaleAnim: new Animated.Value(0.1), + }; + } + + componentDidMount() { + Animated.timing(this.state.scaleAnim, { + duration: OPEN_ANIM_DURATION, + toValue: 1, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + } + + close() { + return new Promise(resolve => { + Animated.timing(this.state.scaleAnim, { + duration: CLOSE_ANIM_DURATION, + toValue: 0, + easing: Easing.in(Easing.cubic), + useNativeDriver: true, + }).start(resolve); + }); + } + + render() { + const { style, children, layouts, ...other } = this.props; + const animation = { + transform: [ { scale: this.state.scaleAnim } ], + opacity: this.state.scaleAnim, + }; + const { position, placement, offset } = computeProperties(layouts); + return ( + + + + {children} + + + ); + } + +} + +const containerStyle = { + left: { + flexDirection: 'row', + }, + right: { + flexDirection: 'row-reverse', + }, + top: { + flexDirection: 'column', + }, + bottom: { + flexDirection: 'column-reverse', + }, +} + +const anchorStyle = ({ offset, placement }) => { + switch (placement) { + case 'right': + return { + top: offset, + transform: [ + { translateX: -anchorOffset }, + { rotate: '45deg' }, + ], + }; + case 'left': + return { + top: offset, + transform: [ + { translateX: anchorOffset }, + { rotate: '45deg' }, + ], + }; + case 'top': + return { + left: offset, + transform: [ + { translateY: anchorOffset }, + { rotate: '45deg' }, + ], + }; + case 'bottom': + return { + left: offset, + transform: [ + { translateY: -anchorOffset }, + { rotate: '45deg' }, + ], + }; + } +} + +export const styles = StyleSheet.create({ + animated: { + padding: popoverPadding, + backgroundColor: 'transparent', + position: 'absolute', + alignItems: 'center', + }, + options: { + borderRadius: 2, + minWidth: anchorHyp, + minHeight: anchorHyp, + backgroundColor: 'white', + + // Shadow only works on iOS. + shadowColor: 'black', + shadowOpacity: 0.3, + shadowOffset: { width: 3, height: 3 }, + shadowRadius: 4, + + // This will elevate the view on Android, causing shadow to be drawn. + elevation: 5, + }, + anchor: { + width: anchorSize, + height: anchorSize, + backgroundColor: 'white', + elevation: 5, + }, +});