diff --git a/docs/component-docs.config.js b/docs/component-docs.config.js index 68c60d4128..7acc917e96 100644 --- a/docs/component-docs.config.js +++ b/docs/component-docs.config.js @@ -41,7 +41,6 @@ function getPages() { .split('\n') .map((line) => line.split(' ').pop().replace(/('|;)/g, '')) .filter((line) => line.startsWith('./components/')) - .filter((line) => line !== './components/FAB/AnimatedFAB') .map((line) => { const file = require.resolve(path.join(__dirname, '../src', line)); if (/\/index\.(js|tsx?)$/.test(file)) { diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 8a7381e88b..69b42bb4a5 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -43,7 +43,7 @@ export const examples: Record< string, React.ComponentType & { title: string } > = { - ...(__DEV__ && { animatedFab: AnimatedFABExample }), + animatedFab: AnimatedFABExample, activityIndicator: ActivityIndicatorExample, appbar: AppbarExample, avatar: AvatarExample, diff --git a/example/src/Examples/AnimatedFABExample.tsx b/example/src/Examples/AnimatedFABExample/AnimatedFABExample.tsx similarity index 66% rename from example/src/Examples/AnimatedFABExample.tsx rename to example/src/Examples/AnimatedFABExample/AnimatedFABExample.tsx index bd96aa2813..e5c5c8026c 100644 --- a/example/src/Examples/AnimatedFABExample.tsx +++ b/example/src/Examples/AnimatedFABExample/AnimatedFABExample.tsx @@ -1,15 +1,14 @@ import * as React from 'react'; import { View, StyleSheet, FlatList, Animated, Platform } from 'react-native'; import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; -import { - Colors, - AnimatedFAB, - useTheme, - Avatar, - Paragraph, -} from 'react-native-paper'; +import { Colors, useTheme, Avatar, Paragraph } from 'react-native-paper'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; -import { animatedFABExampleData } from '../../utils'; +import { animatedFABExampleData } from '../../../utils'; +import CustomFAB from './CustomFAB'; +import CustomFABControls, { + Controls, + initialControls, +} from './CustomFABControls'; type Item = { id: string; @@ -23,55 +22,24 @@ type Item = { bgColor: string; }; -type CustomFABProps = { - animatedValue: Animated.Value; - visible: boolean; - extended: boolean; -}; - -const isIOS = Platform.OS === 'ios'; - -const CustomFAB = ({ animatedValue, visible, extended }: CustomFABProps) => { - const [isExtended, setIsExtended] = React.useState(true); - - React.useEffect(() => { - if (!isIOS) { - animatedValue.addListener(({ value }: { value: number }) => - setIsExtended(value > 0 ? true : false) - ); - } else setIsExtended(extended); - }, [animatedValue, extended]); - - return ( - console.log('Pressed')} - visible={visible} - animateFrom="right" - iconMode="dynamic" - style={styles.fabStyle} - /> - ); -}; - const AnimatedFABExample = () => { const { colors: { background }, } = useTheme(); - const [scrollPosition, setScrollPosition] = React.useState(0); - const [extended, setExtended] = React.useState(false); + const isIOS = Platform.OS === 'ios'; + + const [extended, setExtended] = React.useState(true); const [visible, setVisible] = React.useState(true); + const [controls, setControls] = React.useState(initialControls); + const { current: velocity } = React.useRef( new Animated.Value(0) ); - const renderItem = ({ item }: { item: Item }) => { - return ( + const renderItem = React.useCallback( + ({ item }: { item: Item }) => ( { - ); - }; + ), + [visible] + ); const onScroll = ({ nativeEvent, }: NativeSyntheticEvent) => { + const currentScrollPosition = + Math.floor(nativeEvent?.contentOffset?.y) ?? 0; + if (!isIOS) { - return velocity.setValue(nativeEvent?.velocity?.y ?? 0); + return velocity.setValue(currentScrollPosition); } - const currentScrollPosition = Math.floor(nativeEvent.contentOffset.y); + setExtended(currentScrollPosition <= 0); + }; - const scrollHeight = - nativeEvent.contentSize.height - nativeEvent.layoutMeasurement.height; + const _keyExtractor = React.useCallback( + (item: { id: string }) => item.id, + [] + ); - setScrollPosition(currentScrollPosition); - - if (currentScrollPosition > scrollPosition && scrollPosition > 0) { - setExtended(false); - } else if ( - currentScrollPosition < scrollPosition && - scrollPosition < scrollHeight - ) { - setExtended(true); - } - }; + const { animateFrom, iconMode } = controls; return ( <> item.id} + keyExtractor={_keyExtractor} onEndReachedThreshold={0} scrollEventThrottle={16} style={[styles.flex, { backgroundColor: background }]} @@ -159,10 +124,16 @@ const AnimatedFABExample = () => { ]} onScroll={onScroll} /> + + + ); @@ -213,11 +184,6 @@ const styles = StyleSheet.create({ marginRight: 8, flex: 1, }, - fabStyle: { - right: 16, - bottom: 32, - position: 'absolute', - }, }); export default AnimatedFABExample; diff --git a/example/src/Examples/AnimatedFABExample/CustomFAB.tsx b/example/src/Examples/AnimatedFABExample/CustomFAB.tsx new file mode 100644 index 0000000000..1d6c249c6e --- /dev/null +++ b/example/src/Examples/AnimatedFABExample/CustomFAB.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { + StyleProp, + ViewStyle, + Animated, + StyleSheet, + Platform, +} from 'react-native'; +import { AnimatedFAB } from 'react-native-paper'; + +type CustomFABProps = { + animatedValue: Animated.Value; + visible: boolean; + extended: boolean; + label: string; + animateFrom: 'left' | 'right'; + iconMode?: 'static' | 'dynamic'; + style?: StyleProp; +}; + +const CustomFAB = ({ + animatedValue, + visible, + extended, + label, + animateFrom, + style, + iconMode, +}: CustomFABProps) => { + const [isExtended, setIsExtended] = React.useState(true); + + const isIOS = Platform.OS === 'ios'; + + React.useEffect(() => { + if (!isIOS) { + animatedValue.addListener(({ value }: { value: number }) => { + setIsExtended(value <= 0); + }); + } else setIsExtended(extended); + }, [animatedValue, extended, isIOS]); + + const fabStyle = { [animateFrom]: 16 }; + + return ( + console.log('Pressed')} + visible={visible} + animateFrom={animateFrom} + iconMode={iconMode} + style={[styles.fabStyle, style, fabStyle]} + /> + ); +}; + +export default CustomFAB; + +const styles = StyleSheet.create({ + fabStyle: { + bottom: 16, + position: 'absolute', + }, +}); diff --git a/example/src/Examples/AnimatedFABExample/CustomFABControls.tsx b/example/src/Examples/AnimatedFABExample/CustomFABControls.tsx new file mode 100644 index 0000000000..5ddf2e8bd1 --- /dev/null +++ b/example/src/Examples/AnimatedFABExample/CustomFABControls.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { StyleSheet, View, FlatList } from 'react-native'; +import { Colors, Paragraph, RadioButton } from 'react-native-paper'; +import type { + AnimatedFABAnimateFrom, + AnimatedFABIconMode, +} from 'react-native-paper'; + +export type Controls = { + iconMode: AnimatedFABIconMode; + animateFrom: AnimatedFABAnimateFrom; +}; + +export const initialControls: Controls = { + iconMode: 'static', + animateFrom: 'right', +}; + +type Props = { + controls: Controls; + setControls(controls: React.SetStateAction): void; +}; + +type ControlValue = AnimatedFABIconMode | AnimatedFABAnimateFrom; + +type CustomControlProps = { + name: string; + options: ControlValue[]; + value: ControlValue; + onChange(newValue: ControlValue): void; +}; + +const CustomControl = ({ + name, + options, + value, + onChange, +}: CustomControlProps) => { + const _renderItem = React.useCallback( + ({ item }) => ( + + {item} + + onChange(item)} + /> + + ), + [value, onChange] + ); + + const _keyExtractor = React.useCallback((item) => item, []); + + return ( + + {name} + + + + ); +}; + +const CustomFABControls = ({ + setControls, + controls: { animateFrom, iconMode }, +}: Props) => { + const setIconMode = (newIconMode: AnimatedFABIconMode) => + setControls((state) => ({ ...state, iconMode: newIconMode })); + + const setAnimateFrom = (newAnimateFrom: AnimatedFABAnimateFrom) => + setControls((state) => ({ ...state, animateFrom: newAnimateFrom })); + + return ( + + + + + + ); +}; + +export default CustomFABControls; + +const styles = StyleSheet.create({ + controlsWrapper: { + flex: 1, + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: Colors.white, + paddingVertical: 12, + paddingHorizontal: 16, + }, + controlWrapper: { + flexDirection: 'row', + alignItems: 'center', + }, + controlItemsList: { + flex: 1, + justifyContent: 'flex-end', + }, + controlItem: { + marginLeft: 16, + flexDirection: 'row', + alignItems: 'center', + }, +}); diff --git a/example/src/Examples/AnimatedFABExample/index.ts b/example/src/Examples/AnimatedFABExample/index.ts new file mode 100644 index 0000000000..7561c57b50 --- /dev/null +++ b/example/src/Examples/AnimatedFABExample/index.ts @@ -0,0 +1 @@ +export { default } from './AnimatedFABExample'; diff --git a/src/components/FAB/AnimatedFAB.tsx b/src/components/FAB/AnimatedFAB/AnimatedFAB.tsx similarity index 75% rename from src/components/FAB/AnimatedFAB.tsx rename to src/components/FAB/AnimatedFAB/AnimatedFAB.tsx index 35c60735f6..e12f4d9338 100644 --- a/src/components/FAB/AnimatedFAB.tsx +++ b/src/components/FAB/AnimatedFAB/AnimatedFAB.tsx @@ -1,5 +1,5 @@ -import color from 'color'; import * as React from 'react'; +import color from 'color'; import { Animated, View, @@ -10,21 +10,25 @@ import { ScrollView, Text, Platform, + I18nManager, } from 'react-native'; -import Surface from '../Surface'; -import Icon from '../Icon'; -import TouchableRipple from '../TouchableRipple/TouchableRipple'; -import { withTheme } from '../../core/theming'; -import type { $RemoveChildren } from '../../types'; -import type { IconSource } from './../Icon'; +import Surface from '../../Surface'; +import Icon from '../../Icon'; +import TouchableRipple from '../../TouchableRipple/TouchableRipple'; +import type { $RemoveChildren } from '../../../types'; +import type { IconSource } from '../../Icon'; +import { withTheme } from '../../../core/theming'; import type { AccessibilityState, NativeSyntheticEvent, TextLayoutEventData, } from 'react-native'; -import { white, black } from '../../styles/colors'; -import AnimatedText from '../Typography/AnimatedText'; -import getContrastingColor from '../../utils/getContrastingColor'; +import { white, black } from '../../../styles/colors'; +import AnimatedText from '../../Typography/AnimatedText'; +import { getCombinedStyles } from './utils'; + +export type AnimatedFABIconMode = 'static' | 'dynamic'; +export type AnimatedFABAnimateFrom = 'left' | 'right'; type Props = $RemoveChildren & { /** @@ -71,11 +75,11 @@ type Props = $RemoveChildren & { /** * Whether icon should be translated to the end of extended `FAB` or be static and stay in the same place. The default value is `dynamic`. */ - iconMode?: 'static' | 'dynamic'; + iconMode?: AnimatedFABIconMode; /** * Indicates from which direction animation should be performed. The default value is `right`. */ - animateFrom?: 'right' | 'left'; + animateFrom?: AnimatedFABAnimateFrom; /** * Whether `FAB` should start animation to extend. */ @@ -114,6 +118,7 @@ const AnimatedFAB = ({ const isIOS = Platform.OS === 'ios'; const isAnimatedFromRight = animateFrom === 'right'; const isIconStatic = iconMode === 'static'; + const { isRTL } = I18nManager; const { current: visibility } = React.useRef( new Animated.Value(visible ? 1 : 0) ); @@ -146,11 +151,10 @@ const AnimatedFAB = ({ .rgb() .string(); - const { - backgroundColor = disabled ? disabledColor : theme.colors.accent, - } = (StyleSheet.flatten(style) || {}) as ViewStyle; + const { backgroundColor = disabled ? disabledColor : theme.colors.accent } = + StyleSheet.flatten(style) || {}; - let foregroundColor; + let foregroundColor: string; if (typeof customColor !== 'undefined') { foregroundColor = customColor; @@ -160,11 +164,9 @@ const AnimatedFAB = ({ .rgb() .string(); } else { - foregroundColor = getContrastingColor( - backgroundColor, - white, - 'rgba(0, 0, 0, .54)' - ); + foregroundColor = !color(backgroundColor as string).isLight() + ? white + : 'rgba(0, 0, 0, .54)'; } const rippleColor = color(foregroundColor).alpha(0.32).rgb().string(); @@ -191,25 +193,31 @@ const AnimatedFAB = ({ const currentHeight = Math.ceil(nativeEvent.lines[0].height); if (currentWidth !== textWidth || currentHeight !== textHeight) { - setTextWidth(currentWidth); setTextHeight(currentHeight); + + if (isIOS) { + return setTextWidth(currentWidth - 12); + } + + setTextWidth(currentWidth); } }; - const getSidesPosition = () => { + const propForDirection = (right: T[]): T[] => { if (isAnimatedFromRight) { - return { - left: -distance, - right: undefined, - }; - } else { - return { - left: undefined, - right: distance, - }; + return right; } + + return right.reverse(); }; + const combinedStyles = getCombinedStyles({ + isAnimatedFromRight, + isIconStatic, + distance, + animFAB, + }); + return ( @@ -300,15 +296,10 @@ const AnimatedFAB = ({ style={[ styles.standard, { - transform: [ - { - translateX: animFAB, - }, - ], width: extendedWidth, backgroundColor, }, - getSidesPosition(), + combinedStyles.innerWrapper, ]} > - + @@ -358,25 +343,25 @@ const AnimatedFAB = ({ onTextLayout={isIOS ? onTextLayout : undefined} ellipsizeMode={'tail'} style={[ - isAnimatedFromRight - ? { right: isIconStatic ? SIZE : BORDER_RADIUS } - : { left: isIconStatic ? SIZE : BORDER_RADIUS }, + { + [isAnimatedFromRight || isRTL ? 'right' : 'left']: isIconStatic + ? isIOS + ? SIZE - 10 + : SIZE - 12 + : BORDER_RADIUS, + }, { minWidth: textWidth, top: -BORDER_RADIUS - textHeight / 2, opacity: animFAB.interpolate({ - inputRange: isAnimatedFromRight - ? [distance, 0.7 * distance, 0] - : [0, 0.7 * distance, distance], - outputRange: isAnimatedFromRight ? [1, 0, 0] : [0, 0, 1], + inputRange: propForDirection([distance, 0.7 * distance, 0]), + outputRange: propForDirection([1, 0, 0]), }), transform: [ { translateX: animFAB.interpolate({ - inputRange: isAnimatedFromRight - ? [distance, 0] - : [0, distance], - outputRange: isAnimatedFromRight ? [0, SIZE] : [-SIZE, 0], + inputRange: propForDirection([distance, 0]), + outputRange: propForDirection([0, SIZE]), }), }, ], diff --git a/src/components/FAB/AnimatedFAB/index.ts b/src/components/FAB/AnimatedFAB/index.ts new file mode 100644 index 0000000000..00dfec8aa1 --- /dev/null +++ b/src/components/FAB/AnimatedFAB/index.ts @@ -0,0 +1,3 @@ +export { default } from './AnimatedFAB'; +export * from './AnimatedFAB'; +export * from './utils'; diff --git a/src/components/FAB/AnimatedFAB/utils.ts b/src/components/FAB/AnimatedFAB/utils.ts new file mode 100644 index 0000000000..d35c4da2d8 --- /dev/null +++ b/src/components/FAB/AnimatedFAB/utils.ts @@ -0,0 +1,144 @@ +import { Animated, I18nManager, ViewStyle } from 'react-native'; + +type GetCombinedStylesProps = { + isAnimatedFromRight: boolean; + isIconStatic: boolean; + distance: number; + animFAB: Animated.Value; +}; + +type CombinedStyles = { + innerWrapper: Animated.WithAnimatedValue; + iconWrapper: Animated.WithAnimatedValue; + absoluteFill: Animated.WithAnimatedValue; +}; + +export const getCombinedStyles = ({ + isAnimatedFromRight, + isIconStatic, + distance, + animFAB, +}: GetCombinedStylesProps): CombinedStyles => { + const { isRTL } = I18nManager; + + const defaultPositionStyles = { left: -distance, right: undefined }; + + const combinedStyles: CombinedStyles = { + innerWrapper: { + ...defaultPositionStyles, + }, + iconWrapper: { + ...defaultPositionStyles, + }, + absoluteFill: {}, + }; + + const animatedFromRight = isAnimatedFromRight && !isRTL; + const animatedFromRightRTL = isAnimatedFromRight && isRTL; + const animatedFromLeft = !isAnimatedFromRight && !isRTL; + const animatedFromLeftRTL = !isAnimatedFromRight && isRTL; + + if (animatedFromRight) { + combinedStyles.innerWrapper.transform = [ + { + translateX: animFAB.interpolate({ + inputRange: [distance, 0], + outputRange: [distance, 0], + }), + }, + ]; + combinedStyles.iconWrapper.transform = [ + { + translateX: isIconStatic ? 0 : animFAB, + }, + ]; + combinedStyles.absoluteFill.transform = [ + { + translateX: animFAB.interpolate({ + inputRange: [distance, 0], + outputRange: [Math.abs(distance) / 2, Math.abs(distance)], + }), + }, + ]; + } else if (animatedFromRightRTL) { + combinedStyles.iconWrapper.transform = [ + { + translateX: isIconStatic + ? 0 + : animFAB.interpolate({ + inputRange: [distance, 0], + outputRange: [-distance, 0], + }), + }, + ]; + combinedStyles.innerWrapper.transform = [ + { + translateX: animFAB.interpolate({ + inputRange: [distance, 0], + outputRange: [-distance, 0], + }), + }, + ]; + combinedStyles.absoluteFill.transform = [ + { + translateX: animFAB.interpolate({ + inputRange: [distance, 0], + outputRange: [0, distance], + }), + }, + ]; + } else if (animatedFromLeft) { + combinedStyles.iconWrapper.transform = [ + { + translateX: isIconStatic + ? distance + : animFAB.interpolate({ + inputRange: [0, distance], + outputRange: [distance, distance * 2], + }), + }, + ]; + combinedStyles.innerWrapper.transform = [ + { + translateX: animFAB, + }, + ]; + combinedStyles.absoluteFill.transform = [ + { + translateX: animFAB.interpolate({ + inputRange: [0, distance], + outputRange: [0, Math.abs(distance) / 2], + }), + }, + ]; + } else if (animatedFromLeftRTL) { + combinedStyles.iconWrapper.transform = [ + { + translateX: isIconStatic + ? animFAB.interpolate({ + inputRange: [0, distance], + outputRange: [-distance, -distance * 2], + }) + : -distance, + }, + ]; + combinedStyles.innerWrapper.transform = [ + { + translateX: animFAB.interpolate({ + inputRange: [0, distance], + outputRange: [0, -distance], + }), + }, + ]; + combinedStyles.absoluteFill.transform = [ + { + translateX: animFAB.interpolate({ + inputRange: [0, distance], + outputRange: [0, -distance], + }), + }, + ]; + } + + return combinedStyles; +}; diff --git a/src/components/__tests__/AnimatedFAB.test.js b/src/components/__tests__/AnimatedFAB.test.js new file mode 100644 index 0000000000..9dd39ce01a --- /dev/null +++ b/src/components/__tests__/AnimatedFAB.test.js @@ -0,0 +1,37 @@ +import * as React from 'react'; +import renderer from 'react-test-renderer'; +import AnimatedFAB from '../FAB/AnimatedFAB'; + +it('renders animated fab', () => { + const tree = renderer + .create( {}} icon="plus" />) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders animated fab with label on the right by default', () => { + const tree = renderer + .create( + {}} icon="plus" /> + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders animated fab with label on the left', () => { + const tree = renderer + .create( + {}} + icon="plus" + /> + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/__tests__/__snapshots__/AnimatedFAB.test.js.snap b/src/components/__tests__/__snapshots__/AnimatedFAB.test.js.snap new file mode 100644 index 0000000000..713f8eee4c --- /dev/null +++ b/src/components/__tests__/__snapshots__/AnimatedFAB.test.js.snap @@ -0,0 +1,757 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders animated fab 1`] = ` + + + + + + + + + + + + + + + + +  + + + + + + +`; + +exports[`renders animated fab with label on the left 1`] = ` + + + + + + + + + + + + + + + + +  + + + + + text + + + +`; + +exports[`renders animated fab with label on the right by default 1`] = ` + + + + + + + + + + + + + + + + +  + + + + + text + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 439d34d8fb..241da255fd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,8 @@ import * as Drawer from './components/Drawer/Drawer'; export { Avatar, List, Drawer }; +export * from './components/FAB/AnimatedFAB'; + export { default as Badge } from './components/Badge'; export { default as ActivityIndicator } from './components/ActivityIndicator'; export { default as Banner } from './components/Banner';