UNPKG

react-native-parallax-scroll-view

Version:

A ScrollView-like component with parallax and sticky header support

443 lines (408 loc) 11.2 kB
import React, { Component } from 'react' import { Animated, Dimensions, View, ViewPropTypes } from 'react-native' const styles = require('./styles') import { bool, func, number, string } from 'prop-types' const window = Dimensions.get('window') const SCROLLVIEW_REF = 'ScrollView' const pivotPoint = (a, b) => a - b const renderEmpty = () => <View /> const noRender = () => <View style={{ display: 'none' }} /> // Override `toJSON` of interpolated value because of // an error when serializing style on view inside inspector. // See: https://github.com/jaysoo/react-native-parallax-scroll-view/issues/23 const interpolate = (value, opts) => { const x = value.interpolate(opts) x.toJSON = () => x.__getValue() return x } // Properties accepted by `ParallaxScrollView`. const IPropTypes = { backgroundColor: string, backgroundScrollSpeed: number, fadeOutForeground: bool, fadeOutBackground: bool, contentBackgroundColor: string, onChangeHeaderVisibility: func, parallaxHeaderHeight: number.isRequired, renderBackground: func, renderContentBackground: func, renderFixedHeader: func, renderForeground: func, renderScrollComponent: func, renderStickyHeader: func, stickyHeaderHeight: number, contentContainerStyle: ViewPropTypes.style, outputScaleValue: number } class ParallaxScrollView extends Component { constructor(props) { super(props) if (props.renderStickyHeader && !props.stickyHeaderHeight) { console.warn( 'Property `stickyHeaderHeight` must be set if `renderStickyHeader` is used.' ) } if (props.renderParallaxHeader !== renderEmpty && !props.renderForeground) { console.warn( 'Property `renderParallaxHeader` is deprecated. Use `renderForeground` instead.' ) } this.state = { scrollY: new Animated.Value(0), viewHeight: window.height, viewWidth: window.width } this.scrollY = new Animated.Value(0) this._footerComponent = { setNativeProps() { } } // Initial stub this._footerHeight = 0 } animatedEvent = Animated.event( [{ nativeEvent: { contentOffset: { y: this.scrollY } } }], { useNativeDriver: true } ) render() { const { backgroundColor, backgroundScrollSpeed, children, contentBackgroundColor, fadeOutForeground, fadeOutBackground, parallaxHeaderHeight, renderBackground, renderContentBackground, renderFixedHeader, renderForeground, renderParallaxHeader, renderScrollComponent, renderStickyHeader, stickyHeaderHeight, style, contentContainerStyle, outputScaleValue, ...scrollViewProps } = this.props const background = this._renderBackground({ fadeOutBackground, backgroundScrollSpeed, backgroundColor, parallaxHeaderHeight, stickyHeaderHeight, renderBackground, outputScaleValue }) const foreground = this._renderForeground({ fadeOutForeground, parallaxHeaderHeight, stickyHeaderHeight, renderForeground: renderForeground || renderParallaxHeader }) const bodyComponent = this._wrapChildren(children, { contentBackgroundColor, stickyHeaderHeight, renderContentBackground, contentContainerStyle }) const footerSpacer = this._renderFooterSpacer({ contentBackgroundColor }) const maybeStickyHeader = this._maybeRenderStickyHeader({ parallaxHeaderHeight, stickyHeaderHeight, backgroundColor, renderFixedHeader, renderStickyHeader }) const scrollElement = renderScrollComponent(scrollViewProps) return ( <View style={[style, styles.container]} onLayout={e => this._maybeUpdateViewDimensions(e)} > {background} {React.cloneElement( scrollElement, { ref: SCROLLVIEW_REF, style: [styles.scrollView, scrollElement.props.style], scrollEventThrottle: 1, // Using Native Driver greatly optimizes performance onScroll: Animated.event( [{ nativeEvent: { contentOffset: { y: this.scrollY } } }], { useNativeDriver: true, listener: this._onScroll.bind(this) } ) // onScroll: this._onScroll.bind(this) }, foreground, bodyComponent, footerSpacer )} {maybeStickyHeader} </View> ) } /* * Expose `ScrollView` API so this component is composable with any component that expects a `ScrollView`. */ getScrollResponder() { return this.refs[SCROLLVIEW_REF]._component.getScrollResponder() } getScrollableNode() { return this.getScrollResponder().getScrollableNode() } getInnerViewNode() { return this.getScrollResponder().getInnerViewNode() } scrollTo(...args) { this.getScrollResponder().scrollTo(...args) } setNativeProps(props) { this.refs[SCROLLVIEW_REF].setNativeProps(props) } /* * Private helpers */ _onScroll(e) { const { parallaxHeaderHeight, stickyHeaderHeight, onChangeHeaderVisibility, onScroll: prevOnScroll = () => { } } = this.props this.props.scrollEvent && this.props.scrollEvent(e) const p = pivotPoint(parallaxHeaderHeight, stickyHeaderHeight) // This optimization wont run, since we update the animation value directly in onScroll event // this._maybeUpdateScrollPosition(e) if (e.nativeEvent.contentOffset.y >= p) { onChangeHeaderVisibility(false) } else { onChangeHeaderVisibility(true) } prevOnScroll(e) } // This optimizes the state update of current scrollY since we don't need to // perform any updates when user has scrolled past the pivot point. _maybeUpdateScrollPosition(e) { const { parallaxHeaderHeight, stickyHeaderHeight } = this.props const { scrollY } = this const { nativeEvent: { contentOffset: { y: offsetY } } } = e const p = pivotPoint(parallaxHeaderHeight, stickyHeaderHeight) if (offsetY <= p || scrollY._value <= p) { scrollY.setValue(offsetY) } } _maybeUpdateViewDimensions(e) { const { nativeEvent: { layout: { width, height } } } = e if (width !== this.state.viewWidth || height !== this.state.viewHeight) { this.setState({ viewWidth: width, viewHeight: height }) } } _renderBackground({ fadeOutBackground, backgroundScrollSpeed, backgroundColor, parallaxHeaderHeight, stickyHeaderHeight, renderBackground, outputScaleValue }) { const { viewWidth, viewHeight } = this.state const { scrollY } = this const p = pivotPoint(parallaxHeaderHeight, stickyHeaderHeight) return ( <Animated.View style={[ styles.backgroundImage, { backgroundColor: backgroundColor, height: parallaxHeaderHeight, width: viewWidth, opacity: fadeOutBackground ? interpolate(scrollY, { inputRange: [0, p * (1 / 2), p * (3 / 4), p], outputRange: [1, 0.3, 0.1, 0], extrapolate: 'clamp' }) : 1, transform: [ { translateY: interpolate(scrollY, { inputRange: [0, p], outputRange: [0, -(p / backgroundScrollSpeed)], extrapolateRight: 'extend', extrapolateLeft: 'clamp' }) }, { scale: interpolate(scrollY, { inputRange: [-viewHeight, 0], outputRange: [outputScaleValue * 1.5, 1], extrapolate: 'clamp' }) } ] } ]} > <View> {renderBackground()} </View> </Animated.View> ) } _renderForeground({ fadeOutForeground, parallaxHeaderHeight, stickyHeaderHeight, renderForeground }) { const { scrollY } = this const p = pivotPoint(parallaxHeaderHeight, stickyHeaderHeight) return ( <View style={styles.parallaxHeaderContainer}> <Animated.View style={[ styles.parallaxHeader, { height: parallaxHeaderHeight, opacity: fadeOutForeground ? interpolate(scrollY, { inputRange: [0, p * (1 / 2), p * (3 / 4), p], outputRange: [1, 0.3, 0.1, 0], extrapolate: 'clamp' }) : 1 } ]} > <View style={{ height: parallaxHeaderHeight }}> {renderForeground()} </View> </Animated.View> </View> ) } _wrapChildren( children, { contentBackgroundColor, stickyHeaderHeight, contentContainerStyle, renderContentBackground } ) { const { viewHeight } = this.state const containerStyles = [{ backgroundColor: contentBackgroundColor }] if (contentContainerStyle) containerStyles.push(contentContainerStyle) this.containerHeight = this.state.viewHeight; React.Children.forEach(children, (item) => { if (item && Object.keys(item).length != 0) { this.containerHeight = 0; } }); return ( <View style={[containerStyles, { minHeight: this.containerHeight }]} onLayout={e => { // Adjust the bottom height so we can scroll the parallax header all the way up. const { nativeEvent: { layout: { height } } } = e const footerHeight = Math.max( 0, viewHeight - height - stickyHeaderHeight ) if (this._footerHeight !== footerHeight) { this._footerComponent.setNativeProps({ style: { height: footerHeight } }) this._footerHeight = footerHeight } }} > {renderContentBackground()} {children} </View> ) } _renderFooterSpacer({ contentBackgroundColor }) { return ( <View ref={ref => { if (ref) { this._footerComponent = ref; } }} style={{ backgroundColor: contentBackgroundColor }} /> ) } _maybeRenderStickyHeader({ parallaxHeaderHeight, stickyHeaderHeight, backgroundColor, renderFixedHeader, renderStickyHeader }) { const { viewWidth } = this.state const { scrollY } = this if (renderStickyHeader || renderFixedHeader) { const p = pivotPoint(parallaxHeaderHeight, stickyHeaderHeight) return ( <View style={[ styles.stickyHeader, { width: viewWidth, ...(stickyHeaderHeight ? { height: stickyHeaderHeight } : null) } ]} > {renderStickyHeader ? <Animated.View style={{ backgroundColor: backgroundColor, height: stickyHeaderHeight, opacity: interpolate(scrollY, { inputRange: [0, p], outputRange: [0, 1], extrapolate: 'clamp' }) }} > <Animated.View style={{ transform: [ { translateY: interpolate(scrollY, { inputRange: [0, p], outputRange: [stickyHeaderHeight, 0], extrapolate: 'clamp' }) } ] }} > {renderStickyHeader()} </Animated.View> </Animated.View> : null} {renderFixedHeader && renderFixedHeader()} </View> ) } else { return null } } } ParallaxScrollView.propTypes = IPropTypes ParallaxScrollView.defaultProps = { backgroundScrollSpeed: 5, backgroundColor: '#000', contentBackgroundColor: '#fff', fadeOutForeground: true, onChangeHeaderVisibility: () => { }, renderScrollComponent: props => <Animated.ScrollView {...props} />, renderBackground: renderEmpty, renderContentBackground: noRender, renderParallaxHeader: renderEmpty, // Deprecated (will be removed in 0.18.0) renderForeground: null, stickyHeaderHeight: 0, contentContainerStyle: null, outputScaleValue: 5 } module.exports = ParallaxScrollView