UNPKG

react-native-onboarding-template

Version:

Onboarding Screen template for React Native Apps, Build onboarding for your app within seconds.

875 lines (785 loc) 22.5 kB
/** * react-native-swiper * @author leecade<leecade@163.com> */ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import { Text, View, ViewPropTypes, ScrollView, Dimensions, TouchableOpacity, Platform, ActivityIndicator, } from 'react-native'; /** * Default styles * @type {StyleSheetPropType} */ const styles = { container: { backgroundColor: 'transparent', position: 'relative', flex: 1, }, wrapperIOS: { backgroundColor: 'transparent', }, wrapperAndroid: { backgroundColor: 'transparent', flex: 1, }, slide: { backgroundColor: 'transparent', }, pagination_x: { position: 'absolute', bottom: 25, left: 0, right: 0, flexDirection: 'row', flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'transparent', }, pagination_y: { position: 'absolute', right: 15, top: 0, bottom: 0, flexDirection: 'column', flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'transparent', }, title: { height: 30, justifyContent: 'center', position: 'absolute', paddingLeft: 10, bottom: -30, left: 0, flexWrap: 'nowrap', width: 250, backgroundColor: 'transparent', }, buttonWrapper: { backgroundColor: 'transparent', flexDirection: 'row', position: 'absolute', top: 0, left: 0, flex: 1, paddingHorizontal: 10, paddingVertical: 10, justifyContent: 'space-between', alignItems: 'center', }, buttonText: { fontSize: 50, color: '#007aff', }, }; // missing `module.exports = exports['default'];` with babel6 // export default React.createClass({ export default class extends Component { /** * Props Validation * @type {Object} */ static propTypes = { horizontal: PropTypes.bool, children: PropTypes.node.isRequired, containerStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), style: PropTypes.oneOfType([ PropTypes.object, PropTypes.number, PropTypes.array, ]), scrollViewStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), pagingEnabled: PropTypes.bool, showsHorizontalScrollIndicator: PropTypes.bool, showsVerticalScrollIndicator: PropTypes.bool, bounces: PropTypes.bool, scrollsToTop: PropTypes.bool, removeClippedSubviews: PropTypes.bool, automaticallyAdjustContentInsets: PropTypes.bool, showsPagination: PropTypes.bool, showsButtons: PropTypes.bool, disableNextButton: PropTypes.bool, disablePrevButton: PropTypes.bool, loadMinimal: PropTypes.bool, loadMinimalSize: PropTypes.number, loadMinimalLoader: PropTypes.element, loop: PropTypes.bool, autoplay: PropTypes.bool, autoplayTimeout: PropTypes.number, autoplayDirection: PropTypes.bool, index: PropTypes.number, renderPagination: PropTypes.func, dotStyle: PropTypes.oneOfType([ PropTypes.object, PropTypes.number, PropTypes.array, ]), activeDotStyle: PropTypes.oneOfType([ PropTypes.object, PropTypes.number, PropTypes.array, ]), dotColor: PropTypes.string, activeDotColor: PropTypes.string, /** * Called when the index has changed because the user swiped. */ onIndexChanged: PropTypes.func, }; /** * Default props * @return {object} props * @see http://facebook.github.io/react-native/docs/scrollview.html */ static defaultProps = { horizontal: true, pagingEnabled: true, showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, bounces: false, scrollsToTop: false, removeClippedSubviews: true, automaticallyAdjustContentInsets: false, showsPagination: true, showsButtons: false, disableNextButton: false, disablePrevButton: false, loop: true, loadMinimal: false, loadMinimalSize: 1, autoplay: false, autoplayTimeout: 2.5, autoplayDirection: true, index: 0, onIndexChanged: () => null, }; /** * Init states * @return {object} states */ state = this.initState(this.props); /** * Initial render flag * @type {bool} */ initialRender = true; /** * autoplay timer * @type {null} */ autoplayTimer = null; loopJumpTimer = null; UNSAFE_componentWillReceiveProps(nextProps) { if (!nextProps.autoplay && this.autoplayTimer) clearTimeout(this.autoplayTimer); if (nextProps.index === this.props.index) return; this.setState( this.initState(nextProps, this.props.index !== nextProps.index), ); } componentDidMount() { this.autoplay(); } componentWillUnmount() { this.autoplayTimer && clearTimeout(this.autoplayTimer); this.loopJumpTimer && clearTimeout(this.loopJumpTimer); } UNSAFE_componentWillUpdate(nextProps, nextState) { // If the index has changed, we notify the parent via the onIndexChanged callback if (this.state.index !== nextState.index) this.props.onIndexChanged(nextState.index); } componentDidUpdate(prevProps) { // If autoplay props updated to true, autoplay immediately if (this.props.autoplay && !prevProps.autoplay) { this.autoplay(); } if (this.props.children !== prevProps.children) { if (this.props.loadMinimal && Platform.OS === 'ios') { this.setState({...this.props, index: this.state.index}); } else { this.setState( this.initState({...this.props, index: this.state.index}, true), ); } } } initState(props, updateIndex = false) { // set the current state const state = this.state || {width: 0, height: 0, offset: {x: 0, y: 0}}; const initState = { autoplayEnd: false, children: null, loopJump: false, offset: {}, }; // Support Optional render page initState.children = Array.isArray(props.children) ? props.children.filter(child => child) : props.children; initState.total = initState.children ? initState.children.length || 1 : 0; if (state.total === initState.total && !updateIndex) { // retain the index initState.index = state.index; } else { initState.index = initState.total > 1 ? Math.min(props.index, initState.total - 1) : 0; } // Default: horizontal const {width, height} = Dimensions.get('window'); initState.dir = props.horizontal === false ? 'y' : 'x'; if (props.width) { initState.width = props.width; } else if (this.state && this.state.width) { initState.width = this.state.width; } else { initState.width = width; } if (props.height) { initState.height = props.height; } else if (this.state && this.state.height) { initState.height = this.state.height; } else { initState.height = height; } initState.offset[initState.dir] = initState.dir === 'y' ? height * props.index : width * props.index; this.internals = { ...this.internals, isScrolling: false, }; return initState; } // include internals with state fullState() { return Object.assign({}, this.state, this.internals); } onLayout = event => { const {width, height} = event.nativeEvent.layout; const offset = (this.internals.offset = {}); const state = {width, height}; if (this.state.total > 1) { let setup = this.state.index; if (this.props.loop) { setup++; } offset[this.state.dir] = this.state.dir === 'y' ? height * setup : width * setup; } // only update the offset in state if needed, updating offset while swiping // causes some bad jumping / stuttering if (!this.state.offset) { state.offset = offset; } // related to https://github.com/leecade/react-native-swiper/issues/570 // contentOffset is not working in react 0.48.x so we need to use scrollTo // to emulate offset. if (this.initialRender && this.state.total > 1) { this.scrollView.scrollTo({...offset, animated: false}); this.initialRender = false; } this.setState(state); }; loopJump = () => { if (!this.state.loopJump) return; const i = this.state.index + (this.props.loop ? 1 : 0); const scrollView = this.scrollView; this.loopJumpTimer = setTimeout( () => { if (scrollView.setPageWithoutAnimation) { scrollView.setPageWithoutAnimation(i); } else { if (this.state.index === 0) { scrollView.scrollTo( this.props.horizontal === false ? {x: 0, y: this.state.height, animated: false} : {x: this.state.width, y: 0, animated: false}, ); } else if (this.state.index === this.state.total - 1) { this.props.horizontal === false ? this.scrollView.scrollTo({ x: 0, y: this.state.height * this.state.total, animated: false, }) : this.scrollView.scrollTo({ x: this.state.width * this.state.total, y: 0, animated: false, }); } } }, // Important Parameter // ViewPager 50ms, ScrollView 300ms scrollView.setPageWithoutAnimation ? 50 : 300, ); }; /** * Automatic rolling */ autoplay = () => { if ( !Array.isArray(this.state.children) || !this.props.autoplay || this.internals.isScrolling || this.state.autoplayEnd ) return; this.autoplayTimer && clearTimeout(this.autoplayTimer); this.autoplayTimer = setTimeout(() => { if ( !this.props.loop && (this.props.autoplayDirection ? this.state.index === this.state.total - 1 : this.state.index === 0) ) return this.setState({autoplayEnd: true}); this.scrollBy(this.props.autoplayDirection ? 1 : -1); }, this.props.autoplayTimeout * 1000); }; /** * Scroll begin handle * @param {object} e native event */ onScrollBegin = e => { // update scroll state this.internals.isScrolling = true; this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e, this.fullState(), this); }; /** * Scroll end handle * @param {object} e native event */ onScrollEnd = e => { // update scroll state this.internals.isScrolling = false; // making our events coming from android compatible to updateIndex logic if (!e.nativeEvent.contentOffset) { if (this.state.dir === 'x') { e.nativeEvent.contentOffset = { x: e.nativeEvent.position * this.state.width, }; } else { e.nativeEvent.contentOffset = { y: e.nativeEvent.position * this.state.height, }; } } this.updateIndex(e.nativeEvent.contentOffset, this.state.dir, () => { this.autoplay(); this.loopJump(); }); // if `onMomentumScrollEnd` registered will be called here this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(e, this.fullState(), this); }; /* * Drag end handle * @param {object} e native event */ onScrollEndDrag = e => { const {contentOffset} = e.nativeEvent; const {horizontal} = this.props; const {children, index} = this.state; const {offset} = this.internals; const previousOffset = horizontal ? offset.x : offset.y; const newOffset = horizontal ? contentOffset.x : contentOffset.y; if ( previousOffset === newOffset && (index === 0 || index === children.length - 1) ) { this.internals.isScrolling = false; } }; /** * Update index after scroll * @param {object} offset content offset * @param {string} dir 'x' || 'y' */ updateIndex = (offset, dir, cb) => { const state = this.state; // Android ScrollView will not scrollTo certain offset when props change let index = state.index; if (!this.internals.offset) // Android not setting this onLayout first? https://github.com/leecade/react-native-swiper/issues/582 this.internals.offset = {}; const diff = offset[dir] - this.internals.offset[dir]; const step = dir === 'x' ? state.width : state.height; let loopJump = false; // Do nothing if offset no change. if (!diff) return; // Note: if touch very very quickly and continuous, // the variation of `index` more than 1. // parseInt() ensures it's always an integer index = parseInt(index + Math.round(diff / step)); if (this.props.loop) { if (index <= -1) { index = state.total - 1; offset[dir] = step * state.total; loopJump = true; } else if (index >= state.total) { index = 0; offset[dir] = step; loopJump = true; } } const newState = {}; newState.index = index; newState.loopJump = loopJump; this.internals.offset = offset; // only update offset in state if loopJump is true if (loopJump) { // when swiping to the beginning of a looping set for the third time, // the new offset will be the same as the last one set in state. // Setting the offset to the same thing will not do anything, // so we increment it by 1 then immediately set it to what it should be, // after render. if (offset[dir] === this.internals.offset[dir]) { newState.offset = {x: 0, y: 0}; newState.offset[dir] = offset[dir] + 1; this.setState(newState, () => { this.setState({offset: offset}, cb); }); } else { newState.offset = offset; this.setState(newState, cb); } } else { this.setState(newState, cb); } }; /** * Scroll by index * @param {number} index offset index * @param {bool} animated */ scrollBy = (index, animated = true) => { if (this.internals.isScrolling || this.state.total < 2) return; const state = this.state; const diff = (this.props.loop ? 1 : 0) + index + this.state.index; let x = 0; let y = 0; if (state.dir === 'x') x = diff * state.width; if (state.dir === 'y') y = diff * state.height; this.scrollView && this.scrollView.scrollTo({x, y, animated}); // update scroll state this.internals.isScrolling = true; this.setState({ autoplayEnd: false, }); // trigger onScrollEnd manually in android if (!animated || Platform.OS !== 'ios') { setImmediate(() => { this.onScrollEnd({ nativeEvent: { position: diff, }, }); }); } }; /** * Scroll to index * @param {number} index page * @param {bool} animated */ scrollTo = (index, animated = true) => { if ( this.internals.isScrolling || this.state.total < 2 || index == this.state.index ) return; const state = this.state; const diff = this.state.index + (index - this.state.index); let x = 0; let y = 0; if (state.dir === 'x') x = diff * state.width; if (state.dir === 'y') y = diff * state.height; this.scrollView && this.scrollView.scrollTo({x, y, animated}); // update scroll state this.internals.isScrolling = true; this.setState({ autoplayEnd: false, }); // trigger onScrollEnd manually in android if (!animated || Platform.OS !== 'ios') { setImmediate(() => { this.onScrollEnd({ nativeEvent: { position: diff, }, }); }); } }; scrollViewPropOverrides = () => { const props = this.props; let overrides = {}; /* const scrollResponders = [ 'onMomentumScrollBegin', 'onTouchStartCapture', 'onTouchStart', 'onTouchEnd', 'onResponderRelease', ] */ for (let prop in props) { // if(~scrollResponders.indexOf(prop) if ( typeof props[prop] === 'function' && prop !== 'onMomentumScrollEnd' && prop !== 'renderPagination' && prop !== 'onScrollBeginDrag' ) { let originResponder = props[prop]; overrides[prop] = e => originResponder(e, this.fullState(), this); } } return overrides; }; /** * Render pagination * @return {object} react-dom */ renderPagination = () => { // By default, dots only show when `total` >= 2 if (this.state.total <= 1) return null; let dots = []; const ActiveDot = this.props.activeDot || ( <View style={[ { backgroundColor: this.props.activeDotColor || '#007aff', width: 8, height: 8, borderRadius: 4, marginLeft: 3, marginRight: 3, marginTop: 3, marginBottom: 3, }, this.props.activeDotStyle, ]} /> ); const Dot = this.props.dot || ( <View style={[ { backgroundColor: this.props.dotColor || 'rgba(0,0,0,.2)', width: 8, height: 8, borderRadius: 4, marginLeft: 3, marginRight: 3, marginTop: 3, marginBottom: 3, }, this.props.dotStyle, ]} /> ); for (let i = 0; i < this.state.total; i++) { dots.push( i === this.state.index ? React.cloneElement(ActiveDot, {key: i}) : React.cloneElement(Dot, {key: i}), ); } return ( <View pointerEvents="none" style={[ styles['pagination_' + this.state.dir], this.props.paginationStyle, ]}> {dots} </View> ); }; renderTitle = () => { const child = this.state.children[this.state.index]; const title = child && child.props && child.props.title; return title ? ( <View style={styles.title}> {this.state.children[this.state.index].props.title} </View> ) : null; }; renderNextButton = () => { let button = null; if (this.props.loop || this.state.index !== this.state.total - 1) { button = this.props.nextButton || ( <Text style={styles.buttonText}>›</Text> ); } return ( <TouchableOpacity onPress={() => button !== null && this.scrollBy(1)} disabled={this.props.disableNextButton}> <View>{button}</View> </TouchableOpacity> ); }; renderPrevButton = () => { let button = null; if (this.props.loop || this.state.index !== 0) { button = this.props.prevButton || ( <Text style={styles.buttonText}>‹</Text> ); } return ( <TouchableOpacity onPress={() => button !== null && this.scrollBy(-1)} disabled={this.props.disablePrevButton}> <View>{button}</View> </TouchableOpacity> ); }; renderButtons = () => { return ( <View pointerEvents="box-none" style={[ styles.buttonWrapper, { width: this.state.width, height: this.state.height, }, this.props.buttonWrapperStyle, ]}> {this.renderPrevButton()} {this.renderNextButton()} </View> ); }; refScrollView = view => { this.scrollView = view; }; onPageScrollStateChanged = state => { switch (state) { case 'dragging': return this.onScrollBegin(); case 'idle': case 'settling': if (this.props.onTouchEnd) this.props.onTouchEnd(); } }; renderScrollView = pages => { return ( <ScrollView ref={this.refScrollView} {...this.props} {...this.scrollViewPropOverrides()} contentContainerStyle={[styles.wrapperIOS, this.props.style]} contentOffset={this.state.offset} onScrollBeginDrag={this.onScrollBegin} onMomentumScrollEnd={this.onScrollEnd} onScrollEndDrag={this.onScrollEndDrag} style={this.props.scrollViewStyle}> {pages} </ScrollView> ); }; /** * Default render * @return {object} react-dom */ render() { const {index, total, width, height, children} = this.state; const { containerStyle, loop, loadMinimal, loadMinimalSize, loadMinimalLoader, renderPagination, showsButtons, showsPagination, } = this.props; // let dir = state.dir // let key = 0 const loopVal = loop ? 1 : 0; let pages = []; const pageStyle = [{width: width, height: height}, styles.slide]; const pageStyleLoading = { width, height, flex: 1, justifyContent: 'center', alignItems: 'center', }; // For make infinite at least total > 1 if (total > 1) { // Re-design a loop model for avoid img flickering pages = Object.keys(children); if (loop) { pages.unshift(total - 1 + ''); pages.push('0'); } pages = pages.map((page, i) => { if (loadMinimal) { if ( (i >= index + loopVal - loadMinimalSize && i <= index + loopVal + loadMinimalSize) || // The real first swiper should be keep (loop && i === 1) || // The real last swiper should be keep (loop && i === total - 1) ) { return ( <View style={pageStyle} key={i}> {children[page]} </View> ); } else { return ( <View style={pageStyleLoading} key={i}> {loadMinimalLoader ? loadMinimalLoader : <ActivityIndicator />} </View> ); } } else { return ( <View style={pageStyle} key={i}> {children[page]} </View> ); } }); } else { pages = ( <View style={pageStyle} key={0}> {children} </View> ); } return ( <View style={[styles.container, containerStyle]} onLayout={this.onLayout}> {this.renderScrollView(pages)} {showsPagination && (renderPagination ? renderPagination(index, total, this) : this.renderPagination())} {this.renderTitle()} {showsButtons && this.renderButtons()} </View> ); } }