From caac52095260beabd7038ec82a93847cc38b3f04 Mon Sep 17 00:00:00 2001 From: Hedger Wang Date: Tue, 1 Mar 2016 18:44:58 -0800 Subject: [PATCH] Add gesture handling for the card stack. Reviewed By: ericvicenti Differential Revision: D2995958 fb-gh-sync-id: f66759440b03072b650a572f011cadd06a0180d2 shipit-source-id: f66759440b03072b650a572f011cadd06a0180d2 --- .../NavigationCardStackExample.js | 100 ++++++---- .../NavigationCardStack.js | 10 +- .../NavigationCardStackItem.js | 78 ++++++-- .../NavigationAbstractPanResponder.js | 47 +++++ .../NavigationAnimatedView.js | 28 ++- .../NavigationLinearPanResponder.js | 183 ++++++++++++++++++ 6 files changed, 372 insertions(+), 74 deletions(-) create mode 100644 Libraries/NavigationExperimental/NavigationAbstractPanResponder.js create mode 100644 Libraries/NavigationExperimental/NavigationLinearPanResponder.js diff --git a/Examples/UIExplorer/NavigationExperimental/NavigationCardStackExample.js b/Examples/UIExplorer/NavigationExperimental/NavigationCardStackExample.js index ae08ac75d2..e7feb7c60b 100644 --- a/Examples/UIExplorer/NavigationExperimental/NavigationCardStackExample.js +++ b/Examples/UIExplorer/NavigationExperimental/NavigationCardStackExample.js @@ -14,6 +14,7 @@ 'use strict'; const NavigationExampleRow = require('./NavigationExampleRow'); +const NavigationRootContainer = require('NavigationRootContainer'); const React = require('react-native'); const { @@ -25,62 +26,68 @@ const { const NavigationCardStack = NavigationExperimental.CardStack; const NavigationStateUtils = NavigationExperimental.StateUtils; +function reduceNavigationState(initialState) { + return (currentState, action) => { + switch (action.type) { + case 'RootContainerInitialAction': + return initialState; + + case 'push': + return NavigationStateUtils.push(currentState, {key: action.key}); + + case 'back': + case 'pop': + return currentState.index > 0 ? + NavigationStateUtils.pop(currentState) : + currentState; + + default: + return currentState; + } + }; +} + +const ExampleReducer = reduceNavigationState({ + index: 0, + children: [{key: 'First Route'}], +}); + class NavigationCardStackExample extends React.Component { constructor(props, context) { super(props, context); - this.state = this._getInitialState(); + + this._renderNavigation = this._renderNavigation.bind(this); this._renderScene = this._renderScene.bind(this); - this._push = this._push.bind(this); - this._pop = this._pop.bind(this); this._toggleDirection = this._toggleDirection.bind(this); + + this.state = {isHorizontal: true}; } render() { + return ( + + ); + } + + _renderNavigation(navigationState, onNavigate) { return ( ); } - _getInitialState() { - const navigationState = { - index: 0, - children: [{key: 'First Route'}], - }; - return { - isHorizontal: true, - navigationState, - }; - } - - _push() { - const state = this.state.navigationState; - const nextState = NavigationStateUtils.push( - state, - {key: 'Route ' + (state.index + 1)}, - ); - this.setState({ - navigationState: nextState, - }); - } - - _pop() { - const state = this.state.navigationState; - const nextState = state.index > 0 ? - NavigationStateUtils.pop(state) : - state; - - this.setState({ - navigationState: nextState, - }); - } - _renderScene(props) { + const {navigationParentState, onNavigate} = props; return ( { + onNavigate({ + type: 'push', + key: 'Route ' + navigationParentState.children.length, + }); + }} /> { + onNavigate({ + type: 'pop', + }); + }} /> . */ @@ -119,9 +160,8 @@ class NavigationCardStackItem extends React.Component { const { direction, index, - navigationState, + navigationParentState, position, - layout, } = this.props; const { height, @@ -161,8 +201,16 @@ class NavigationCardStackItem extends React.Component { ], }; + let panHandlers = null; + if (navigationParentState.index === index) { + const delegate = new PanResponderDelegate(this.props); + const panResponder = new NavigationLinearPanResponder(delegate); + panHandlers = panResponder.panHandlers; + } + return ( {this.props.renderScene(this.props)} @@ -200,16 +248,12 @@ class NavigationCardStackItem extends React.Component { } } -const Directions = { - HORIZONTAL: 'horizontal', - VERTICAL: 'vertical', -}; - NavigationCardStackItem.propTypes = { direction: PropTypes.oneOf([Directions.HORIZONTAL, Directions.VERTICAL]), index: PropTypes.number.isRequired, layout: PropTypes.object.isRequired, navigationState: PropTypes.object.isRequired, + navigationParentState: PropTypes.object.isRequired, position: PropTypes.object.isRequired, renderScene: PropTypes.func.isRequired, }; @@ -218,10 +262,6 @@ NavigationCardStackItem.defaultProps = { direction: Directions.HORIZONTAL, }; -NavigationCardStackItem = NavigationContainer.create(NavigationCardStackItem); - -NavigationCardStackItem.Directions = Directions; - const styles = StyleSheet.create({ main: { backgroundColor: '#E9E9EF', @@ -237,6 +277,4 @@ const styles = StyleSheet.create({ }, }); - - -module.exports = NavigationCardStackItem; +module.exports = NavigationContainer.create(NavigationCardStackItem); diff --git a/Libraries/NavigationExperimental/NavigationAbstractPanResponder.js b/Libraries/NavigationExperimental/NavigationAbstractPanResponder.js new file mode 100644 index 0000000000..d6000a4ce6 --- /dev/null +++ b/Libraries/NavigationExperimental/NavigationAbstractPanResponder.js @@ -0,0 +1,47 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule NavigationAbstractPanResponder + * @flow + */ +'use strict'; + +const PanResponder = require('PanResponder'); + +const invariant = require('invariant'); + +const EmptyPanHandlers = { + onMoveShouldSetPanResponder: null, + onPanResponderGrant: null, + onPanResponderMove: null, + onPanResponderRelease: null, + onPanResponderTerminate: null, +}; + +/** + * Abstract class that defines the common interface of PanResponder that handles + * the gesture actions. + */ +class NavigationAbstractPanResponder { + + panHandlers: Object; + + constructor() { + const config = {}; + Object.keys(EmptyPanHandlers).forEach(name => { + const fn: any = (this: any)[name]; + + invariant( + typeof fn === 'function', + 'subclass of `NavigationAbstractPanResponder` must implement method %s', + name + ); + + config[name] = fn.bind(this); + }, this); + + this.panHandlers = PanResponder.create(config).panHandlers; + } +} + +module.exports = NavigationAbstractPanResponder; diff --git a/Libraries/NavigationExperimental/NavigationAnimatedView.js b/Libraries/NavigationExperimental/NavigationAnimatedView.js index bf3024ce87..4d1f5b68f1 100644 --- a/Libraries/NavigationExperimental/NavigationAnimatedView.js +++ b/Libraries/NavigationExperimental/NavigationAnimatedView.js @@ -87,6 +87,8 @@ type NavigationStateRendererProps = { layout: Layout, // The state of the the containing navigation view. navigationParentState: NavigationParentState, + + onNavigate: (action: any) => void, }; type NavigationStateRenderer = ( @@ -100,11 +102,12 @@ type TimingSetter = ( ) => void; type Props = { - navigationState: NavigationParentState; - renderScene: NavigationStateRenderer; - renderOverlay: ?NavigationStateRenderer; - style: any; - setTiming: ?TimingSetter; + navigationState: NavigationParentState, + onNavigate: (action: any) => void, + renderScene: NavigationStateRenderer, + renderOverlay: ?NavigationStateRenderer, + style: any, + setTiming: ?TimingSetter, }; class NavigationAnimatedView extends React.Component { @@ -224,18 +227,23 @@ class NavigationAnimatedView extends React.Component { layout: this._getLayout(), navigationParentState: this.props.navigationState, navigationState: scene.state, + onNavigate: this.props.onNavigate, position: this.state.position, }); } _renderOverlay() { - const {renderOverlay} = this.props; + const { + onNavigate, + renderOverlay, + navigationState, + } = this.props; if (renderOverlay) { - const parentState = this.props.navigationState; return renderOverlay({ - index: parentState.index, + index: navigationState.index, layout: this._getLayout(), - navigationParentState: parentState, - navigationState: parentState.children[parentState.index], + navigationParentState: navigationState, + navigationState: navigationState.children[navigationState.index], + onNavigate: onNavigate, position: this.state.position, }); } diff --git a/Libraries/NavigationExperimental/NavigationLinearPanResponder.js b/Libraries/NavigationExperimental/NavigationLinearPanResponder.js new file mode 100644 index 0000000000..704f305c77 --- /dev/null +++ b/Libraries/NavigationExperimental/NavigationLinearPanResponder.js @@ -0,0 +1,183 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule NavigationLinearPanResponder + * @flow + * @typechecks + */ +'use strict'; + +const Animated = require('Animated'); +const NavigationAbstractPanResponder = require('NavigationAbstractPanResponder'); + +const clamp = require('clamp'); + +/** + * The duration of the card animation in milliseconds. + */ +const ANIMATION_DURATION = 250; + +/** + * The threshold to invoke the `onNavigate` action. + * For instance, `1 / 3` means that moving greater than 1 / 3 of the width of + * the view will navigate. + */ +const POSITION_THRESHOLD = 1 / 3; + +/** + * The threshold (in pixels) to start the gesture action. + */ +const RESPOND_THRESHOLD = 15; + +/** + * The threshold (in speed) to finish the gesture action. + */ +const VELOCITY_THRESHOLD = 100; + +/** + * Primitive gesture directions. + */ +const Directions = { + 'HORIZONTAL': 'horizontal', + 'VERTICAL': 'vertical', +}; + +/** + * Primitive gesture actions. + */ +const Actions = { + // The gesture to navigate backward. + // This is done by swiping from the left to the right or from the top to the + // bottom. + BACK: {type: 'back'}, +}; + +import type { + Layout, + Position, +} from 'NavigationAnimatedView'; + +export type OnNavigateHandler = (action: {type: string}) => void; + +export type Direction = $Enum; + +/** + * The type interface of the object that provides the information required by + * NavigationLinearPanResponder. + */ +export type NavigationLinearPanResponderDelegate = { + getDirection: () => Direction; + getIndex: () => number, + getLayout: () => Layout, + getPosition: () => Position, + onNavigate: OnNavigateHandler, +}; + +/** + * Pan responder that handles the One-dimensional gesture (horizontal or + * vertical). + */ +class NavigationLinearPanResponder extends NavigationAbstractPanResponder { + static Actions: Object; + static Directions: Object; + + _isResponding: boolean; + _startValue: number; + _delegate: NavigationLinearPanResponderDelegate; + + constructor(delegate: NavigationLinearPanResponderDelegate) { + super(); + this._isResponding = false; + this._startValue = 0; + this._delegate = delegate; + } + + onMoveShouldSetPanResponder(event: any, gesture: any): boolean { + const delegate = this._delegate; + const layout = delegate.getLayout(); + const isVertical = delegate.getDirection() === Directions.VERTICAL; + const axis = isVertical ? 'dy' : 'dx'; + const index = delegate.getIndex(); + const distance = isVertical ? + layout.height.__getValue() : + layout.width.__getValue(); + + return ( + Math.abs(gesture[axis]) > RESPOND_THRESHOLD && + distance > 0 && + index > 0 + ); + } + + onPanResponderGrant(): void { + this._isResponding = false; + this._delegate.getPosition().stopAnimation((value: number) => { + this._isResponding = true; + this._startValue = value; + }); + } + + onPanResponderMove(event: any, gesture: any): void { + if (!this._isResponding) { + return; + } + + const delegate = this._delegate; + const layout = delegate.getLayout(); + const isVertical = delegate.getDirection() === Directions.VERTICAL; + const axis = isVertical ? 'dy' : 'dx'; + const index = delegate.getIndex(); + const distance = isVertical ? + layout.height.__getValue() : + layout.width.__getValue(); + + const value = clamp( + index - 1, + this._startValue - (gesture[axis] / distance), + index + ); + + this._delegate.getPosition().setValue(value); + } + + onPanResponderRelease(event: any, gesture: any): void { + if (!this._isResponding) { + return; + } + + this._isResponding = false; + + const delegate = this._delegate; + const isVertical = delegate.getDirection() === Directions.VERTICAL; + const axis = isVertical ? 'dy' : 'dx'; + const index = delegate.getIndex(); + const velocity = gesture[axis]; + + delegate.getPosition().stopAnimation((value: number) => { + this._reset(); + if (velocity > VELOCITY_THRESHOLD || value <= index - POSITION_THRESHOLD) { + delegate.onNavigate(Actions.BACK); + } + }); + } + + onPanResponderTerminate(): void { + this._isResponding = false; + this._reset(); + } + + _reset(): void { + Animated.timing( + this._delegate.getPosition(), + { + toValue: this._delegate.getIndex(), + duration: ANIMATION_DURATION, + } + ).start(); + } +} + +NavigationLinearPanResponder.Actions = Actions; +NavigationLinearPanResponder.Directions = Directions; + +module.exports = NavigationLinearPanResponder;