diff --git a/src/components/ScrollView/ScrollViewBase.js b/src/components/ScrollView/ScrollViewBase.js new file mode 100644 index 00000000..46a976ff --- /dev/null +++ b/src/components/ScrollView/ScrollViewBase.js @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2016-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ + +import debounce from 'lodash.debounce' +import React, { Component, PropTypes } from 'react' +import View from '../View' + +/** + * Encapsulates the Web-specific scroll throttling and disabling logic + */ +export default class ScrollViewBase extends Component { + static propTypes = { + ...View.propTypes, + onScroll: PropTypes.func, + onTouchMove: PropTypes.func, + onWheel: PropTypes.func, + scrollEnabled: PropTypes.bool, + scrollEventThrottle: PropTypes.number + }; + + static defaultProps = { + scrollEnabled: true + }; + + constructor(props) { + super(props) + this._debouncedOnScrollEnd = debounce(this._handleScrollEnd, 100) + this._handlePreventableScrollEvent = this._handlePreventableScrollEvent.bind(this) + this._handleScroll = this._handleScroll.bind(this) + this._state = { isScrolling: false } + } + + _handlePreventableScrollEvent(handler) { + return (e) => { + if (!this.props.scrollEnabled) { + e.preventDefault() + } else { + if (handler) handler(e) + } + } + } + + _handleScroll(e) { + const { scrollEventThrottle } = this.props + // A scroll happened, so the scroll bumps the debounce. + this._debouncedOnScrollEnd(e) + if (this._state.isScrolling) { + // Scroll last tick may have changed, check if we need to notify + if (this._shouldEmitScrollEvent(this._state.scrollLastTick, scrollEventThrottle)) { + this._handleScrollTick(e) + } + } else { + // Weren't scrolling, so we must have just started + this._handleScrollStart(e) + } + } + + _handleScrollStart(e) { + this._state.isScrolling = true + this._state.scrollLastTick = Date.now() + } + + _handleScrollTick(e) { + const { onScroll } = this.props + this._state.scrollLastTick = Date.now() + if (onScroll) onScroll(e) + } + + _handleScrollEnd(e) { + const { onScroll } = this.props + this._state.isScrolling = false + if (onScroll) onScroll(e) + } + + _shouldEmitScrollEvent(lastTick, eventThrottle) { + const timeSinceLastTick = Date.now() - lastTick + return (eventThrottle > 0 && timeSinceLastTick >= (1000 / eventThrottle)) + } + + render() { + return ( + + ) + } +} diff --git a/src/components/ScrollView/index.js b/src/components/ScrollView/index.js index 226c2eed..9091e2d7 100644 --- a/src/components/ScrollView/index.js +++ b/src/components/ScrollView/index.js @@ -1,130 +1,225 @@ -import { NativeMethodsDecorator } from '../../modules/NativeMethodsMixin' -import debounce from 'lodash.debounce' -import React, { Component, PropTypes } from 'react' -import StyleSheet from '../../apis/StyleSheet' -import View from '../View' +/** + * Copyright (c) 2016-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ -@NativeMethodsDecorator -export default class ScrollView extends Component { - static propTypes = { - children: PropTypes.any, - contentContainerStyle: View.propTypes.style, +import dismissKeyboard from '../../modules/dismissKeyboard' +import invariant from 'fbjs/lib/invariant' +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import ScrollResponder from '../../modules/ScrollResponder' +import ScrollViewBase from './ScrollViewBase' +import StyleSheet from '../../apis/StyleSheet' +import StyleSheetPropType from '../../apis/StyleSheet/StyleSheetPropType' +import View from '../View' +import ViewStylePropTypes from '../View/ViewStylePropTypes' + +const INNERVIEW = 'InnerScrollView' +const SCROLLVIEW = 'ScrollView' + +const ScrollView = React.createClass({ + propTypes: { + ...View.propTypes, + children: View.propTypes.children, + contentContainerStyle: StyleSheetPropType(ViewStylePropTypes), horizontal: PropTypes.bool, + keyboardDismissMode: PropTypes.oneOf([ 'none', 'interactive', 'on-drag' ]), + onContentSizeChange: PropTypes.func, onScroll: PropTypes.func, + refreshControl: PropTypes.element, scrollEnabled: PropTypes.bool, scrollEventThrottle: PropTypes.number, - style: View.propTypes.style - }; + style: StyleSheetPropType(ViewStylePropTypes) + }, - static defaultProps = { - contentContainerStyle: {}, - horizontal: false, - scrollEnabled: true, - scrollEventThrottle: 0, - style: {} - }; + mixins: [ScrollResponder.Mixin], - constructor(...args) { - super(...args) - this._debouncedOnScrollEnd = debounce(this._onScrollEnd, 100) - this.state = { - isScrolling: false - } - } + getInitialState() { + return this.scrollResponderMixinGetInitialState() + }, - _onScroll(e) { - const { scrollEventThrottle } = this.props - const { isScrolling, scrollLastTick } = this.state + setNativeProps(props: Object) { + this.refs[SCROLLVIEW].setNativeProps(props) + }, - // A scroll happened, so the scroll bumps the debounce. - this._debouncedOnScrollEnd(e) + /** + * 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(): Component { + return this + }, - if (isScrolling) { - // Scroll last tick may have changed, check if we need to notify - if (this._shouldEmitScrollEvent(scrollLastTick, scrollEventThrottle)) { - this._onScrollTick(e) - } + getScrollableNode(): any { + return ReactDOM.findDOMNode(this.refs[SCROLLVIEW]) + }, + + getInnerViewNode(): any { + return ReactDOM.findDOMNode(this.refs[INNERVIEW]) + }, + + /** + * Scrolls to a given x, y offset, either immediately or with a smooth animation. + * Syntax: + * + * scrollTo(options: {x: number = 0; y: number = 0; animated: boolean = true}) + * + * Note: The weird argument signature is due to the fact that, for historical reasons, + * the function also accepts separate arguments as as alternative to the options object. + * This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED. + */ + scrollTo( + 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 { - // Weren't scrolling, so we must have just started - this._onScrollStart(e) + ({x, y, animated} = y || {}) } - } - _onScrollStart() { - this.setState({ - isScrolling: true, - scrollLastTick: Date.now() - }) - } + this.getScrollResponder().scrollResponderScrollTo({x: x || 0, y: y || 0, animated: animated !== false}) + }, - _onScrollTick(e) { - const { onScroll } = this.props - this.setState({ - scrollLastTick: Date.now() - }) - if (onScroll) onScroll(e) - } + /** + * Deprecated, do not use. + */ + scrollWithoutAnimationTo(y: number = 0, x: number = 0) { + console.warn('`scrollWithoutAnimationTo` is deprecated. Use `scrollTo` instead') + this.scrollTo({x, y, animated: false}) + }, - _onScrollEnd(e) { - const { onScroll } = this.props - this.setState({ - isScrolling: false - }) - if (onScroll) onScroll(e) - } + handleScroll(e: Object) { + if (process.env.NODE_ENV !== 'production') { + if (this.props.onScroll && !this.props.scrollEventThrottle) { + console.log( + '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.' + ) + } + } - _shouldEmitScrollEvent(lastTick, eventThrottle) { - const timeSinceLastTick = Date.now() - lastTick - return (eventThrottle > 0 && timeSinceLastTick >= (1000 / eventThrottle)) - } + if (this.props.keyboardDismissMode === 'on-drag') { + dismissKeyboard() + } - _maybePreventScroll(e) { - const { scrollEnabled } = this.props - if (!scrollEnabled) e.preventDefault() - } + this.scrollResponderHandleScroll(e) + }, + + _handleContentOnLayout(e: Object) { + const { width, height } = e.nativeEvent.layout + this.props.onContentSizeChange && this.props.onContentSizeChange(width, height) + }, render() { - const { - children, - contentContainerStyle, - horizontal, - style - } = this.props + const scrollViewStyle = [ + styles.base, + this.props.horizontal && styles.baseHorizontal + ] + + const contentContainerStyle = [ + styles.contentContainer, + this.props.horizontal && styles.contentContainerHorizontal, + this.props.contentContainerStyle + ] + + if (process.env.NODE_ENV !== 'production' && this.props.style) { + const style = StyleSheet.flatten(this.props.style) + const 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 contentContainer = ( + + ) + + const props = { + ...this.props, + style: [scrollViewStyle, this.props.style], + onTouchStart: this.scrollResponderHandleTouchStart, + onTouchMove: this.scrollResponderHandleTouchMove, + onTouchEnd: this.scrollResponderHandleTouchEnd, + onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag, + onScrollEndDrag: this.scrollResponderHandleScrollEndDrag, + onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin, + onMomentumScrollEnd: this.scrollResponderHandleMomentumScrollEnd, + onStartShouldSetResponder: this.scrollResponderHandleStartShouldSetResponder, + onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture, + onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder, + onScroll: this.handleScroll, + onResponderGrant: this.scrollResponderHandleResponderGrant, + onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest, + onResponderTerminate: this.scrollResponderHandleTerminate, + onResponderRelease: this.scrollResponderHandleResponderRelease, + onResponderReject: this.scrollResponderHandleResponderReject + } + + const ScrollViewClass = ScrollViewBase + + invariant( + ScrollViewClass !== undefined, + 'ScrollViewClass must not be undefined' + ) + + var refreshControl = this.props.refreshControl + if (refreshControl) { + return React.cloneElement( + refreshControl, + { style: props.style }, + + {contentContainer} + + ) + } return ( - this._onScroll(e)} - onTouchMove={(e) => this._maybePreventScroll(e)} - onWheel={(e) => this._maybePreventScroll(e)} - style={[ - styles.initial, - style - ]} - > - {children ? ( - - ) : null} - + + {contentContainer} + ) } -} +}) const styles = StyleSheet.create({ - initial: { + base: { flex: 1, - overflow: 'auto' + overflowX: 'hidden', + overflowY: 'auto' }, - initialContentContainer: { + baseHorizontal: { + overflowX: 'auto', + overflowY: 'hidden' + }, + contentContainer: { flex: 1 }, - row: { + contentContainerHorizontal: { flexDirection: 'row' } })