/** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. * * @providesModule ScrollView * @flow */ 'use strict'; const Animated = require('Animated'); const ColorPropType = require('ColorPropType'); const EdgeInsetsPropType = require('EdgeInsetsPropType'); const Platform = require('Platform'); const PointPropType = require('PointPropType'); const PropTypes = require('prop-types'); const React = require('React'); const ReactNative = require('ReactNative'); const ScrollResponder = require('ScrollResponder'); const ScrollViewStickyHeader = require('ScrollViewStickyHeader'); const StyleSheet = require('StyleSheet'); const StyleSheetPropType = require('StyleSheetPropType'); const View = require('View'); const ViewPropTypes = require('ViewPropTypes'); const ViewStylePropTypes = require('ViewStylePropTypes'); const dismissKeyboard = require('dismissKeyboard'); const flattenStyle = require('flattenStyle'); const invariant = require('fbjs/lib/invariant'); const processDecelerationRate = require('processDecelerationRate'); const requireNativeComponent = require('requireNativeComponent'); /** * Component that wraps platform ScrollView while providing * integration with touch locking "responder" system. * * Keep in mind that ScrollViews must have a bounded height in order to work, * since they contain unbounded-height children into a bounded container (via * a scroll interaction). In order to bound the height of a ScrollView, either * set the height of the view directly (discouraged) or make sure all parent * views have bounded height. Forgetting to transfer `{flex: 1}` down the * view stack can lead to errors here, which the element inspector makes * easy to debug. * * Doesn't yet support other contained responders from blocking this scroll * view from becoming the responder. * * * `` vs [``](/react-native/docs/flatlist.html) - which one to use? * * `ScrollView` simply renders all its react child components at once. That * makes it very easy to understand and use. * * On the other hand, this has a performance downside. Imagine you have a very * long list of items you want to display, maybe several screens worth of * content. Creating JS components and native views for everything all at once, * much of which may not even be shown, will contribute to slow rendering and * increased memory usage. * * This is where `FlatList` comes into play. `FlatList` renders items lazily, * just when they are about to appear, and removes items that scroll way off * screen to save memory and processing time. * * `FlatList` is also handy if you want to render separators between your items, * multiple columns, infinite scroll loading, or any number of other features it * supports out of the box. */ // $FlowFixMe(>=0.41.0) const ScrollView = React.createClass({ propTypes: { ...ViewPropTypes, /** * Controls whether iOS should automatically adjust the content inset * for scroll views that are placed behind a navigation bar or * tab bar/ toolbar. The default value is true. * @platform ios */ automaticallyAdjustContentInsets: PropTypes.bool, /** * The amount by which the scroll view content is inset from the edges * of the scroll view. Defaults to `{top: 0, left: 0, bottom: 0, right: 0}`. * @platform ios */ contentInset: EdgeInsetsPropType, /** * Used to manually set the starting scroll offset. * The default value is `{x: 0, y: 0}`. * @platform ios */ contentOffset: PointPropType, /** * When true, the scroll view bounces when it reaches the end of the * content if the content is larger then the scroll view along the axis of * the scroll direction. When false, it disables all bouncing even if * the `alwaysBounce*` props are true. The default value is true. * @platform ios */ bounces: PropTypes.bool, /** * When true, gestures can drive zoom past min/max and the zoom will animate * to the min/max value at gesture end, otherwise the zoom will not exceed * the limits. * @platform ios */ bouncesZoom: PropTypes.bool, /** * When true, the scroll view bounces horizontally when it reaches the end * even if the content is smaller than the scroll view itself. The default * value is true when `horizontal={true}` and false otherwise. * @platform ios */ alwaysBounceHorizontal: PropTypes.bool, /** * When true, the scroll view bounces vertically when it reaches the end * even if the content is smaller than the scroll view itself. The default * value is false when `horizontal={true}` and true otherwise. * @platform ios */ alwaysBounceVertical: PropTypes.bool, /** * When true, the scroll view automatically centers the content when the * content is smaller than the scroll view bounds; when the content is * larger than the scroll view, this property has no effect. The default * value is false. * @platform ios */ centerContent: PropTypes.bool, /** * These styles will be applied to the scroll view content container which * wraps all of the child views. Example: * * return ( * * * ); * ... * const styles = StyleSheet.create({ * contentContainer: { * paddingVertical: 20 * } * }); */ contentContainerStyle: StyleSheetPropType(ViewStylePropTypes), /** * A floating-point number that determines how quickly the scroll view * decelerates after the user lifts their finger. You may also use string * shortcuts `"normal"` and `"fast"` which match the underlying iOS settings * for `UIScrollViewDecelerationRateNormal` and * `UIScrollViewDecelerationRateFast` respectively. * - normal: 0.998 (the default) * - fast: 0.99 * @platform ios */ decelerationRate: PropTypes.oneOfType([ PropTypes.oneOf(['fast', 'normal']), PropTypes.number, ]), /** * When true, the scroll view's children are arranged horizontally in a row * instead of vertically in a column. The default value is false. */ horizontal: PropTypes.bool, /** * The style of the scroll indicators. * - `default` (the default), same as `black`. * - `black`, scroll indicator is black. This style is good against a light background. * - `white`, scroll indicator is white. This style is good against a dark background. * @platform ios */ indicatorStyle: PropTypes.oneOf([ 'default', // default 'black', 'white', ]), /** * When true, the ScrollView will try to lock to only vertical or horizontal * scrolling while dragging. The default value is false. * @platform ios */ directionalLockEnabled: PropTypes.bool, /** * When false, once tracking starts, won't try to drag if the touch moves. * The default value is true. * @platform ios */ canCancelContentTouches: PropTypes.bool, /** * Determines whether the keyboard gets dismissed in response to a drag. * - 'none' (the default), drags do not dismiss the keyboard. * - 'on-drag', the keyboard is dismissed when a drag begins. * - 'interactive', the keyboard is dismissed interactively with the drag and moves in * synchrony with the touch; dragging upwards cancels the dismissal. * On android this is not supported and it will have the same behavior as 'none'. */ keyboardDismissMode: PropTypes.oneOf([ 'none', // default 'interactive', 'on-drag', ]), /** * Determines when the keyboard should stay visible after a tap. * * - 'never' (the default), tapping outside of the focused text input when the keyboard * is up dismisses the keyboard. When this happens, children won't receive the tap. * - 'always', the keyboard will not dismiss automatically, and the scroll view will not * catch taps, but children of the scroll view can catch taps. * - 'handled', the keyboard will not dismiss automatically when the tap was handled by * a children, (or captured by an ancestor). * - false, deprecated, use 'never' instead * - true, deprecated, use 'always' instead */ keyboardShouldPersistTaps: PropTypes.oneOf(['always', 'never', 'handled', false, true]), /** * The maximum allowed zoom scale. The default value is 1.0. * @platform ios */ maximumZoomScale: PropTypes.number, /** * The minimum allowed zoom scale. The default value is 1.0. * @platform ios */ minimumZoomScale: PropTypes.number, /** * Fires at most once per frame during scrolling. The frequency of the * events can be controlled using the `scrollEventThrottle` prop. */ onScroll: PropTypes.func, /** * Called when a scrolling animation ends. * @platform ios */ onScrollAnimationEnd: PropTypes.func, /** * Called when scrollable content view of the ScrollView changes. * * Handler function is passed the content width and content height as parameters: * `(contentWidth, contentHeight)` * * It's implemented using onLayout handler attached to the content container * which this ScrollView renders. */ onContentSizeChange: PropTypes.func, /** * When true, the scroll view stops on multiples of the scroll view's size * when scrolling. This can be used for horizontal pagination. The default * value is false. */ pagingEnabled: PropTypes.bool, /** * When false, the content does not scroll. * The default value is true. */ scrollEnabled: PropTypes.bool, /** * This controls how often the scroll event will be fired while scrolling * (as a time interval in ms). A lower number yields better accuracy for code * that is tracking the scroll position, but can lead to scroll performance * problems due to the volume of information being send over the bridge. * You will not notice a difference between values set between 1-16 as the * JS run loop is synced to the screen refresh rate. If you do not need precise * scroll position tracking, set this value higher to limit the information * being sent across the bridge. The default value is zero, which results in * the scroll event being sent only once each time the view is scrolled. * @platform ios */ scrollEventThrottle: PropTypes.number, /** * The amount by which the scroll view indicators are inset from the edges * of the scroll view. This should normally be set to the same value as * the `contentInset`. Defaults to `{0, 0, 0, 0}`. * @platform ios */ scrollIndicatorInsets: EdgeInsetsPropType, /** * When true, the scroll view scrolls to top when the status bar is tapped. * The default value is true. * @platform ios */ scrollsToTop: PropTypes.bool, /** * When true, shows a horizontal scroll indicator. * The default value is true. */ showsHorizontalScrollIndicator: PropTypes.bool, /** * When true, shows a vertical scroll indicator. * The default value is true. */ showsVerticalScrollIndicator: PropTypes.bool, /** * An array of child indices determining which children get docked to the * top of the screen when scrolling. For example, passing * `stickyHeaderIndices={[0]}` will cause the first child to be fixed to the * top of the scroll view. This property is not supported in conjunction * with `horizontal={true}`. */ stickyHeaderIndices: PropTypes.arrayOf(PropTypes.number), style: StyleSheetPropType(ViewStylePropTypes), /** * When set, causes the scroll view to stop at multiples of the value of * `snapToInterval`. This can be used for paginating through children * that have lengths smaller than the scroll view. Used in combination * with `snapToAlignment`. * @platform ios */ snapToInterval: PropTypes.number, /** * When `snapToInterval` is set, `snapToAlignment` will define the relationship * of the snapping to the scroll view. * - `start` (the default) will align the snap at the left (horizontal) or top (vertical) * - `center` will align the snap in the center * - `end` will align the snap at the right (horizontal) or bottom (vertical) * @platform ios */ snapToAlignment: PropTypes.oneOf([ 'start', // default 'center', 'end', ]), /** * Experimental: When true, offscreen child views (whose `overflow` value is * `hidden`) are removed from their native backing superview when offscreen. * This can improve scrolling performance on long lists. The default value is * true. */ removeClippedSubviews: PropTypes.bool, /** * The current scale of the scroll view content. The default value is 1.0. * @platform ios */ zoomScale: PropTypes.number, /** * A RefreshControl component, used to provide pull-to-refresh * functionality for the ScrollView. Only works for vertical ScrollViews * (`horizontal` prop must be `false`). * * See [RefreshControl](docs/refreshcontrol.html). */ refreshControl: PropTypes.element, /** * Sometimes a scrollview takes up more space than its content fills. When this is * the case, this prop will fill the rest of the scrollview with a color to avoid setting * a background and creating unnecessary overdraw. This is an advanced optimization * that is not needed in the general case. * @platform android */ endFillColor: ColorPropType, /** * Tag used to log scroll performance on this scroll view. Will force * momentum events to be turned on (see sendMomentumEvents). This doesn't do * anything out of the box and you need to implement a custom native * FpsListener for it to be useful. * @platform android */ scrollPerfTag: PropTypes.string, /** * Used to override default value of overScroll mode. * * Possible values: * * - `'auto'` - Default value, allow a user to over-scroll * this view only if the content is large enough to meaningfully scroll. * - `'always'` - Always allow a user to over-scroll this view. * - `'never'` - Never allow a user to over-scroll this view. * * @platform android */ overScrollMode: PropTypes.oneOf([ 'auto', 'always', 'never', ]), }, mixins: [ScrollResponder.Mixin], _scrollAnimatedValue: (new Animated.Value(0): Animated.Value), _scrollAnimatedValueAttachment: (null: ?{detach: () => void}), _stickyHeaderRefs: (new Map(): Map), _headerLayoutYs: (new Map(): Map), getInitialState: function() { return this.scrollResponderMixinGetInitialState(); }, componentWillMount: function() { this._scrollAnimatedValue = new Animated.Value(0); this._stickyHeaderRefs = new Map(); this._headerLayoutYs = new Map(); }, componentDidMount: function() { this._updateAnimatedNodeAttachment(); }, componentDidUpdate: function() { this._updateAnimatedNodeAttachment(); }, componentWillUnmount: function() { if (this._scrollAnimatedValueAttachment) { this._scrollAnimatedValueAttachment.detach(); } }, setNativeProps: function(props: Object) { this._scrollViewRef && this._scrollViewRef.setNativeProps(props); }, /** * Returns a reference to the underlying scroll responder, which supports * operations like `scrollTo`. All ScrollView-like components should * implement this method so that they can be composed while providing access * to the underlying scroll responder's methods. */ getScrollResponder: function(): ScrollView { return this; }, getScrollableNode: function(): any { return ReactNative.findNodeHandle(this._scrollViewRef); }, getInnerViewNode: function(): any { return ReactNative.findNodeHandle(this._innerViewRef); }, /** * Scrolls to a given x, y offset, either immediately or with a smooth animation. * * Example: * * `scrollTo({x: 0, y: 0, animated: true})` * * Note: The weird function signature is due to the fact that, for historical reasons, * the function also accepts separate arguments as an alternative to the options object. * This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED. */ scrollTo: function( y?: number | { x?: number, y?: number, animated?: boolean }, x?: number, animated?: boolean ) { if (typeof y === 'number') { console.warn('`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, ' + 'animated: true})` instead.'); } else { ({x, y, animated} = y || {}); } this.getScrollResponder().scrollResponderScrollTo( {x: x || 0, y: y || 0, animated: animated !== false} ); }, /** * If this is a vertical ScrollView scrolls to the bottom. * If this is a horizontal ScrollView scrolls to the right. * * Use `scrollToEnd({animated: true})` for smooth animated scrolling, * `scrollToEnd({animated: false})` for immediate scrolling. * If no options are passed, `animated` defaults to true. */ scrollToEnd: function( options?: { animated?: boolean }, ) { // Default to true const animated = (options && options.animated) !== false; this.getScrollResponder().scrollResponderScrollToEnd({ animated: animated, }); }, /** * Deprecated, use `scrollTo` instead. */ scrollWithoutAnimationTo: function(y: number = 0, x: number = 0) { console.warn('`scrollWithoutAnimationTo` is deprecated. Use `scrollTo` instead'); this.scrollTo({x, y, animated: false}); }, _getKeyForIndex: function(index, childArray) { const child = childArray[index]; return child && child.key; }, _updateAnimatedNodeAttachment: function() { if (this._scrollAnimatedValueAttachment) { this._scrollAnimatedValueAttachment.detach(); } if (this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0) { this._scrollAnimatedValueAttachment = Animated.attachNativeEvent( this._scrollViewRef, 'onScroll', [{nativeEvent: {contentOffset: {y: this._scrollAnimatedValue}}}] ); } }, _setStickyHeaderRef: function(key, ref) { if (ref) { this._stickyHeaderRefs.set(key, ref); } else { this._stickyHeaderRefs.delete(key); } }, _onStickyHeaderLayout: function(index, event, key) { if (!this.props.stickyHeaderIndices) { return; } const childArray = React.Children.toArray(this.props.children); if (key !== this._getKeyForIndex(index, childArray)) { // ignore stale layout update return; } const layoutY = event.nativeEvent.layout.y; this._headerLayoutYs.set(key, layoutY); const indexOfIndex = this.props.stickyHeaderIndices.indexOf(index); const previousHeaderIndex = this.props.stickyHeaderIndices[indexOfIndex - 1]; if (previousHeaderIndex != null) { const previousHeader = this._stickyHeaderRefs.get( this._getKeyForIndex(previousHeaderIndex, childArray) ); previousHeader && previousHeader.setNextHeaderY(layoutY); } }, _handleScroll: function(e: Object) { if (__DEV__) { if (this.props.onScroll && this.props.scrollEventThrottle == null && Platform.OS === 'ios') { console.log( // eslint-disable-line no-console-disallow 'You specified `onScroll` on a but not ' + '`scrollEventThrottle`. You will only receive one event. ' + 'Using `16` you get all the events but be aware that it may ' + 'cause frame drops, use a bigger number if you don\'t need as ' + 'much precision.' ); } } if (Platform.OS === 'android') { if (this.props.keyboardDismissMode === 'on-drag') { dismissKeyboard(); } } this.scrollResponderHandleScroll(e); }, _handleContentOnLayout: function(e: Object) { const {width, height} = e.nativeEvent.layout; this.props.onContentSizeChange && this.props.onContentSizeChange(width, height); }, _scrollViewRef: (null: ?ScrollView), _setScrollViewRef: function(ref: ?ScrollView) { this._scrollViewRef = ref; }, _innerViewRef: (null: ?View), _setInnerViewRef: function(ref: ?View) { this._innerViewRef = ref; }, render: function() { let ScrollViewClass; let ScrollContentContainerViewClass; if (Platform.OS === 'ios') { ScrollViewClass = RCTScrollView; ScrollContentContainerViewClass = RCTScrollContentView; } else if (Platform.OS === 'android') { if (this.props.horizontal) { ScrollViewClass = AndroidHorizontalScrollView; } else { ScrollViewClass = AndroidScrollView; } ScrollContentContainerViewClass = View; } invariant( ScrollViewClass !== undefined, 'ScrollViewClass must not be undefined' ); invariant( ScrollContentContainerViewClass !== undefined, 'ScrollContentContainerViewClass must not be undefined' ); const contentContainerStyle = [ this.props.horizontal && styles.contentContainerHorizontal, this.props.contentContainerStyle, ]; let style, childLayoutProps; if (__DEV__ && this.props.style) { style = flattenStyle(this.props.style); childLayoutProps = ['alignItems', 'justifyContent'] .filter((prop) => style && style[prop] !== undefined); invariant( childLayoutProps.length === 0, 'ScrollView child layout (' + JSON.stringify(childLayoutProps) + ') must be applied through the contentContainerStyle prop.' ); } let contentSizeChangeProps = {}; if (this.props.onContentSizeChange) { contentSizeChangeProps = { onLayout: this._handleContentOnLayout, }; } const {stickyHeaderIndices} = this.props; const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0; const childArray = hasStickyHeaders && React.Children.toArray(this.props.children); const children = hasStickyHeaders ? childArray.map((child, index) => { const indexOfIndex = child ? stickyHeaderIndices.indexOf(index) : -1; if (indexOfIndex > -1) { const key = child.key; const nextIndex = stickyHeaderIndices[indexOfIndex + 1]; return ( this._setStickyHeaderRef(key, ref)} nextHeaderLayoutY={ this._headerLayoutYs.get(this._getKeyForIndex(nextIndex, childArray)) } onLayout={(event) => this._onStickyHeaderLayout(index, event, key)} scrollAnimatedValue={this._scrollAnimatedValue}> {child} ); } else { return child; } }) : this.props.children; const contentContainer = {children} ; const alwaysBounceHorizontal = this.props.alwaysBounceHorizontal !== undefined ? this.props.alwaysBounceHorizontal : this.props.horizontal; const alwaysBounceVertical = this.props.alwaysBounceVertical !== undefined ? this.props.alwaysBounceVertical : !this.props.horizontal; const baseStyle = this.props.horizontal ? styles.baseHorizontal : styles.baseVertical; const props = { ...this.props, alwaysBounceHorizontal, alwaysBounceVertical, style: ([baseStyle, this.props.style]: ?Array), // Override the onContentSizeChange from props, since this event can // bubble up from TextInputs onContentSizeChange: null, onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin, onMomentumScrollEnd: this.scrollResponderHandleMomentumScrollEnd, onResponderGrant: this.scrollResponderHandleResponderGrant, onResponderReject: this.scrollResponderHandleResponderReject, onResponderRelease: this.scrollResponderHandleResponderRelease, onResponderTerminate: this.scrollResponderHandleTerminate, onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest, onScroll: this._handleScroll, onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag, onScrollEndDrag: this.scrollResponderHandleScrollEndDrag, onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder, onStartShouldSetResponder: this.scrollResponderHandleStartShouldSetResponder, onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture, onTouchEnd: this.scrollResponderHandleTouchEnd, onTouchMove: this.scrollResponderHandleTouchMove, onTouchStart: this.scrollResponderHandleTouchStart, scrollEventThrottle: hasStickyHeaders ? 1 : this.props.scrollEventThrottle, sendMomentumEvents: (this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd) ? true : false, }; const { decelerationRate } = this.props; if (decelerationRate) { props.decelerationRate = processDecelerationRate(decelerationRate); } const refreshControl = this.props.refreshControl; if (refreshControl) { if (Platform.OS === 'ios') { // On iOS the RefreshControl is a child of the ScrollView. // tvOS lacks native support for RefreshControl, so don't include it in that case return ( {Platform.isTVOS ? null : refreshControl} {contentContainer} ); } else if (Platform.OS === 'android') { // On Android wrap the ScrollView with a AndroidSwipeRefreshLayout. // Since the ScrollView is wrapped add the style props to the // AndroidSwipeRefreshLayout and use flex: 1 for the ScrollView. // Note: we should only apply props.style on the wrapper // however, the ScrollView still needs the baseStyle to be scrollable return React.cloneElement( refreshControl, {style: props.style}, {contentContainer} ); } } return ( {contentContainer} ); } }); const styles = StyleSheet.create({ baseVertical: { flexGrow: 1, flexShrink: 1, flexDirection: 'column', overflow: 'scroll', }, baseHorizontal: { flexGrow: 1, flexShrink: 1, flexDirection: 'row', overflow: 'scroll', }, contentContainerHorizontal: { flexDirection: 'row', }, }); let nativeOnlyProps, AndroidScrollView, AndroidHorizontalScrollView, RCTScrollView, RCTScrollContentView; if (Platform.OS === 'android') { nativeOnlyProps = { nativeOnly: { sendMomentumEvents: true, } }; AndroidScrollView = requireNativeComponent( 'RCTScrollView', (ScrollView: ReactClass), nativeOnlyProps ); AndroidHorizontalScrollView = requireNativeComponent( 'AndroidHorizontalScrollView', (ScrollView: ReactClass), nativeOnlyProps ); } else if (Platform.OS === 'ios') { nativeOnlyProps = { nativeOnly: { onMomentumScrollBegin: true, onMomentumScrollEnd : true, onScrollBeginDrag: true, onScrollEndDrag: true, } }; RCTScrollView = requireNativeComponent( 'RCTScrollView', (ScrollView: ReactClass), nativeOnlyProps, ); RCTScrollContentView = requireNativeComponent('RCTScrollContentView', View); } module.exports = ScrollView;