diff --git a/docs/assets/screenshots/fab-group.png b/docs/assets/screenshots/fab-group.png new file mode 100644 index 0000000000..118de10d0f Binary files /dev/null and b/docs/assets/screenshots/fab-group.png differ diff --git a/example/src/FABExample.js b/example/src/FABExample.js index c94e125aef..5db8dfb608 100644 --- a/example/src/FABExample.js +++ b/example/src/FABExample.js @@ -2,17 +2,23 @@ import * as React from 'react'; import { View, StyleSheet } from 'react-native'; -import { Colors, FAB, withTheme } from 'react-native-paper'; +import { Colors, FAB, FABGroup, withTheme } from 'react-native-paper'; import type { Theme } from 'react-native-paper/types'; type Props = { theme: Theme, }; -class ButtonExample extends React.Component { +type State = { + open: boolean, +}; + +class ButtonExample extends React.Component { static title = 'Floating Action Button'; - _handlePress = () => {}; + state = { + open: false, + }; render() { const { @@ -20,21 +26,33 @@ class ButtonExample extends React.Component { colors: { background }, }, } = this.props; + return ( - - + {}} /> + {}} /> {}} + /> + {} }, + { icon: 'star', label: 'Star', onPress: () => {} }, + { icon: 'email', label: 'Email', onPress: () => {} }, + { icon: 'notifications', label: 'Remind', onPress: () => {} }, + ]} + onStateChange={({ open }) => this.setState({ open })} + onPress={() => { + if (this.state.open) { + // do something if the speed dial is open + } + }} /> diff --git a/src/components/CrossFadeIcon.js b/src/components/CrossFadeIcon.js new file mode 100644 index 0000000000..6037a39e45 --- /dev/null +++ b/src/components/CrossFadeIcon.js @@ -0,0 +1,143 @@ +/* @flow */ + +import * as React from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; +import { polyfill } from 'react-lifecycles-compat'; +import Icon, { isValidIcon, isEqualIcon } from './Icon'; +import type { IconSource } from './Icon'; + +type Props = { + /** + * Icon to display for the `CrossFadeIcon`. + */ + source: IconSource, + /** + * Color of the icon. + */ + color: string, + /** + * Size of the icon. + */ + size: number, +}; + +type State = { + currentIcon: IconSource, + previousIcon: ?IconSource, + fade: Animated.Value, +}; + +class CrossFadeIcon extends React.Component { + static getDerivedStateFromProps(nextProps: Props, nextState: State) { + if (nextState.currentIcon === nextProps.source) { + return null; + } + + return { + currentIcon: nextProps.source, + previousIcon: nextState.currentIcon, + }; + } + + state = { + currentIcon: this.props.source, + previousIcon: null, + fade: new Animated.Value(1), + }; + + componentDidUpdate(prevProps: Props, prevState: State) { + const { previousIcon } = this.state; + + if ( + !isValidIcon(previousIcon) || + isEqualIcon(previousIcon, prevState.previousIcon) + ) { + return; + } + + this.state.fade.setValue(1); + + Animated.timing(this.state.fade, { + duration: 200, + toValue: 0, + }).start(); + } + + render() { + const { color, size } = this.props; + const opacityPrev = this.state.fade; + const opacityNext = this.state.previousIcon + ? this.state.fade.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], + }) + : 1; + + const rotatePrev = this.state.fade.interpolate({ + inputRange: [0, 1], + outputRange: ['-90deg', '0deg'], + }); + + const rotateNext = this.state.previousIcon + ? this.state.fade.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '-180deg'], + }) + : '0deg'; + + return ( + + {this.state.previousIcon ? ( + + + + ) : null} + + + + + ); + } +} + +polyfill(CrossFadeIcon); + +export default CrossFadeIcon; + +const styles = StyleSheet.create({ + content: { + alignItems: 'center', + justifyContent: 'center', + }, + icon: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); diff --git a/src/components/FAB.js b/src/components/FAB.js index ed255d5077..fef345e195 100644 --- a/src/components/FAB.js +++ b/src/components/FAB.js @@ -2,9 +2,9 @@ import color from 'color'; import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, View, Animated } from 'react-native'; import Paper from './Paper'; -import Icon from './Icon'; +import CrossFadeIcon from './CrossFadeIcon'; import Text from './Typography/Text'; import TouchableRipple from './TouchableRipple'; import { white } from '../styles/colors'; @@ -12,6 +12,8 @@ import withTheme from '../core/withTheme'; import type { Theme } from '../types'; import type { IconSource } from './Icon'; +const AnimatedPaper = Animated.createAnimatedComponent(Paper); + type Props = { /** * Icon to display for the `FAB`. @@ -80,7 +82,8 @@ class FAB extends React.Component { ...rest } = this.props; - const backgroundColor = theme.colors.accent; + const { backgroundColor = theme.colors.accent } = + StyleSheet.flatten(style) || {}; const isDark = typeof dark === 'boolean' ? dark : !color(backgroundColor).light(); const textColor = iconColor || (isDark ? white : 'rgba(0, 0, 0, .54)'); @@ -90,7 +93,7 @@ class FAB extends React.Component { .string(); return ( - @@ -105,8 +108,9 @@ class FAB extends React.Component { styles.content, label ? styles.extended : small ? styles.small : styles.standard, ]} + pointerEvents="none" > - + {label ? ( { ) : null} - + ); } } diff --git a/src/components/FABGroup.js b/src/components/FABGroup.js new file mode 100644 index 0000000000..177247966f --- /dev/null +++ b/src/components/FABGroup.js @@ -0,0 +1,310 @@ +/* @flow */ + +import * as React from 'react'; +import { + View, + StyleSheet, + Animated, + TouchableWithoutFeedback, + StatusBar, +} from 'react-native'; +import { polyfill } from 'react-lifecycles-compat'; +import color from 'color'; +import Text from './Typography/Text'; +import Card from './Card/Card'; +import ThemedPortal from './Portal/ThemedPortal'; +import FAB from './FAB'; +import withTheme from '../core/withTheme'; +import type { Theme } from '../types'; +import type { IconSource } from './Icon'; + +type Props = { + /** + * Action items to display in the form of a speed dial. + * An action item should contain the following properties: + * - `icon`: icon to display (required) + * - `label`: optional label text + * - `color`: custom icon color of the action item + * - `onPress`: callback that is called when `FAB` is pressed (required) + */ + actions: Array<{ + icon: string, + label?: string, + color?: string, + onPress: () => mixed, + }>, + /** + * Icon to display for the `FAB`. + * You can toggle it based on whether the speed dial is open to display a different icon. + */ + icon: IconSource, + /** + * Custom icon color for the `FAB`. + */ + color?: string, + /** + * Function to execute on pressing the `FAB`. + */ + onPress?: () => mixed, + /** + * Whether the speed dial is open. + */ + open: boolean, + /** + * Callback which is called on opening and closing the speed dial. + * The open state needs to be updated when it's called, otherwise the change is dropped. + */ + onStateChange: (state: { open: boolean }) => mixed, + /** + * @optional + */ + theme: Theme, +}; + +type State = { + backdrop: Animated.Value, + animations: Animated.Value[], +}; + +/** + * FABGroup displays a stack of FABs with related actions in a speed dial. + * + *
+ * + *
+ * + * ## Usage + * ```js + * import React from 'react'; + * import { FABGroup, StyleSheet } from 'react-native-paper'; + * + * export default class MyComponent extends React.Component { + * state = { + * open: false, + * }; + * + * render() { + * return ( + * {} }, + * { icon: 'star', label: 'Star', onPress: () => {} }, + * { icon: 'email', label: 'Email', onPress: () => {} }, + * { icon: 'notifications', label: 'Remind', onPress: () => {} }, + * ]} + * onStateChange={({ open }) => this.setState({ open })} + * onPress={() => { + * if (this.state.open) { + * // do something if the speed dial is open + * } + * }} + * /> + * ); + * } + * } + * ``` + */ +class FABGroup extends React.Component { + static getDerivedStateFromProps(nextProps, prevState) { + return { + animations: nextProps.actions.map( + (_, i) => + prevState.animations[i] || new Animated.Value(nextProps.open ? 1 : 0) + ), + }; + } + + state = { + backdrop: new Animated.Value(0), + animations: [], + }; + + componentDidUpdate(prevProps) { + if (this.props.open === prevProps.open) { + return; + } + + if (this.props.open) { + Animated.parallel([ + Animated.timing(this.state.backdrop, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }), + Animated.stagger( + 50, + this.state.animations + .map(animation => + Animated.timing(animation, { + toValue: 1, + duration: 150, + useNativeDriver: true, + }) + ) + .reverse() + ), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(this.state.backdrop, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + ...this.state.animations.map(animation => + Animated.timing(animation, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }) + ), + ]).start(); + } + } + + _toggleOpen = () => this.props.onStateChange({ open: !this.props.open }); + + render() { + const { actions, icon, open, onPress, theme } = this.props; + + const labelColor = theme.dark + ? theme.colors.text + : color(theme.colors.text) + .fade(0.54) + .rgb() + .string(); + const backdropOpacity = open + ? this.state.backdrop.interpolate({ + inputRange: [0, 0.5, 1], + // $FlowFixMe + outputRange: [0, 1, 1], + }) + : this.state.backdrop; + + const opacities = this.state.animations; + const scales = opacities.map( + opacity => + open + ? opacity.interpolate({ + inputRange: [0, 1], + // $FlowFixMe + outputRange: [0.8, 1], + }) + : 1 + ); + + return ( + + {open ? ( + + ) : null} + + + + + + {actions.map((it, i) => { + if (it.primary) { + return null; + } + return ( + + + {it.label && ( + + {it.label} + + )} + + + + ); + })} + + { + onPress && onPress(); + this._toggleOpen(); + }} + icon={icon} + color={this.props.color} + style={styles.fab} + /> + + + ); + } +} + +polyfill(FABGroup); + +export default withTheme(FABGroup); + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + alignItems: 'flex-end', + justifyContent: 'flex-end', + }, + fab: { + marginHorizontal: 16, + marginBottom: 16, + marginTop: 0, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + }, + label: { + borderRadius: 5, + paddingHorizontal: 12, + paddingVertical: 6, + marginVertical: 8, + marginHorizontal: 16, + elevation: 2, + }, + item: { + marginHorizontal: 24, + marginBottom: 16, + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + }, +}); diff --git a/src/components/Icon.js b/src/components/Icon.js index b6622c90b5..18d013c6a8 100644 --- a/src/components/Icon.js +++ b/src/components/Icon.js @@ -44,7 +44,7 @@ type Props = IconProps & { name: IconSource, }; -const isImageSource = source => +const isImageSource = (source: any) => // source is an object with uri (typeof source === 'object' && source !== null && @@ -53,6 +53,25 @@ const isImageSource = source => // source is a module, e.g. - require('image') typeof source === 'number'; +const getIconId = (source: any) => { + if ( + typeof source === 'object' && + source !== null && + (Object.prototype.hasOwnProperty.call(source, 'uri') && + typeof source.uri === 'string') + ) { + return source.uri; + } + + return source; +}; + +export const isValidIcon = (source: any) => + typeof source === 'string' || isImageSource(source); + +export const isEqualIcon = (a: any, b: any) => + a === b || getIconId(a) === getIconId(b); + const Icon = ({ name, color, size, ...rest }: Props) => { if (typeof name === 'string') { return ( diff --git a/src/components/__tests__/__snapshots__/FAB.test.js.snap b/src/components/__tests__/__snapshots__/FAB.test.js.snap index 9c582e7897..69ab9f6407 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.js.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.js.snap @@ -2,6 +2,7 @@ exports[`renders extended FAB 1`] = ` @@ -57,6 +53,7 @@ exports[`renders extended FAB 1`] = ` tvParallaxProperties={undefined} > - -  - + + +  + + + @@ -203,6 +230,7 @@ exports[`renders normal FAB 1`] = ` tvParallaxProperties={undefined} > - -  - + + +  + + + @@ -248,6 +310,7 @@ exports[`renders normal FAB 1`] = ` exports[`renders small FAB 1`] = ` @@ -303,6 +361,7 @@ exports[`renders small FAB 1`] = ` tvParallaxProperties={undefined} > - -  - + + +  + + + diff --git a/src/index.js b/src/index.js index 3171854def..e58d9a8696 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,7 @@ export { default as Searchbar } from './components/Searchbar'; export { default as SearchBar } from './components/Searchbar'; export { default as Snackbar } from './components/Snackbar'; export { default as Switch } from './components/Switch'; +export { default as FABGroup } from './components/FABGroup'; export { default as Toolbar } from './components/Toolbar/Toolbar'; export { default as ToolbarAction } from './components/Toolbar/ToolbarAction'; export {