From b299eb6c5913ef972990d1a73d6c2a0d555767da Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Mon, 4 Jun 2018 11:39:55 -0700 Subject: [PATCH] [add] SwipeableFlatList and SwipeableListView --- .../src/moduleMap.js | 1 + .../examples/src/RNTester/RNTesterList.web.js | 16 +- .../src/exports/SwipeableFlatList/index.js | 11 + .../src/exports/SwipeableListView/index.js | 11 + packages/react-native-web/src/index.js | 12 +- .../react-native/SwipeableFlatList/index.js | 187 +++++++++ .../SwipeableListViewDataSource.js | 113 +++++ .../react-native/SwipeableListView/index.js | 211 ++++++++++ .../vendor/react-native/SwipeableRow/index.js | 387 ++++++++++++++++++ 9 files changed, 935 insertions(+), 14 deletions(-) create mode 100644 packages/react-native-web/src/exports/SwipeableFlatList/index.js create mode 100644 packages/react-native-web/src/exports/SwipeableListView/index.js create mode 100644 packages/react-native-web/src/vendor/react-native/SwipeableFlatList/index.js create mode 100644 packages/react-native-web/src/vendor/react-native/SwipeableListView/SwipeableListViewDataSource.js create mode 100644 packages/react-native-web/src/vendor/react-native/SwipeableListView/index.js create mode 100644 packages/react-native-web/src/vendor/react-native/SwipeableRow/index.js diff --git a/packages/babel-plugin-react-native-web/src/moduleMap.js b/packages/babel-plugin-react-native-web/src/moduleMap.js index 7db088bb..92bbeb82 100644 --- a/packages/babel-plugin-react-native-web/src/moduleMap.js +++ b/packages/babel-plugin-react-native-web/src/moduleMap.js @@ -45,6 +45,7 @@ module.exports = { Slider: true, StatusBar: true, StyleSheet: true, + SwipeableFlatList: true, SwipeableListView: true, Switch: true, Text: true, diff --git a/packages/examples/src/RNTester/RNTesterList.web.js b/packages/examples/src/RNTester/RNTesterList.web.js index 209d06ca..0d0bde36 100644 --- a/packages/examples/src/RNTester/RNTesterList.web.js +++ b/packages/examples/src/RNTester/RNTesterList.web.js @@ -97,14 +97,14 @@ const ComponentExamples: Array = [ key: 'StatusBarExample', module: require('./StatusBarExample') }, - //{ - // key: 'SwipeableFlatListExample', - // module: require('./SwipeableFlatListExample'), - //}, - //{ - // key: 'SwipeableListViewExample', - // module: require('./SwipeableListViewExample'), - //}, + { + key: 'SwipeableFlatListExample', + module: require('./SwipeableFlatListExample'), + }, + { + key: 'SwipeableListViewExample', + module: require('./SwipeableListViewExample'), + }, { key: 'SwitchExample', module: require('./SwitchExample') diff --git a/packages/react-native-web/src/exports/SwipeableFlatList/index.js b/packages/react-native-web/src/exports/SwipeableFlatList/index.js new file mode 100644 index 00000000..1e1f5ebb --- /dev/null +++ b/packages/react-native-web/src/exports/SwipeableFlatList/index.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2016-present, Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import SwipeableFlatList from '../../vendor/react-native/SwipeableFlatList'; +export default SwipeableFlatList; diff --git a/packages/react-native-web/src/exports/SwipeableListView/index.js b/packages/react-native-web/src/exports/SwipeableListView/index.js new file mode 100644 index 00000000..430755ee --- /dev/null +++ b/packages/react-native-web/src/exports/SwipeableListView/index.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2016-present, Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import SwipeableListView from '../../vendor/react-native/SwipeableListView'; +export default SwipeableListView; diff --git a/packages/react-native-web/src/index.js b/packages/react-native-web/src/index.js index 4ee97a32..7d03f827 100644 --- a/packages/react-native-web/src/index.js +++ b/packages/react-native-web/src/index.js @@ -55,6 +55,8 @@ import ScrollView from './exports/ScrollView'; import SectionList from './exports/SectionList'; import Slider from './exports/Slider'; import StatusBar from './exports/StatusBar'; +import SwipeableFlatList from './exports/SwipeableFlatList'; +import SwipeableListView from './exports/SwipeableListView'; import Switch from './exports/Switch'; import Text from './exports/Text'; import TextInput from './exports/TextInput'; @@ -86,8 +88,6 @@ const ProgressBarAndroid = UnimplementedView; const ProgressViewIOS = UnimplementedView; const SegmentedControlIOS = UnimplementedView; const SnapshotViewIOS = UnimplementedView; -const SwipeableFlatList = UnimplementedView; -const SwipeableListView = UnimplementedView; const TabBarIOS = UnimplementedView; const ToastAndroid = UnimplementedView; const ToolbarAndroid = UnimplementedView; @@ -164,6 +164,8 @@ export { SectionList, Slider, StatusBar, + SwipeableFlatList, + SwipeableListView, Switch, Text, TextInput, @@ -192,8 +194,6 @@ export { ProgressViewIOS, SegmentedControlIOS, SnapshotViewIOS, - SwipeableFlatList, - SwipeableListView, TabBarIOS, ToastAndroid, ToolbarAndroid, @@ -271,6 +271,8 @@ const ReactNative = { SectionList, Slider, StatusBar, + SwipeableFlatList, + SwipeableListView, Switch, Text, TextInput, @@ -299,8 +301,6 @@ const ReactNative = { ProgressViewIOS, SegmentedControlIOS, SnapshotViewIOS, - SwipeableFlatList, - SwipeableListView, TabBarIOS, ToastAndroid, ToolbarAndroid, diff --git a/packages/react-native-web/src/vendor/react-native/SwipeableFlatList/index.js b/packages/react-native-web/src/vendor/react-native/SwipeableFlatList/index.js new file mode 100644 index 00000000..929983c6 --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/SwipeableFlatList/index.js @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @providesModule SwipeableFlatList + * @noflow + * @format + */ +'use strict'; + +import type {Props as FlatListProps} from '../FlatList'; +import type {renderItemType} from '../VirtualizedList'; + +import PropTypes from 'prop-types'; +import React from 'react'; +import SwipeableRow from '../SwipeableRow'; +import FlatList from '../FlatList'; + +type SwipableListProps = { + /** + * To alert the user that swiping is possible, the first row can bounce + * on component mount. + */ + bounceFirstRowOnMount: boolean, + // Maximum distance to open to after a swipe + maxSwipeDistance: number | (Object => number), + // Callback method to render the view that will be unveiled on swipe + renderQuickActions: renderItemType, +}; + +type Props = SwipableListProps & FlatListProps; + +type State = { + openRowKey: ?string, +}; + +/** + * A container component that renders multiple SwipeableRow's in a FlatList + * implementation. This is designed to be a drop-in replacement for the + * standard React Native `FlatList`, so use it as if it were a FlatList, but + * with extra props, i.e. + * + * + * + * SwipeableRow can be used independently of this component, but the main + * benefit of using this component is + * + * - It ensures that at most 1 row is swiped open (auto closes others) + * - It can bounce the 1st row of the list so users know it's swipeable + * - Increase performance on iOS by locking list swiping when row swiping is occurring + * - More to come + */ + +class SwipeableFlatList extends React.Component, State> { + props: Props; + state: State; + + _flatListRef: ?FlatList = null; + _shouldBounceFirstRowOnMount: boolean = false; + + static propTypes = { + ...FlatList.propTypes, + + /** + * To alert the user that swiping is possible, the first row can bounce + * on component mount. + */ + bounceFirstRowOnMount: PropTypes.bool.isRequired, + + // Maximum distance to open to after a swipe + maxSwipeDistance: PropTypes.oneOfType([PropTypes.number, PropTypes.func]) + .isRequired, + + // Callback method to render the view that will be unveiled on swipe + renderQuickActions: PropTypes.func.isRequired, + }; + + static defaultProps = { + ...FlatList.defaultProps, + bounceFirstRowOnMount: true, + renderQuickActions: () => null, + }; + + constructor(props: Props, context: any): void { + super(props, context); + this.state = { + openRowKey: null, + }; + + this._shouldBounceFirstRowOnMount = this.props.bounceFirstRowOnMount; + } + + render(): React.Node { + return ( + { + this._flatListRef = ref; + }} + onScroll={this._onScroll} + renderItem={this._renderItem} + /> + ); + } + + _onScroll = (e): void => { + // Close any opens rows on ListView scroll + if (this.state.openRowKey) { + this.setState({ + openRowKey: null, + }); + } + + this.props.onScroll && this.props.onScroll(e); + }; + + _renderItem = (info: Object): ?React.Element => { + const slideoutView = this.props.renderQuickActions(info); + const key = this.props.keyExtractor(info.item, info.index); + + // If renderQuickActions is unspecified or returns falsey, don't allow swipe + if (!slideoutView) { + return this.props.renderItem(info); + } + + let shouldBounceOnMount = false; + if (this._shouldBounceFirstRowOnMount) { + this._shouldBounceFirstRowOnMount = false; + shouldBounceOnMount = true; + } + + return ( + this._onOpen(key)} + onClose={() => this._onClose(key)} + shouldBounceOnMount={shouldBounceOnMount} + onSwipeEnd={this._setListViewScrollable} + onSwipeStart={this._setListViewNotScrollable}> + {this.props.renderItem(info)} + + ); + }; + + // This enables rows having variable width slideoutView. + _getMaxSwipeDistance(info: Object): number { + if (typeof this.props.maxSwipeDistance === 'function') { + return this.props.maxSwipeDistance(info); + } + + return this.props.maxSwipeDistance; + } + + _setListViewScrollableTo(value: boolean) { + if (this._flatListRef) { + this._flatListRef.setNativeProps({ + scrollEnabled: value, + }); + } + } + + _setListViewScrollable = () => { + this._setListViewScrollableTo(true); + }; + + _setListViewNotScrollable = () => { + this._setListViewScrollableTo(false); + }; + + _onOpen(key: any): void { + this.setState({ + openRowKey: key, + }); + } + + _onClose(key: any): void { + this.setState({ + openRowKey: null, + }); + } +} + +export default SwipeableFlatList; diff --git a/packages/react-native-web/src/vendor/react-native/SwipeableListView/SwipeableListViewDataSource.js b/packages/react-native-web/src/vendor/react-native/SwipeableListView/SwipeableListViewDataSource.js new file mode 100644 index 00000000..05d8a16e --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/SwipeableListView/SwipeableListViewDataSource.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @providesModule SwipeableListViewDataSource + */ +'use strict'; + +import ListViewDataSource from '../ListView/ListViewDataSource'; + +/** + * Data source wrapper around ListViewDataSource to allow for tracking of + * which row is swiped open and close opened row(s) when another row is swiped + * open. + * + * See https://github.com/facebook/react-native/pull/5602 for why + * ListViewDataSource is not subclassed. + */ +class SwipeableListViewDataSource { + _previousOpenRowID: string; + _openRowID: string; + + _dataBlob: any; + _dataSource: ListViewDataSource; + + rowIdentities: Array>; + sectionIdentities: Array; + + constructor(params: Object) { + this._dataSource = new ListViewDataSource({ + getRowData: params.getRowData, + getSectionHeaderData: params.getSectionHeaderData, + rowHasChanged: (row1, row2) => { + /** + * Row needs to be re-rendered if its swiped open/close status is + * changed, or its data blob changed. + */ + return ( + (row1.id !== this._previousOpenRowID && row2.id === this._openRowID) || + (row1.id === this._previousOpenRowID && row2.id !== this._openRowID) || + params.rowHasChanged(row1, row2) + ); + }, + sectionHeaderHasChanged: params.sectionHeaderHasChanged, + }); + } + + cloneWithRowsAndSections( + dataBlob: any, + sectionIdentities: ?Array, + rowIdentities: ?Array> + ): SwipeableListViewDataSource { + this._dataSource = this._dataSource.cloneWithRowsAndSections( + dataBlob, + sectionIdentities, + rowIdentities + ); + + this._dataBlob = dataBlob; + this.rowIdentities = this._dataSource.rowIdentities; + this.sectionIdentities = this._dataSource.sectionIdentities; + + return this; + } + + // For the actual ListView to use + getDataSource(): ListViewDataSource { + return this._dataSource; + } + + getOpenRowID(): ?string { + return this._openRowID; + } + + getFirstRowID(): ?string { + /** + * If rowIdentities is specified, find the first data row from there since + * we don't want to attempt to bounce section headers. If unspecified, find + * the first data row from _dataBlob. + */ + if (this.rowIdentities) { + return this.rowIdentities[0] && this.rowIdentities[0][0]; + } + return Object.keys(this._dataBlob)[0]; + } + + getLastRowID(): ?string { + if (this.rowIdentities && this.rowIdentities.length) { + const lastSection = this.rowIdentities[this.rowIdentities.length - 1]; + if (lastSection && lastSection.length) { + return lastSection[lastSection.length - 1]; + } + } + return Object.keys(this._dataBlob)[this._dataBlob.length - 1]; + } + + setOpenRowID(rowID: string): SwipeableListViewDataSource { + this._previousOpenRowID = this._openRowID; + this._openRowID = rowID; + + this._dataSource = this._dataSource.cloneWithRowsAndSections( + this._dataBlob, + this.sectionIdentities, + this.rowIdentities + ); + + return this; + } +} + +export default SwipeableListViewDataSource; diff --git a/packages/react-native-web/src/vendor/react-native/SwipeableListView/index.js b/packages/react-native-web/src/vendor/react-native/SwipeableListView/index.js new file mode 100644 index 00000000..fe20cc4f --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/SwipeableListView/index.js @@ -0,0 +1,211 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @providesModule SwipeableListView + * @noflow + */ +'use strict'; + +import ListView from '../ListView'; +import PropTypes from 'prop-types'; +import React from 'react'; +import SwipeableListViewDataSource from './SwipeableListViewDataSource'; +import SwipeableRow from '../SwipeableRow'; + +type DefaultProps = { + bounceFirstRowOnMount: boolean, + renderQuickActions: Function, +}; + +type Props = { + bounceFirstRowOnMount: boolean, + dataSource: SwipeableListViewDataSource, + maxSwipeDistance: number | (rowData: any, sectionID: string, rowID: string) => number, + onScroll?: ?Function, + renderRow: Function, + renderQuickActions: Function, +}; + +type State = { + dataSource: Object, +}; + +/** + * A container component that renders multiple SwipeableRow's in a ListView + * implementation. This is designed to be a drop-in replacement for the + * standard React Native `ListView`, so use it as if it were a ListView, but + * with extra props, i.e. + * + * let ds = SwipeableListView.getNewDataSource(); + * ds.cloneWithRowsAndSections(dataBlob, ?sectionIDs, ?rowIDs); + * // .. + * + * + * SwipeableRow can be used independently of this component, but the main + * benefit of using this component is + * + * - It ensures that at most 1 row is swiped open (auto closes others) + * - It can bounce the 1st row of the list so users know it's swipeable + * - More to come + */ +class SwipeableListView extends React.Component { + props: Props; + state: State; + + _listViewRef: ?React.Element = null; + _shouldBounceFirstRowOnMount: boolean = false; + + static getNewDataSource(): Object { + return new SwipeableListViewDataSource({ + getRowData: (data, sectionID, rowID) => data[sectionID][rowID], + getSectionHeaderData: (data, sectionID) => data[sectionID], + rowHasChanged: (row1, row2) => row1 !== row2, + sectionHeaderHasChanged: (s1, s2) => s1 !== s2, + }); + } + + static propTypes = { + /** + * To alert the user that swiping is possible, the first row can bounce + * on component mount. + */ + bounceFirstRowOnMount: PropTypes.bool.isRequired, + /** + * Use `SwipeableListView.getNewDataSource()` to get a data source to use, + * then use it just like you would a normal ListView data source + */ + dataSource: PropTypes.instanceOf(SwipeableListViewDataSource).isRequired, + // Maximum distance to open to after a swipe + maxSwipeDistance: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.func, + ]).isRequired, + // Callback method to render the swipeable view + renderRow: PropTypes.func.isRequired, + // Callback method to render the view that will be unveiled on swipe + renderQuickActions: PropTypes.func.isRequired, + }; + + static defaultProps = { + bounceFirstRowOnMount: false, + renderQuickActions: () => null, + }; + + constructor(props: Props, context: any): void { + super(props, context); + + this._shouldBounceFirstRowOnMount = this.props.bounceFirstRowOnMount; + this.state = { + dataSource: this.props.dataSource, + }; + } + + UNSAFE_componentWillReceiveProps(nextProps: Props): void { + if (this.state.dataSource.getDataSource() !== nextProps.dataSource.getDataSource()) { + this.setState({ + dataSource: nextProps.dataSource, + }); + } + } + + render(): React.Node { + return ( + { + this._listViewRef = ref; + }} + dataSource={this.state.dataSource.getDataSource()} + onScroll={this._onScroll} + renderRow={this._renderRow} + /> + ); + } + + _onScroll = (e): void => { + // Close any opens rows on ListView scroll + if (this.props.dataSource.getOpenRowID()) { + this.setState({ + dataSource: this.state.dataSource.setOpenRowID(null), + }); + } + this.props.onScroll && this.props.onScroll(e); + } + + /** + * This is a work-around to lock vertical `ListView` scrolling on iOS and + * mimic Android behaviour. Locking vertical scrolling when horizontal + * scrolling is active allows us to significantly improve framerates + * (from high 20s to almost consistently 60 fps) + */ + _setListViewScrollable(value: boolean): void { + if (this._listViewRef && typeof this._listViewRef.setNativeProps === 'function') { + this._listViewRef.setNativeProps({ + scrollEnabled: value, + }); + } + } + + // Passing through ListView's getScrollResponder() function + getScrollResponder(): ?Object { + if (this._listViewRef && typeof this._listViewRef.getScrollResponder === 'function') { + return this._listViewRef.getScrollResponder(); + } + } + + // This enables rows having variable width slideoutView. + _getMaxSwipeDistance(rowData: Object, sectionID: string, rowID: string): number { + if (typeof this.props.maxSwipeDistance === 'function') { + return this.props.maxSwipeDistance(rowData, sectionID, rowID); + } + + return this.props.maxSwipeDistance; + } + + _renderRow = (rowData: Object, sectionID: string, rowID: string): React.Element => { + const slideoutView = this.props.renderQuickActions(rowData, sectionID, rowID); + + // If renderQuickActions is unspecified or returns falsey, don't allow swipe + if (!slideoutView) { + return this.props.renderRow(rowData, sectionID, rowID); + } + + let shouldBounceOnMount = false; + if (this._shouldBounceFirstRowOnMount) { + this._shouldBounceFirstRowOnMount = false; + shouldBounceOnMount = rowID === this.props.dataSource.getFirstRowID(); + } + + return ( + this._onOpen(rowData.id)} + onClose={() => this._onClose(rowData.id)} + onSwipeEnd={() => this._setListViewScrollable(true)} + onSwipeStart={() => this._setListViewScrollable(false)} + shouldBounceOnMount={shouldBounceOnMount}> + {this.props.renderRow(rowData, sectionID, rowID)} + + ); + }; + + _onOpen(rowID: string): void { + this.setState({ + dataSource: this.state.dataSource.setOpenRowID(rowID), + }); + } + + _onClose(rowID: string): void { + this.setState({ + dataSource: this.state.dataSource.setOpenRowID(null), + }); + } +} + +export default SwipeableListView; diff --git a/packages/react-native-web/src/vendor/react-native/SwipeableRow/index.js b/packages/react-native-web/src/vendor/react-native/SwipeableRow/index.js new file mode 100644 index 00000000..7be98632 --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/SwipeableRow/index.js @@ -0,0 +1,387 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @providesModule SwipeableRow + * @noflow + */ +'use strict'; + +import Animated from '../../../exports/Animated'; +import I18nManager from '../../../exports/I18nManager'; +import PanResponder from '../../../exports/PanResponder'; +import React from 'react'; +import PropTypes from 'prop-types'; +import StyleSheet from '../../../exports/StyleSheet'; +/* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an error + * found when Flow v0.54 was deployed. To see the error delete this comment and + * run Flow. */ +import TimerMixin from 'react-timer-mixin'; +import View from '../../../exports/View'; + +import createReactClass from 'create-react-class'; +import emptyFunction from 'fbjs/lib/emptyFunction'; + +const isRTL = () => I18nManager.isRTL; + +// NOTE: Eventually convert these consts to an input object of configurations + +// Position of the left of the swipable item when closed +const CLOSED_LEFT_POSITION = 0; +// Minimum swipe distance before we recognize it as such +const HORIZONTAL_SWIPE_DISTANCE_THRESHOLD = 10; +// Minimum swipe speed before we fully animate the user's action (open/close) +const HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD = 0.3; +// Factor to divide by to get slow speed; i.e. 4 means 1/4 of full speed +const SLOW_SPEED_SWIPE_FACTOR = 4; +// Time, in milliseconds, of how long the animated swipe should be +const SWIPE_DURATION = 300; + +/** + * On SwipeableListView mount, the 1st item will bounce to show users it's + * possible to swipe + */ +const ON_MOUNT_BOUNCE_DELAY = 700; +const ON_MOUNT_BOUNCE_DURATION = 400; + +// Distance left of closed position to bounce back when right-swiping from closed +const RIGHT_SWIPE_BOUNCE_BACK_DISTANCE = 30; +const RIGHT_SWIPE_BOUNCE_BACK_DURATION = 300; +/** + * Max distance of right swipe to allow (right swipes do functionally nothing). + * Must be multiplied by SLOW_SPEED_SWIPE_FACTOR because gestureState.dx tracks + * how far the finger swipes, and not the actual animation distance. +*/ +const RIGHT_SWIPE_THRESHOLD = 30 * SLOW_SPEED_SWIPE_FACTOR; + +/** + * Creates a swipable row that allows taps on the main item and a custom View + * on the item hidden behind the row. Typically this should be used in + * conjunction with SwipeableListView for additional functionality, but can be + * used in a normal ListView. See the renderRow for SwipeableListView to see how + * to use this component separately. + */ +const SwipeableRow = createReactClass({ + displayName: 'SwipeableRow', + _panResponder: {}, + _previousLeft: CLOSED_LEFT_POSITION, + + mixins: [TimerMixin], + + propTypes: { + children: PropTypes.any, + isOpen: PropTypes.bool, + preventSwipeRight: PropTypes.bool, + maxSwipeDistance: PropTypes.number.isRequired, + onOpen: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + onSwipeEnd: PropTypes.func.isRequired, + onSwipeStart: PropTypes.func.isRequired, + // Should bounce the row on mount + shouldBounceOnMount: PropTypes.bool, + /** + * A ReactElement that is unveiled when the user swipes + */ + slideoutView: PropTypes.node.isRequired, + /** + * The minimum swipe distance required before fully animating the swipe. If + * the user swipes less than this distance, the item will return to its + * previous (open/close) position. + */ + swipeThreshold: PropTypes.number.isRequired, + }, + + getInitialState(): Object { + return { + currentLeft: new Animated.Value(this._previousLeft), + /** + * In order to render component A beneath component B, A must be rendered + * before B. However, this will cause "flickering", aka we see A briefly + * then B. To counter this, _isSwipeableViewRendered flag is used to set + * component A to be transparent until component B is loaded. + */ + isSwipeableViewRendered: false, + rowHeight: (null: ?number), + }; + }, + + getDefaultProps(): Object { + return { + isOpen: false, + preventSwipeRight: false, + maxSwipeDistance: 0, + onOpen: emptyFunction, + onClose: emptyFunction, + onSwipeEnd: emptyFunction, + onSwipeStart: emptyFunction, + swipeThreshold: 30, + }; + }, + + UNSAFE_componentWillMount(): void { + this._panResponder = PanResponder.create({ + onMoveShouldSetPanResponderCapture: this._handleMoveShouldSetPanResponderCapture, + onPanResponderGrant: this._handlePanResponderGrant, + onPanResponderMove: this._handlePanResponderMove, + onPanResponderRelease: this._handlePanResponderEnd, + onPanResponderTerminationRequest: this._onPanResponderTerminationRequest, + onPanResponderTerminate: this._handlePanResponderEnd, + onShouldBlockNativeResponder: (event, gestureState) => false, + }); + }, + + componentDidMount(): void { + if (this.props.shouldBounceOnMount) { + /** + * Do the on mount bounce after a delay because if we animate when other + * components are loading, the animation will be laggy + */ + this.setTimeout(() => { + this._animateBounceBack(ON_MOUNT_BOUNCE_DURATION); + }, ON_MOUNT_BOUNCE_DELAY); + } + }, + + UNSAFE_componentWillReceiveProps(nextProps: Object): void { + /** + * We do not need an "animateOpen(noCallback)" because this animation is + * handled internally by this component. + */ + if (this.props.isOpen && !nextProps.isOpen) { + this._animateToClosedPosition(); + } + }, + + render(): React.Element { + // The view hidden behind the main view + let slideOutView; + if (this.state.isSwipeableViewRendered && this.state.rowHeight) { + slideOutView = ( + + {this.props.slideoutView} + + ); + } + + // The swipeable item + const swipeableView = ( + + {this.props.children} + + ); + + return ( + + {slideOutView} + {swipeableView} + + ); + }, + + close(): void { + this.props.onClose(); + this._animateToClosedPosition(); + }, + + _onSwipeableViewLayout(event: Object): void { + this.setState({ + isSwipeableViewRendered: true, + rowHeight: event.nativeEvent.layout.height, + }); + }, + + _handleMoveShouldSetPanResponderCapture( + event: Object, + gestureState: Object, + ): boolean { + // Decides whether a swipe is responded to by this component or its child + return gestureState.dy < 10 && this._isValidSwipe(gestureState); + }, + + _handlePanResponderGrant(event: Object, gestureState: Object): void { + + }, + + _handlePanResponderMove(event: Object, gestureState: Object): void { + if (this._isSwipingExcessivelyRightFromClosedPosition(gestureState)) { + return; + } + + this.props.onSwipeStart(); + + if (this._isSwipingRightFromClosed(gestureState)) { + this._swipeSlowSpeed(gestureState); + } else { + this._swipeFullSpeed(gestureState); + } + }, + + _isSwipingRightFromClosed(gestureState: Object): boolean { + const gestureStateDx = isRTL() ? -gestureState.dx : gestureState.dx; + return this._previousLeft === CLOSED_LEFT_POSITION && gestureStateDx > 0; + }, + + _swipeFullSpeed(gestureState: Object): void { + this.state.currentLeft.setValue(this._previousLeft + gestureState.dx); + }, + + _swipeSlowSpeed(gestureState: Object): void { + this.state.currentLeft.setValue( + this._previousLeft + gestureState.dx / SLOW_SPEED_SWIPE_FACTOR, + ); + }, + + _isSwipingExcessivelyRightFromClosedPosition(gestureState: Object): boolean { + /** + * We want to allow a BIT of right swipe, to allow users to know that + * swiping is available, but swiping right does not do anything + * functionally. + */ + const gestureStateDx = isRTL() ? -gestureState.dx : gestureState.dx; + return ( + this._isSwipingRightFromClosed(gestureState) && + gestureStateDx > RIGHT_SWIPE_THRESHOLD + ); + }, + + _onPanResponderTerminationRequest( + event: Object, + gestureState: Object, + ): boolean { + return false; + }, + + _animateTo( + toValue: number, + duration: number = SWIPE_DURATION, + callback: Function = emptyFunction, + ): void { + Animated.timing( + this.state.currentLeft, + { + duration, + toValue, + useNativeDriver: true, + }, + ).start(() => { + this._previousLeft = toValue; + callback(); + }); + }, + + _animateToOpenPosition(): void { + const maxSwipeDistance = isRTL() ? -this.props.maxSwipeDistance : this.props.maxSwipeDistance; + this._animateTo(-maxSwipeDistance); + }, + + _animateToOpenPositionWith( + speed: number, + distMoved: number, + ): void { + /** + * Ensure the speed is at least the set speed threshold to prevent a slow + * swiping animation + */ + speed = ( + speed > HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD ? + speed : + HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD + ); + /** + * Calculate the duration the row should take to swipe the remaining distance + * at the same speed the user swiped (or the speed threshold) + */ + const duration = Math.abs((this.props.maxSwipeDistance - Math.abs(distMoved)) / speed); + const maxSwipeDistance = isRTL() ? -this.props.maxSwipeDistance : this.props.maxSwipeDistance; + this._animateTo(-maxSwipeDistance, duration); + }, + + _animateToClosedPosition(duration: number = SWIPE_DURATION): void { + this._animateTo(CLOSED_LEFT_POSITION, duration); + }, + + _animateToClosedPositionDuringBounce(): void { + this._animateToClosedPosition(RIGHT_SWIPE_BOUNCE_BACK_DURATION); + }, + + _animateBounceBack(duration: number): void { + /** + * When swiping right, we want to bounce back past closed position on release + * so users know they should swipe right to get content. + */ + const swipeBounceBackDistance = isRTL() ? + -RIGHT_SWIPE_BOUNCE_BACK_DISTANCE : + RIGHT_SWIPE_BOUNCE_BACK_DISTANCE; + this._animateTo( + -swipeBounceBackDistance, + duration, + this._animateToClosedPositionDuringBounce, + ); + }, + + // Ignore swipes due to user's finger moving slightly when tapping + _isValidSwipe(gestureState: Object): boolean { + if (this.props.preventSwipeRight && this._previousLeft === CLOSED_LEFT_POSITION && gestureState.dx > 0) { + return false; + } + + return Math.abs(gestureState.dx) > HORIZONTAL_SWIPE_DISTANCE_THRESHOLD; + }, + + _shouldAnimateRemainder(gestureState: Object): boolean { + /** + * If user has swiped past a certain distance, animate the rest of the way + * if they let go + */ + return ( + Math.abs(gestureState.dx) > this.props.swipeThreshold || + gestureState.vx > HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD + ); + }, + + _handlePanResponderEnd(event: Object, gestureState: Object): void { + const horizontalDistance = isRTL() ? -gestureState.dx : gestureState.dx; + if (this._isSwipingRightFromClosed(gestureState)) { + this.props.onOpen(); + this._animateBounceBack(RIGHT_SWIPE_BOUNCE_BACK_DURATION); + } else if (this._shouldAnimateRemainder(gestureState)) { + if (horizontalDistance < 0) { + // Swiped left + this.props.onOpen(); + this._animateToOpenPositionWith(gestureState.vx, horizontalDistance); + } else { + // Swiped right + this.props.onClose(); + this._animateToClosedPosition(); + } + } else { + if (this._previousLeft === CLOSED_LEFT_POSITION) { + this._animateToClosedPosition(); + } else { + this._animateToOpenPosition(); + } + } + + this.props.onSwipeEnd(); + }, +}); + +const styles = StyleSheet.create({ + slideOutContainer: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, +}); + +export default SwipeableRow;