From a6526fd10a2f3d6bad5af4717d3fa966dc263a0e Mon Sep 17 00:00:00 2001 From: Artur Yorsh Date: Mon, 15 Oct 2018 18:14:06 +0300 Subject: [PATCH] feat(tab-set): base implementation --- src/components/tabset/rkTabSet.component.js | 170 +++++++++++++++++- .../tabset/rkTabSetIndicator.component.js | 94 ++++++++++ .../tabset/rkTabSetItem.component.js | 56 ++++++ 3 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 src/components/tabset/rkTabSetIndicator.component.js create mode 100644 src/components/tabset/rkTabSetItem.component.js diff --git a/src/components/tabset/rkTabSet.component.js b/src/components/tabset/rkTabSet.component.js index 455eab31e..672edf796 100644 --- a/src/components/tabset/rkTabSet.component.js +++ b/src/components/tabset/rkTabSet.component.js @@ -1,15 +1,169 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { View } from 'react-native'; +import { + View, + FlatList, + TouchableOpacity, +} from 'react-native'; +import { RkTabSetItem } from './rkTabSetItem.component'; +import { RkStyleSheet } from '../../styles/styleSheet'; +import { RkTabSetIndicator } from './rkTabSetIndicator.component'; +/** + * @extends React.Component + * + * @property {function} onGalleryItemChange - Gallery (modal) item change callback + */ export class RkTabSet extends React.Component { - static propTypes = {}; + static propTypes = { + children: PropTypes.arrayOf(PropTypes.instanceOf(RkTabSetItem)).isRequired, + onItemChange: PropTypes.func, + }; + static defaultProps = { + onItemChange: (() => null), + }; - render() { - return ( - + state = { + selectedIndex: 0, + contentWidth: -1, + }; + + contentContainerRef = undefined; + contentIndicatorRef = undefined; + + constructor(props) { + super(props); + const isChildSelected = (child) => child.props.isSelected; + const derivedSelectedIndex = props.children.findIndex(isChildSelected); + this.state.selectedIndex = derivedSelectedIndex < 0 ? 0 : derivedSelectedIndex; + } + + onTabPress = (index) => { + this.scrollToIndex({ index }); + }; + + onLayout = (event) => { + this.setState({ + contentWidth: event.nativeEvent.layout.width, + }); + }; + + onContentContainerScroll = (event) => { + const selectedIndex = Math.round(event.nativeEvent.contentOffset.x / this.state.contentWidth); + const isIndexInBounds = selectedIndex >= 0 && selectedIndex <= this.props.children.length; + + // TODO: scroll indicator on container gesture scroll + // + // const contentIndicatorOffset = event.nativeEvent.contentOffset.x / this.props.children.length; + // this.contentIndicatorRef.scrollToOffset({ offset: contentIndicatorOffset }); + + if (isIndexInBounds && selectedIndex !== this.state.selectedIndex) { + this.onItemChange(selectedIndex); + } + }; + + onItemChange = (index) => { + const change = { + previous: this.state.selectedIndex, + current: index, + }; + this.setState({ + selectedIndex: change.current, + }); + this.props.onItemChange(change); + }; + + /** + * @param params - object: { index: number, animated: boolean } + * @param onComplete - function: scroll completion callback + */ + scrollToIndex = (params, onComplete = (() => null)) => { + this.contentContainerRef.scrollToIndex(params); + this.contentIndicatorRef.scrollToIndex(params); + }; + + /** + * @param params - object: { offset: number, animated: boolean } + * @param onComplete - function: scroll completion callback + */ + scrollToOffset = (params, onComplete = (() => null)) => { + this.contentContainerRef.scrollToIndex(params); + this.contentIndicatorRef.scrollToIndex(params); + }; - - ); + getItemKey = (item, index) => index.toString(); + + getTabContentViews = () => { + const mapChildToChildContentView = (child) => child.props.children; + return React.Children.map(this.props.children, mapChildToChildContentView); + }; + + getItemLayout = (item, index) => ({ + length: this.state.contentWidth, + offset: this.state.contentWidth * index, + index, + }); + + setContentIndicatorRef = (ref) => { + this.contentIndicatorRef = ref; + }; + + setContentContainerRef = (ref) => { + this.contentContainerRef = ref; + }; + + renderTabContent = ({ item }) => { + const itemStyle = [{ width: this.state.contentWidth }, item.props.style]; + return React.cloneElement(item, { style: itemStyle }); + }; + + renderTab = (item, index) => ( + this.onTabPress(index)}> + {React.cloneElement(item, { isSelected: this.state.selectedIndex === index })} + + ); + + renderTabs = () => this.props.children.map(this.renderTab); + + renderPlaceholder = () => ( + + ); + + renderView = () => ( + + {this.renderTabs()} + + + + ); + + render() { + return this.state.contentWidth < 0 ? this.renderPlaceholder() : this.renderView(); } -} \ No newline at end of file +} + +const styles = RkStyleSheet.create(theme => ({ + tabContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + tabContentContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +})); diff --git a/src/components/tabset/rkTabSetIndicator.component.js b/src/components/tabset/rkTabSetIndicator.component.js new file mode 100644 index 000000000..85e1321d8 --- /dev/null +++ b/src/components/tabset/rkTabSetIndicator.component.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + View, + Animated, +} from 'react-native'; +import { RkStyleSheet } from '../../styles/styleSheet'; + +const defaultAnimationDuration = 200; + +export class RkTabSetIndicator extends React.PureComponent { + static propTypes = { + itemCount: PropTypes.number.isRequired, + contentWidth: PropTypes.number.isRequired, + }; + + contentOffset = new Animated.Value(0); + + /** + * @param params - object: { index: number, animated: boolean } + * @param onComplete - function: scroll completion callback + */ + scrollToIndex(params, onComplete = (() => null)) { + this.scrollToOffset({ + offset: (this.props.contentWidth / this.props.itemCount) * params.index, + ...params, + }, onComplete); + } + + /** + * @param params - object: { offset: number, animated: boolean } + * @param onComplete - function: scroll completion callback + */ + scrollByOffset(params, onComplete = (() => null)) { + this.scrollToOffset({ + offset: this.contentOffset + params.offset, + ...params, + }, onComplete); + } + + /** + * @param params - object: { offset: number, animated: boolean } + * @param onComplete - function: scroll completion callback + */ + scrollToOffset(params, onComplete = (() => null)) { + this.getContentOffsetAnimation(params).start(onComplete); + } + + /** + * @param params - object: { + * offset: { + * x: number, + * y: number, + * }, + * animated: boolean + * } + */ + getContentOffsetAnimation = (params) => { + const isAnimated = params.animated === undefined ? true : params.animated; + const animationDuration = isAnimated ? defaultAnimationDuration : 0; + return Animated.timing(this.contentOffset, { + toValue: params.offset, + duration: animationDuration, + }); + }; + + render() { + const transform = { + transform: [{ translateX: this.contentOffset }], + }; + return ( + + + + ); + } +} + +const styles = RkStyleSheet.create(theme => ({ + container: { + height: 16, + backgroundColor: 'black', + paddingVertical: 2, + }, + content: { + flex: 1, + backgroundColor: 'yellow', + }, +})); diff --git a/src/components/tabset/rkTabSetItem.component.js b/src/components/tabset/rkTabSetItem.component.js new file mode 100644 index 000000000..484a37b85 --- /dev/null +++ b/src/components/tabset/rkTabSetItem.component.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + View, + Text, +} from 'react-native'; +import { RkStyleSheet } from '../../styles/styleSheet'; + +export class RkTabSetItem extends React.PureComponent { + static propTypes = { + title: PropTypes.string, + isSelected: PropTypes.bool, + }; + static defaultProps = { + title: '', + isSelected: false, + }; + + state = { + isSelected: RkTabSetItem.defaultProps.isSelected, + }; + + static getDerivedStateFromProps(props) { + return { + isSelected: props.isSelected, + }; + } + + getContentStyle = (state) => ({ + title: { + base: styles.title, + selected: state.isSelected ? styles.titleSelected : null, + }, + }); + + render() { + const { title } = this.getContentStyle(this.state); + return ( + + {this.props.title} + + ); + } +} + +const styles = RkStyleSheet.create(theme => ({ + container: { + padding: 16, + }, + title: { + fontWeight: '500', + }, + titleSelected: { + fontWeight: '700', + }, +}));