react-native-tab-view
Version:
Tab view component for React Native
472 lines (467 loc) • 15.4 kB
JavaScript
"use strict";
import * as React from 'react';
import { Animated, I18nManager, Platform, StyleSheet, View } from 'react-native';
import useLatestCallback from 'use-latest-callback';
import { TabBarIndicator } from "./TabBarIndicator.js";
import { TabBarItem } from "./TabBarItem.js";
import { useAnimatedValue } from "./useAnimatedValue.js";
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { createElement as _createElement } from "react";
const useNativeDriver = Platform.OS !== 'web';
const Separator = ({
width
}) => {
return /*#__PURE__*/_jsx(View, {
style: {
width
}
});
};
const getFlattenedTabWidth = style => {
const tabStyle = StyleSheet.flatten(style);
return tabStyle?.width;
};
const getFlattenedPaddingStart = style => {
const flattenStyle = StyleSheet.flatten(style);
return flattenStyle ? flattenStyle.paddingLeft || flattenStyle.paddingStart || flattenStyle.paddingHorizontal || 0 : 0;
};
const getFlattenedPaddingEnd = style => {
const flattenStyle = StyleSheet.flatten(style);
return flattenStyle ? flattenStyle.paddingRight || flattenStyle.paddingEnd || flattenStyle.paddingHorizontal || 0 : 0;
};
const convertPaddingPercentToSize = (value, layout) => {
switch (typeof value) {
case 'number':
return value;
case 'string':
if (value.endsWith('%')) {
const width = parseFloat(value);
if (Number.isFinite(width)) {
return layout.width * (width / 100);
}
}
}
return 0;
};
const getComputedTabWidth = (index, layout, routes, scrollEnabled, tabWidths, flattenedWidth, flattenedPaddingStart, flattenedPaddingEnd, gap) => {
if (flattenedWidth === 'auto') {
return tabWidths[routes[index].key] || 0;
}
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 (scrollEnabled) {
return layout.width / 5 * 2;
}
const gapTotalWidth = (gap ?? 0) * (routes.length - 1);
const paddingTotalWidth = convertPaddingPercentToSize(flattenedPaddingStart, layout) + convertPaddingPercentToSize(flattenedPaddingEnd, layout);
return (layout.width - gapTotalWidth - paddingTotalWidth) / routes.length;
};
const getMaxScrollDistance = (tabBarWidth, layoutWidth) => tabBarWidth - layoutWidth;
const getTranslateX = (scrollAmount, maxScrollDistance, direction) => Animated.multiply(Platform.OS === 'android' && direction === 'rtl' ? Animated.add(maxScrollDistance, Animated.multiply(scrollAmount, -1)) : scrollAmount, direction === 'rtl' ? 1 : -1);
const getTabBarWidth = ({
navigationState,
layout,
gap,
scrollEnabled,
flattenedTabWidth,
flattenedPaddingStart,
flattenedPaddingEnd,
tabWidths
}) => {
const {
routes
} = navigationState;
const paddingsWidth = Math.max(0, convertPaddingPercentToSize(flattenedPaddingStart, layout) + convertPaddingPercentToSize(flattenedPaddingEnd, layout));
return routes.reduce((acc, _, i) => acc + (i > 0 ? gap ?? 0 : 0) + getComputedTabWidth(i, layout, routes, scrollEnabled, tabWidths, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, gap), paddingsWidth);
};
const normalizeScrollValue = ({
layout,
navigationState,
gap,
scrollEnabled,
tabWidths,
value,
flattenedTabWidth,
flattenedPaddingStart,
flattenedPaddingEnd,
direction
}) => {
const tabBarWidth = getTabBarWidth({
layout,
navigationState,
tabWidths,
gap,
scrollEnabled,
flattenedTabWidth,
flattenedPaddingStart,
flattenedPaddingEnd
});
const maxDistance = getMaxScrollDistance(tabBarWidth, layout.width);
const scrollValue = Math.max(Math.min(value, maxDistance), 0);
if (Platform.OS === 'android' && direction === 'rtl') {
// 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;
};
const getScrollAmount = ({
layout,
navigationState,
gap,
scrollEnabled,
flattenedTabWidth,
tabWidths,
flattenedPaddingStart,
flattenedPaddingEnd,
direction
}) => {
const paddingInitial = direction === 'rtl' ? convertPaddingPercentToSize(flattenedPaddingEnd, layout) : convertPaddingPercentToSize(flattenedPaddingStart, layout);
const centerDistance = Array.from({
length: navigationState.index + 1
}).reduce((total, _, i) => {
const tabWidth = getComputedTabWidth(i, layout, navigationState.routes, scrollEnabled, tabWidths, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, gap);
// 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 + (i > 0 ? gap ?? 0 : 0) + (navigationState.index === i ? tabWidth / 2 : tabWidth);
}, paddingInitial);
const scrollAmount = centerDistance - layout.width / 2;
return normalizeScrollValue({
layout,
navigationState,
tabWidths,
value: scrollAmount,
gap,
scrollEnabled,
flattenedTabWidth,
flattenedPaddingStart,
flattenedPaddingEnd,
direction
});
};
const getLabelTextDefault = ({
route
}) => route.title;
const getAccessibleDefault = ({
route
}) => typeof route.accessible !== 'undefined' ? route.accessible : true;
const getAccessibilityLabelDefault = ({
route
}) => typeof route.accessibilityLabel === 'string' ? route.accessibilityLabel : typeof route.title === 'string' ? route.title : undefined;
const renderIndicatorDefault = props => /*#__PURE__*/_jsx(TabBarIndicator, {
...props
});
const getTestIdDefault = ({
route
}) => route.testID;
// How many items measurements should we update per batch.
// Defaults to 10, since that's whats FlatList is using in initialNumToRender.
const MEASURE_PER_BATCH = 10;
export function TabBar({
renderIndicator = renderIndicatorDefault,
gap = 0,
scrollEnabled,
jumpTo,
navigationState,
position,
activeColor,
bounces,
contentContainerStyle,
inactiveColor,
indicatorContainerStyle,
indicatorStyle,
onTabLongPress,
onTabPress,
pressColor,
pressOpacity,
direction = I18nManager.getConstants().isRTL ? 'rtl' : 'ltr',
renderTabBarItem,
style,
tabStyle,
layout: propLayout,
testID,
android_ripple,
options
}) {
const [layout, setLayout] = React.useState(propLayout ?? {
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 flattenedPaddingEnd = getFlattenedPaddingEnd(contentContainerStyle);
const flattenedPaddingStart = getFlattenedPaddingStart(contentContainerStyle);
const scrollOffset = getScrollAmount({
layout,
navigationState,
tabWidths,
gap,
scrollEnabled,
flattenedTabWidth,
flattenedPaddingStart,
flattenedPaddingEnd,
direction
});
const hasMeasuredTabWidths = Boolean(layout.width) && routes.slice(0, navigationState.index).every(r => typeof tabWidths[r.key] === 'number');
React.useEffect(() => {
if (isFirst.current) {
isFirst.current = false;
return;
}
if (isWidthDynamic && !hasMeasuredTabWidths) {
return;
}
if (scrollEnabled) {
flatListRef.current?.scrollToOffset({
offset: scrollOffset,
animated: true
});
}
}, [hasMeasuredTabWidths, isWidthDynamic, scrollEnabled, scrollOffset]);
const handleLayout = e => {
const {
height,
width
} = e.nativeEvent.layout;
setLayout(layout => layout.width === width && layout.height === height ? layout : {
width,
height
});
};
const tabBarWidth = getTabBarWidth({
layout,
navigationState,
tabWidths,
gap,
scrollEnabled,
flattenedTabWidth,
flattenedPaddingStart,
flattenedPaddingEnd
});
const separatorsWidth = Math.max(0, routes.length - 1) * gap;
const paddingsWidth = Math.max(0, convertPaddingPercentToSize(flattenedPaddingStart, layout) + convertPaddingPercentToSize(flattenedPaddingEnd, layout));
const translateX = React.useMemo(() => getTranslateX(scrollAmount, getMaxScrollDistance(tabBarWidth, layout.width), direction), [direction, layout.width, scrollAmount, tabBarWidth]);
const renderItem = React.useCallback(({
item: route,
index
}) => {
const {
testID = getTestIdDefault({
route
}),
labelText = getLabelTextDefault({
route
}),
accessible = getAccessibleDefault({
route
}),
accessibilityLabel = getAccessibilityLabelDefault({
route
}),
...rest
} = options?.[route.key] ?? {};
const onLayout = isWidthDynamic ? e => {
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 we have more than 10 routes divide updating tabWidths into multiple batches. Here we update only first batch of 10 items.
if (routes.length > MEASURE_PER_BATCH && index === MEASURE_PER_BATCH && routes.slice(0, MEASURE_PER_BATCH).every(r => typeof measuredTabWidths.current[r.key] === 'number')) {
setTabWidths({
...measuredTabWidths.current
});
} else if (routes.every(r => typeof measuredTabWidths.current[r.key] === 'number')) {
// 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
setTabWidths({
...measuredTabWidths.current
});
}
} : undefined;
const onPress = () => {
const event = {
route,
defaultPrevented: false,
preventDefault: () => {
event.defaultPrevented = true;
}
};
onTabPress?.(event);
if (event.defaultPrevented) {
return;
}
jumpTo(route.key);
};
const onLongPress = () => onTabLongPress?.({
route
});
// Calculate the default width for tab for FlatList to work
const defaultTabWidth = !isWidthDynamic ? getComputedTabWidth(index, layout, routes, scrollEnabled, tabWidths, getFlattenedTabWidth(tabStyle), getFlattenedPaddingEnd(contentContainerStyle), getFlattenedPaddingStart(contentContainerStyle), gap) : undefined;
const props = {
...rest,
key: route.key,
position,
route,
navigationState,
testID,
labelText,
accessible,
accessibilityLabel,
activeColor,
inactiveColor,
pressColor,
pressOpacity,
onLayout,
onPress,
onLongPress,
style: tabStyle,
defaultTabWidth,
android_ripple
};
return /*#__PURE__*/_jsxs(_Fragment, {
children: [gap > 0 && index > 0 ? /*#__PURE__*/_jsx(Separator, {
width: gap
}) : null, renderTabBarItem ? renderTabBarItem(props) : /*#__PURE__*/_createElement(TabBarItem, {
...props,
key: props.key
})]
});
}, [position, navigationState, options, activeColor, inactiveColor, pressColor, pressOpacity, isWidthDynamic, tabStyle, layout, routes, scrollEnabled, tabWidths, contentContainerStyle, gap, android_ripple, renderTabBarItem, onTabPress, jumpTo, onTabLongPress]);
const keyExtractor = React.useCallback(item => item.key, []);
const contentContainerStyleMemoized = React.useMemo(() => [styles.tabContent, scrollEnabled ? {
width: tabBarWidth
} : null, contentContainerStyle], [contentContainerStyle, scrollEnabled, tabBarWidth]);
const handleScroll = React.useMemo(() => Animated.event([{
nativeEvent: {
contentOffset: {
x: scrollAmount
}
}
}], {
useNativeDriver
}), [scrollAmount]);
const handleViewableItemsChanged = useLatestCallback(({
changed
}) => {
if (routes.length <= MEASURE_PER_BATCH) {
return;
}
// Get next vievable item
const item = changed[changed.length - 1];
const index = item?.index || 0;
if (item.isViewable && (index % 10 === 0 || index === navigationState.index || index === routes.length - 1)) {
setTabWidths({
...measuredTabWidths.current
});
}
});
return /*#__PURE__*/_jsxs(Animated.View, {
onLayout: handleLayout,
style: [styles.tabBar, style],
children: [/*#__PURE__*/_jsx(Animated.View, {
pointerEvents: "none",
style: [styles.indicatorContainer, scrollEnabled ? {
transform: [{
translateX
}]
} : null, scrollEnabled ? {
width: tabBarWidth
} : null, indicatorContainerStyle],
children: renderIndicator({
position,
layout,
navigationState,
jumpTo,
direction,
width: isWidthDynamic ? 'auto' : Math.max(0, (tabBarWidth - separatorsWidth - paddingsWidth) / routes.length),
style: [indicatorStyle, {
start: flattenedPaddingStart,
end: flattenedPaddingEnd
}],
getTabWidth: i => getComputedTabWidth(i, layout, routes, scrollEnabled, tabWidths, flattenedTabWidth, flattenedPaddingEnd, flattenedPaddingStart, gap),
gap
})
}), /*#__PURE__*/_jsx(View, {
style: styles.scroll,
children: /*#__PURE__*/_jsx(Animated.FlatList, {
data: routes,
keyExtractor: keyExtractor,
horizontal: true,
role: "tablist",
keyboardShouldPersistTaps: "handled",
scrollEnabled: scrollEnabled,
bounces: bounces,
initialNumToRender: MEASURE_PER_BATCH,
onViewableItemsChanged: handleViewableItemsChanged,
alwaysBounceHorizontal: false,
scrollsToTop: false,
showsHorizontalScrollIndicator: false,
showsVerticalScrollIndicator: false,
automaticallyAdjustContentInsets: false,
overScrollMode: "never",
contentContainerStyle: contentContainerStyleMemoized,
scrollEventThrottle: 16,
renderItem: renderItem,
onScroll: handleScroll,
ref: flatListRef,
testID: testID
})
})]
});
}
const styles = StyleSheet.create({
scroll: {
overflow: Platform.select({
default: 'scroll',
web: undefined
})
},
tabBar: {
zIndex: 1,
backgroundColor: '#2196f3',
elevation: 4,
...Platform.select({
default: {
shadowColor: 'black',
shadowOpacity: 0.1,
shadowRadius: StyleSheet.hairlineWidth,
shadowOffset: {
height: StyleSheet.hairlineWidth,
width: 0
}
},
web: {
boxShadow: '0 1px 1px rgba(0, 0, 0, 0.1)'
}
})
},
tabContent: {
flexGrow: 1,
flexDirection: 'row',
flexWrap: 'nowrap'
},
indicatorContainer: {
position: 'absolute',
top: 0,
start: 0,
end: 0,
bottom: 0
}
});
//# sourceMappingURL=TabBar.js.map