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(); +}