UNPKG

@personio/react-native-spring-scrollview

Version:

An cross-platform (iOS & Android) spring ScrollView

535 lines (498 loc) 15.9 kB
/** * Author: Shi(bolan0000@icloud.com) * Date: 2019/1/17 * Copyright (c) 2018, AoTang, Inc. * * Description: */ import * as React from "react"; import { Animated, requireNativeComponent, View, findNodeHandle, UIManager, Keyboard, Platform, NativeModules, StyleSheet, ViewProps, ViewStyle, ScrollView } from "react-native"; import * as TextInputState from "react-native/Libraries/Components/TextInput/TextInputState"; import { FooterStatus } from "./LoadingFooter"; import { NormalHeader } from "./NormalHeader"; import { NormalFooter } from "./NormalFooter"; import type { HeaderStatus } from "./RefreshHeader"; import { idx } from "./idx"; import type { Offset, SpringScrollViewPropType } from "./Types"; import { styles } from "./styles"; export class SpringScrollView extends React.PureComponent<SpringScrollViewPropType> { _offsetY: Animated.Value; _offsetX: Animated.Value; _offsetYValue: number = 0; _event; _keyboardHeight: number; _refreshHeader; _loadingFooter; _width: number; _height: number; _scrollView: View; _indicatorOpacity: Animated.Value = new Animated.Value(1); _contentHeight: number; _contentWidth: number; _refreshStatus: HeaderStatus = "waiting"; _loadingStatus: FooterStatus = "waiting"; _indicatorAnimation; _nativeOffset; constructor(props: SpringScrollViewPropType) { super(props); this.obtainScrollEvent(props); this._offsetX.setValue(props.initialContentOffset.x); this._offsetY.setValue(props.initialContentOffset.y); } componentWillReceiveProps(nextProps: SpringScrollViewPropType) { if (nextProps.onNativeContentOffsetExtract !== this.props.onNativeContentOffsetExtract) { this.obtainScrollEvent(nextProps); } } obtainScrollEvent(props: SpringScrollViewPropType) { if (!props) props = {}; this._nativeOffset = { x: new Animated.Value(0), y: new Animated.Value(0), ...props.onNativeContentOffsetExtract }; this._offsetY = this._nativeOffset.y; this._offsetX = this._nativeOffset.x; this._event = Animated.event( [ { nativeEvent: { contentOffset: this._nativeOffset } } ], { useNativeDriver: true, listener: this._onScroll } ); } render() { const { style, inverted, children, onRefresh, onLoading, refreshHeader: Refresh, loadingFooter: Loading } = this.props; const wStyle = StyleSheet.flatten([ styles.wrapperStyle, style, { transform: inverted ? [{ scaleY: -1 }] : [] } ]); const elements = ( <SpringScrollViewNative {...this.props} ref={ref => (this._scrollView = ref)} style={Platform.OS === "android" ? wStyle : { flex: 1 }} onScroll={this._event} refreshHeaderHeight={onRefresh ? Refresh.height : 0} loadingFooterHeight={onLoading ? Loading.height : 0} onLayout={this._onWrapperLayoutChange} onTouchBegin={Platform.OS === "android" && this._onTouchBegin} onTouchStart={Platform.OS === "ios" && this._onTouchBegin} onMomentumScrollEnd={this._onMomentumScrollEnd} scrollEventThrottle={1} onNativeContentOffsetExtract={this._nativeOffset} > <SpringScrollContentViewNative style={this.props.contentStyle} collapsable={false} onLayout={this._onContentLayoutChange} > {this._renderRefreshHeader()} {this._renderLoadingFooter()} {children} </SpringScrollContentViewNative> {this._renderHorizontalIndicator()} {this._renderVerticalIndicator()} </SpringScrollViewNative> ); if (Platform.OS === "android") return elements; return ( <ScrollView style={wStyle} contentContainerStyle={{ flex: 1 }} keyboardShouldPersistTaps={this.props.keyboardShouldPersistTaps} keyboardDismissMode={this.props.keyboardDismissMode} scrollEnabled={false} > {elements} </ScrollView> ); } _renderRefreshHeader() { const { onRefresh, refreshHeader: Refresh } = this.props; const measured = this._height !== undefined && this._contentHeight !== undefined; if (!measured) return null; return ( onRefresh && ( <Animated.View style={this._getRefreshHeaderStyle()}> <Refresh ref={ref => (this._refreshHeader = ref)} offset={this._offsetY} maxHeight={Refresh.height} /> </Animated.View> ) ); } _renderLoadingFooter() { const { onLoading, loadingFooter: Footer } = this.props; const measured = this._height !== undefined && this._contentHeight !== undefined; if (!measured) return null; return ( onLoading && ( <Animated.View style={this._getLoadingFooterStyle()}> <Footer ref={ref => (this._loadingFooter = ref)} offset={this._offsetY} maxHeight={Footer.height} bottomOffset={this._contentHeight - this._height} /> </Animated.View> ) ); } _renderVerticalIndicator() { if (Platform.OS === "ios") return null; const { showsVerticalScrollIndicator } = this.props; const measured = this._height !== undefined && this._contentHeight !== undefined; if (!measured) return null; return ( showsVerticalScrollIndicator && this._contentHeight > this._height && ( <Animated.View style={this._getVerticalIndicatorStyle()} /> ) ); } _renderHorizontalIndicator() { if (Platform.OS === "ios") return null; const { showsHorizontalScrollIndicator } = this.props; const measured = this._height !== undefined && this._contentHeight !== undefined; if (!measured) return null; return ( showsHorizontalScrollIndicator && this._contentWidth > this._width && ( <Animated.View style={this._getHorizontalIndicatorStyle()} /> ) ); } componentDidMount() { this._beginIndicatorDismissAnimation(); this._keyboardShowSub = Keyboard.addListener( Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", this._onKeyboardWillShow ); this._keyboardHideSub = Keyboard.addListener( Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide", this._onKeyboardWillHide ); } componentDidUpdate() { this._beginIndicatorDismissAnimation(); } componentWillUnmount() { this._keyboardShowSub.remove(); this._keyboardHideSub.remove(); } scrollTo(offset: Offset, animated: boolean = true) { if (Platform.OS === "ios") { NativeModules.SpringScrollView.scrollTo( findNodeHandle(this._scrollView), offset.x, offset.y, animated ); } else if (Platform.OS === "android") { UIManager.dispatchViewManagerCommand(findNodeHandle(this._scrollView), 10002, [ offset.x, offset.y, animated ]); } return new Promise((resolve, reject) => { if (animated) setTimeout(resolve, 500); else resolve(); }); } scroll(offset: Offset, animated: boolean = true) { return this.scrollTo({ x: offset.x, y: offset.y + this._offsetYValue }, animated); } scrollToBegin(animated: boolean) { return this.scrollTo({ x: 0, y: 0 }, animated); } scrollToEnd(animated: boolean = true) { let toOffsetY = this._contentHeight - this._height; if (toOffsetY < 0) toOffsetY = 0; return this.scrollTo({ x: 0, y: toOffsetY }, animated); } endRefresh() { if (Platform.OS === "ios") { NativeModules.SpringScrollView.endRefresh(findNodeHandle(this._scrollView)); } else if (Platform.OS === "android") { UIManager.dispatchViewManagerCommand(findNodeHandle(this._scrollView), 10000, []); } } endLoading() { if (Platform.OS === "ios") { NativeModules.SpringScrollView.endLoading(findNodeHandle(this._scrollView)); } else if (Platform.OS === "android") { UIManager.dispatchViewManagerCommand(findNodeHandle(this._scrollView), 10001, []); } } _onKeyboardWillShow = evt => { this.props.textInputRefs.every(input => { if (idx(() => input.current.isFocused())) { input.current.measure((x, y, w, h, l, t) => { this._keyboardHeight = t + h - evt.endCoordinates.screenY + this.props.inputToolBarHeight; this._keyboardHeight > 0 && this.scroll({ x: 0, y: this._keyboardHeight }); }); return false; } return true; }); }; _onKeyboardWillHide = () => { if (this._keyboardHeight > 0) { this.scroll({ x: 0, y: -this._keyboardHeight }); this._keyboardHeight = 0; } }; _beginIndicatorDismissAnimation() { this._indicatorOpacity.setValue(1); this._indicatorAnimation && this._indicatorAnimation.stop(); this._indicatorAnimation = Animated.timing(this._indicatorOpacity, { toValue: 0, delay: 500, duration: 500, useNativeDriver: true }); this._indicatorAnimation.start(({ finished }) => { if (!finished) { this._indicatorOpacity.setValue(1); } this._indicatorAnimation = null; }); } _onScroll = e => { const { contentOffset: { x, y }, refreshStatus, loadingStatus } = e.nativeEvent; this._offsetYValue = y; if (this._refreshStatus !== refreshStatus) { this._toRefreshStatus(refreshStatus); this.props.onRefresh && refreshStatus === "refreshing" && this.props.onRefresh(); } if (this._loadingStatus !== loadingStatus) { this._toLoadingStatus(loadingStatus); this.props.onLoading && loadingStatus === "loading" && this.props.onLoading(); } this.props.onScroll && this.props.onScroll(e); if (!this._indicatorAnimation) { this._indicatorOpacity.setValue(1); } }; _toRefreshStatus(status: HeaderStatus) { this._refreshStatus = status; idx(() => this._refreshHeader.changeToState(status)); } _toLoadingStatus(status: FooterStatus) { this._loadingStatus = status; idx(() => this._loadingFooter.changeToState(status)); } _getVerticalIndicatorStyle() { const indicatorHeight = this._height / this._contentHeight * this._height; return { position: "absolute", top: 0, right: 2, height: indicatorHeight, width: 3, borderRadius: 3, opacity: this._indicatorOpacity, backgroundColor: "#A8A8A8", transform: [ { translateY: Animated.multiply(this._offsetY, this._height / this._contentHeight) } ] }; } _getHorizontalIndicatorStyle() { const indicatorWidth = this._width / this._contentWidth * this._width; return { position: "absolute", bottom: 2, left: 0, height: 3, width: indicatorWidth, borderRadius: 3, opacity: this._indicatorOpacity, backgroundColor: "#A8A8A8", transform: [ { translateX: Animated.multiply(this._offsetX, this._width / this._contentWidth) } ] }; } _getRefreshHeaderStyle() { const rHeight = this.props.refreshHeader.height; const style = this.props.refreshHeader.style; let transform = []; if (style === "topping") { transform = [ { translateY: this._offsetY.interpolate({ inputRange: [-rHeight - 1, -rHeight, 0, 1], outputRange: [-1, 0, rHeight, rHeight] }) } ]; } else if (style === "stickyScrollView") { transform = [ { translateY: this._offsetY.interpolate({ inputRange: [-rHeight - 1, -rHeight, 0, 1], outputRange: [-1, 0, 0, 0] }) } ]; } else if (style !== "stickyContent") { console.warn( "unsupported value: '", style, "' in SpringScrollView, " + "select one in 'topping','stickyScrollView','stickyContent' please" ); } if (this.props.inverted) transform.push({ scaleY: -1 }); return { position: "absolute", top: -rHeight, right: 0, height: rHeight, left: 0, transform }; } _getLoadingFooterStyle() { const fHeight = this.props.loadingFooter.height; const maxOffset = this._contentHeight - this._height; const style = this.props.loadingFooter.style; let transform = []; if (style === "bottoming") { transform = [ { translateY: this._offsetY.interpolate({ inputRange: [maxOffset - 1, maxOffset, maxOffset + fHeight, maxOffset + fHeight + 1], outputRange: [-fHeight, -fHeight, 0, 1] }) } ]; } else if (style === "stickyScrollView") { transform = [ { translateY: this._offsetY.interpolate({ inputRange: [maxOffset - 1, maxOffset, maxOffset + fHeight, maxOffset + fHeight + 1], outputRange: [0, 0, 0, 1] }) } ]; } else if (style !== "stickyContent") { console.warn( "unsupported value: '", style, "' in SpringScrollView, " + "select one in 'bottoming','stickyScrollView','stickyContent' please" ); } if (this.props.inverted) transform.push({ scaleY: -1 }); return { position: "absolute", right: 0, top: this._height > this._contentHeight ? this._height : this._contentHeight, height: fHeight, left: 0, transform }; } _onWrapperLayoutChange = ({ nativeEvent: { layout: { x, y, width, height } } }) => { if (this._height !== height || this._width !== width) { this.props.onSizeChange && this.props.onSizeChange({ width, height }); this._height = height; this._width = width; if (!this._contentHeight) return; if (this._contentHeight < this._height) this._contentHeight = height; if (this._offsetYValue > this._contentHeight - this._height) this.scrollToEnd(); this.forceUpdate(); } }; _onContentLayoutChange = ({ nativeEvent: { layout: { x, y, width, height } } }) => { if (this._contentHeight !== height || this._contentWidth !== width) { this.props.onContentSizeChange && this.props.onContentSizeChange({ width, height }); this._contentHeight = height; this._contentWidth = width; if (!this._height) return; if (this._contentHeight < this._height) this._contentHeight = this._height; if (this._offsetYValue > this._contentHeight - this._height) this.scrollToEnd(false); this.forceUpdate(); } }; _onTouchBegin = () => { if (TextInputState.currentlyFocusedField()) TextInputState.blurTextInput(TextInputState.currentlyFocusedField()); this.props.tapToHideKeyboard && Keyboard.dismiss(); this.props.onTouchBegin && this.props.onTouchBegin(); }; _onMomentumScrollEnd = () => { this._beginIndicatorDismissAnimation(); this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(); }; static defaultProps = { bounces: true, scrollEnabled: true, refreshHeader: NormalHeader, loadingFooter: NormalFooter, textInputRefs: [], inputToolBarHeight: 44, tapToHideKeyboard: true, initOffset: { x: 0, y: 0 }, keyboardShouldPersistTaps: "always", showsVerticalScrollIndicator: true, showsHorizontalScrollIndicator: true, initialContentOffset: { x: 0, y: 0 }, alwaysBounceVertical: true }; } const SpringScrollViewNative = Animated.createAnimatedComponent( requireNativeComponent("SpringScrollView", SpringScrollView) ); const SpringScrollContentViewNative = Platform.OS === "ios" ? requireNativeComponent("SpringScrollContentView") : View;