From 17ff01fdaf43044a44cf55aabbd9e235619812d5 Mon Sep 17 00:00:00 2001 From: Andy Osei Date: Sat, 22 Feb 2020 17:45:20 +0000 Subject: [PATCH] Feature/Segment component --- src/segment/Option.js | 126 ++++++++++++++++++++++ src/segment/OptionBar.js | 125 ++++++++++++++++++++++ src/segment/OptionIndicator.js | 117 +++++++++++++++++++++ src/segment/SegmentView.js | 115 ++++++++++++++++++++ src/segment/ViewPager.js | 186 +++++++++++++++++++++++++++++++++ src/segment/index.js | 7 ++ 6 files changed, 676 insertions(+) create mode 100644 src/segment/Option.js create mode 100644 src/segment/OptionBar.js create mode 100644 src/segment/OptionIndicator.js create mode 100644 src/segment/SegmentView.js create mode 100644 src/segment/ViewPager.js create mode 100644 src/segment/index.js diff --git a/src/segment/Option.js b/src/segment/Option.js new file mode 100644 index 0000000..ddc0217 --- /dev/null +++ b/src/segment/Option.js @@ -0,0 +1,126 @@ +import React from "react"; +import { Text, StyleSheet, TouchableOpacity, Dimensions } from "react-native"; +import PropTypes from "prop-types"; + +const { width: deviceWidth } = Dimensions.get("window"); + +class Option extends React.Component { + static defaultProps = { + selected: false + }; + + onPress = () => { + if (this.props.onSelect) { + this.props.onSelect(!this.props.selected); + } + }; + + isValidString = value => { + if (value && value.length > 0) { + return true; + } + return false; + }; + + getComponentStyle = () => { + // const optionWidth = deviceWidth / this.props.optionsCount; + const optionWidth = deviceWidth / 5; + return { + width: optionWidth + }; + }; + + renderTitle = style => { + const { title, titleStyle } = this.props; + + const titleColor = this.props.selected + ? (this.props.indicatorStyle && this.props.indicatorStyle.color) || + this.props.theme.COLORS.BLACK + : this.props.theme.COLORS.MUTED; + + return ( + + {title} + + ); + }; + + renderIcon = style => { + const iconElement = this.props.icon(style); + + return React.cloneElement(iconElement, { + key: 2, + style: [style, iconElement.props.style] + }); + }; + + renderChildren = style => { + const { title, icon } = this.props; + + return [ + icon && this.renderIcon(style.icon), + this.isValidString(title) && this.renderTitle(style.title) + ]; + }; + + render() { + const { style, ...rest } = this.props; + const componentStyle = this.getComponentStyle(); + const [iconElement, titleElement] = this.renderChildren({ + icon: styles.icon, + title: styles.title + }); + + return ( + + {iconElement} + {titleElement} + + ); + } +} + +Option.propTypes = { + style: PropTypes.any, + selected: PropTypes.bool, + onSelect: PropTypes.func, + optionsCount: PropTypes.number, + indicatorStyle: PropTypes.object, + theme: PropTypes.any, + rest: PropTypes.any +}; + +export default Option; + +const styles = StyleSheet.create({ + container: { + justifyContent: "center", + alignItems: "center" + }, + icon: { + height: 24, + marginVertical: 2, + tintColor: "#8F9BB3", + width: 24 + }, + title: { + fontWeight: "bold", + marginVertical: 2 + } +}); diff --git a/src/segment/OptionBar.js b/src/segment/OptionBar.js new file mode 100644 index 0000000..82c5369 --- /dev/null +++ b/src/segment/OptionBar.js @@ -0,0 +1,125 @@ +import React from "react"; +import { StyleSheet, View, ScrollView } from "react-native"; +import PropTypes from "prop-types"; +import OptionIndicator from "./OptionIndicator"; + +class OptionsBar extends React.Component { + static defaultProps = { + selectedIndex: 0 + }; + + optionIndicatorRef = React.createRef(); + + onOptionSelect = index => { + if (this.props.onSelect) { + this.props.onSelect(index); + } + }; + + scrollToIndex(params) { + const { current: optionIndicator } = this.optionIndicatorRef; + optionIndicator.scrollToIndex(params); + } + + scrollToOffset(params) { + const { current: optionIndicator } = this.optionIndicatorRef; + optionIndicator.scrollToOffset(params); + } + + isOptionSelected = index => { + return index === this.props.selectedIndex; + }; + + renderOption = (element, index) => { + return React.cloneElement(element, { + key: index, + style: [styles.item, element.props.style], + selected: this.isOptionSelected(index), + onSelect: () => this.onOptionSelect(index), + optionsCount: React.Children.count(this.props.children), + indicatorStyle: this.props.indicatorStyle, + theme: this.props.theme + }); + }; + + renderOptions = source => { + return React.Children.map(source, this.renderOption); + }; + + render() { + const { + style, + indicatorStyle, + selectedIndex, + children, + theme, + ...rest + } = this.props; + + const options = this.renderOptions(children); + + return ( + + + + {options} + + + + + ); + } +} + +OptionsBar.propTypes = { + style: PropTypes.any, + indicatorStyle: PropTypes.any, + selectedIndex: PropTypes.number, + children: PropTypes.node, + onSelect: PropTypes.func, + theme: PropTypes.any, + rest: PropTypes.any +}; + +export default OptionsBar; + +const styles = StyleSheet.create({ + optionsWrapper: { + flex: 1, + flexDirection: "row", + backgroundColor: "#FFFFFF", + paddingVertical: 4 + }, + item: { + flex: 1 + }, + indicator: { + borderRadius: 2, + height: 2 + } +}); diff --git a/src/segment/OptionIndicator.js b/src/segment/OptionIndicator.js new file mode 100644 index 0000000..593aed8 --- /dev/null +++ b/src/segment/OptionIndicator.js @@ -0,0 +1,117 @@ +import React from "react"; +import { + Animated, + Easing, + I18nManager, + Dimensions, + StyleSheet +} from "react-native"; +import PropTypes from "prop-types"; + +const { width: deviceWidth } = Dimensions.get("window"); + +class TabIndicator extends React.Component { + static defaultProps = { + selectedPosition: 0, + animationDuration: 200 + }; + + indicatorWidth; + contentOffset = new Animated.Value(0); + + componentDidMount() { + this.contentOffset.addListener(this.onContentOffsetAnimationStateChanged); + } + + shouldComponentUpdate(nextProps) { + return this.props.selectedPosition !== nextProps.selectedPosition; + } + + componentDidUpdate() { + const { selectedPosition: index } = this.props; + + this.scrollToIndex({ index, animated: true }); + } + + componentWillUnmount() { + this.contentOffset.removeAllListeners(); + } + + scrollToIndex(params) { + const { index, ...rest } = params; + const offset = this.indicatorWidth * index; + + this.scrollToOffset({ offset, ...rest }); + } + + scrollToOffset(params) { + this.createOffsetAnimation(params).start( + this.onContentOffsetAnimationStateEnd + ); + } + + onContentOffsetAnimationStateChanged = state => {}; + + onContentOffsetAnimationStateEnd = result => {}; + + createOffsetAnimation = params => { + const animationDuration = params.animated + ? this.props.animationDuration + : 0; + + return Animated.timing(this.contentOffset, { + toValue: I18nManager.isRTL ? -params.offset : params.offset, + duration: animationDuration, + easing: Easing.linear + }); + }; + + onLayout = event => { + this.indicatorWidth = event.nativeEvent.layout.width; + + this.scrollToOffset({ + offset: this.indicatorWidth * this.props.selectedPosition, + animated: false + }); + }; + + getComponentStyle = () => { + // const indicatorWidth = deviceWidth / this.props.positions; + const indicatorWidth = deviceWidth / 5; + + return { + width: indicatorWidth, + transform: [{ translateX: this.contentOffset }] + }; + }; + + render() { + const { style, ...rest } = this.props; + const componentStyle = this.getComponentStyle(); + + return ( + + ); + } +} + +TabIndicator.propTypes = { + style: PropTypes.any, + selectedPosition: PropTypes.number, + animationDuration: PropTypes.number, + rest: PropTypes.any +}; + +export default TabIndicator; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + bottom: 0, + left: 0 + } +}); diff --git a/src/segment/SegmentView.js b/src/segment/SegmentView.js new file mode 100644 index 0000000..a49baa1 --- /dev/null +++ b/src/segment/SegmentView.js @@ -0,0 +1,115 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import PropTypes from 'prop-types'; +import { withGalio } from '../theme'; +import OptionBar from './OptionBar'; +import ViewPager from './ViewPager'; + +class OptionViewChildren { + options = []; + content = []; +} + +class SegmentView extends React.Component { + static defaultProps = { + selectedIndex: 0, + }; + + viewPagerRef = React.createRef(); + optionBarRef = React.createRef(); + + onBarSelect = index => { + const { current: viewPager } = this.viewPagerRef; + if (viewPager) { + viewPager.scrollToIndex({ index, animated: true }); + } + }; + + onPagerOffsetChange = offset => { + const { current: optionBar } = this.optionBarRef; + if (optionBar) { + // const optionCount = React.Children.count(optionBar.props.children); + optionBar.scrollToOffset({ offset: offset / 5 }); + } + }; + + onPagerSelect = selectedIndex => { + if (this.props.onSelect) { + this.props.onSelect(selectedIndex); + } + }; + + renderChild = (element, index) => { + return { + option: React.cloneElement(element, { key: index }), + content: element.props.children, + }; + }; + + renderChildren = source => { + return React.Children.toArray(source).reduce((acc, element, index) => { + const { option, content } = this.renderChild(element, index); + return { + options: [...acc.options, option], + content: [...acc.content, content], + }; + }, new OptionViewChildren()); + }; + + render() { + const { + style, + selectedIndex, + children, + optionBarStyle, + indicatorStyle, + theme, + ...rest + } = this.props; + + const { options, content } = this.renderChildren(children); + + return ( + + + {options} + + + {content} + + + ); + } +} + +SegmentView.propTypes = { + style: PropTypes.any, + selectedIndex: PropTypes.number, + children: PropTypes.node, + optionBarStyle: PropTypes.any, + indicatorStyle: PropTypes.any, + shouldLoadComponent: PropTypes.func, + theme: PropTypes.any, + rest: PropTypes.any, +}; + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, +}); + +export default withGalio(SegmentView); diff --git a/src/segment/ViewPager.js b/src/segment/ViewPager.js new file mode 100644 index 0000000..d474b61 --- /dev/null +++ b/src/segment/ViewPager.js @@ -0,0 +1,186 @@ +import React from "react"; +import { + Animated, + Easing, + PanResponder, + StyleSheet, + View, + I18nManager +} from "react-native"; +import PropTypes from "prop-types"; + +export default class ViewPager extends React.Component { + static defaultProps = { + selectedIndex: 0, + shouldLoadComponent: () => true + }; + + containerRef = React.createRef(); + contentWidth = 0; + contentOffsetValue = 0; + contentOffset = new Animated.Value(this.contentOffsetValue); + panResponder = PanResponder.create(this); + + componentDidMount() { + this.contentOffset.addListener(this.onContentOffsetAnimationStateChanged); + } + + componentDidUpdate(prevProps) { + if (prevProps.selectedIndex !== this.props.selectedIndex) { + const index = this.props.selectedIndex; + this.scrollToIndex({ index, animated: true }); + } + } + + componentWillUnmount() { + this.contentOffset.removeAllListeners(); + } + + onMoveShouldSetPanResponder = (event, state) => { + const isHorizontalMove = + Math.abs(state.dx) > 0 && Math.abs(state.dx) > Math.abs(state.dy); + + if (isHorizontalMove) { + const i18nOffset = I18nManager.isRTL ? -state.dx : state.dx; + const nextSelectedIndex = + this.props.selectedIndex - Math.sign(i18nOffset); + + return nextSelectedIndex >= 0 && nextSelectedIndex < this.getChildCount(); + } + + return false; + }; + + onPanResponderMove = (event, state) => { + const i18nOffset = I18nManager.isRTL + ? -this.contentWidth + : this.contentWidth; + const selectedPageOffset = this.props.selectedIndex * i18nOffset; + + this.contentOffset.setValue(state.dx - selectedPageOffset); + }; + + onPanResponderRelease = (event, state) => { + if ( + Math.abs(state.vx) >= 0.5 || + Math.abs(state.dx) >= 0.5 * this.contentWidth + ) { + const i18nOffset = I18nManager.isRTL ? -state.dx : state.dx; + const index = + i18nOffset > 0 + ? this.props.selectedIndex - 1 + : this.props.selectedIndex + 1; + this.scrollToIndex({ index, animated: true }); + } else { + const index = this.props.selectedIndex; + this.scrollToIndex({ index, animated: true }); + } + }; + + scrollToIndex(params) { + const { index, ...rest } = params; + + const childCount = this.getChildCount() - 1; + const offset = + this.contentWidth * + (index < 0 ? 0 : index > childCount ? childCount : index); + this.scrollToOffset({ offset, ...rest }); + } + + scrollToOffset = params => { + this.createOffsetAnimation(params).start( + this.onContentOffsetAnimationStateEnd + ); + }; + + onLayout = event => { + this.contentWidth = event.nativeEvent.layout.width / this.getChildCount(); + + this.scrollToIndex({ + index: this.props.selectedIndex + }); + }; + + onContentOffsetAnimationStateChanged = state => { + this.contentOffsetValue = I18nManager.isRTL ? state.value : -state.value; + + if (this.props.onOffsetChange) { + this.props.onOffsetChange(this.contentOffsetValue); + } + }; + + onContentOffsetAnimationStateEnd = result => { + const selectedIndex = this.contentOffsetValue / this.contentWidth; + + if (selectedIndex !== this.props.selectedIndex && this.props.onSelect) { + this.props.onSelect(Math.round(selectedIndex)); + } + }; + + createOffsetAnimation = params => { + const animationDuration = params.animated ? 300 : 0; + + return Animated.timing(this.contentOffset, { + toValue: I18nManager.isRTL ? params.offset : -params.offset, + easing: Easing.linear, + duration: animationDuration + }); + }; + + renderChild = (source, index) => { + const contentView = this.props.shouldLoadComponent(index) ? source : null; + + return {contentView}; + }; + + renderChildren = source => { + return React.Children.map(source, this.renderChild); + }; + + getChildCount = () => { + return React.Children.count(this.props.children); + }; + + getContainerStyle = () => { + return { + width: `${100 * this.getChildCount()}%`, + transform: [{ translateX: this.contentOffset }] + }; + }; + + render() { + const { style, children, ...rest } = this.props; + + return ( + + {this.renderChildren(children)} + + ); + } +} + +ViewPager.propTypes = { + style: PropTypes.any, + selectedIndex: PropTypes.number, + shouldLoadComponent: PropTypes.func, + onOffsetChange: PropTypes.func, + onSelect: PropTypes.func, + children: PropTypes.node, + rest: PropTypes.any +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "row" + }, + contentContainer: { + flex: 1, + width: "100%" + } +}); diff --git a/src/segment/index.js b/src/segment/index.js new file mode 100644 index 0000000..f293c91 --- /dev/null +++ b/src/segment/index.js @@ -0,0 +1,7 @@ +import View from "./SegmentView"; +import Option from "./Option"; + +export default { + View, + Option +};