Skip to content

Commit

Permalink
feat(tab-set): base implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
Artur Yorsh committed Oct 23, 2018
1 parent f5b59ac commit a6526fd
Show file tree
Hide file tree
Showing 3 changed files with 312 additions and 8 deletions.
170 changes: 162 additions & 8 deletions src/components/tabset/rkTabSet.component.js
Original file line number Diff line number Diff line change
@@ -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 (
<View>
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);
};

</View>
);
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) => (
<TouchableOpacity key={index.toString()} onPress={() => this.onTabPress(index)}>
{React.cloneElement(item, { isSelected: this.state.selectedIndex === index })}
</TouchableOpacity>
);

renderTabs = () => this.props.children.map(this.renderTab);

renderPlaceholder = () => (
<View onLayout={this.onLayout} />
);

renderView = () => (
<View style={{ flex: 1 }}>
<View style={styles.tabContainer}>{this.renderTabs()}</View>
<RkTabSetIndicator
ref={this.setContentIndicatorRef}
contentWidth={this.state.contentWidth}
itemCount={this.props.children.length}
/>
<FlatList
ref={this.setContentContainerRef}
horizontal={true}
pagingEnabled={true}
initialScrollIndex={this.state.selectedIndex}
data={this.getTabContentViews()}
onScroll={this.onContentContainerScroll}
renderItem={this.renderTabContent}
getItemLayout={this.getItemLayout}
keyExtractor={this.getItemKey}
/>
</View>
);

render() {
return this.state.contentWidth < 0 ? this.renderPlaceholder() : this.renderView();
}
}
}

const styles = RkStyleSheet.create(theme => ({
tabContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
},
tabContentContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
}));
94 changes: 94 additions & 0 deletions src/components/tabset/rkTabSetIndicator.component.js
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<Animated.View style={[
styles.content,
{ width: this.props.contentWidth / this.props.itemCount },
transform,
]}
/>
</View>
);
}
}

const styles = RkStyleSheet.create(theme => ({
container: {
height: 16,
backgroundColor: 'black',
paddingVertical: 2,
},
content: {
flex: 1,
backgroundColor: 'yellow',
},
}));
56 changes: 56 additions & 0 deletions src/components/tabset/rkTabSetItem.component.js
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<Text style={[title.base, title.selected]}>{this.props.title}</Text>
</View>
);
}
}

const styles = RkStyleSheet.create(theme => ({
container: {
padding: 16,
},
title: {
fontWeight: '500',
},
titleSelected: {
fontWeight: '700',
},
}));

0 comments on commit a6526fd

Please sign in to comment.