UNPKG

@toxclient/shathui

Version:

Platform-agnostic Chat UI components for The Universal Tox Client.

315 lines (275 loc) 9.17 kB
import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Animated, View, Text, Platform, Dimensions, PanResponder } from 'react-native'; import { noSelect } from '../../utils'; import Touchable from '../Touchable'; import styles from './styles'; class TabsView extends Component { constructor(props) { super(props); this.canMoveScreen = this.canMoveScreen.bind(this); this.startGesture = this.startGesture.bind(this); this.respondToGesture = this.respondToGesture.bind(this); this.terminateGesture = this.terminateGesture.bind(this); this.state = { tabsCount: 0, selectedIndex: 0, previousIndex: 0, pendingIndex: null, animated: new Animated.Value(0), offsetX: new Animated.Value(0) }; } componentWillMount() { const { defaultTabIndex, children } = this.props; const { width } = this.state; const tabsCount = React.Children.toArray(children).length; this.setState({ selectedIndex: defaultTabIndex, tabsCount, offsetX: new Animated.Value(tabsCount * width) }); this.panResponder = PanResponder.create({ onMoveShouldSetPanResponder: this.canMoveScreen, onMoveShouldSetPanResponderCapture: this.canMoveScreen, onPanResponderGrant: this.startGesture, onPanResponderMove: this.respondToGesture, onPanResponderTerminate: this.terminateGesture, onPanResponderRelease: this.terminateGesture, onPanResponderTerminationRequest: () => true }); } componentDidMount() { this.transitionTo(this.props.defaultTabIndex, false); } shouldComponentUpdate(nextProps, nextState) { const { previousIndex, selectedIndex, tabsCount } = this.state; if (previousIndex === null || selectedIndex !== nextState.selectedIndex || tabsCount <= 0) { return false; } return true; } isMovingHorizontally(evt, gestureState) { const { swipeTolerance } = this.props; return ( Math.abs(gestureState.dx) > Math.abs(gestureState.dy * swipeTolerance) && Math.abs(gestureState.vx) > Math.abs(gestureState.vy * swipeTolerance) ); } canMoveScreen(evt, gestureState) { const { deadZone } = this.props; const { tabsCount, selectedIndex } = this.state; return ( this.isMovingHorizontally(evt, gestureState) && ((gestureState.dx >= deadZone && selectedIndex > 0) || (gestureState.dx <= -deadZone && selectedIndex < tabsCount - 1)) ); } startGesture(evt, gestureState) { const { onSwipeStart } = this.props; if (typeof onSwipeStart === 'function') { onSwipeStart(evt, gestureState); } this.state.animated.stopAnimation(); } respondToGesture(evt, gestureState) { const { tabsCount, selectedIndex } = this.state; if ( (gestureState.dx > 0 && selectedIndex <= 0) || (gestureState.dx < 0 && selectedIndex >= tabsCount - 1) ) { return; } this.state.animated.setValue(gestureState.dx); } terminateGesture(evt, gestureState) { const { tabsCount, selectedIndex, pendingIndex } = this.state; const { width, onSwipeEnd } = this.props; let swipeDistanceThreshold = width / 1.75; let swipeVelocityThreshold = 0.15; if (typeof onSwipeEnd === 'function') { onSwipeEnd(evt, gestureState); } /** * On Android, velocity is way lower due to timestamp being in nanosecond * we MUST normalize it to have the same velocity on both iOS and Android */ if (Platform.OS === 'android') { swipeVelocityThreshold = swipeVelocityThreshold / 1000000; } const currentIndex = pendingIndex ? pendingIndex : selectedIndex; let nextIndex = currentIndex; if ( Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.vx) > Math.abs(gestureState.vy) && (Math.abs(gestureState.dx) > swipeDistanceThreshold || Math.abs(gestureState.vx) > swipeVelocityThreshold) ) { nextIndex = Math.round( Math.min( Math.max(0, currentIndex - gestureState.dx / Math.abs(gestureState.dx)), tabsCount - 1 ) ); this.setState({ selectedIndex: nextIndex }); } if (!isFinite(nextIndex)) { nextIndex = currentIndex; } this.transitionTo(nextIndex); } transitionTo(index, animated = true) { const { width, animTension, animFriction } = this.props; const offset = -index * width; const animationConfig = { useNativeDriver: Platform.OS === 'android', tension: animTension, friction: animFriction }; // The following allows for initial tab index to avoid being // animated (which creates a laggy effect) let animationTiming = Animated.spring; if (!animated) { animationTiming = Animated.timing; animationConfig.duration = 0; } Animated.parallel([ animationTiming(this.state.animated, { toValue: 0, ...animationConfig }), animationTiming(this.state.offsetX, { toValue: offset, ...animationConfig }) ]).start(({ finished }) => { if (finished) { this.setState({ pendingIndex: null }); } }); this.setState({ pendingIndex: index }); } render() { const { tabsCount, animated, offsetX } = this.state; const { children, backgroundColor, tabsColor, iconsColor, underlineColor, underlineHeight, width } = this.props; const childrens = React.Children.toArray(children); const maxTranslate = width * (tabsCount - 1); const tabLineTranslateX = Animated.add(animated, offsetX).interpolate({ inputRange: [-maxTranslate, 0], outputRange: [maxTranslate / tabsCount, 0], extrapolate: 'clamp' }); const translateX = Animated.add(animated, offsetX).interpolate({ inputRange: [-maxTranslate, 0], outputRange: [-maxTranslate, 0], extrapolate: 'clamp' }); return ( <View style={{ ...styles.container, backgroundColor: backgroundColor }}> <View style={styles.tabBar}> <View style={styles.tabs}> {childrens.map((view, index) => { return ( <Touchable key={view.props.icon} style={styles.touchable} onPress={() => this.transitionTo(index)} > <View style={[ styles.tab, { width: width / tabsCount, backgroundColor: tabsColor } ]} > <Text style={{ color: iconsColor }}>{view.props.icon}</Text> </View> </Touchable> ); })} </View> <Animated.View style={{ backgroundColor: underlineColor, transform: [ // eslint-disable-next-line { translateX: tabLineTranslateX != null ? tabLineTranslateX : 0 }, { translateY: -underlineHeight } ], height: underlineHeight, width: width / tabsCount - 1 }} /> </View> <Animated.View {...this.panResponder.panHandlers} style={{ ...styles.contentView, // Fix an issue with Android Bridge that expect a number. // When the interpolation fails for some reason, it crash. // Monkey-patching this, :( // eslint-disable-next-line transform: [{ translateX: translateX != null ? translateX : 0 }], width: tabsCount * width, maxWidth: tabsCount * width, minWidth: tabsCount * width, ...Platform.select({ web: { overflowX: 'hidden' } }) }} > {childrens.map(view => { return ( <View key={view.props.children} style={{ width: width, maxWidth: width, ...noSelect }} > {view.props.children} </View> ); })} </Animated.View> </View> ); } } TabsView.propTypes = { children: PropTypes.array.isRequired, defaultTabIndex: PropTypes.number, backgroundColor: PropTypes.string, tabsColor: PropTypes.string, iconsColor: PropTypes.string, underlineColor: PropTypes.string, underlineHeight: PropTypes.number, width: PropTypes.number, deadZone: PropTypes.number, swipeTolerance: PropTypes.number, animFriction: PropTypes.number, animTension: PropTypes.number, onSwipeStart: PropTypes.func, onSwipeEnd: PropTypes.func }; TabsView.defaultProps = { defaultTabIndex: 0, underlineColor: 'white', iconsColor: 'white', tabsColor: 'blue', backgroundColor: 'white', underlineHeight: 3, width: Dimensions.get('window').width, deadZone: 12, swipeTolerance: 2, animFriction: 35, animTension: 200, onSwipeStart: () => null, onSwipeEnd: () => null }; export default TabsView;