/* @flow */
import * as React from 'react';
import PropTypes from 'prop-types';
import {
Animated,
NativeModules,
StyleSheet,
View,
ScrollView,
Platform,
I18nManager,
} from 'react-native';
import TouchableItem from './TouchableItem';
import { SceneRendererPropType } from './PropTypes';
import type { Scene, SceneRendererProps } from './TypeDefinitions';
import type {
ViewStyleProp,
TextStyleProp,
} from 'react-native/Libraries/StyleSheet/StyleSheet';
type IndicatorProps<T> = SceneRendererProps<T> & {
width: number,
};
type Props<T> = SceneRendererProps<T> & {
scrollEnabled?: boolean,
bounces?: boolean,
pressColor?: string,
pressOpacity?: number,
getLabelText: (scene: Scene<T>) => ?string,
getAccessible: (scene: Scene<T>) => ?boolean,
getAccessibilityLabel: (scene: Scene<T>) => ?string,
getTestID: (scene: Scene<T>) => ?string,
renderLabel?: (scene: Scene<T>) => React.Node,
renderIcon?: (scene: Scene<T>) => React.Node,
renderBadge?: (scene: Scene<T>) => React.Node,
renderIndicator?: (props: IndicatorProps<T>) => React.Node,
onTabPress?: (scene: Scene<T>) => mixed,
onTabLongPress?: (scene: Scene<T>) => mixed,
tabStyle?: ViewStyleProp,
indicatorStyle?: ViewStyleProp,
labelStyle?: TextStyleProp,
style?: ViewStyleProp,
};
type State = {|
visibility: Animated.Value,
scrollAmount: Animated.Value,
initialOffset: ?{| x: number, y: number |},
|};
const useNativeDriver = Boolean(NativeModules.NativeAnimatedModule);
export default class TabBar<T: *> extends React.Component<Props<T>, State> {
static propTypes = {
...SceneRendererPropType,
scrollEnabled: PropTypes.bool,
bounces: PropTypes.bool,
pressColor: TouchableItem.propTypes.pressColor,
pressOpacity: TouchableItem.propTypes.pressOpacity,
getLabelText: PropTypes.func,
getAccessible: PropTypes.func,
getAccessibilityLabel: PropTypes.func,
getTestID: PropTypes.func,
renderIcon: PropTypes.func,
renderLabel: PropTypes.func,
renderIndicator: PropTypes.func,
onTabPress: PropTypes.func,
onTabLongPress: PropTypes.func,
labelStyle: PropTypes.any,
style: PropTypes.any,
};
static defaultProps = {
getLabelText: ({ route }: Scene<T>) =>
typeof route.title === 'string' ? route.title.toUpperCase() : route.title,
getAccessible: ({ route }: Scene<T>) =>
typeof route.accessible !== 'undefined' ? route.accessible : true,
getAccessibilityLabel: ({ route }: Scene<T>) => route.accessibilityLabel,
getTestID: ({ route }: Scene<T>) => route.testID,
};
constructor(props: Props<T>) {
super(props);
let initialVisibility = 1;
if (this.props.scrollEnabled) {
const tabWidth = this._getTabWidth(this.props);
if (!tabWidth) {
initialVisibility = 0;
}
}
const initialOffset =
this.props.scrollEnabled && this.props.layout.width
? {
x: this._getScrollAmount(
this.props,
this.props.navigationState.index
),
y: 0,
}
: undefined;
this.state = {
visibility: new Animated.Value(initialVisibility),
scrollAmount: new Animated.Value(0),
initialOffset,
};
}
componentDidMount() {
this.props.scrollEnabled && this._startTrackingPosition();
}
componentDidUpdate(prevProps: Props<T>) {
const prevTabWidth = this._getTabWidth(prevProps);
const currentTabWidth = this._getTabWidth(this.props);
const pendingIndex =
typeof this._pendingIndex === 'number'
? this._pendingIndex
: this.props.navigationState.index;
this._pendingIndex = null;
if (prevTabWidth !== currentTabWidth && currentTabWidth) {
this.state.visibility.setValue(1);
}
if (
prevProps.navigationState.routes.length !==
this.props.navigationState.routes.length ||
prevProps.layout.width !== this.props.layout.width
) {
this._resetScroll(this.props.navigationState.index, false);
} else if (prevProps.navigationState.index !== pendingIndex) {
this._resetScroll(this.props.navigationState.index);
}
}
componentWillUnmount() {
this._stopTrackingPosition();
}
_scrollView: ?ScrollView;
_isIntial: boolean = true;
_isManualScroll: boolean = false;
_isMomentumScroll: boolean = false;
_pendingIndex: ?number;
_scrollResetCallback: any;
_lastPanX: ?number;
_lastOffsetX: ?number;
_panXListener: string;
_offsetXListener: string;
_startTrackingPosition = () => {
this._offsetXListener = this.props.offsetX.addListener(({ value }) => {
this._lastOffsetX = value;
this._handlePosition();
});
this._panXListener = this.props.panX.addListener(({ value }) => {
this._lastPanX = value;
this._handlePosition();
});
};
_stopTrackingPosition = () => {
this.props.offsetX.removeListener(this._offsetXListener);
this.props.panX.removeListener(this._panXListener);
};
_handlePosition = () => {
const { navigationState, layout } = this.props;
if (layout.width === 0) {
// Don't do anything if we don't have layout yet
return;
}
const panX = typeof this._lastPanX === 'number' ? this._lastPanX : 0;
const offsetX =
typeof this._lastOffsetX === 'number'
? this._lastOffsetX
: -navigationState.index * layout.width;
const value = (panX + offsetX) / -(layout.width || 0.001);
this._adjustScroll(value);
};
_renderLabel = (scene: Scene<*>) => {
if (typeof this.props.renderLabel !== 'undefined') {
return this.props.renderLabel(scene);
}
const label = this.props.getLabelText(scene);
if (typeof label !== 'string') {
return null;
}
return (
<Animated.Text style={[styles.tabLabel, this.props.labelStyle]}>
{label}
</Animated.Text>
);
};
_renderIndicator = (props: IndicatorProps<T>) => {
if (typeof this.props.renderIndicator !== 'undefined') {
return this.props.renderIndicator(props);
}
const { width, position, navigationState } = props;
const translateX = Animated.multiply(
Animated.multiply(
position.interpolate({
inputRange: [-1, navigationState.routes.length],
outputRange: [-1, navigationState.routes.length],
extrapolate: 'clamp',
}),
width
),
I18nManager.isRTL ? -1 : 1
);
return (
<Animated.View
style={[
styles.indicator,
{ width, transform: [{ translateX }] },
this.props.indicatorStyle,
]}
/>
);
};
_getTabWidth = props => {
const { layout, navigationState, tabStyle } = props;
const flattened = StyleSheet.flatten(tabStyle);
if (flattened) {
switch (typeof flattened.width) {
case 'number':
return flattened.width;
case 'string':
if (flattened.width.endsWith('%')) {
const width = parseFloat(flattened.width);
if (Number.isFinite(width)) {
return layout.width * (width / 100);
}
}
}
}
if (props.scrollEnabled) {
return (layout.width / 5) * 2;
}
return layout.width / navigationState.routes.length;
};
_handleTabPress = ({ route }: Scene<*>) => {
this._pendingIndex = this.props.navigationState.routes.indexOf(route);
if (this.props.onTabPress) {
this.props.onTabPress({ route });
}
this.props.jumpTo(route.key);
};
_handleTabLongPress = ({ route }: Scene<*>) => {
if (this.props.onTabLongPress) {
this.props.onTabLongPress({ route });
}
};
_normalizeScrollValue = (props, value) => {
const { layout, navigationState } = props;
const tabWidth = this._getTabWidth(props);
const tabBarWidth = Math.max(
tabWidth * navigationState.routes.length,
layout.width
);
const maxDistance = tabBarWidth - layout.width;
return Math.max(Math.min(value, maxDistance), 0);
};
_getScrollAmount = (props, i) => {
const { layout } = props;
const tabWidth = this._getTabWidth(props);
const centerDistance = tabWidth * (i + 1 / 2);
const scrollAmount = centerDistance - layout.width / 2;
return this._normalizeScrollValue(props, scrollAmount);
};
_adjustScroll = (value: number) => {
if (this.props.scrollEnabled) {
global.cancelAnimationFrame(this._scrollResetCallback);
this._scrollView &&
this._scrollView.scrollTo({
x: this._normalizeScrollValue(
this.props,
this._getScrollAmount(this.props, value)
),
animated: !this._isIntial, // Disable animation for the initial render
});
this._isIntial = false;
}
};
_resetScroll = (value: number, animated = true) => {
if (this.props.scrollEnabled) {
global.cancelAnimationFrame(this._scrollResetCallback);
this._scrollResetCallback = global.requestAnimationFrame(() => {
this._scrollView &&
this._scrollView.scrollTo({
x: this._getScrollAmount(this.props, value),
animated,
});
});
}
};
_handleBeginDrag = () => {
// onScrollBeginDrag fires when user touches the ScrollView
this._isManualScroll = true;
this._isMomentumScroll = false;
};
_handleEndDrag = () => {
// onScrollEndDrag fires when user lifts his finger
// onMomentumScrollBegin fires after touch end
// run the logic in next frame so we get onMomentumScrollBegin first
global.requestAnimationFrame(() => {
if (this._isMomentumScroll) {
return;
}
this._isManualScroll = false;
});
};
_handleMomentumScrollBegin = () => {
// onMomentumScrollBegin fires on flick, as well as programmatic scroll
this._isMomentumScroll = true;
};
_handleMomentumScrollEnd = () => {
// onMomentumScrollEnd fires when the scroll finishes
this._isMomentumScroll = false;
this._isManualScroll = false;
};
render() {
const { position, navigationState, scrollEnabled, bounces } = this.props;
const { routes } = navigationState;
const tabWidth = this._getTabWidth(this.props);
const tabBarWidth = tabWidth * routes.length;
// Prepend '-1', so there are always at least 2 items in inputRange
const inputRange = [-1, ...routes.map((x, i) => i)];
const translateX = Animated.multiply(this.state.scrollAmount, -1);
return (
<Animated.View style={[styles.tabBar, this.props.style]}>
<Animated.View
pointerEvents="none"
style={[
styles.indicatorContainer,
scrollEnabled
? { width: tabBarWidth, transform: [{ translateX }] }
: null,
]}
>
{this._renderIndicator({
...this.props,
width: tabWidth,
})}
</Animated.View>
<View style={styles.scroll}>
<Animated.ScrollView
horizontal
keyboardShouldPersistTaps="handled"
scrollEnabled={scrollEnabled}
bounces={bounces}
alwaysBounceHorizontal={false}
scrollsToTop={false}
showsHorizontalScrollIndicator={false}
automaticallyAdjustContentInsets={false}
overScrollMode="never"
contentContainerStyle={[
styles.tabContent,
scrollEnabled ? null : styles.container,
]}
scrollEventThrottle={1}
onScroll={Animated.event(
[
{
nativeEvent: {
contentOffset: { x: this.state.scrollAmount },
},
},
],
{ useNativeDriver }
)}
onScrollBeginDrag={this._handleBeginDrag}
onScrollEndDrag={this._handleEndDrag}
onMomentumScrollBegin={this._handleMomentumScrollBegin}
onMomentumScrollEnd={this._handleMomentumScrollEnd}
contentOffset={this.state.initialOffset}
ref={el => (this._scrollView = el && el.getNode())}
>
{routes.map((route, i) => {
const outputRange = inputRange.map(
inputIndex => (inputIndex === i ? 1 : 0.7)
);
const opacity = Animated.multiply(
this.state.visibility,
position.interpolate({
inputRange,
outputRange,
})
);
const label = this._renderLabel({ route });
const icon = this.props.renderIcon
? this.props.renderIcon({ route })
: null;
const badge = this.props.renderBadge
? this.props.renderBadge({ route })
: null;
const tabStyle = {};
tabStyle.opacity = opacity;
if (icon) {
if (label) {
tabStyle.paddingTop = 8;
} else {
tabStyle.padding = 12;
}
}
const passedTabStyle = StyleSheet.flatten(this.props.tabStyle);
const isWidthSet =
(passedTabStyle &&
typeof passedTabStyle.width !== 'undefined') ||
scrollEnabled === true;
const tabContainerStyle = {};
if (isWidthSet) {
tabStyle.width = tabWidth;
}
if (passedTabStyle && typeof passedTabStyle.flex === 'number') {
tabContainerStyle.flex = passedTabStyle.flex;
} else if (!isWidthSet) {
tabContainerStyle.flex = 1;
}
let accessibilityLabel = this.props.getAccessibilityLabel({
route,
});
accessibilityLabel =
typeof accessibilityLabel !== 'undefined'
? accessibilityLabel
: this.props.getLabelText({ route });
const isFocused = i === navigationState.index;
return (
<TouchableItem
borderless
key={route.key}
testID={this.props.getTestID({ route })}
accessible={this.props.getAccessible({ route })}
accessibilityLabel={accessibilityLabel}
accessibilityTraits={
isFocused ? ['button', 'selected'] : 'button'
}
accessibilityComponentType="button"
accessibilityRole="button"
accessibilityStates={isFocused ? ['selected'] : []}
pressColor={this.props.pressColor}
pressOpacity={this.props.pressOpacity}
delayPressIn={0}
onPress={() => this._handleTabPress({ route })}
onLongPress={() => this._handleTabLongPress({ route })}
style={tabContainerStyle}
>
<View pointerEvents="none" style={styles.container}>
<Animated.View
style={[
styles.tabItem,
tabStyle,
passedTabStyle,
styles.container,
]}
>
{icon}
{label}
</Animated.View>
{badge ? (
<Animated.View
style={[
styles.badge,
{ opacity: this.state.visibility },
]}
>
{badge}
</Animated.View>
) : null}
</View>
</TouchableItem>
);
})}
</Animated.ScrollView>
</View>
</Animated.View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scroll: {
overflow: Platform.OS === 'web' ? ('auto': any) : 'scroll',
},
tabBar: {
backgroundColor: '#2196f3',
elevation: 4,
shadowColor: 'black',
shadowOpacity: 0.1,
shadowRadius: StyleSheet.hairlineWidth,
shadowOffset: {
height: StyleSheet.hairlineWidth,
},
// We don't need zIndex on Android, disable it since it's buggy
zIndex: Platform.OS === 'android' ? 0 : 1,
},
tabContent: {
flexDirection: 'row',
flexWrap: 'nowrap',
},
tabLabel: {
backgroundColor: 'transparent',
color: 'white',
margin: 8,
},
tabItem: {
flex: 1,
padding: 8,
alignItems: 'center',
justifyContent: 'center',
},
badge: {
position: 'absolute',
top: 0,
right: 0,
},
indicatorContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
indicator: {
backgroundColor: '#ffeb3b',
position: 'absolute',
left: 0,
bottom: 0,
right: 0,
height: 2,
},
});