From 88f5dedffd0ad71b0f66936c471d3635ad6b08b8 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Sat, 11 Apr 2020 20:01:35 -0700 Subject: [PATCH] [change] Add Pressable and replace Touchables Port and rewrite "Pressability" from React Native as "PressResponder". This integrates a press target with the responder system on web. It avoids performing layout measurement during gestures by eschewing React Native's iOS-like UX in favor of expected Web UX: a press target will look pressed until the pointer is released, even if the pointer has moved outside the bounding rect of the target. The PressResponder is used to reimplement the existing Touchables. It's expected that they will eventually be removed in favor of Pressable. Fix #1583 Fix #1564 Fix #1534 Fix #1419 Fix #1219 Fix #1166 --- .eslintrc | 5 +- .../src/moduleMap.js | 1 + .../Pressable/Pressable.stories.mdx | 66 +++ .../Pressable/examples/DelayEvents.js | 62 ++ .../components/Pressable/examples/Disabled.js | 34 ++ .../Pressable/examples/FeedbackEvents.js | 94 +++ .../components/Pressable/examples/index.js | 3 + .../docs/src/components/ScrollView/helpers.js | 6 +- .../TouchableHighlight.stories.mdx | 27 + .../examples/DelayEvents.js | 20 +- .../TouchableHighlight/examples/Disabled.js | 57 +- .../examples/FeedbackEvents.js | 20 +- .../examples/StyleOverrides.js | 51 +- .../TouchableOpacity.stories.mdx | 20 +- .../TouchableOpacity/examples/DelayEvents.js | 20 +- .../TouchableOpacity/examples/Disabled.js | 63 +- .../examples/FeedbackEvents.js | 24 +- .../TouchableOpacity/examples/index.js | 4 +- .../src/exports/Pressable/index.js | 174 ++++++ .../src/exports/TouchableHighlight/index.js | 455 +++++---------- .../src/exports/TouchableOpacity/index.js | 384 ++++-------- .../exports/TouchableWithoutFeedback/index.js | 249 ++++---- packages/react-native-web/src/index.js | 1 + .../src/modules/PressResponder/index.js | 548 ++++++++++++++++++ .../modules/PressResponder/usePressEvents.js | 37 ++ 25 files changed, 1495 insertions(+), 930 deletions(-) create mode 100644 packages/docs/src/components/Pressable/Pressable.stories.mdx create mode 100644 packages/docs/src/components/Pressable/examples/DelayEvents.js create mode 100644 packages/docs/src/components/Pressable/examples/Disabled.js create mode 100644 packages/docs/src/components/Pressable/examples/FeedbackEvents.js create mode 100644 packages/docs/src/components/Pressable/examples/index.js create mode 100644 packages/react-native-web/src/exports/Pressable/index.js create mode 100644 packages/react-native-web/src/modules/PressResponder/index.js create mode 100644 packages/react-native-web/src/modules/PressResponder/usePressEvents.js diff --git a/.eslintrc b/.eslintrc index 919c9ea5..874dbebd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -34,6 +34,8 @@ "navigator": false, "window": false, // Flow global types, + "$Diff": false, + "$ElementType": false, "$Enum": false, "$PropertyType": false, "$ReadOnly": false, @@ -48,7 +50,8 @@ "ReactPropsCheckType": false, "ReactPropTypes": false, "ResizeObserver": false, - "SyntheticEvent": false + "SyntheticEvent": false, + "TimeoutID": false, }, "rules": { "camelcase": 0, diff --git a/packages/babel-plugin-react-native-web/src/moduleMap.js b/packages/babel-plugin-react-native-web/src/moduleMap.js index 5bc0ee6c..c3156222 100644 --- a/packages/babel-plugin-react-native-web/src/moduleMap.js +++ b/packages/babel-plugin-react-native-web/src/moduleMap.js @@ -33,6 +33,7 @@ module.exports = { Picker: true, PixelRatio: true, Platform: true, + Pressable: true, ProgressBar: true, RefreshControl: true, SafeAreaView: true, diff --git a/packages/docs/src/components/Pressable/Pressable.stories.mdx b/packages/docs/src/components/Pressable/Pressable.stories.mdx new file mode 100644 index 00000000..5ac540e8 --- /dev/null +++ b/packages/docs/src/components/Pressable/Pressable.stories.mdx @@ -0,0 +1,66 @@ +import { Meta, Props, Story, Preview } from '@storybook/addon-docs/blocks'; +import * as Stories from './examples'; + + + +# Pressable + +... + +## Props + +| Name | Type | Default | +| ------------------------- | --------- | ------- | +| ...ViewProps | | | +| delayLongPress | ?number | 500 | +| delayPressIn | ?number | 0 | +| delayPressOut | ?number | 0 | +| disabled | ?boolean | false | +| onLongPress | ?Function | | +| onPress | ?Function | | +| onPressIn | ?Function | | +| onPressOut | ?Function | | + +### delayLongPress + +Delay in ms, from `onPressIn` to before `onLongPress` is called. The default is `500`. + +### delayPressIn + +Delay in ms, from pointer down to before `onPressIn` is called. + +### delayPressOut + +Delay in ms, from pointer up to before `onPressOut` is called. + +### disabled + +Disables all pointer interactions with the element. + + + + + + + +### onLongPress + +Called when the pointer is held down for as long as the value of `delayLongPress`. + +### onPress + +Called when the pointer is released, but not if cancelled (e.g. by a scroll that steals the responder lock). + +## Examples + + + + + + + + + + + + diff --git a/packages/docs/src/components/Pressable/examples/DelayEvents.js b/packages/docs/src/components/Pressable/examples/DelayEvents.js new file mode 100644 index 00000000..c7075e73 --- /dev/null +++ b/packages/docs/src/components/Pressable/examples/DelayEvents.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { StyleSheet, Text, Pressable, View } from 'react-native'; + +export default function DelayEvents() { + const [eventLog, updateEventLog] = React.useState([]); + + const handlePress = eventName => { + return () => { + const limit = 6; + updateEventLog(state => { + const nextState = state.slice(0, limit - 1); + nextState.unshift(eventName); + return nextState; + }); + }; + }; + + return ( + + + + + Pressable + + + + + {eventLog.map((e, ii) => ( + {e} + ))} + + + ); +} + +const styles = StyleSheet.create({ + touchableText: { + borderRadius: 8, + padding: 5, + borderWidth: 1, + borderColor: 'black', + color: '#007AFF', + borderStyle: 'solid', + textAlign: 'center' + }, + eventLogBox: { + padding: 10, + marginTop: 10, + height: 120, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#f0f0f0', + backgroundColor: '#f9f9f9' + } +}); diff --git a/packages/docs/src/components/Pressable/examples/Disabled.js b/packages/docs/src/components/Pressable/examples/Disabled.js new file mode 100644 index 00000000..b85593e4 --- /dev/null +++ b/packages/docs/src/components/Pressable/examples/Disabled.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { StyleSheet, View, Text, Pressable } from 'react-native'; + +const action = msg => () => { + console.log(msg); +}; + +export default function Disabled() { + return ( + + + + Disabled Pressable + + + + + + Enabled Pressable + + + + ); +} + +const styles = StyleSheet.create({ + row: { + justifyContent: 'center', + flexDirection: 'row' + }, + block: { + padding: 10 + } +}); diff --git a/packages/docs/src/components/Pressable/examples/FeedbackEvents.js b/packages/docs/src/components/Pressable/examples/FeedbackEvents.js new file mode 100644 index 00000000..173bf55e --- /dev/null +++ b/packages/docs/src/components/Pressable/examples/FeedbackEvents.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +export default function FeedbackEvents() { + const [eventLog, updateEventLog] = React.useState([]); + + const handlePress = eventName => { + return () => { + const limit = 6; + updateEventLog(state => { + const nextState = state.slice(0, limit - 1); + nextState.unshift(eventName); + return nextState; + }); + }; + }; + + return ( + + + + + Press Me + + + + + + ({ + padding: 10, + margin: 10, + borderWidth: 1, + borderColor: focused ? 'blue' : null, + backgroundColor: pressed ? 'lightblue' : 'white' + })} + > + ({ + padding: 10, + margin: 10, + borderWidth: 1, + borderColor: focused ? 'blue' : null, + backgroundColor: pressed ? 'lightblue' : 'white' + })} + > + Nested pressables + + + + + + {eventLog.map((e, ii) => ( + {e} + ))} + + + ); +} + +const styles = StyleSheet.create({ + touchableText: { + borderRadius: 8, + padding: 5, + borderWidth: 1, + borderColor: 'black', + color: '#007AFF', + borderStyle: 'solid', + textAlign: 'center' + }, + eventLogBox: { + padding: 10, + marginTop: 10, + height: 120, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#f0f0f0', + backgroundColor: '#f9f9f9' + } +}); diff --git a/packages/docs/src/components/Pressable/examples/index.js b/packages/docs/src/components/Pressable/examples/index.js new file mode 100644 index 00000000..5e41fe04 --- /dev/null +++ b/packages/docs/src/components/Pressable/examples/index.js @@ -0,0 +1,3 @@ +export { default as delayEvents } from './DelayEvents'; +export { default as disabled } from './Disabled'; +export { default as feedbackEvents } from './FeedbackEvents'; diff --git a/packages/docs/src/components/ScrollView/helpers.js b/packages/docs/src/components/ScrollView/helpers.js index d10c688a..777041aa 100644 --- a/packages/docs/src/components/ScrollView/helpers.js +++ b/packages/docs/src/components/ScrollView/helpers.js @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; export const Button = ({ label, onPress }) => ( @@ -9,9 +9,9 @@ export const Button = ({ label, onPress }) => ( function Item(props) { return ( - + {props.msg} - + ); } diff --git a/packages/docs/src/components/TouchableHighlight/TouchableHighlight.stories.mdx b/packages/docs/src/components/TouchableHighlight/TouchableHighlight.stories.mdx index 51a3cdbc..9112a4a2 100644 --- a/packages/docs/src/components/TouchableHighlight/TouchableHighlight.stories.mdx +++ b/packages/docs/src/components/TouchableHighlight/TouchableHighlight.stories.mdx @@ -42,3 +42,30 @@ Called immediately after the underlay is shown ### underlayColor The color of the underlay that will show through when the touch is active. + +## Examples + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/src/components/TouchableHighlight/examples/DelayEvents.js b/packages/docs/src/components/TouchableHighlight/examples/DelayEvents.js index d9c3d9c6..964493f3 100644 --- a/packages/docs/src/components/TouchableHighlight/examples/DelayEvents.js +++ b/packages/docs/src/components/TouchableHighlight/examples/DelayEvents.js @@ -1,28 +1,10 @@ import React, { PureComponent } from 'react'; -import { - StyleSheet, - Text, - TouchableHighlight, - TouchableOpacity, - TouchableWithoutFeedback, - View -} from 'react-native'; - -const Touchables = { - highlight: TouchableHighlight, - opacity: TouchableOpacity, - withoutFeedback: TouchableWithoutFeedback -}; +import { StyleSheet, Text, TouchableHighlight as Touchable, View } from 'react-native'; export default class TouchableDelayEvents extends PureComponent { - static defaultProps = { - touchable: 'highlight' - }; - state = { eventLog: [] }; render() { - const Touchable = Touchables[this.props.touchable]; const { displayName } = Touchable; return ( diff --git a/packages/docs/src/components/TouchableHighlight/examples/Disabled.js b/packages/docs/src/components/TouchableHighlight/examples/Disabled.js index 81e953e9..6a46e8eb 100644 --- a/packages/docs/src/components/TouchableHighlight/examples/Disabled.js +++ b/packages/docs/src/components/TouchableHighlight/examples/Disabled.js @@ -1,18 +1,11 @@ import React from 'react'; -import { - StyleSheet, - View, - Text, - TouchableHighlight, - TouchableOpacity, - TouchableWithoutFeedback -} from 'react-native'; +import { StyleSheet, View, Text, TouchableHighlight } from 'react-native'; const action = msg => () => { console.log(msg); }; -class TouchableHighlightDisabled extends React.Component { +export default class TouchableHighlightDisabled extends React.Component { render() { return ( @@ -39,52 +32,6 @@ class TouchableHighlightDisabled extends React.Component { } } -class TouchableOpacityDisabled extends React.Component { - render() { - return ( - - - Disabled TouchableOpacity - - - - Enabled TouchableOpacity - - - ); - } -} - -class TouchableWithoutFeedbackDisabled extends React.Component { - render() { - return ( - - - - Disabled TouchableWithoutFeedback - - - - - - Enabled TouchableWithoutFeedback - - - - ); - } -} - -export { TouchableHighlightDisabled, TouchableOpacityDisabled, TouchableWithoutFeedbackDisabled }; - const styles = StyleSheet.create({ row: { justifyContent: 'center', diff --git a/packages/docs/src/components/TouchableHighlight/examples/FeedbackEvents.js b/packages/docs/src/components/TouchableHighlight/examples/FeedbackEvents.js index 283240e1..dc4081b3 100644 --- a/packages/docs/src/components/TouchableHighlight/examples/FeedbackEvents.js +++ b/packages/docs/src/components/TouchableHighlight/examples/FeedbackEvents.js @@ -1,28 +1,10 @@ import React, { PureComponent } from 'react'; -import { - StyleSheet, - Text, - TouchableHighlight, - TouchableOpacity, - TouchableWithoutFeedback, - View -} from 'react-native'; - -const Touchables = { - highlight: TouchableHighlight, - opacity: TouchableOpacity, - withoutFeedback: TouchableWithoutFeedback -}; +import { StyleSheet, Text, TouchableHighlight as Touchable, View } from 'react-native'; export default class TouchableFeedbackEvents extends PureComponent { - static defaultProps = { - touchable: 'highlight' - }; - state = { eventLog: [] }; render() { - const Touchable = Touchables[this.props.touchable]; return ( diff --git a/packages/docs/src/components/TouchableHighlight/examples/StyleOverrides.js b/packages/docs/src/components/TouchableHighlight/examples/StyleOverrides.js index 064e4675..9ecc7330 100644 --- a/packages/docs/src/components/TouchableHighlight/examples/StyleOverrides.js +++ b/packages/docs/src/components/TouchableHighlight/examples/StyleOverrides.js @@ -1,36 +1,33 @@ import React from 'react'; import { StyleSheet, View, Text, TouchableHighlight } from 'react-native'; -export default class TouchableCustomStyleOverridesExample extends React.Component { - buttons = ['One', 'Two', 'Three']; - state = {}; +const buttons = ['One', 'Two', 'Three']; - select = selectedButton => event => { - const newState = {}; - this.buttons.forEach(button => { - newState[button] = selectedButton === button; - }); - this.setState(newState); - }; +export default function TouchableCustomStyleOverridesExample() { + const [state, setState] = React.useState({}); - render() { - return ( - - {this.buttons.map(button => { - return ( - - {button} - - ); - })} - - ); + function select(item) { + return function handler(e) { + setState({ [item]: true }); + }; } + + return ( + + {buttons.map(button => { + return ( + + {button} + + ); + })} + + ); } const styles = StyleSheet.create({ diff --git a/packages/docs/src/components/TouchableOpacity/TouchableOpacity.stories.mdx b/packages/docs/src/components/TouchableOpacity/TouchableOpacity.stories.mdx index 3d858d68..ac65d03a 100644 --- a/packages/docs/src/components/TouchableOpacity/TouchableOpacity.stories.mdx +++ b/packages/docs/src/components/TouchableOpacity/TouchableOpacity.stories.mdx @@ -22,8 +22,22 @@ added to the view hierarchy. Be aware that this can affect layout. Determines what the opacity of the wrapped view should be when active. -## Instance methods +## Examples -### setOpacityTo(number) + + + + + -Sets the opacity. + + + + + + + + + + + diff --git a/packages/docs/src/components/TouchableOpacity/examples/DelayEvents.js b/packages/docs/src/components/TouchableOpacity/examples/DelayEvents.js index d9c3d9c6..83659b32 100644 --- a/packages/docs/src/components/TouchableOpacity/examples/DelayEvents.js +++ b/packages/docs/src/components/TouchableOpacity/examples/DelayEvents.js @@ -1,28 +1,10 @@ import React, { PureComponent } from 'react'; -import { - StyleSheet, - Text, - TouchableHighlight, - TouchableOpacity, - TouchableWithoutFeedback, - View -} from 'react-native'; - -const Touchables = { - highlight: TouchableHighlight, - opacity: TouchableOpacity, - withoutFeedback: TouchableWithoutFeedback -}; +import { StyleSheet, Text, TouchableOpacity as Touchable, View } from 'react-native'; export default class TouchableDelayEvents extends PureComponent { - static defaultProps = { - touchable: 'highlight' - }; - state = { eventLog: [] }; render() { - const Touchable = Touchables[this.props.touchable]; const { displayName } = Touchable; return ( diff --git a/packages/docs/src/components/TouchableOpacity/examples/Disabled.js b/packages/docs/src/components/TouchableOpacity/examples/Disabled.js index 81e953e9..4adfcd26 100644 --- a/packages/docs/src/components/TouchableOpacity/examples/Disabled.js +++ b/packages/docs/src/components/TouchableOpacity/examples/Disabled.js @@ -1,49 +1,16 @@ import React from 'react'; -import { - StyleSheet, - View, - Text, - TouchableHighlight, - TouchableOpacity, - TouchableWithoutFeedback -} from 'react-native'; +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; const action = msg => () => { console.log(msg); }; -class TouchableHighlightDisabled extends React.Component { - render() { - return ( - - - Disabled TouchableHighlight - - - - Enabled TouchableHighlight - - - ); - } -} - -class TouchableOpacityDisabled extends React.Component { +export default class TouchableOpacityDisabled extends React.Component { render() { return ( @@ -63,28 +30,6 @@ class TouchableOpacityDisabled extends React.Component { } } -class TouchableWithoutFeedbackDisabled extends React.Component { - render() { - return ( - - - - Disabled TouchableWithoutFeedback - - - - - - Enabled TouchableWithoutFeedback - - - - ); - } -} - -export { TouchableHighlightDisabled, TouchableOpacityDisabled, TouchableWithoutFeedbackDisabled }; - const styles = StyleSheet.create({ row: { justifyContent: 'center', diff --git a/packages/docs/src/components/TouchableOpacity/examples/FeedbackEvents.js b/packages/docs/src/components/TouchableOpacity/examples/FeedbackEvents.js index 283240e1..11755d71 100644 --- a/packages/docs/src/components/TouchableOpacity/examples/FeedbackEvents.js +++ b/packages/docs/src/components/TouchableOpacity/examples/FeedbackEvents.js @@ -1,32 +1,14 @@ import React, { PureComponent } from 'react'; -import { - StyleSheet, - Text, - TouchableHighlight, - TouchableOpacity, - TouchableWithoutFeedback, - View -} from 'react-native'; - -const Touchables = { - highlight: TouchableHighlight, - opacity: TouchableOpacity, - withoutFeedback: TouchableWithoutFeedback -}; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; export default class TouchableFeedbackEvents extends PureComponent { - static defaultProps = { - touchable: 'highlight' - }; - state = { eventLog: [] }; render() { - const Touchable = Touchables[this.props.touchable]; return ( - Press Me - + {this.state.eventLog.map((e, ii) => ( diff --git a/packages/docs/src/components/TouchableOpacity/examples/index.js b/packages/docs/src/components/TouchableOpacity/examples/index.js index e41b4b00..5e41fe04 100644 --- a/packages/docs/src/components/TouchableOpacity/examples/index.js +++ b/packages/docs/src/components/TouchableOpacity/examples/index.js @@ -1,3 +1,3 @@ -//export { default as color } from './Color'; +export { default as delayEvents } from './DelayEvents'; export { default as disabled } from './Disabled'; -//export { default as onPress } from './OnPress'; +export { default as feedbackEvents } from './FeedbackEvents'; diff --git a/packages/react-native-web/src/exports/Pressable/index.js b/packages/react-native-web/src/exports/Pressable/index.js new file mode 100644 index 00000000..02008353 --- /dev/null +++ b/packages/react-native-web/src/exports/Pressable/index.js @@ -0,0 +1,174 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type { PressResponderConfig } from '../../modules/PressResponder'; +import type { ViewProps } from '../View'; + +import * as React from 'react'; +import { useMemo, useState, useRef, useImperativeHandle } from 'react'; +import usePressEvents from '../../modules/PressResponder/usePressEvents'; +import View from '../View'; + +export type StateCallbackType = $ReadOnly<{| + focused: boolean, + pressed: boolean +|}>; + +type ViewStyleProp = $PropertyType; + +type Props = $ReadOnly<{| + accessibilityLabel?: $PropertyType, + accessibilityLiveRegion?: $PropertyType, + accessibilityRole?: $PropertyType, + accessibilityState?: $PropertyType, + accessibilityValue?: $PropertyType, + accessible?: $PropertyType, + focusable?: ?boolean, + importantForAccessibility?: $PropertyType, + children: React.Node | ((state: StateCallbackType) => React.Node), + // Duration (in milliseconds) from `onPressIn` before `onLongPress` is called. + delayLongPress?: ?number, + // Duration (in milliseconds) from `onPressStart` is called after pointerdown + delayPressIn?: ?number, + // Duration (in milliseconds) from `onPressEnd` is called after pointerup. + delayPressOut?: ?number, + // Whether the press behavior is disabled. + disabled?: ?boolean, + // Additional distance outside of this view in which a press is detected. + hitSlop?: $PropertyType, + // Called when the view blurs + onBlur?: $PropertyType, + // Called when the view is focused + onFocus?: $PropertyType, + // Called when this view's layout changes + onLayout?: $PropertyType, + // Called when a long-tap gesture is detected. + onLongPress?: $PropertyType, + // Called when a single tap gesture is detected. + onPress?: $PropertyType, + // Called when a touch is engaged, before `onPress`. + onPressIn?: $PropertyType, + // Called when a touch is moving, after `onPressIn`. + onPressMove?: $PropertyType, + // Called when a touch is released, before `onPress`. + onPressOut?: $PropertyType, + style?: ViewStyleProp | ((state: StateCallbackType) => ViewStyleProp), + testID?: $PropertyType, + /** + * Used only for documentation or testing (e.g. snapshot testing). + */ + testOnly_pressed?: ?boolean +|}>; + +/** + * Component used to build display components that should respond to whether the + * component is currently pressed or not. + */ +function Pressable(props: Props, forwardedRef): React.Node { + const { + accessible, + children, + delayLongPress, + delayPressIn, + delayPressOut, + disabled, + focusable, + onBlur, + onFocus, + onLongPress, + onPress, + onPressMove, + onPressIn, + onPressOut, + style, + testOnly_pressed, + ...rest + } = props; + + const hostRef = useRef(null); + const viewRef = useRef | null>(null); + const [focused, setFocused] = useForceableState(false); + const [pressed, setPressed] = useForceableState(testOnly_pressed === true); + useImperativeHandle(forwardedRef, () => viewRef.current); + + const pressEventHandlers = usePressEvents( + hostRef, + useMemo( + () => ({ + delayLongPress, + delayPressStart: delayPressIn, + delayPressEnd: delayPressOut, + disabled, + onLongPress, + onPress, + onPressChange: setPressed, + onPressStart: onPressIn, + onPressMove, + onPressEnd: onPressOut + }), + [ + delayLongPress, + delayPressIn, + delayPressOut, + disabled, + onLongPress, + onPress, + onPressIn, + onPressMove, + onPressOut, + setPressed + ] + ) + ); + + const accessibilityState = { disabled, ...props.accessibilityState }; + const interactionState = { focused, pressed }; + + function createFocusHandler(callback, value) { + return function(event) { + if (event.nativeEvent.target === hostRef.current) { + setFocused(value); + if (callback != null) { + callback(event); + } + } + }; + } + + return ( + + {typeof children === 'function' ? children(interactionState) : children} + + ); +} + +function useForceableState(forced: boolean): [boolean, (boolean) => void] { + const [pressed, setPressed] = useState(false); + return [pressed || forced, setPressed]; +} + +const MemoedPressable = React.memo(React.forwardRef(Pressable)); +MemoedPressable.displayName = 'Pressable'; + +export default (MemoedPressable: React.AbstractComponent>); diff --git a/packages/react-native-web/src/exports/TouchableHighlight/index.js b/packages/react-native-web/src/exports/TouchableHighlight/index.js index db3db8aa..aa7c4a9e 100644 --- a/packages/react-native-web/src/exports/TouchableHighlight/index.js +++ b/packages/react-native-web/src/exports/TouchableHighlight/index.js @@ -4,42 +4,58 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ + 'use strict'; +import type { ColorValue } from '../../types'; import type { Props as TouchableWithoutFeedbackProps } from '../TouchableWithoutFeedback'; +import type { ViewProps } from '../View'; -import applyNativeMethods from '../../modules/applyNativeMethods'; -import createReactClass from 'create-react-class'; -import ensurePositiveDelayProps from '../Touchable/ensurePositiveDelayProps'; import * as React from 'react'; +import { useCallback, useMemo, useState, useRef, useImperativeHandle } from 'react'; +import usePressEvents from '../../modules/PressResponder/usePressEvents'; import StyleSheet from '../StyleSheet'; -import Touchable from '../Touchable'; import View from '../View'; -type Event = Object; -type PressEvent = Object; - -const DEFAULT_PROPS = { - activeOpacity: 0.85, - delayPressOut: 100, - underlayColor: 'black' -}; - -const PRESS_RETENTION_OFFSET = { top: 20, left: 20, right: 20, bottom: 30 }; +type ViewStyle = $PropertyType; type Props = $ReadOnly<{| ...TouchableWithoutFeedbackProps, activeOpacity?: ?number, - underlayColor?: ?any, - style?: ?any, - onShowUnderlay?: ?() => void, + hostRef: React.Ref, onHideUnderlay?: ?() => void, - testOnly_pressed?: ?boolean + onShowUnderlay?: ?() => void, + style?: ViewStyle, + testOnly_pressed?: ?boolean, + underlayColor?: ?ColorValue |}>; +type ExtraStyles = $ReadOnly<{| + child: ViewStyle, + underlay: ViewStyle +|}>; + +function createExtraStyles(activeOpacity, underlayColor): ExtraStyles { + return { + child: { opacity: activeOpacity ?? 0.85 }, + underlay: { + backgroundColor: underlayColor === undefined ? 'black' : underlayColor + } + }; +} + +function hasPressHandler(props): boolean { + return ( + props.onPress != null || + props.onPressIn != null || + props.onPressOut != null || + props.onLongPress != null + ); +} + /** * A wrapper for making views respond properly to touches. * On press down, the opacity of the wrapped view is decreased, which allows @@ -52,295 +68,130 @@ type Props = $ReadOnly<{| * * TouchableHighlight must have one child (not zero or more than one). * If you wish to have several child components, wrap them in a View. - * - * Example: - * - * ``` - * renderButton: function() { - * return ( - * - * - * - * ); - * }, - * ``` - * - * - * ### Example - * - * ```ReactNativeWebPlayer - * import React, { Component } from 'react' - * import { - * AppRegistry, - * StyleSheet, - * TouchableHighlight, - * Text, - * View, - * } from 'react-native' - * - * class App extends Component { - * constructor(props) { - * super(props) - * this.state = { count: 0 } - * } - * - * onPress = () => { - * this.setState({ - * count: this.state.count+1 - * }) - * } - * - * render() { - * return ( - * - * - * Touch Here - * - * - * - * { this.state.count !== 0 ? this.state.count: null} - * - * - * - * ) - * } - * } - * - * const styles = StyleSheet.create({ - * container: { - * flex: 1, - * justifyContent: 'center', - * paddingHorizontal: 10 - * }, - * button: { - * alignItems: 'center', - * backgroundColor: '#DDDDDD', - * padding: 10 - * }, - * countContainer: { - * alignItems: 'center', - * padding: 10 - * }, - * countText: { - * color: '#FF00FF' - * } - * }) - * - * AppRegistry.registerComponent('App', () => App) - * ``` - * */ +function TouchableHighlight(props: Props, forwardedRef): React.Node { + const { + accessible, + activeOpacity, + children, + delayPressIn, + delayPressOut, + delayLongPress, + disabled, + focusable, + onHideUnderlay, + onLongPress, + onPress, + onPressIn, + onPressOut, + onShowUnderlay, + rejectResponderTermination, + style, + testOnly_pressed, + underlayColor, + ...rest + } = props; -// eslint-disable-next-line react/prefer-es6-class -const TouchableHighlight = ((createReactClass({ - displayName: 'TouchableHighlight', + const hostRef = useRef(null); + const viewRef = useRef | null>(null); + useImperativeHandle(forwardedRef, () => viewRef.current); - mixins: [Touchable.Mixin.withoutDefaultFocusAndBlur], + const [extraStyles, setExtraStyles] = useState( + testOnly_pressed === true ? createExtraStyles(activeOpacity, underlayColor) : null + ); - getDefaultProps: () => DEFAULT_PROPS, - - getInitialState: function() { - this._isMounted = false; - if (this.props.testOnly_pressed) { - return { - ...this.touchableGetInitialState(), - extraChildStyle: { - opacity: this.props.activeOpacity - }, - extraUnderlayStyle: { - backgroundColor: this.props.underlayColor - } - }; - } else { - return { - ...this.touchableGetInitialState(), - extraChildStyle: null, - extraUnderlayStyle: null - }; - } - }, - - componentDidMount: function() { - this._isMounted = true; - ensurePositiveDelayProps(this.props); - }, - - componentWillUnmount: function() { - this._isMounted = false; - clearTimeout(this._hideTimeout); - }, - - UNSAFE_componentWillReceiveProps: function(nextProps) { - ensurePositiveDelayProps(nextProps); - }, - - /* - viewConfig: { - uiViewClassName: 'RCTView', - validAttributes: ReactNativeViewAttributes.RCTView, - }, - */ - - /** - * `Touchable.Mixin` self callbacks. The mixin will invoke these if they are - * defined on your component. - */ - touchableHandleActivePressIn: function(e: PressEvent) { - clearTimeout(this._hideTimeout); - this._hideTimeout = null; - this._showUnderlay(); - this.props.onPressIn && this.props.onPressIn(e); - }, - - touchableHandleActivePressOut: function(e: PressEvent) { - if (!this._hideTimeout) { - this._hideUnderlay(); - } - this.props.onPressOut && this.props.onPressOut(e); - }, - - touchableHandleFocus: function(e: Event) { - this.props.onFocus && this.props.onFocus(e); - }, - - touchableHandleBlur: function(e: Event) { - this.props.onBlur && this.props.onBlur(e); - }, - - touchableHandlePress: function(e: PressEvent) { - clearTimeout(this._hideTimeout); - this._showUnderlay(); - this._hideTimeout = setTimeout(this._hideUnderlay, this.props.delayPressOut); - this.props.onPress && this.props.onPress(e); - }, - - touchableHandleLongPress: function(e: PressEvent) { - this.props.onLongPress && this.props.onLongPress(e); - }, - - touchableGetPressRectOffset: function() { - return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; - }, - - touchableGetHitSlop: function() { - return this.props.hitSlop; - }, - - touchableGetHighlightDelayMS: function() { - return this.props.delayPressIn; - }, - - touchableGetLongPressDelayMS: function() { - return this.props.delayLongPress; - }, - - touchableGetPressOutDelayMS: function() { - return this.props.delayPressOut; - }, - - _showUnderlay: function() { - if (!this._isMounted || !this._hasPressHandler()) { + const showUnderlay = useCallback(() => { + if (!hasPressHandler(props)) { return; } - this.setState({ - extraChildStyle: { - opacity: this.props.activeOpacity - }, - extraUnderlayStyle: { - backgroundColor: this.props.underlayColor + setExtraStyles(createExtraStyles(activeOpacity, underlayColor)); + if (onShowUnderlay != null) { + onShowUnderlay(); + } + }, [activeOpacity, onShowUnderlay, props, underlayColor]); + + const hideUnderlay = useCallback(() => { + if (testOnly_pressed === true) { + return; + } + if (hasPressHandler(props)) { + setExtraStyles(null); + if (onHideUnderlay != null) { + onHideUnderlay(); } - }); - this.props.onShowUnderlay && this.props.onShowUnderlay(); - }, - - _hideUnderlay: function() { - clearTimeout(this._hideTimeout); - this._hideTimeout = null; - if (this.props.testOnly_pressed) { - return; } - if (this._hasPressHandler()) { - this.setState({ - extraChildStyle: null, - extraUnderlayStyle: null - }); - this.props.onHideUnderlay && this.props.onHideUnderlay(); - } - }, + }, [onHideUnderlay, props, testOnly_pressed]); - _hasPressHandler: function() { - return !!( - this.props.onPress || - this.props.onPressIn || - this.props.onPressOut || - this.props.onLongPress - ); - }, + const pressEventHandlers = usePressEvents( + hostRef, + useMemo( + () => ({ + cancelable: !rejectResponderTermination, + disabled, + delayLongPress, + delayPressStart: delayPressIn, + delayPressEnd: delayPressOut, + onLongPress, + onPress, + onPressStart(event) { + showUnderlay(); + if (onPressIn != null) { + onPressIn(event); + } + }, + onPressEnd(event) { + hideUnderlay(); + if (onPressOut != null) { + onPressOut(event); + } + } + }), + [ + delayLongPress, + delayPressIn, + delayPressOut, + disabled, + onLongPress, + onPress, + onPressIn, + onPressOut, + rejectResponderTermination, + showUnderlay, + hideUnderlay + ] + ) + ); - render: function() { - const child = React.Children.only(this.props.children); - return ( - - {React.cloneElement(child, { - style: StyleSheet.compose( - child.props.style, - this.state.extraChildStyle - ) - })} - {Touchable.renderDebugView({ - color: 'green', - hitSlop: this.props.hitSlop - })} - - ); - } -}): any): React.ComponentType); + const child = React.Children.only(children); + + return ( + + {React.cloneElement(child, { + style: StyleSheet.compose( + child.props.style, + extraStyles && extraStyles.child + ) + })} + + ); +} const styles = StyleSheet.create({ root: { @@ -352,4 +203,10 @@ const styles = StyleSheet.create({ } }); -export default applyNativeMethods(TouchableHighlight); +const MemoedTouchableHighlight = React.memo(React.forwardRef(TouchableHighlight)); +MemoedTouchableHighlight.displayName = 'TouchableHighlight'; + +export default (MemoedTouchableHighlight: React.AbstractComponent< + Props, + React.ElementRef +>); diff --git a/packages/react-native-web/src/exports/TouchableOpacity/index.js b/packages/react-native-web/src/exports/TouchableOpacity/index.js index e1833db9..60e4dc82 100644 --- a/packages/react-native-web/src/exports/TouchableOpacity/index.js +++ b/packages/react-native-web/src/exports/TouchableOpacity/index.js @@ -4,293 +4,151 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * @flow strict-local * @format - * @flow */ 'use strict'; import type { Props as TouchableWithoutFeedbackProps } from '../TouchableWithoutFeedback'; +import type { ViewProps } from '../View'; -import applyNativeMethods from '../../modules/applyNativeMethods'; -import createReactClass from 'create-react-class'; -import ensurePositiveDelayProps from '../Touchable/ensurePositiveDelayProps'; import * as React from 'react'; +import { useCallback, useMemo, useState, useRef, useImperativeHandle } from 'react'; +import usePressEvents from '../../modules/PressResponder/usePressEvents'; import StyleSheet from '../StyleSheet'; -import Touchable from '../Touchable'; import View from '../View'; -const flattenStyle = StyleSheet.flatten; - -type Event = Object; -type PressEvent = Object; - -const PRESS_RETENTION_OFFSET = { top: 20, left: 20, right: 20, bottom: 30 }; +type ViewStyle = $PropertyType; type Props = $ReadOnly<{| ...TouchableWithoutFeedbackProps, activeOpacity?: ?number, - style?: ?any + style?: ?ViewStyle |}>; +function getStyleOpacityWithDefault(style): number { + const flatStyle = StyleSheet.flatten(style); + const opacityValue = flatStyle != null ? flatStyle.opacity : null; + return typeof opacityValue === 'number' ? opacityValue : 1; +} + /** * A wrapper for making views respond properly to touches. * On press down, the opacity of the wrapped view is decreased, dimming it. - * - * Opacity is controlled by wrapping the children in an Animated.View, which is - * added to the view hiearchy. Be aware that this can affect layout. - * - * Example: - * - * ``` - * renderButton: function() { - * return ( - * - * - * - * ); - * }, - * ``` - * ### Example - * - * ```ReactNativeWebPlayer - * import React, { Component } from 'react' - * import { - * AppRegistry, - * StyleSheet, - * TouchableOpacity, - * Text, - * View, - * } from 'react-native' - * - * class App extends Component { - * constructor(props) { - * super(props) - * this.state = { count: 0 } - * } - * - * onPress = () => { - * this.setState({ - * count: this.state.count+1 - * }) - * } - * - * render() { - * return ( - * - * - * Touch Here - * - * - * - * { this.state.count !== 0 ? this.state.count: null} - * - * - * - * ) - * } - * } - * - * const styles = StyleSheet.create({ - * container: { - * flex: 1, - * justifyContent: 'center', - * paddingHorizontal: 10 - * }, - * button: { - * alignItems: 'center', - * backgroundColor: '#DDDDDD', - * padding: 10 - * }, - * countContainer: { - * alignItems: 'center', - * padding: 10 - * }, - * countText: { - * color: '#FF00FF' - * } - * }) - * - * AppRegistry.registerComponent('App', () => App) - * ``` - * */ +function TouchableOpacity(props: Props, forwardedRef): React.Node { + const { + accessible, + activeOpacity, + delayPressIn, + delayPressOut, + delayLongPress, + disabled, + focusable, + onLongPress, + onPress, + onPressIn, + onPressOut, + rejectResponderTermination, + style, + ...rest + } = props; -// eslint-disable-next-line react/prefer-es6-class -const TouchableOpacity = ((createReactClass({ - displayName: 'TouchableOpacity', - mixins: [Touchable.Mixin.withoutDefaultFocusAndBlur], + const hostRef = useRef(null); + const viewRef = useRef | null>(null); + useImperativeHandle(forwardedRef, () => viewRef.current); - getDefaultProps: function() { - return { - activeOpacity: 0.2 - }; - }, + const styleOpacity = getStyleOpacityWithDefault(style); + const [duration, setDuration] = useState('0s'); + const [opacity, setOpacity] = useState(styleOpacity); - getInitialState: function() { - return { - ...this.touchableGetInitialState(), - anim: this._getChildStyleOpacityWithDefault() - }; - }, + const setOpacityTo = useCallback( + (value: number, duration: number) => { + setOpacity(value); + setDuration(duration ? `${duration / 1000}s` : '0s'); + }, + [setOpacity, setDuration] + ); - componentDidMount: function() { - ensurePositiveDelayProps(this.props); - }, + const opacityActive = useCallback( + (duration: number) => { + setOpacityTo(activeOpacity ?? 0.2, duration); + }, + [activeOpacity, setOpacityTo] + ); - UNSAFE_componentWillReceiveProps: function(nextProps) { - ensurePositiveDelayProps(nextProps); - }, + const opacityInactive = useCallback( + (duration: number) => { + setOpacityTo(styleOpacity, duration); + }, + [setOpacityTo, styleOpacity] + ); - componentDidUpdate: function(prevProps, prevState) { - if (this.props.disabled !== prevProps.disabled) { - this._opacityInactive(250); - } - }, + const pressEventHandlers = usePressEvents( + hostRef, + useMemo( + () => ({ + cancelable: !rejectResponderTermination, + disabled, + delayLongPress, + delayPressStart: delayPressIn, + delayPressEnd: delayPressOut, + onLongPress, + onPress, + onPressStart(event) { + opacityActive(event.dispatchConfig.registrationName === 'onResponderGrant' ? 0 : 150); + if (onPressIn != null) { + onPressIn(event); + } + }, + onPressEnd(event) { + opacityInactive(250); + if (onPressOut != null) { + onPressOut(event); + } + } + }), + [ + delayLongPress, + delayPressIn, + delayPressOut, + disabled, + onLongPress, + onPress, + onPressIn, + onPressOut, + opacityActive, + opacityInactive, + rejectResponderTermination + ] + ) + ); - /** - * Animate the touchable to a new opacity. - */ - setOpacityTo: function(value: number, duration: number) { - this.setNativeProps({ - style: { - opacity: value, - transitionDuration: duration ? `${duration / 1000}s` : '0s' - } - }); - }, - - /** - * `Touchable.Mixin` self callbacks. The mixin will invoke these if they are - * defined on your component. - */ - touchableHandleActivePressIn: function(e: PressEvent) { - if (e.dispatchConfig.registrationName === 'onResponderGrant') { - this._opacityActive(0); - } else { - this._opacityActive(150); - } - this.props.onPressIn && this.props.onPressIn(e); - }, - - touchableHandleActivePressOut: function(e: PressEvent) { - this._opacityInactive(250); - this.props.onPressOut && this.props.onPressOut(e); - }, - - touchableHandleFocus: function(e: Event) { - //if (Platform.isTV) { - // this._opacityActive(150); - //} - this.props.onFocus && this.props.onFocus(e); - }, - - touchableHandleBlur: function(e: Event) { - //if (Platform.isTV) { - // this._opacityInactive(250); - //} - this.props.onBlur && this.props.onBlur(e); - }, - - touchableHandlePress: function(e: PressEvent) { - this.props.onPress && this.props.onPress(e); - }, - - touchableHandleLongPress: function(e: PressEvent) { - this.props.onLongPress && this.props.onLongPress(e); - }, - - touchableGetPressRectOffset: function() { - return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; - }, - - touchableGetHitSlop: function() { - return this.props.hitSlop; - }, - - touchableGetHighlightDelayMS: function() { - return this.props.delayPressIn || 0; - }, - - touchableGetLongPressDelayMS: function() { - return this.props.delayLongPress === 0 ? 0 : this.props.delayLongPress || 500; - }, - - touchableGetPressOutDelayMS: function() { - return this.props.delayPressOut; - }, - - _opacityActive: function(duration: number) { - this.setOpacityTo(this.props.activeOpacity, duration); - }, - - _opacityInactive: function(duration: number) { - this.setOpacityTo(this._getChildStyleOpacityWithDefault(), duration); - }, - - _getChildStyleOpacityWithDefault: function() { - const childStyle = flattenStyle(this.props.style) || {}; - return childStyle.opacity == null ? 1 : childStyle.opacity; - }, - - render: function() { - return ( - - {this.props.children} - {Touchable.renderDebugView({ - color: 'cyan', - hitSlop: this.props.hitSlop - })} - - ); - } -}): any): React.ComponentType); + return ( + + ); +} const styles = StyleSheet.create({ root: { @@ -304,4 +162,10 @@ const styles = StyleSheet.create({ } }); -export default applyNativeMethods(TouchableOpacity); +const MemoedTouchableOpacity = React.memo(React.forwardRef(TouchableOpacity)); +MemoedTouchableOpacity.displayName = 'TouchableOpacity'; + +export default (MemoedTouchableOpacity: React.AbstractComponent< + Props, + React.ElementRef +>); diff --git a/packages/react-native-web/src/exports/TouchableWithoutFeedback/index.js b/packages/react-native-web/src/exports/TouchableWithoutFeedback/index.js index e8c915cb..3d06c3a5 100644 --- a/packages/react-native-web/src/exports/TouchableWithoutFeedback/index.js +++ b/packages/react-native-web/src/exports/TouchableWithoutFeedback/index.js @@ -4,33 +4,54 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * @flow strict-local * @format - * @flow */ 'use strict'; -import createReactClass from 'create-react-class'; -import ensurePositiveDelayProps from '../Touchable/ensurePositiveDelayProps'; +import type { PressResponderConfig } from '../../modules/PressResponder'; +import type { ViewProps } from '../View'; + import * as React from 'react'; -import Touchable from '../Touchable'; -import View from '../View'; +import { useMemo, useRef, useImperativeHandle } from 'react'; +import usePressEvents from '../../modules/PressResponder/usePressEvents'; -type BlurEvent = Object; -type FocusEvent = Object; -type PressEvent = Object; -type LayoutEvent = Object; -type EdgeInsetsProp = Object; +export type Props = $ReadOnly<{| + accessibilityLabel?: $PropertyType, + accessibilityLiveRegion?: $PropertyType, + accessibilityRole?: $PropertyType, + accessibilityState?: $PropertyType, + accessibilityValue?: $PropertyType, + accessible?: $PropertyType, + children?: ?React.Node, + delayLongPress?: ?number, + delayPressIn?: ?number, + delayPressOut?: ?number, + disabled?: ?boolean, + focusable?: ?boolean, + hitSlop?: $PropertyType, + importantForAccessibility?: $PropertyType, + nativeID?: $PropertyType, + onBlur?: $PropertyType, + onFocus?: $PropertyType, + onLayout?: $PropertyType, + onLongPress?: $PropertyType, + onPress?: $PropertyType, + onPressIn?: $PropertyType, + onPressOut?: $PropertyType, + rejectResponderTermination?: ?boolean, + testID?: $PropertyType +|}>; -const PRESS_RETENTION_OFFSET = { top: 20, left: 20, right: 20, bottom: 30 }; - -const OVERRIDE_PROPS = [ +const PASSTHROUGH_PROPS = [ 'accessibilityLabel', - 'accessibilityHint', - 'accessibilityIgnoresInvertColors', + 'accessibilityLiveRegion', 'accessibilityRole', 'accessibilityState', + 'accessibilityValue', 'hitSlop', + 'importantForAccessibility', 'nativeID', 'onBlur', 'onFocus', @@ -38,138 +59,80 @@ const OVERRIDE_PROPS = [ 'testID' ]; -export type Props = $ReadOnly<{| - accessible?: ?boolean, - accessibilityLabel?: ?string, - accessibilityHint?: ?string, - accessibilityIgnoresInvertColors?: ?boolean, - accessibilityRole?: ?string, - accessibilityState?: ?Object, - children?: ?React.Node, - delayLongPress?: ?number, - delayPressIn?: ?number, - delayPressOut?: ?number, - disabled?: ?boolean, - hitSlop?: ?EdgeInsetsProp, - nativeID?: ?string, - touchSoundDisabled?: ?boolean, - onBlur?: ?(e: BlurEvent) => void, - onFocus?: ?(e: FocusEvent) => void, - onLayout?: ?(event: LayoutEvent) => mixed, - onLongPress?: ?(event: PressEvent) => mixed, - onPress?: ?(event: PressEvent) => mixed, - onPressIn?: ?(event: PressEvent) => mixed, - onPressOut?: ?(event: PressEvent) => mixed, - pressRetentionOffset?: ?EdgeInsetsProp, - rejectResponderTermination?: ?boolean, - testID?: ?string -|}>; +function TouchableWithoutFeedback(props: Props, forwardedRef): React.Node { + const { + accessible, + delayPressIn, + delayPressOut, + delayLongPress, + disabled, + focusable, + onLongPress, + onPress, + onPressIn, + onPressOut, + rejectResponderTermination + } = props; -/** - * Do not use unless you have a very good reason. All elements that - * respond to press should have a visual feedback when touched. - * - * TouchableWithoutFeedback supports only one child. - * If you wish to have several child components, wrap them in a View. - */ -// eslint-disable-next-line react/prefer-es6-class -const TouchableWithoutFeedback = ((createReactClass({ - displayName: 'TouchableWithoutFeedback', - mixins: [Touchable.Mixin], + const hostRef = useRef(null); + const viewRef = useRef(null); + useImperativeHandle(forwardedRef, () => viewRef.current); - getInitialState: function() { - return this.touchableGetInitialState(); - }, + const pressEventHandlers = usePressEvents( + hostRef, + useMemo( + () => ({ + cancelable: !rejectResponderTermination, + disabled, + delayLongPress, + delayPressStart: delayPressIn, + delayPressEnd: delayPressOut, + onLongPress, + onPress, + onPressStart: onPressIn, + onPressEnd: onPressOut + }), + [ + disabled, + delayPressIn, + delayPressOut, + delayLongPress, + onLongPress, + onPress, + onPressIn, + onPressOut, + rejectResponderTermination + ] + ) + ); - componentDidMount: function() { - ensurePositiveDelayProps(this.props); - }, + const element = React.Children.only(props.children); + const children = [element.props.children]; + const elementProps: { [string]: mixed, ... } = { + ...pressEventHandlers, + accessible: accessible !== false, + accessibilityState: { + disabled, + ...props.accessibilityState + }, + focusable: focusable !== false && onPress !== undefined, + forwardedRef: hostRef, + ref: viewRef + }; - UNSAFE_componentWillReceiveProps: function(nextProps: Object) { - ensurePositiveDelayProps(nextProps); - }, - - /** - * `Touchable.Mixin` self callbacks. The mixin will invoke these if they are - * defined on your component. - */ - touchableHandlePress: function(e: PressEvent) { - this.props.onPress && this.props.onPress(e); - }, - - touchableHandleActivePressIn: function(e: PressEvent) { - this.props.onPressIn && this.props.onPressIn(e); - }, - - touchableHandleActivePressOut: function(e: PressEvent) { - this.props.onPressOut && this.props.onPressOut(e); - }, - - touchableHandleLongPress: function(e: PressEvent) { - this.props.onLongPress && this.props.onLongPress(e); - }, - - touchableGetPressRectOffset: function(): typeof PRESS_RETENTION_OFFSET { - // $FlowFixMe Invalid prop usage - return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; - }, - - touchableGetHitSlop: function(): ?Object { - return this.props.hitSlop; - }, - - touchableGetHighlightDelayMS: function(): number { - return this.props.delayPressIn || 0; - }, - - touchableGetLongPressDelayMS: function(): number { - return this.props.delayLongPress === 0 ? 0 : this.props.delayLongPress || 500; - }, - - touchableGetPressOutDelayMS: function(): number { - return this.props.delayPressOut || 0; - }, - - render: function(): React.Element { - // Note(avik): remove dynamic typecast once Flow has been upgraded - // $FlowFixMe(>=0.41.0) - // eslint-disable-next-line - const child = React.Children.only(this.props.children); - let children = child.props.children; - if (Touchable.TOUCH_TARGET_DEBUG && child.type === View) { - children = React.Children.toArray(children); - children.push(Touchable.renderDebugView({ color: 'red', hitSlop: this.props.hitSlop })); + for (const prop of PASSTHROUGH_PROPS) { + if (props[prop] !== undefined) { + elementProps[prop] = props[prop]; } - - const overrides = {}; - for (const prop of OVERRIDE_PROPS) { - if (this.props[prop] !== undefined) { - overrides[prop] = this.props[prop]; - } - } - - overrides.accessibilityState = { - disabled: this.props.disabled, - ...this.props.accessibilityState - }; - - return (React: any).cloneElement(child, { - ...overrides, - accessible: this.props.accessible !== false, - //clickable: - // this.props.clickable !== false && this.props.onPress !== undefined, - //onClick: this.touchableHandlePress, - onKeyDown: this.touchableHandleKeyEvent, - onKeyUp: this.touchableHandleKeyEvent, - onStartShouldSetResponder: this.touchableHandleStartShouldSetResponder, - onResponderTerminationRequest: this.touchableHandleResponderTerminationRequest, - onResponderGrant: this.touchableHandleResponderGrant, - onResponderMove: this.touchableHandleResponderMove, - onResponderRelease: this.touchableHandleResponderRelease, - onResponderTerminate: this.touchableHandleResponderTerminate, - children - }); } -}): any): React.ComponentType); -export default TouchableWithoutFeedback; + return React.cloneElement(element, elementProps, ...children); +} + +const MemoedTouchableWithoutFeedback = React.memo(React.forwardRef(TouchableWithoutFeedback)); +MemoedTouchableWithoutFeedback.displayName = 'TouchableWithoutFeedback'; + +export default (MemoedTouchableWithoutFeedback: React.AbstractComponent< + Props, + React.ElementRef +>); diff --git a/packages/react-native-web/src/index.js b/packages/react-native-web/src/index.js index 9e584ae6..9248a3e0 100644 --- a/packages/react-native-web/src/index.js +++ b/packages/react-native-web/src/index.js @@ -40,6 +40,7 @@ export { default as ImageBackground } from './exports/ImageBackground'; export { default as KeyboardAvoidingView } from './exports/KeyboardAvoidingView'; export { default as Modal } from './exports/Modal'; export { default as Picker } from './exports/Picker'; +export { default as Pressable } from './exports/Pressable'; export { default as ProgressBar } from './exports/ProgressBar'; export { default as RefreshControl } from './exports/RefreshControl'; export { default as SafeAreaView } from './exports/SafeAreaView'; diff --git a/packages/react-native-web/src/modules/PressResponder/index.js b/packages/react-native-web/src/modules/PressResponder/index.js new file mode 100644 index 00000000..bbc6d8b1 --- /dev/null +++ b/packages/react-native-web/src/modules/PressResponder/index.js @@ -0,0 +1,548 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import invariant from 'fbjs/lib/invariant'; + +type ClickEvent = any; +type KeyboardEvent = any; +type ResponderEvent = any; + +export type PressResponderConfig = $ReadOnly<{| + // The gesture can be interrupted by a parent gesture, e.g., scroll. + // Defaults to true. + cancelable?: ?boolean, + // Whether to disable initialization of the press gesture. + disabled?: ?boolean, + // Duration (in addition to `delayPressStart`) after which a press gesture is + // considered a long press gesture. Defaults to 500 (milliseconds). + delayLongPress?: ?number, + // Duration to wait after press down before calling `onPressStart`. + delayPressStart?: ?number, + // Duration to wait after letting up before calling `onPressEnd`. + delayPressEnd?: ?number, + // Called when a long press gesture has been triggered. + onLongPress?: ?(event: ResponderEvent) => void, + // Called when a press gestute has been triggered. + onPress?: ?(event: ClickEvent) => void, + // Called when the press is activated to provide visual feedback. + onPressChange?: ?(event: ResponderEvent) => void, + // Called when the press is activated to provide visual feedback. + onPressStart?: ?(event: ResponderEvent) => void, + // Called when the press location moves. (This should rarely be used.) + onPressMove?: ?(event: ResponderEvent) => void, + // Called when the press is deactivated to undo visual feedback. + onPressEnd?: ?(event: ResponderEvent) => void +|}>; + +export type EventHandlers = $ReadOnly<{| + onClick: (event: ClickEvent) => void, + onContextMenu: (event: ClickEvent) => void, + onKeyDown: (event: KeyboardEvent) => void, + onKeyUp: (event: KeyboardEvent) => void, + onResponderGrant: (event: ResponderEvent) => void, + onResponderMove: (event: ResponderEvent) => void, + onResponderRelease: (event: ResponderEvent) => void, + onResponderTerminate: (event: ResponderEvent) => void, + onResponderTerminationRequest: (event: ResponderEvent) => boolean, + onStartShouldSetResponder: (event: ResponderEvent) => boolean +|}>; + +type TouchState = + | 'NOT_RESPONDER' + | 'RESPONDER_INACTIVE_PRESS_START' + | 'RESPONDER_ACTIVE_PRESS_START' + | 'RESPONDER_ACTIVE_LONG_PRESS_START' + | 'ERROR'; + +type TouchSignal = + | 'DELAY' + | 'RESPONDER_GRANT' + | 'RESPONDER_RELEASE' + | 'RESPONDER_TERMINATED' + | 'LONG_PRESS_DETECTED'; + +const DELAY = 'DELAY'; +const ERROR = 'ERROR'; +const LONG_PRESS_DETECTED = 'LONG_PRESS_DETECTED'; +const NOT_RESPONDER = 'NOT_RESPONDER'; +const RESPONDER_ACTIVE_LONG_PRESS_START = 'RESPONDER_ACTIVE_LONG_PRESS_START'; +const RESPONDER_ACTIVE_PRESS_START = 'RESPONDER_ACTIVE_PRESS_START'; +const RESPONDER_INACTIVE_PRESS_START = 'RESPONDER_INACTIVE_PRESS_START'; +const RESPONDER_GRANT = 'RESPONDER_GRANT'; +const RESPONDER_RELEASE = 'RESPONDER_RELEASE'; +const RESPONDER_TERMINATED = 'RESPONDER_TERMINATED'; + +const Transitions = Object.freeze({ + NOT_RESPONDER: { + DELAY: ERROR, + RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START, + RESPONDER_RELEASE: ERROR, + RESPONDER_TERMINATED: ERROR, + LONG_PRESS_DETECTED: ERROR + }, + RESPONDER_INACTIVE_PRESS_START: { + DELAY: RESPONDER_ACTIVE_PRESS_START, + RESPONDER_GRANT: ERROR, + RESPONDER_RELEASE: NOT_RESPONDER, + RESPONDER_TERMINATED: NOT_RESPONDER, + LONG_PRESS_DETECTED: ERROR + }, + RESPONDER_ACTIVE_PRESS_START: { + DELAY: ERROR, + RESPONDER_GRANT: ERROR, + RESPONDER_RELEASE: NOT_RESPONDER, + RESPONDER_TERMINATED: NOT_RESPONDER, + LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START + }, + RESPONDER_ACTIVE_LONG_PRESS_START: { + DELAY: ERROR, + RESPONDER_GRANT: ERROR, + RESPONDER_RELEASE: NOT_RESPONDER, + RESPONDER_TERMINATED: NOT_RESPONDER, + LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START + }, + ERROR: { + DELAY: NOT_RESPONDER, + RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START, + RESPONDER_RELEASE: NOT_RESPONDER, + RESPONDER_TERMINATED: NOT_RESPONDER, + LONG_PRESS_DETECTED: NOT_RESPONDER + } +}); + +const isActiveSignal = signal => + signal === RESPONDER_ACTIVE_PRESS_START || signal === RESPONDER_ACTIVE_LONG_PRESS_START; + +const isPressStartSignal = signal => + signal === RESPONDER_INACTIVE_PRESS_START || + signal === RESPONDER_ACTIVE_PRESS_START || + signal === RESPONDER_ACTIVE_LONG_PRESS_START; + +const isTerminalSignal = signal => signal === RESPONDER_TERMINATED || signal === RESPONDER_RELEASE; + +const DEFAULT_LONG_PRESS_DELAY_MS = 450; // 500 - 50 +const DEFAULT_PRESS_DELAY_MS = 50; + +/** + * =========================== PressResponder Tutorial =========================== + * + * The `PressResponder` class helps you create press interactions by analyzing the + * geometry of elements and observing when another responder (e.g. ScrollView) + * has stolen the touch lock. It offers hooks for your component to provide + * interaction feedback to the user: + * + * - When a press has activated (e.g. highlight an element) + * - When a press has deactivated (e.g. un-highlight an element) + * - When a press sould trigger an action, meaning it activated and deactivated + * while within the geometry of the element without the lock being stolen. + * + * A high quality interaction isn't as simple as you might think. There should + * be a slight delay before activation. Moving your finger beyond an element's + * bounds should trigger deactivation, but moving the same finger back within an + * element's bounds should trigger reactivation. + * + * In order to use `PressResponder`, do the following: + * + * const pressResponder = new PressResponder(config); + * + * 2. Choose the rendered component who should collect the press events. On that + * element, spread `pressability.getEventHandlers()` into its props. + * + * return ( + * + * ); + * + * 3. Reset `PressResponder` when your component unmounts. + * + * componentWillUnmount() { + * this.state.pressResponder.reset(); + * } + * + * ==================== Implementation Details ==================== + * + * `PressResponder` only assumes that there exists a `HitRect` node. The `PressRect` + * is an abstract box that is extended beyond the `HitRect`. + * + * # Geometry + * + * ┌────────────────────────┐ + * │ ┌──────────────────┐ │ - Presses start anywhere within `HitRect`. + * │ │ ┌────────────┐ │ │ + * │ │ │ VisualRect │ │ │ + * │ │ └────────────┘ │ │ - When pressed down for sufficient amount of time + * │ │ HitRect │ │ before letting up, `VisualRect` activates. + * │ └──────────────────┘ │ + * │ Out Region o │ + * └────────────────────│───┘ + * └────── When the press is released outside the `HitRect`, + * the responder is NOT eligible for a "press". + * + * # State Machine + * + * ┌───────────────┐ ◀──── RESPONDER_RELEASE + * │ NOT_RESPONDER │ + * └───┬───────────┘ ◀──── RESPONDER_TERMINATED + * │ + * │ RESPONDER_GRANT (HitRect) + * │ + * ▼ + * ┌─────────────────────┐ ┌───────────────────┐ ┌───────────────────┐ + * │ RESPONDER_INACTIVE_ │ DELAY │ RESPONDER_ACTIVE_ │ T + DELAY │ RESPONDER_ACTIVE_ │ + * │ PRESS_START ├────────▶ │ PRESS_START ├────────────▶ │ LONG_PRESS_START │ + * └─────────────────────┘ └───────────────────┘ └───────────────────┘ + * + * T + DELAY => LONG_PRESS_DELAY + DELAY + * + * Not drawn are the side effects of each transition. The most important side + * effect is the invocation of `onLongPress`. Only when the browser produces a + * `click` event is `onPress` invoked. + */ +export default class PressResponder { + _config: PressResponderConfig; + _eventHandlers: ?EventHandlers = null; + _isPointerTouch: ?boolean = false; + _longPressDelayTimeout: ?TimeoutID = null; + _longPressDispatched: ?boolean = false; + _pressDelayTimeout: ?TimeoutID = null; + _pressOutDelayTimeout: ?TimeoutID = null; + _responderID: ?any; + _touchActivatePosition: ?$ReadOnly<{| + pageX: number, + pageY: number + |}>; + _touchState: TouchState = NOT_RESPONDER; + + constructor(config: PressResponderConfig) { + this.configure(config); + } + + configure(config: PressResponderConfig): void { + this._config = config; + } + + /** + * Resets any pending timers. This should be called on unmount. + */ + reset(): void { + this._cancelLongPressDelayTimeout(); + this._cancelPressDelayTimeout(); + this._cancelPressOutDelayTimeout(); + } + + /** + * Returns a set of props to spread into the interactive element. + */ + getEventHandlers(): EventHandlers { + if (this._eventHandlers == null) { + this._eventHandlers = this._createEventHandlers(); + } + return this._eventHandlers; + } + + _createEventHandlers(): EventHandlers { + const start = (event: ResponderEvent, shouldDelay?: boolean): void => { + event.persist(); + + this._cancelPressOutDelayTimeout(); + + this._longPressDispatched = false; + this._responderID = event.currentTarget; + this._touchState = NOT_RESPONDER; + this._isPointerTouch = event.nativeEvent.type === 'touchstart'; + + this._receiveSignal(RESPONDER_GRANT, event); + + const delayPressStart = normalizeDelay( + this._config.delayPressStart, + 0, + DEFAULT_PRESS_DELAY_MS + ); + + if (shouldDelay !== false && delayPressStart > 0) { + this._pressDelayTimeout = setTimeout(() => { + this._receiveSignal(DELAY, event); + }, delayPressStart); + } else { + this._receiveSignal(DELAY, event); + } + + const delayLongPress = normalizeDelay( + this._config.delayLongPress, + 10, + DEFAULT_LONG_PRESS_DELAY_MS + ); + this._longPressDelayTimeout = setTimeout(() => { + this._handleLongPress(event); + }, delayLongPress + delayPressStart); + }; + + const end = (event: ResponderEvent): void => { + this._receiveSignal(RESPONDER_RELEASE, event); + }; + + return { + onStartShouldSetResponder: (): boolean => { + const { disabled } = this._config; + if (disabled == null) { + return true; + } + return !disabled; + }, + + onKeyDown: event => { + if (this._touchState === NOT_RESPONDER) { + if (event.key === ' ' || event.key === 'Enter') { + start(event, false); + } + } + if (this._responderID) { + event.stopPropagation(); + } + }, + + onKeyUp: event => { + if (event.key === ' ' || event.key === 'Enter') { + end(event); + } + event.stopPropagation(); + }, + + onResponderGrant: event => start(event), + + onResponderMove: event => { + if (this._config.onPressMove != null) { + this._config.onPressMove(event); + } + const touch = getTouchFromResponderEvent(event); + if (this._touchActivatePosition != null) { + const deltaX = this._touchActivatePosition.pageX - touch.pageX; + const deltaY = this._touchActivatePosition.pageY - touch.pageY; + if (Math.hypot(deltaX, deltaY) > 10) { + this._cancelLongPressDelayTimeout(); + } + } + }, + + onResponderRelease: event => end(event), + + onResponderTerminate: event => { + this._receiveSignal(RESPONDER_TERMINATED, event); + }, + + onResponderTerminationRequest: (event): boolean => { + const { cancelable, disabled, onLongPress } = this._config; + // If `onLongPress` is provided, don't terminate on `contextmenu` as default + // behavior will be prevented for non-mouse pointers. + if ( + !disabled && + onLongPress != null && + this._isPointerTouch && + event.nativeEvent.type === 'contextmenu' + ) { + return false; + } + if (cancelable == null) { + return true; + } + return cancelable; + }, + + // NOTE: this diverges from react-native@0.62 in 2 significant ways + // * The `onPress` callback is not connected to the responder system (the native + // `click` event must be used but is dispatched in many scenarios where no pointers + // are on the screen.) Therefore, it's possible for `onPress` to be called without + // `onPress{Start,End}` being called first. + // * The `onPress` callback is only be called on the first ancestor of the native + // `click` target that is using the PressResponder. + // * The event's `nativeEvent` is a `MouseEvent` not a `TouchEvent`. + onClick: (event: any): void => { + const { disabled, onPress } = this._config; + if (!disabled) { + if (event.nativeEvent.__responderStoppedPropagation !== true) { + if (this._longPressDispatched) { + event.preventDefault(); + } else if (event.ctrlKey === false && event.altKey === false && onPress != null) { + onPress(event); + } + event.nativeEvent.__responderStoppedPropagation = true; + } + } + }, + + // If `onLongPress` is provided and a touch pointer is being used, prevent the + // default context menu from opening. + onContextMenu: (event: any): void => { + const { disabled, onLongPress } = this._config; + if (!disabled && onLongPress != null && this._isPointerTouch && !event.defaultPrevented) { + event.preventDefault(); + } + } + }; + } + + /** + * Receives a state machine signal, performs side effects of the transition + * and stores the new state. Validates the transition as well. + */ + _receiveSignal(signal: TouchSignal, event: ResponderEvent): void { + const prevState = this._touchState; + let nextState = null; + if (Transitions[prevState] != null) { + nextState = Transitions[prevState][signal]; + } + if (this._responderID == null && signal === RESPONDER_RELEASE) { + return; + } + invariant( + nextState != null && nextState !== ERROR, + 'PressResponder: Invalid signal `%s` for state `%s` on responder: %s', + signal, + prevState, + this._responderID + ); + if (prevState !== nextState) { + this._performTransitionSideEffects(prevState, nextState, signal, event); + this._touchState = nextState; + } + } + + /** + * Performs a transition between touchable states and identify any activations + * or deactivations (and callback invocations). + */ + _performTransitionSideEffects( + prevState: TouchState, + nextState: TouchState, + signal: TouchSignal, + event: ResponderEvent + ): void { + if (isTerminalSignal(signal)) { + this._isPointerTouch = false; + this._touchActivatePosition = null; + this._cancelLongPressDelayTimeout(); + } + + if (isPressStartSignal(prevState) && signal === LONG_PRESS_DETECTED) { + const { onLongPress } = this._config; + if (onLongPress != null) { + onLongPress(event); + this._longPressDispatched = true; + } + } + + const isPrevActive = isActiveSignal(prevState); + const isNextActive = isActiveSignal(nextState); + + if (!isPrevActive && isNextActive) { + this._activate(event); + } else if (isPrevActive && !isNextActive) { + this._deactivate(event); + } + + if (isPressStartSignal(prevState) && signal === RESPONDER_RELEASE) { + const { onLongPress, onPress } = this._config; + if (onPress != null) { + const isPressCanceledByLongPress = + onLongPress != null && prevState === RESPONDER_ACTIVE_LONG_PRESS_START; + if (!isPressCanceledByLongPress) { + // If we never activated (due to delays), activate and deactivate now. + if (!isNextActive && !isPrevActive) { + this._activate(event); + this._deactivate(event); + } + } + } + } + + this._cancelPressDelayTimeout(); + } + + _activate(event: ResponderEvent): void { + const { onPressChange, onPressStart } = this._config; + const touch = getTouchFromResponderEvent(event); + this._touchActivatePosition = { + pageX: touch.pageX, + pageY: touch.pageY + }; + if (onPressStart != null) { + onPressStart(event); + } + if (onPressChange != null) { + onPressChange(true); + } + } + + _deactivate(event: ResponderEvent): void { + const { onPressChange, onPressEnd } = this._config; + function end() { + if (onPressEnd != null) { + onPressEnd(event); + } + if (onPressChange != null) { + onPressChange(false); + } + } + const delayPressEnd = normalizeDelay(this._config.delayPressEnd); + if (delayPressEnd > 0) { + this._pressOutDelayTimeout = setTimeout(() => { + end(); + }, delayPressEnd); + } else { + end(); + } + } + + _handleLongPress(event: ResponderEvent): void { + if ( + this._touchState === RESPONDER_ACTIVE_PRESS_START || + this._touchState === RESPONDER_ACTIVE_LONG_PRESS_START + ) { + this._receiveSignal(LONG_PRESS_DETECTED, event); + } + } + + _cancelLongPressDelayTimeout(): void { + if (this._longPressDelayTimeout != null) { + clearTimeout(this._longPressDelayTimeout); + this._longPressDelayTimeout = null; + } + } + + _cancelPressDelayTimeout(): void { + if (this._pressDelayTimeout != null) { + clearTimeout(this._pressDelayTimeout); + this._pressDelayTimeout = null; + } + } + + _cancelPressOutDelayTimeout(): void { + if (this._pressOutDelayTimeout != null) { + clearTimeout(this._pressOutDelayTimeout); + this._pressOutDelayTimeout = null; + } + } +} + +function normalizeDelay(delay: ?number, min = 0, fallback = 0): number { + return Math.max(min, delay ?? fallback); +} + +function getTouchFromResponderEvent(event: ResponderEvent) { + const { changedTouches, touches } = event.nativeEvent; + if (touches != null && touches.length > 0) { + return touches[0]; + } + if (changedTouches != null && changedTouches.length > 0) { + return changedTouches[0]; + } + return event.nativeEvent; +} diff --git a/packages/react-native-web/src/modules/PressResponder/usePressEvents.js b/packages/react-native-web/src/modules/PressResponder/usePressEvents.js new file mode 100644 index 00000000..029422b2 --- /dev/null +++ b/packages/react-native-web/src/modules/PressResponder/usePressEvents.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import PressResponder, { type EventHandlers, type PressResponderConfig } from './index'; +import { useEffect, useRef } from 'react'; + +export default function usePressEvents(hostRef: any, config: PressResponderConfig): EventHandlers { + const pressResponderRef = useRef(null); + if (pressResponderRef.current == null) { + pressResponderRef.current = new PressResponder(config); + } + const pressResponder = pressResponderRef.current; + + // Re-configure to use the current node and configuration. + useEffect(() => { + pressResponder.configure(config); + }, [config, pressResponder]); + + // Reset the `pressResponder` when cleanup needs to occur. This is + // a separate effect because we do not want to rest the responder when `config` changes. + useEffect(() => { + return () => { + pressResponder.reset(); + }; + }, [pressResponder]); + + return pressResponder.getEventHandlers(); +}