tuya-panel-kit
Version:
a functional component library for developing tuya device panels!
629 lines (605 loc) • 19.6 kB
JavaScript
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import {
View,
ViewPropTypes,
Animated,
Easing,
ColorPropType,
StyleSheet,
PanResponder,
} from 'react-native';
import { CoreUtils, RatioUtils } from '../../utils';
import { FRICTION_LEVEL, DECELERATION } from './constant';
import {
Center,
StyledTab,
StyledTabBtn,
StyledTabText,
AnimatedView,
AnimatedUnderline,
} from './styled';
import {
getTabWidth,
getIndexByDeltaX,
getNearestIndexByDeltaX,
getCenteredScrollIndex,
isValidPress,
isValidSwipe,
reduceTabLayoutLeft,
} from './utils';
import TabMask from './tab-mask';
import TabPanel from './tab-panel';
import TabContent from './tab-content';
import TabScrollView from './tab-scroll-view';
import TYText from '../TYText';
const { get } = CoreUtils;
const { winWidth } = RatioUtils;
export default class Tabs extends Component {
static TabPanel = TabPanel;
static TabContent = TabContent;
static TabScrollView = TabScrollView;
static propTypes = {
accessibilityLabel: PropTypes.string,
/**
* Tabs 的样式
*/
style: ViewPropTypes.style,
/**
* 存在 TabContent 时,包裹着 Tabs 以及 TabContent 的容器样式
*/
wrapperStyle: ViewPropTypes.style,
/**
* 单个 Tab 的样式
*/
tabStyle: ViewPropTypes.style,
/**
* 单个激活 Tab 的样式
*/
tabActiveStyle: ViewPropTypes.style,
/**
* 未激活的文本样式
*/
tabTextStyle: TYText.propTypes.style,
/**
* 激活的文本样式
*/
tabActiveTextStyle: TYText.propTypes.style,
/**
* 存在 TabContent 时才有效,TabContent 的样式
*/
tabContentStyle: ViewPropTypes.style,
/**
* 下划线的样式
*/
underlineStyle: ViewPropTypes.style,
/**
* 下环线的宽度,不设置则默认跟随文字大小
*/
underlineWidth: PropTypes.number,
/**
* 默认的激活值,想成为非受控组件时使用
*/
defaultActiveKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* 激活值,如果给定了则成为受控组件,需搭配 onChange 使用
*/
activeKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* 数据源
*/
dataSource: PropTypes.array.isRequired,
/**
* 是否禁用 Tabs 标签页(注意只针对 Tabs,不针对 TabContent)
*/
disabled: PropTypes.bool,
/**
* 一屏下最多可存在的tab数量
*/
maxItem: PropTypes.number,
/**
* Tab 与 TabContent 同时存在时,Tab 的排列位置
*/
tabPosition: PropTypes.oneOf(['top', 'bottom']),
/**
* Tab Content 是否可滚动
*/
swipeable: PropTypes.bool,
/**
* Tabs 和下划线激活时的颜色
*/
activeColor: ColorPropType,
/**
* Tabs 的背景色
*/
background: ColorPropType,
/**
* TabContent 是否需要预加载
*/
preload: PropTypes.bool,
/**
* TabContent 预加载延迟时间
*/
preloadTimeout: PropTypes.number,
/**
* TabContent 的加速度阈值,滑动速率超过该阈值直接判断为下一页
*/
velocityThreshold: PropTypes.number,
/**
* 按需完毕之前的占位元素
*/
renderPlaceholder: PropTypes.func,
/**
* Tab变更回调
*/
onChange: PropTypes.func,
/**
* Tab 的子元素,一般为 TabContent
*/
children: PropTypes.array,
/**
* 右边额外的留白距离
*/
extraSpace: PropTypes.number,
/**
* 动画配置
*/
animationConfig: PropTypes.shape({
duration: PropTypes.number,
easing: PropTypes.func,
delay: PropTypes.number,
isInteraction: PropTypes.bool,
useNativeDriver: PropTypes.bool, // always false
}),
};
static defaultProps = {
accessibilityLabel: 'Tabs',
style: null,
wrapperStyle: null,
tabStyle: null,
tabActiveStyle: null,
tabTextStyle: null,
tabActiveTextStyle: null,
tabContentStyle: null,
underlineStyle: null,
underlineWidth: undefined,
defaultActiveKey: 0,
activeKey: undefined,
disabled: false,
maxItem: 4,
tabPosition: 'top',
swipeable: true,
activeColor: undefined, // 默认跟随主题色
background: '#fff',
onChange: undefined,
preload: true,
preloadTimeout: 375,
velocityThreshold: 0.5,
renderPlaceholder: undefined,
children: undefined,
extraSpace: 0,
animationConfig: {
duration: 200,
easing: Easing.linear,
delay: 0,
isInteraction: true,
useNativeDriver: false,
},
};
constructor(props) {
super(props);
if (
Array.isArray(props.dataSource) &&
Array.isArray(props.children) &&
props.dataSource.length !== props.children.length
) {
console.warn('Tabs: 数据源与children数量不匹配,请检查是否配置错误');
}
this.state = {
activeIndex: this.getCurActiveIndex(props),
scrollX: new Animated.Value(0), // 只在tabs数量超过maxItem时使用到
underlineLeft: new Animated.Value(0),
underlineWidth: new Animated.Value(0),
};
const styleObj = StyleSheet.flatten([props.wrapperStyle, props.style]);
this._tabsWidth = (styleObj.width || winWidth) - props.extraSpace;
this._tabWidth = getTabWidth(props.maxItem, this._tabsWidth);
this._bounds = [0, -this._tabWidth * props.dataSource.length + this._tabsWidth]; // x轴左右边界坐标
this._curDeltaX = 0; // 当前的x轴偏移量
this._tabIsReady = false;
this._tabLayouts = [];
this._cachedChildren = Array.isArray(props.children)
? new Array(props.children.length).fill(0)
: [];
this._panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => !this.props.disabled,
onStartShouldSetPanResponderCapture: () => !this.props.disabled,
onMoveShouldSetPanResponder: () => !this.props.disabled,
onMoveShouldSetPanResponderCapture: () => !this.props.disabled,
// TODO: 确认是否能被终止
onPanResponderTerminationRequest: () => !this.props.disabled, // 上层的responder是否能中断当前的responder
onPanResponderGrant: () => {},
onPanResponderMove: this._handleMove,
onPanResponderRelease: this._handleRelease,
onPanResponderTerminate: this._handleRelease,
});
}
componentWillReceiveProps(nextProps) {
if (this._tabIsReady && typeof nextProps.activeKey !== 'undefined') {
this.setState({ activeIndex: this.getCurActiveIndex(nextProps) }, () => {
this._startUnderlineAnimation(this.state.activeIndex);
});
}
}
componentWillUnmount() {
this._stopAllAnimations();
}
get isMultiScreen() {
return this.props.dataSource.length > this.props.maxItem;
}
/**
* @desc 根据当前的`activeKey`获取当前激活的索引
* @param {Object} props - 当前
*/
getCurActiveIndex = props => {
const { activeKey, defaultActiveKey } = props;
const { dataSource } = this.props;
const activeIndex = dataSource.findIndex(
d => d.value === activeKey || d.value === defaultActiveKey
);
return activeIndex === -1 ? 0 : activeIndex;
};
/**
* @desc 获取对应索引对应的tab布局属性
* @param {Number} idx - 索引
*/
getCurTabLayout = idx => {
const curTabLayout = get(this._tabLayouts, `${idx}`, {});
return curTabLayout;
};
/**
* @desc 滚动tabs到对应索引的位置
* @param {Number} idx - 滚动到哪个索引的位置
* @param {Function} cb - 滚动动画结束回调
*/
scrollToIndex = (idx, cb) => {
const { animationConfig, dataSource } = this.props;
if (idx > dataSource.length - 1) {
return;
}
const toValue = -this._tabWidth * idx;
this._stopAllAnimations();
this._curDeltaX = toValue;
Animated.timing(this.state.scrollX, {
toValue,
...animationConfig,
useNativeDriver: false,
}).start(cb);
};
/**
* @desc 滚动下划线到对应索引的位置
* @param {Number} idx - 要滚动到下划线的索引
* @param {Function} cb - 滚动动画结束回调
*/
_startUnderlineAnimation = (idx, cb) => {
const { animationConfig, dataSource, maxItem } = this.props;
if (idx > dataSource.length - 1) {
return;
}
const curTabLayout = this.getCurTabLayout(idx);
this._stopAllAnimations();
this.animationFn = Animated.parallel([
Animated.timing(this.state.underlineLeft, {
toValue: curTabLayout.left,
...animationConfig,
useNativeDriver: false,
}),
Animated.timing(this.state.underlineWidth, {
toValue: curTabLayout.width,
...animationConfig,
useNativeDriver: false,
}),
]);
this.animationFn.start(() => {
const scrollIdx = getCenteredScrollIndex(idx, maxItem, dataSource.length);
this.scrollToIndex(scrollIdx);
typeof cb === 'function' && cb();
});
};
_stopAllAnimations = () => {
this.state.scrollX.stopAnimation();
this.state.underlineLeft.stopAnimation();
this.state.underlineWidth.stopAnimation();
};
/**
* @desc 根据x轴偏移量计算出tabs滑动的位置
* @param {Number} dx - x轴偏移量
*/
_moveTo(dx) {
let deltaX = this._curDeltaX + dx;
const [leftBound, rightBound] = this._bounds;
if (dx > 0 && deltaX >= leftBound) {
// 超出左边界
deltaX = leftBound + (deltaX - leftBound) * FRICTION_LEVEL;
} else if (dx < 0 && deltaX <= rightBound) {
// 超出右边界
deltaX = rightBound + (deltaX - rightBound) * FRICTION_LEVEL;
}
this.state.scrollX.setValue(deltaX);
return deltaX;
}
_handleMove = (e, { dx }) => {
if (this.isMultiScreen) {
this._moveTo(dx);
}
};
_handleRelease = ({ nativeEvent }, { dx, dy, vx }) => {
const isPress = isValidPress(dx, dy);
if (isPress) {
const { locationX } = nativeEvent;
const deltaX = Math.abs(this._curDeltaX) + Math.abs(locationX);
const idx = getIndexByDeltaX(deltaX, this._tabWidth);
this._handleTabChange(this.props.dataSource[idx], idx);
} else if (this.isMultiScreen) {
const [leftBound, rightBound] = this._bounds;
const { dataSource, maxItem } = this.props;
const deltaX = this._moveTo(dx);
const maxIdx = Math.max(dataSource.length - maxItem, 0);
if ((dx > 0 && deltaX >= leftBound) || (dx < 0 && deltaX <= rightBound)) {
const idx = getNearestIndexByDeltaX(deltaX, this._tabWidth, maxIdx);
this.scrollToIndex(idx);
} else if (isValidSwipe(vx, dx)) {
this.state.scrollX.addListener(({ value }) => {
if (value > leftBound) {
this._curDeltaX = leftBound;
this.state.scrollX.stopAnimation();
this.state.scrollX.setValue(leftBound);
} else if (value < rightBound) {
this._curDeltaX = rightBound;
this.state.scrollX.stopAnimation();
this.state.scrollX.setValue(rightBound);
} else {
this._curDeltaX = value;
}
});
Animated.decay(this.state.scrollX, {
velocity: vx,
deceleration: DECELERATION,
}).start(() => {
this._curDeltaX = this.state.scrollX._value;
this.state.scrollX.removeAllListeners();
});
} else {
this._curDeltaX = deltaX;
}
}
};
_handleTabLayout = ({ nativeEvent: { layout } }, idx) => {
const { dataSource } = this.props;
this._tabLayouts[idx] = layout;
this._tabIsReady = this._tabLayouts.filter(d => !!d).length === dataSource.length;
if (this._tabIsReady) {
this._tabLayouts = reduceTabLayoutLeft(this._tabLayouts);
this._startUnderlineAnimation(this.state.activeIndex);
}
};
_handleTabChange = (tab, idx) => {
const { dataSource, activeKey, onChange } = this.props;
if (idx > dataSource.length - 1 || (tab && tab.disabled)) {
return;
}
if (typeof activeKey === 'undefined') {
this.setState({ activeIndex: idx }, () => {
this._startUnderlineAnimation(idx);
});
}
typeof onChange === 'function' && this.props.onChange(tab, idx);
};
/**
* @desc 根据tabContent滑动的位置动态计算`下划线`的`宽度`和`偏移量`,仿原生动效
* @param {Object} gestureState
* @param {Number} idx - 距离当前滑动偏移量最近的索引
* @param {Number} percent - 当前滑动偏移量相对content宽度的百分比
*/
_handleTabContentMove = (gestureState, idx, percent) => {
const { dataSource } = this.props;
const { dx } = gestureState;
const minIdx = 0;
const maxIdx = dataSource.length - 1;
const isToRight = dx < 0;
const rPercent = isToRight ? percent : 1 - percent;
const isNextPage = rPercent >= 0.5;
if (isToRight) {
const nextIdx = Math.min(isNextPage ? idx : idx + 1, maxIdx);
if (this.state.activeIndex === maxIdx && nextIdx === maxIdx) {
return;
}
const curTabLayout = this.getCurTabLayout(this.state.activeIndex);
const nextTabLayout = this.getCurTabLayout(nextIdx);
const { left: curLeft, width: curWidth } = curTabLayout;
const { left: nextLeft, width: nextWidth } = nextTabLayout;
const moveDelta = curWidth * 0.666667;
const totalLen = nextLeft + nextWidth * 0.5 - curLeft - curWidth;
let newWidth = curTabLayout.width + (totalLen - moveDelta) * Math.min(rPercent * 2, 1);
let newLeft = curLeft + moveDelta * Math.min(rPercent * 2, 1);
if (isNextPage) {
const extraWidth = nextLeft - curLeft;
newWidth -= extraWidth * Math.min((rPercent - 0.5) * 2, 1);
newLeft += extraWidth * Math.min((rPercent - 0.5) * 2, 1);
}
this.state.underlineWidth.setValue(newWidth);
this.state.underlineLeft.setValue(newLeft);
} else {
const nextIdx = Math.max(isNextPage ? idx : idx - 1, minIdx);
if (this.state.activeIndex === minIdx && nextIdx === minIdx) {
return;
}
const curTabLayout = this.getCurTabLayout(this.state.activeIndex);
const nextTabLayout = this.getCurTabLayout(nextIdx);
const { left: curLeft, width: curWidth } = curTabLayout;
const { left: nextLeft, width: nextWidth } = nextTabLayout;
const moveDelta = curWidth * 0.333333;
const totalLen = curLeft - nextLeft - nextWidth * 0.5;
let newWidth = curTabLayout.width + (totalLen - moveDelta) * Math.min(rPercent * 2, 1);
let newLeft = curLeft - moveDelta * Math.min(rPercent * 2, 1);
if (isNextPage) {
const extraWidth = curLeft - nextLeft;
newWidth -= extraWidth * Math.min((rPercent - 0.5) * 2, 1);
newLeft -= extraWidth * Math.min((rPercent - 0.5) * 2, 1);
}
this.state.underlineWidth.setValue(newWidth);
this.state.underlineLeft.setValue(newLeft - (newWidth - curWidth));
}
};
_handleTabContentRelease = (gestureState, idx) => {
const { dataSource } = this.props;
this._handleTabChange(dataSource[idx], idx);
this._startUnderlineAnimation(idx);
};
_renderTab = (tab, idx) => {
const {
accessibilityLabel,
tabStyle,
tabActiveStyle,
tabTextStyle,
tabActiveTextStyle,
activeColor,
underlineWidth,
} = this.props;
const { label, renderTab, ...rest } = tab;
const isActive = idx === this.state.activeIndex;
const isFixedWidth = typeof underlineWidth === 'number';
const TabText = (
<StyledTabText
style={[tabTextStyle, isActive && tabActiveTextStyle]}
color={activeColor}
text={label}
isActive={isActive}
/>
);
return (
<Center
key={idx}
{...rest}
accessibilityLabel={`${accessibilityLabel}_${idx}`}
style={[{ width: this._tabWidth }, tab.disabled && { opacity: 0.3 }]}
>
<StyledTabBtn
style={[isFixedWidth && { width: underlineWidth }, tabStyle, isActive && tabActiveStyle]}
onLayout={evt => this._handleTabLayout(evt, idx)}
>
{!isFixedWidth
? typeof renderTab === 'function'
? renderTab(isActive, this.state, this.props)
: TabText
: null}
</StyledTabBtn>
{isFixedWidth
? typeof renderTab === 'function'
? renderTab(isActive, this.state, this.props)
: TabText
: null}
</Center>
);
};
_renderTabs = () => {
const { dataSource } = this.props;
if (this.isMultiScreen) {
const width = dataSource.length * this._tabWidth;
return (
<AnimatedView
style={{
width,
transform: [
{
translateX: this.state.scrollX,
},
],
}}
>
{dataSource.map(this._renderTab)}
</AnimatedView>
);
}
return dataSource.map(this._renderTab);
};
_renderUnderline = () => {
const { activeColor, underlineStyle, dataSource } = this.props;
const { activeIndex } = this.state;
const { backgroundColor } = StyleSheet.flatten([underlineStyle]);
const disabled = get(dataSource, `${activeIndex}.disabled`, false);
return (
<AnimatedUnderline
style={[
underlineStyle,
disabled && { opacity: 0.3 },
{
width: this.state.underlineWidth,
transform: [{ translateX: Animated.add(this.state.scrollX, this.state.underlineLeft) }],
},
]}
color={backgroundColor || activeColor}
/>
);
};
render() {
const {
accessibilityLabel,
style,
wrapperStyle,
tabContentStyle,
dataSource,
tabPosition,
swipeable,
maxItem,
background,
preload,
preloadTimeout,
velocityThreshold,
renderPlaceholder,
children,
} = this.props;
const showMask = this.state.activeIndex <= dataSource.length - maxItem;
const tabsComponent = (
<StyledTab
key="Tabs"
style={[style, { width: this._tabsWidth, backgroundColor: background }]}
pointerEvents="box-only"
{...this._panResponder.panHandlers}
>
{this._renderTabs()}
{this._renderUnderline()}
<TabMask visible={this.isMultiScreen && showMask} color={background} />
</StyledTab>
);
if (React.Children.count(children) > 0) {
const content = [
tabsComponent,
<TabContent
key="TabContent"
accessibilityLabel={accessibilityLabel}
style={[tabContentStyle, { width: this._tabsWidth }]}
activeIndex={this.state.activeIndex}
disabled={!swipeable}
preload={preload}
preloadTimeout={preloadTimeout}
velocityThreshold={velocityThreshold}
renderPlaceholder={renderPlaceholder}
onMove={this._handleTabContentMove}
onRelease={this._handleTabContentRelease}
>
{this.props.children}
</TabContent>,
];
if (tabPosition === 'bottom') content.reverse();
return (
<View
style={[{ flex: 1, overflow: 'hidden', backgroundColor: 'transparent' }, wrapperStyle]}
>
{content}
</View>
);
}
return tabsComponent;
}
}