From 9c960a3bb5a1fe509d17c255fac56ec3087e7175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwa=C5=9Bniewski?= Date: Tue, 11 Oct 2022 12:39:20 +0200 Subject: [PATCH] refactor: change components to function based (#1392) This pull request introduces function based components. The goal is to make this library easier to maintain and easier to contribute to. --- .../example/package.json | 3 +- .../react-native-tab-view/example/yarn.lock | 5 + packages/react-native-tab-view/package.json | 3 + .../src/PagerViewAdapter.tsx | 7 +- .../src/PanResponderAdapter.tsx | 9 +- .../react-native-tab-view/src/SceneMap.tsx | 18 +- .../react-native-tab-view/src/SceneView.tsx | 176 ++-- packages/react-native-tab-view/src/TabBar.tsx | 842 ++++++++++-------- .../src/TabBarIndicator.tsx | 222 +++-- .../react-native-tab-view/src/TabBarItem.tsx | 399 +++++---- packages/react-native-tab-view/yarn.lock | 5 + 11 files changed, 881 insertions(+), 808 deletions(-) diff --git a/packages/react-native-tab-view/example/package.json b/packages/react-native-tab-view/example/package.json index 1ca3c84e7a..3d9443ade2 100644 --- a/packages/react-native-tab-view/example/package.json +++ b/packages/react-native-tab-view/example/package.json @@ -22,7 +22,8 @@ "react-native": "0.69.5", "react-native-pager-view": "5.4.25", "react-native-safe-area-context": "4.3.1", - "react-native-web": "~0.18.7" + "react-native-web": "~0.18.7", + "use-latest-callback": "^0.1.5" }, "devDependencies": { "babel-plugin-module-resolver": "^4.1.0", diff --git a/packages/react-native-tab-view/example/yarn.lock b/packages/react-native-tab-view/example/yarn.lock index 55baf82692..67f11d206c 100644 --- a/packages/react-native-tab-view/example/yarn.lock +++ b/packages/react-native-tab-view/example/yarn.lock @@ -11415,6 +11415,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-latest-callback@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.5.tgz#a4a836c08fa72f6608730b5b8f4bbd9c57c04f51" + integrity sha512-HtHatS2U4/h32NlkhupDsPlrbiD27gSH5swBdtXbCAlc6pfOFzaj0FehW/FO12rx8j2Vy4/lJScCiJyM01E+bQ== + use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" diff --git a/packages/react-native-tab-view/package.json b/packages/react-native-tab-view/package.json index b70fb61329..42f6becf51 100644 --- a/packages/react-native-tab-view/package.json +++ b/packages/react-native-tab-view/package.json @@ -97,5 +97,8 @@ } ] ] + }, + "dependencies": { + "use-latest-callback": "^0.1.5" } } diff --git a/packages/react-native-tab-view/src/PagerViewAdapter.tsx b/packages/react-native-tab-view/src/PagerViewAdapter.tsx index bd27e7b7d5..6d894a5883 100644 --- a/packages/react-native-tab-view/src/PagerViewAdapter.tsx +++ b/packages/react-native-tab-view/src/PagerViewAdapter.tsx @@ -129,8 +129,13 @@ export default function PagerViewAdapter({ }; }, []); + const memoizedPosition = React.useMemo( + () => Animated.add(position, offset), + [offset, position] + ); + return children({ - position: Animated.add(position, offset), + position: memoizedPosition, addEnterListener, jumpTo, render: (children) => ( diff --git a/packages/react-native-tab-view/src/PanResponderAdapter.tsx b/packages/react-native-tab-view/src/PanResponderAdapter.tsx index 0b11fc9cda..f5c59a6266 100644 --- a/packages/react-native-tab-view/src/PanResponderAdapter.tsx +++ b/packages/react-native-tab-view/src/PanResponderAdapter.tsx @@ -283,10 +283,13 @@ export default function PanResponderAdapter({ I18nManager.isRTL ? -1 : 1 ); + const position = React.useMemo( + () => (layout.width ? Animated.divide(panX, -layout.width) : null), + [layout.width, panX] + ); + return children({ - position: layout.width - ? Animated.divide(panX, -layout.width) - : new Animated.Value(index), + position: position ?? new Animated.Value(index), addEnterListener, jumpTo, render: (children) => ( diff --git a/packages/react-native-tab-view/src/SceneMap.tsx b/packages/react-native-tab-view/src/SceneMap.tsx index 9b433c6a70..6d6035c51d 100644 --- a/packages/react-native-tab-view/src/SceneMap.tsx +++ b/packages/react-native-tab-view/src/SceneMap.tsx @@ -1,19 +1,23 @@ import * as React from 'react'; import type { SceneRendererProps } from './types'; -class SceneComponent< - T extends { component: React.ComponentType } -> extends React.PureComponent { - render() { - const { component, ...rest } = this.props; +type SceneProps = { + route: any; +} & Omit; + +const SceneComponent = React.memo( + } & SceneProps>({ + component, + ...rest + }: T) => { return React.createElement(component, rest); } -} +); export default function SceneMap(scenes: { [key: string]: React.ComponentType; }) { - return ({ route, jumpTo, position }: SceneRendererProps & { route: any }) => ( + return ({ route, jumpTo, position }: SceneProps) => ( = SceneRendererProps & style?: StyleProp; }; -type State = { - loading: boolean; -}; - -export default class SceneView extends React.Component< - Props, - State -> { - static getDerivedStateFromProps(props: Props, state: State) { - if ( - state.loading && - Math.abs(props.navigationState.index - props.index) <= - props.lazyPreloadDistance - ) { - // Always render the route when it becomes focused - return { loading: false }; - } - - return null; +export default function SceneView({ + children, + navigationState, + lazy, + layout, + index, + lazyPreloadDistance, + addEnterListener, + style, +}: Props) { + const [isLoading, setIsLoading] = React.useState( + Math.abs(navigationState.index - index) > lazyPreloadDistance + ); + + if ( + isLoading && + Math.abs(navigationState.index - index) <= lazyPreloadDistance + ) { + // Always render the route when it becomes focused + setIsLoading(false); } - state = { - loading: - Math.abs(this.props.navigationState.index - this.props.index) > - this.props.lazyPreloadDistance, - }; + React.useEffect(() => { + const handleEnter = (value: number) => { + // If we're entering the current route, we need to load it + if (value === index) { + setIsLoading((prevState) => { + if (prevState) { + return false; + } + return prevState; + }); + } + }; + + let unsubscribe: (() => void) | undefined; + let timer: NodeJS.Timeout; - componentDidMount() { - if (this.props.lazy) { + if (lazy && isLoading) { // If lazy mode is enabled, listen to when we enter screens - this.unsubscribe = this.props.addEnterListener(this.handleEnter); - } else if (this.state.loading) { + unsubscribe = addEnterListener(handleEnter); + } else if (isLoading) { // If lazy mode is not enabled, render the scene with a delay if not loaded already // This improves the initial startup time as the scene is no longer blocking - this.timerHandler = setTimeout( - () => this.setState({ loading: false }), - 0 - ); + timer = setTimeout(() => setIsLoading(false), 0); } - } - componentDidUpdate(prevProps: Props, prevState: State) { - if ( - this.props.lazy !== prevProps.lazy || - this.state.loading !== prevState.loading - ) { - // We only need the listener if the tab hasn't loaded yet and lazy is enabled - if (this.props.lazy && this.state.loading) { - this.unsubscribe?.(); - this.unsubscribe = this.props.addEnterListener(this.handleEnter); - } else { - this.unsubscribe?.(); + return () => { + unsubscribe?.(); + clearTimeout(timer); + }; + }, [addEnterListener, index, isLoading, lazy]); + + const focused = navigationState.index === index; + + return ( + + { + // Only render the route only if it's either focused or layout is available + // When layout is not available, we must not render unfocused routes + // so that the focused route can fill the screen + focused || layout.width ? children({ loading: isLoading }) : null } - } - } - - componentWillUnmount() { - this.unsubscribe?.(); - - if (this.timerHandler) { - clearTimeout(this.timerHandler); - this.timerHandler = undefined; - } - } - - private timerHandler: NodeJS.Timeout | undefined; - - private unsubscribe: (() => void) | null = null; - - private handleEnter = (value: number) => { - const { index } = this.props; - - // If we're entering the current route, we need to load it - if (value === index) { - this.setState((prevState) => { - if (prevState.loading) { - return { loading: false }; - } - - return null; - }); - } - }; - - render() { - const { navigationState, index, layout, style } = this.props; - const { loading } = this.state; - - const focused = navigationState.index === index; - - return ( - - { - // Only render the route only if it's either focused or layout is available - // When layout is not available, we must not render unfocused routes - // so that the focused route can fill the screen - focused || layout.width ? this.props.children({ loading }) : null - } - - ); - } + + ); } const styles = StyleSheet.create({ diff --git a/packages/react-native-tab-view/src/TabBar.tsx b/packages/react-native-tab-view/src/TabBar.tsx index 45e3d35c6a..440aed00f2 100644 --- a/packages/react-native-tab-view/src/TabBar.tsx +++ b/packages/react-native-tab-view/src/TabBar.tsx @@ -22,6 +22,7 @@ import type { Layout, Event, } from './types'; +import useAnimatedValue from './useAnimatedValue'; export type Props = SceneRendererProps & { navigationState: NavigationState; @@ -31,10 +32,10 @@ export type Props = SceneRendererProps & { inactiveColor?: string; pressColor?: string; pressOpacity?: number; - getLabelText: (scene: Scene) => string | undefined; - getAccessible: (scene: Scene) => boolean | undefined; - getAccessibilityLabel: (scene: Scene) => string | undefined; - getTestID: (scene: Scene) => string | undefined; + getLabelText?: (scene: Scene) => string | undefined; + getAccessible?: (scene: Scene) => boolean | undefined; + getAccessibilityLabel?: (scene: Scene) => string | undefined; + getTestID?: (scene: Scene) => string | undefined; renderLabel?: ( scene: Scene & { focused: boolean; @@ -48,7 +49,7 @@ export type Props = SceneRendererProps & { } ) => React.ReactNode; renderBadge?: (scene: Scene) => React.ReactNode; - renderIndicator: (props: IndicatorProps) => React.ReactNode; + renderIndicator?: (props: IndicatorProps) => React.ReactNode; renderTabBarItem?: ( props: TabBarItemProps & { key: string } ) => React.ReactElement; @@ -63,435 +64,494 @@ export type Props = SceneRendererProps & { gap?: number; }; -type State = { - layout: Layout; - tabWidths: { [key: string]: number }; -}; +type FlattenedTabWidth = string | number | undefined; const Separator = ({ width }: { width: number }) => { return ; }; -export default class TabBar extends React.Component< - Props, - State -> { - static defaultProps = { - getLabelText: ({ route }: Scene) => route.title, - getAccessible: ({ route }: Scene) => - typeof route.accessible !== 'undefined' ? route.accessible : true, - getAccessibilityLabel: ({ route }: Scene) => - typeof route.accessibilityLabel === 'string' - ? route.accessibilityLabel - : typeof route.title === 'string' - ? route.title - : undefined, - getTestID: ({ route }: Scene) => route.testID, - renderIndicator: (props: IndicatorProps) => ( - - ), - gap: 0, - }; +const getFlattenedTabWidth = (style: StyleProp) => { + const tabStyle = StyleSheet.flatten(style); - state: State = { - layout: { width: 0, height: 0 }, - tabWidths: {}, - }; + return tabStyle?.width; +}; + +const getComputedTabWidth = ( + index: number, + layout: Layout, + routes: Route[], + scrollEnabled: boolean | undefined, + tabWidths: { [key: string]: number }, + flattenedWidth: FlattenedTabWidth +) => { + if (flattenedWidth === 'auto') { + return tabWidths[routes[index].key] || 0; + } - componentDidUpdate(prevProps: Props, prevState: State) { - const { navigationState } = this.props; - const { layout, tabWidths } = this.state; - - if ( - prevProps.navigationState.routes.length !== - navigationState.routes.length || - prevProps.navigationState.index !== navigationState.index || - prevState.layout.width !== layout.width || - prevState.tabWidths !== tabWidths - ) { - if ( - this.getFlattenedTabWidth(this.props.tabStyle) === 'auto' && - !( - layout.width && - navigationState.routes.every( - (r) => typeof tabWidths[r.key] === 'number' - ) - ) - ) { - // When tab width is dynamic, only adjust the scroll once we have all tab widths and layout - return; + switch (typeof flattenedWidth) { + case 'number': + return flattenedWidth; + case 'string': + if (flattenedWidth.endsWith('%')) { + const width = parseFloat(flattenedWidth); + if (Number.isFinite(width)) { + return layout.width * (width / 100); + } } + } - this.resetScroll(navigationState.index); - } + if (scrollEnabled) { + return (layout.width / 5) * 2; } + return layout.width / routes.length; +}; - // to store the layout.width of each tab - // when all onLayout's are fired, this would be set in state - private measuredTabWidths: { [key: string]: number } = {}; +const getMaxScrollDistance = (tabBarWidth: number, layoutWidth: number) => + tabBarWidth - layoutWidth; + +const getTranslateX = ( + scrollAmount: Animated.Value, + maxScrollDistance: number +) => + Animated.multiply( + Platform.OS === 'android' && I18nManager.isRTL + ? Animated.add(maxScrollDistance, Animated.multiply(scrollAmount, -1)) + : scrollAmount, + I18nManager.isRTL ? 1 : -1 + ); + +const getTabBarWidth = ({ + navigationState, + layout, + gap, + scrollEnabled, + flattenedTabWidth, + tabWidths, +}: Pick, 'navigationState' | 'gap' | 'layout' | 'scrollEnabled'> & { + tabWidths: Record; + flattenedTabWidth: FlattenedTabWidth; +}) => { + const { routes } = navigationState; + + return routes.reduce( + (acc, _, i) => + acc + + (i > 0 ? gap ?? 0 : 0) + + getComputedTabWidth( + i, + layout, + routes, + scrollEnabled, + tabWidths, + flattenedTabWidth + ), + 0 + ); +}; - private scrollAmount = new Animated.Value(0); +const normalizeScrollValue = ({ + layout, + navigationState, + gap, + scrollEnabled, + tabWidths, + value, + flattenedTabWidth, +}: Pick, 'layout' | 'navigationState' | 'gap' | 'scrollEnabled'> & { + tabWidths: Record; + value: number; + flattenedTabWidth: FlattenedTabWidth; +}) => { + const tabBarWidth = getTabBarWidth({ + layout, + navigationState, + tabWidths, + gap, + scrollEnabled, + flattenedTabWidth, + }); + const maxDistance = getMaxScrollDistance(tabBarWidth, layout.width); + const scrollValue = Math.max(Math.min(value, maxDistance), 0); + + if (Platform.OS === 'android' && I18nManager.isRTL) { + // On Android, scroll value is not applied in reverse in RTL + // so we need to manually adjust it to apply correct value + return maxDistance - scrollValue; + } - private flatListRef = React.createRef(); + return scrollValue; +}; - private getFlattenedTabWidth = (style: StyleProp) => { - const tabStyle = StyleSheet.flatten(style); +const getScrollAmount = ({ + layout, + navigationState, + gap, + scrollEnabled, + flattenedTabWidth, + tabWidths, +}: Pick, 'layout' | 'navigationState' | 'scrollEnabled' | 'gap'> & { + tabWidths: Record; + flattenedTabWidth: FlattenedTabWidth; +}) => { + const centerDistance = Array.from({ + length: navigationState.index + 1, + }).reduce((total, _, i) => { + const tabWidth = getComputedTabWidth( + i, + layout, + navigationState.routes, + scrollEnabled, + tabWidths, + flattenedTabWidth + ); - return tabStyle ? tabStyle.width : undefined; - }; + // To get the current index centered we adjust scroll amount by width of indexes + // 0 through (i - 1) and add half the width of current index i + return ( + total + + (navigationState.index === i + ? (tabWidth + (gap ?? 0)) / 2 + : tabWidth + (gap ?? 0)) + ); + }, 0); + + const scrollAmount = centerDistance - layout.width / 2; + + return normalizeScrollValue({ + layout, + navigationState, + tabWidths, + value: scrollAmount, + gap, + scrollEnabled, + flattenedTabWidth, + }); +}; - private getComputedTabWidth = ( - index: number, - layout: Layout, - routes: Route[], - scrollEnabled: boolean | undefined, - tabWidths: { [key: string]: number }, - flattenedWidth: string | number | undefined - ) => { - if (flattenedWidth === 'auto') { - return tabWidths[routes[index].key] || 0; +const getLabelTextDefault = ({ route }: Scene) => route.title; + +const getAccessibleDefault = ({ route }: Scene) => + typeof route.accessible !== 'undefined' ? route.accessible : true; + +const getAccessibilityLabelDefault = ({ route }: Scene) => + typeof route.accessibilityLabel === 'string' + ? route.accessibilityLabel + : typeof route.title === 'string' + ? route.title + : undefined; + +const renderIndicatorDefault = (props: IndicatorProps) => ( + +); + +const getTestIdDefault = ({ route }: Scene) => route.testID; + +export default function TabBar({ + getLabelText = getLabelTextDefault, + getAccessible = getAccessibleDefault, + getAccessibilityLabel = getAccessibilityLabelDefault, + getTestID = getTestIdDefault, + renderIndicator = renderIndicatorDefault, + gap = 0, + scrollEnabled, + jumpTo, + navigationState, + position, + activeColor, + bounces, + contentContainerStyle, + inactiveColor, + indicatorContainerStyle, + indicatorStyle, + labelStyle, + onTabLongPress, + onTabPress, + pressColor, + pressOpacity, + renderBadge, + renderIcon, + renderLabel, + renderTabBarItem, + style, + tabStyle, +}: Props) { + const [layout, setLayout] = React.useState({ width: 0, height: 0 }); + const [tabWidths, setTabWidths] = React.useState>({}); + const flatListRef = React.useRef(null); + const isFirst = React.useRef(true); + const scrollAmount = useAnimatedValue(0); + const measuredTabWidths = React.useRef>({}); + + const { routes } = navigationState; + const flattenedTabWidth = getFlattenedTabWidth(tabStyle); + const isWidthDynamic = flattenedTabWidth === 'auto'; + const scrollOffset = getScrollAmount({ + layout, + navigationState, + tabWidths, + gap, + scrollEnabled, + flattenedTabWidth, + }); + + const hasMeasuredTabWidths = + Boolean(layout.width) && + routes.every((r) => typeof tabWidths[r.key] === 'number'); + + React.useEffect(() => { + if (isFirst.current) { + isFirst.current = false; + return; } - switch (typeof flattenedWidth) { - case 'number': - return flattenedWidth; - case 'string': - if (flattenedWidth.endsWith('%')) { - const width = parseFloat(flattenedWidth); - if (Number.isFinite(width)) { - return layout.width * (width / 100); - } - } + if (isWidthDynamic && !hasMeasuredTabWidths) { + // When tab width is dynamic, only adjust the scroll once we have all tab widths and layout + return; } if (scrollEnabled) { - return (layout.width / 5) * 2; - } - return layout.width / routes.length; - }; - - private getMaxScrollDistance = (tabBarWidth: number, layoutWidth: number) => - tabBarWidth - layoutWidth; - - private getTabBarWidth = (props: Props, state: State) => { - const { layout, tabWidths } = state; - const { scrollEnabled, tabStyle } = props; - const { routes } = props.navigationState; - - return routes.reduce( - (acc, _, i) => - acc + - (i > 0 ? props.gap ?? 0 : 0) + - this.getComputedTabWidth( - i, - layout, - routes, - scrollEnabled, - tabWidths, - this.getFlattenedTabWidth(tabStyle) - ), - 0 - ); - }; - - private normalizeScrollValue = ( - props: Props, - state: State, - value: number - ) => { - const { layout } = state; - const tabBarWidth = this.getTabBarWidth(props, state); - const maxDistance = this.getMaxScrollDistance(tabBarWidth, layout.width); - const scrollValue = Math.max(Math.min(value, maxDistance), 0); - - if (Platform.OS === 'android' && I18nManager.isRTL) { - // On Android, scroll value is not applied in reverse in RTL - // so we need to manually adjust it to apply correct value - return maxDistance - scrollValue; - } - - return scrollValue; - }; - - private getScrollAmount = (props: Props, state: State, index: number) => { - const { layout, tabWidths } = state; - const { scrollEnabled, tabStyle } = props; - const { routes } = props.navigationState; - - const centerDistance = Array.from({ length: index + 1 }).reduce( - (total, _, i) => { - const tabWidth = this.getComputedTabWidth( - i, - layout, - routes, - scrollEnabled, - tabWidths, - this.getFlattenedTabWidth(tabStyle) - ); - - // To get the current index centered we adjust scroll amount by width of indexes - // 0 through (i - 1) and add half the width of current index i - return ( - total + - (index === i - ? (tabWidth + (props.gap ?? 0)) / 2 - : tabWidth + (props.gap ?? 0)) - ); - }, - 0 - ); - - const scrollAmount = centerDistance - layout.width / 2; - - return this.normalizeScrollValue(props, state, scrollAmount); - }; - - private resetScroll = (index: number) => { - if (this.props.scrollEnabled) { - this.flatListRef.current?.scrollToOffset({ - offset: this.getScrollAmount(this.props, this.state, index), + flatListRef.current?.scrollToOffset({ + offset: scrollOffset, animated: true, }); } - }; + }, [hasMeasuredTabWidths, isWidthDynamic, scrollEnabled, scrollOffset]); - private handleLayout = (e: LayoutChangeEvent) => { + const handleLayout = (e: LayoutChangeEvent) => { const { height, width } = e.nativeEvent.layout; - if ( - this.state.layout.width === width && - this.state.layout.height === height - ) { - return; - } - - this.setState({ - layout: { - height, - width, - }, - }); + setLayout((layout) => + layout.width === width && layout.height === height + ? layout + : { width, height } + ); }; - private getTranslateX = ( - scrollAmount: Animated.Value, - maxScrollDistance: number - ) => - Animated.multiply( - Platform.OS === 'android' && I18nManager.isRTL - ? Animated.add(maxScrollDistance, Animated.multiply(scrollAmount, -1)) - : scrollAmount, - I18nManager.isRTL ? 1 : -1 - ); + const tabBarWidth = getTabBarWidth({ + layout, + navigationState, + tabWidths, + gap, + scrollEnabled, + flattenedTabWidth, + }); + + const separatorsWidth = Math.max(0, routes.length - 1) * gap; + const separatorPercent = (separatorsWidth / tabBarWidth) * 100; + const tabBarWidthPercent = `${routes.length * 40}%`; + + const translateX = React.useMemo( + () => + getTranslateX( + scrollAmount, + getMaxScrollDistance(tabBarWidth, layout.width) + ), + [layout.width, scrollAmount, tabBarWidth] + ); + + const renderItem = React.useCallback( + ({ item: route, index }: ListRenderItemInfo) => { + const props: TabBarItemProps & { key: string } = { + key: route.key, + position: position, + route: route, + navigationState: navigationState, + getAccessibilityLabel: getAccessibilityLabel, + getAccessible: getAccessible, + getLabelText: getLabelText, + getTestID: getTestID, + renderBadge: renderBadge, + renderIcon: renderIcon, + renderLabel: renderLabel, + activeColor: activeColor, + inactiveColor: inactiveColor, + pressColor: pressColor, + pressOpacity: pressOpacity, + onLayout: isWidthDynamic + ? (e: LayoutChangeEvent) => { + measuredTabWidths.current[route.key] = e.nativeEvent.layout.width; + + // When we have measured widths for all of the tabs, we should updates the state + // We avoid doing separate setState for each layout since it triggers multiple renders and slows down app + if ( + routes.every( + (r) => typeof measuredTabWidths.current[r.key] === 'number' + ) + ) { + setTabWidths({ ...measuredTabWidths.current }); + } + } + : undefined, + onPress: () => { + const event: Scene & Event = { + route, + defaultPrevented: false, + preventDefault: () => { + event.defaultPrevented = true; + }, + }; + + onTabPress?.(event); + + if (event.defaultPrevented) { + return; + } - render() { - const { - position, - navigationState, - jumpTo, - scrollEnabled, - bounces, + jumpTo(route.key); + }, + onLongPress: () => onTabLongPress?.({ route }), + labelStyle: labelStyle, + style: tabStyle, + // Calculate the deafult width for tab for FlatList to work + defaultTabWidth: !isWidthDynamic + ? getComputedTabWidth( + index, + layout, + routes, + scrollEnabled, + tabWidths, + getFlattenedTabWidth(tabStyle) + ) + : undefined, + }; + + return ( + <> + {gap > 0 && index > 0 ? : null} + {renderTabBarItem ? ( + renderTabBarItem(props) + ) : ( + + )} + + ); + }, + [ + activeColor, + gap, getAccessibilityLabel, getAccessible, getLabelText, getTestID, + inactiveColor, + isWidthDynamic, + jumpTo, + labelStyle, + layout, + navigationState, + onTabLongPress, + onTabPress, + position, + pressColor, + pressOpacity, renderBadge, renderIcon, renderLabel, renderTabBarItem, - activeColor, - inactiveColor, - pressColor, - pressOpacity, - onTabPress, - onTabLongPress, + routes, + scrollEnabled, tabStyle, - labelStyle, - indicatorStyle, + tabWidths, + ] + ); + + const keyExtractor = React.useCallback((item: T) => item.key, []); + + const contentContainerStyleMemoized = React.useMemo( + () => [ + styles.tabContent, + scrollEnabled + ? { + width: + tabBarWidth > separatorsWidth ? tabBarWidth : tabBarWidthPercent, + } + : styles.container, contentContainerStyle, - style, - indicatorContainerStyle, - gap = 0, - } = this.props; - const { layout, tabWidths } = this.state; - const { routes } = navigationState; - - const isWidthDynamic = this.getFlattenedTabWidth(tabStyle) === 'auto'; - const tabBarWidth = this.getTabBarWidth(this.props, this.state); - const separatorsWidth = Math.max(0, routes.length - 1) * gap; - const separatorPercent = (separatorsWidth / tabBarWidth) * 100; - - const tabBarWidthPercent = `${routes.length * 40}%`; - const translateX = this.getTranslateX( - this.scrollAmount, - this.getMaxScrollDistance(tabBarWidth, layout.width) - ); - - return ( + ], + [ + contentContainerStyle, + scrollEnabled, + separatorsWidth, + tabBarWidth, + tabBarWidthPercent, + ] + ); + + const handleScroll = React.useMemo( + () => + Animated.event( + [ + { + nativeEvent: { + contentOffset: { x: scrollAmount }, + }, + }, + ], + { useNativeDriver: true } + ), + [scrollAmount] + ); + + return ( + separatorsWidth + ? { width: tabBarWidth - separatorsWidth } + : scrollEnabled + ? { width: tabBarWidthPercent } + : null, + indicatorContainerStyle, + ]} > - separatorsWidth - ? { width: tabBarWidth - separatorsWidth } - : scrollEnabled - ? { width: tabBarWidthPercent } - : null, - indicatorContainerStyle, - ]} - > - {this.props.renderIndicator({ - position, - layout, - navigationState, - jumpTo, - width: isWidthDynamic - ? 'auto' - : `${(100 - separatorPercent) / routes.length}%`, - style: indicatorStyle, - getTabWidth: (i: number) => - this.getComputedTabWidth( - i, - layout, - routes, - scrollEnabled, - tabWidths, - this.getFlattenedTabWidth(tabStyle) - ), - gap, - })} - - - []} - keyExtractor={(item) => item.key} - horizontal - accessibilityRole="tablist" - keyboardShouldPersistTaps="handled" - scrollEnabled={scrollEnabled} - bounces={bounces} - alwaysBounceHorizontal={false} - scrollsToTop={false} - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - automaticallyAdjustContentInsets={false} - overScrollMode="never" - contentContainerStyle={[ - styles.tabContent, - scrollEnabled - ? { - width: - tabBarWidth > separatorsWidth - ? tabBarWidth - : tabBarWidthPercent, - } - : styles.container, - contentContainerStyle, - ]} - scrollEventThrottle={16} - renderItem={({ item: route, index }: ListRenderItemInfo) => { - const props: TabBarItemProps & { key: string } = { - key: route.key, - position: position, - route: route, - navigationState: navigationState, - getAccessibilityLabel: getAccessibilityLabel, - getAccessible: getAccessible, - getLabelText: getLabelText, - getTestID: getTestID, - renderBadge: renderBadge, - renderIcon: renderIcon, - renderLabel: renderLabel, - activeColor: activeColor, - inactiveColor: inactiveColor, - pressColor: pressColor, - pressOpacity: pressOpacity, - onLayout: isWidthDynamic - ? (e) => { - this.measuredTabWidths[route.key] = - e.nativeEvent.layout.width; - - // When we have measured widths for all of the tabs, we should updates the state - // We avoid doing separate setState for each layout since it triggers multiple renders and slows down app - if ( - routes.every( - (r) => - typeof this.measuredTabWidths[r.key] === 'number' - ) - ) { - this.setState({ - tabWidths: { ...this.measuredTabWidths }, - }); - } - } - : undefined, - onPress: () => { - const event: Scene & Event = { - route, - defaultPrevented: false, - preventDefault: () => { - event.defaultPrevented = true; - }, - }; - - onTabPress?.(event); - - if (event.defaultPrevented) { - return; - } - - this.props.jumpTo(route.key); - }, - onLongPress: () => onTabLongPress?.({ route }), - labelStyle: labelStyle, - style: [ - tabStyle, - // Calculate the deafult width for tab for FlatList to work. - this.getFlattenedTabWidth(tabStyle) === undefined && { - width: this.getComputedTabWidth( - index, - layout, - routes, - scrollEnabled, - tabWidths, - this.getFlattenedTabWidth(tabStyle) - ), - }, - ], - }; - - return ( - - {gap > 0 && index > 0 ? : null} - {renderTabBarItem ? ( - renderTabBarItem(props) - ) : ( - - )} - - ); - }} - onScroll={Animated.event( - [ - { - nativeEvent: { - contentOffset: { x: this.scrollAmount }, - }, - }, - ], - { useNativeDriver: true } - )} - ref={this.flatListRef} - /> - + {renderIndicator({ + position, + layout, + navigationState, + jumpTo, + width: isWidthDynamic + ? 'auto' + : `${(100 - separatorPercent) / routes.length}%`, + style: indicatorStyle, + getTabWidth: (i: number) => + getComputedTabWidth( + i, + layout, + routes, + scrollEnabled, + tabWidths, + flattenedTabWidth + ), + gap, + })} - ); - } + + []} + keyExtractor={keyExtractor} + horizontal + accessibilityRole="tablist" + keyboardShouldPersistTaps="handled" + scrollEnabled={scrollEnabled} + bounces={bounces} + alwaysBounceHorizontal={false} + scrollsToTop={false} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + automaticallyAdjustContentInsets={false} + overScrollMode="never" + contentContainerStyle={contentContainerStyleMemoized} + scrollEventThrottle={16} + renderItem={renderItem} + onScroll={handleScroll} + ref={flatListRef} + /> + + + ); } const styles = StyleSheet.create({ diff --git a/packages/react-native-tab-view/src/TabBarIndicator.tsx b/packages/react-native-tab-view/src/TabBarIndicator.tsx index 6f236bc928..46322a8ffe 100644 --- a/packages/react-native-tab-view/src/TabBarIndicator.tsx +++ b/packages/react-native-tab-view/src/TabBarIndicator.tsx @@ -10,6 +10,7 @@ import { } from 'react-native'; import type { Route, SceneRendererProps, NavigationState } from './types'; +import useAnimatedValue from './useAnimatedValue'; export type GetTabWidth = (index: number) => number; @@ -21,126 +22,119 @@ export type Props = SceneRendererProps & { gap?: number; }; -export default class TabBarIndicator extends React.Component< - Props -> { - componentDidMount() { - this.fadeInIndicator(); - } +const getTranslateX = ( + position: Animated.AnimatedInterpolation, + routes: Route[], + getTabWidth: GetTabWidth, + gap?: number +) => { + const inputRange = routes.map((_, i) => i); + + // every index contains widths at all previous indices + const outputRange = routes.reduce((acc, _, i) => { + if (i === 0) return [0]; + return [...acc, acc[i - 1] + getTabWidth(i - 1) + (gap ?? 0)]; + }, []); + + const translateX = position.interpolate({ + inputRange, + outputRange, + extrapolate: 'clamp', + }); + + return Animated.multiply(translateX, I18nManager.isRTL ? -1 : 1); +}; - componentDidUpdate() { - this.fadeInIndicator(); +export default function TabBarIndicator({ + getTabWidth, + layout, + navigationState, + position, + width, + gap, + style, +}: Props) { + const isIndicatorShown = React.useRef(false); + const isWidthDynamic = width === 'auto'; + + const opacity = useAnimatedValue(isWidthDynamic ? 0 : 1); + + const hasMeasuredTabWidths = + Boolean(layout.width) && + navigationState.routes.every((_, i) => getTabWidth(i)); + + React.useEffect(() => { + const fadeInIndicator = () => { + if ( + !isIndicatorShown.current && + isWidthDynamic && + // We should fade-in the indicator when we have widths for all the tab items + hasMeasuredTabWidths + ) { + isIndicatorShown.current = true; + + Animated.timing(opacity, { + toValue: 1, + duration: 150, + easing: Easing.in(Easing.linear), + useNativeDriver: true, + }).start(); + } + }; + + fadeInIndicator(); + + return () => opacity.stopAnimation(); + }, [hasMeasuredTabWidths, isWidthDynamic, opacity]); + + const { routes } = navigationState; + + const transform = []; + + if (layout.width) { + const translateX = + routes.length > 1 ? getTranslateX(position, routes, getTabWidth, gap) : 0; + + transform.push({ translateX }); } - private fadeInIndicator = () => { - const { navigationState, layout, width, getTabWidth } = this.props; - - if ( - !this.isIndicatorShown && - width === 'auto' && - layout.width && - // We should fade-in the indicator when we have widths for all the tab items - navigationState.routes.every((_, i) => getTabWidth(i)) - ) { - this.isIndicatorShown = true; - - Animated.timing(this.opacity, { - toValue: 1, - duration: 150, - easing: Easing.in(Easing.linear), - useNativeDriver: true, - }).start(); - } - }; - - private isIndicatorShown = false; - - private opacity = new Animated.Value(this.props.width === 'auto' ? 0 : 1); - - private getTranslateX = ( - position: Animated.AnimatedInterpolation, - routes: Route[], - getTabWidth: GetTabWidth, - gap?: number - ) => { + if (width === 'auto') { const inputRange = routes.map((_, i) => i); - - // every index contains widths at all previous indices - const outputRange = routes.reduce((acc, _, i) => { - if (i === 0) return [0]; - return [...acc, acc[i - 1] + getTabWidth(i - 1) + (gap ?? 0)]; - }, []); - - const translateX = position.interpolate({ - inputRange, - outputRange, - extrapolate: 'clamp', - }); - - return Animated.multiply(translateX, I18nManager.isRTL ? -1 : 1); - }; - - render() { - const { - position, - navigationState, - getTabWidth, - width, - style, - layout, - gap, - } = this.props; - const { routes } = navigationState; - - const transform = []; - - if (layout.width) { - const translateX = - routes.length > 1 - ? this.getTranslateX(position, routes, getTabWidth, gap) - : 0; - - transform.push({ translateX }); - } - - if (width === 'auto') { - const inputRange = routes.map((_, i) => i); - const outputRange = inputRange.map(getTabWidth); - - transform.push( - { - scaleX: - routes.length > 1 - ? position.interpolate({ - inputRange, - outputRange, - extrapolate: 'clamp', - }) - : outputRange[0], - }, - { translateX: 0.5 } - ); - } - - return ( - + const outputRange = inputRange.map(getTabWidth); + + transform.push( + { + scaleX: + routes.length > 1 + ? position.interpolate({ + inputRange, + outputRange, + extrapolate: 'clamp', + }) + : outputRange[0], + }, + { translateX: 0.5 } ); } + + return ( + + ); } const styles = StyleSheet.create({ diff --git a/packages/react-native-tab-view/src/TabBarItem.tsx b/packages/react-native-tab-view/src/TabBarItem.tsx index 80d2e1c0e3..10b8d5f7f4 100644 --- a/packages/react-native-tab-view/src/TabBarItem.tsx +++ b/packages/react-native-tab-view/src/TabBarItem.tsx @@ -8,6 +8,7 @@ import { TextStyle, ViewStyle, } from 'react-native'; +import useLatestCallback from 'use-latest-callback'; import PlatformPressable from './PlatformPressable'; import type { Scene, Route, NavigationState } from './types'; @@ -37,6 +38,7 @@ export type Props = { onLayout?: (event: LayoutChangeEvent) => void; onPress: () => void; onLongPress: () => void; + defaultTabWidth?: number; labelStyle?: StyleProp; style: StyleProp; }; @@ -44,219 +46,246 @@ export type Props = { const DEFAULT_ACTIVE_COLOR = 'rgba(255, 255, 255, 1)'; const DEFAULT_INACTIVE_COLOR = 'rgba(255, 255, 255, 0.7)'; -export default class TabBarItem extends React.Component< - Props -> { - private getActiveOpacity = ( - position: Animated.AnimatedInterpolation, - routes: Route[], - tabIndex: number - ) => { - if (routes.length > 1) { - const inputRange = routes.map((_, i) => i); +const getActiveOpacity = ( + position: Animated.AnimatedInterpolation, + routesLength: number, + tabIndex: number +) => { + if (routesLength > 1) { + const inputRange = Array.from({ length: routesLength }, (_, i) => i); - return position.interpolate({ - inputRange, - outputRange: inputRange.map((i) => (i === tabIndex ? 1 : 0)), - }); - } else { - return 1; - } - }; - - private getInactiveOpacity = ( - position: Animated.AnimatedInterpolation, - routes: Route[], - tabIndex: number - ) => { - if (routes.length > 1) { - const inputRange = routes.map((_: Route, i: number) => i); - - return position.interpolate({ - inputRange, - outputRange: inputRange.map((i: number) => (i === tabIndex ? 0 : 1)), - }); - } else { - return 0; - } - }; - - render() { - const { - route, - position, - navigationState, - renderLabel: renderLabelCustom, - renderIcon, - renderBadge, - getLabelText, - getTestID, - getAccessibilityLabel, - getAccessible, - activeColor: activeColorCustom, - inactiveColor: inactiveColorCustom, - pressColor, - pressOpacity, - labelStyle, - style, - onLayout, - onPress, - onLongPress, - } = this.props; - - const tabIndex = navigationState.routes.indexOf(route); - const isFocused = navigationState.index === tabIndex; - - const labelColorFromStyle = StyleSheet.flatten(labelStyle || {}).color; - - const activeColor = - activeColorCustom !== undefined - ? activeColorCustom - : typeof labelColorFromStyle === 'string' - ? labelColorFromStyle - : DEFAULT_ACTIVE_COLOR; - const inactiveColor = - inactiveColorCustom !== undefined - ? inactiveColorCustom - : typeof labelColorFromStyle === 'string' - ? labelColorFromStyle - : DEFAULT_INACTIVE_COLOR; + return position.interpolate({ + inputRange, + outputRange: inputRange.map((i) => (i === tabIndex ? 1 : 0)), + }); + } else { + return 1; + } +}; - const activeOpacity = this.getActiveOpacity( - position, - navigationState.routes, - tabIndex - ); - const inactiveOpacity = this.getInactiveOpacity( - position, - navigationState.routes, - tabIndex - ); +const getInactiveOpacity = ( + position: Animated.AnimatedInterpolation, + routesLength: number, + tabIndex: number +) => { + if (routesLength > 1) { + const inputRange = Array.from({ length: routesLength }, (_, i) => i); - let icon: React.ReactNode | null = null; - let label: React.ReactNode | null = null; + return position.interpolate({ + inputRange, + outputRange: inputRange.map((i: number) => (i === tabIndex ? 0 : 1)), + }); + } else { + return 0; + } +}; - if (renderIcon) { - const activeIcon = renderIcon({ - route, - focused: true, - color: activeColor, - }); - const inactiveIcon = renderIcon({ - route, - focused: false, - color: inactiveColor, - }); +type TabBarItemInternalProps = Omit< + Props, + 'navigationState' +> & { + isFocused: boolean; + index: number; + routesLength: number; +}; - if (inactiveIcon != null && activeIcon != null) { - icon = ( - - - {inactiveIcon} - - - {activeIcon} - - - ); - } - } +const TabBarItemInternal = ({ + getAccessibilityLabel, + getAccessible, + getLabelText, + getTestID, + onLongPress, + onPress, + isFocused, + position, + route, + style, + inactiveColor: inactiveColorCustom, + activeColor: activeColorCustom, + labelStyle, + onLayout, + index: tabIndex, + pressColor, + pressOpacity, + renderBadge, + renderIcon, + defaultTabWidth, + routesLength, + renderLabel: renderLabelCustom, +}: TabBarItemInternalProps) => { + const labelColorFromStyle = StyleSheet.flatten(labelStyle || {}).color; - const renderLabel = - renderLabelCustom !== undefined - ? renderLabelCustom - : ({ route, color }: { route: T; color: string }) => { - const labelText = getLabelText({ route }); + const activeColor = + activeColorCustom !== undefined + ? activeColorCustom + : typeof labelColorFromStyle === 'string' + ? labelColorFromStyle + : DEFAULT_ACTIVE_COLOR; + const inactiveColor = + inactiveColorCustom !== undefined + ? inactiveColorCustom + : typeof labelColorFromStyle === 'string' + ? labelColorFromStyle + : DEFAULT_INACTIVE_COLOR; - if (typeof labelText === 'string') { - return ( - - {labelText} - - ); - } + const activeOpacity = getActiveOpacity(position, routesLength, tabIndex); + const inactiveOpacity = getInactiveOpacity(position, routesLength, tabIndex); - return labelText; - }; + let icon: React.ReactNode | null = null; + let label: React.ReactNode | null = null; - if (renderLabel) { - const activeLabel = renderLabel({ - route, - focused: true, - color: activeColor, - }); - const inactiveLabel = renderLabel({ - route, - focused: false, - color: inactiveColor, - }); + if (renderIcon) { + const activeIcon = renderIcon({ + route, + focused: true, + color: activeColor, + }); + const inactiveIcon = renderIcon({ + route, + focused: false, + color: inactiveColor, + }); - label = ( - + if (inactiveIcon != null && activeIcon != null) { + icon = ( + - {inactiveLabel} + {inactiveIcon} - {activeLabel} + {activeIcon} ); } + } - const tabStyle = StyleSheet.flatten(style); - const isWidthSet = tabStyle?.width !== undefined; - const tabContainerStyle: ViewStyle | null = isWidthSet ? null : { flex: 1 }; - - const scene = { route }; + const renderLabel = + renderLabelCustom !== undefined + ? renderLabelCustom + : (labelProps: { route: T; color: string }) => { + const labelText = getLabelText({ route: labelProps.route }); - let accessibilityLabel = getAccessibilityLabel(scene); + if (typeof labelText === 'string') { + return ( + + {labelText} + + ); + } - accessibilityLabel = - typeof accessibilityLabel !== 'undefined' - ? accessibilityLabel - : getLabelText(scene); + return labelText; + }; - const badge = renderBadge ? renderBadge(scene) : null; + if (renderLabel) { + const activeLabel = renderLabel({ + route, + focused: true, + color: activeColor, + }); + const inactiveLabel = renderLabel({ + route, + focused: false, + color: inactiveColor, + }); - return ( - - - {icon} - {label} - {badge != null ? {badge} : null} - - + label = ( + + + {inactiveLabel} + + + {activeLabel} + + ); } + + const tabStyle = StyleSheet.flatten(style); + const isWidthSet = tabStyle?.width !== undefined; + + const tabContainerStyle: ViewStyle | null = isWidthSet + ? null + : { width: defaultTabWidth }; + + const scene = { route }; + + let accessibilityLabel = getAccessibilityLabel(scene); + + accessibilityLabel = + typeof accessibilityLabel !== 'undefined' + ? accessibilityLabel + : getLabelText(scene); + + const badge = renderBadge ? renderBadge(scene) : null; + + return ( + + + {icon} + {label} + {badge != null ? {badge} : null} + + + ); +}; + +const MemoizedTabBarItemInternal = React.memo( + TabBarItemInternal +) as typeof TabBarItemInternal; + +function TabBarItem(props: Props) { + const { onPress, onLongPress, onLayout, navigationState, route, ...rest } = + props; + const onPressLatest = useLatestCallback(onPress); + const onLongPressLatest = useLatestCallback(onLongPress); + const onLayoutLatest = useLatestCallback(onLayout ? onLayout : () => {}); + + const tabIndex = navigationState.routes.indexOf(route); + + return ( + + ); } +export default TabBarItem; + const styles = StyleSheet.create({ label: { margin: 4, diff --git a/packages/react-native-tab-view/yarn.lock b/packages/react-native-tab-view/yarn.lock index 1edb86c68f..74efc45eb5 100644 --- a/packages/react-native-tab-view/yarn.lock +++ b/packages/react-native-tab-view/yarn.lock @@ -10717,6 +10717,11 @@ url-join@5.0.0: resolved "https://registry.yarnpkg.com/url-join/-/url-join-5.0.0.tgz#c2f1e5cbd95fa91082a93b58a1f42fecb4bdbcf1" integrity sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA== +use-latest-callback@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.5.tgz#a4a836c08fa72f6608730b5b8f4bbd9c57c04f51" + integrity sha512-HtHatS2U4/h32NlkhupDsPlrbiD27gSH5swBdtXbCAlc6pfOFzaj0FehW/FO12rx8j2Vy4/lJScCiJyM01E+bQ== + use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"