diff --git a/change/@office-iss-react-native-win32-dd07e0e2-ca29-4722-b473-36dc41e699ce.json b/change/@office-iss-react-native-win32-dd07e0e2-ca29-4722-b473-36dc41e699ce.json new file mode 100644 index 00000000000..c80dee97a7a --- /dev/null +++ b/change/@office-iss-react-native-win32-dd07e0e2-ca29-4722-b473-36dc41e699ce.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Port windows pressable with extra desktop support to win32", + "packageName": "@office-iss/react-native-win32", + "email": "saadnajmi2@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/react-native-windows-06381c40-100f-42e5-a931-427f39f7375a.json b/change/react-native-windows-06381c40-100f-42e5-a931-427f39f7375a.json new file mode 100644 index 00000000000..011f31b7c91 --- /dev/null +++ b/change/react-native-windows-06381c40-100f-42e5-a931-427f39f7375a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Fork HoverState.js to allow hover events on Pressable", + "packageName": "react-native-windows", + "email": "saadnajmi2@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/@react-native-windows/tester/src/js/examples/Pressable/PressableExample.windows.js b/packages/@react-native-windows/tester/src/js/examples/Pressable/PressableExample.windows.js index b25dc21c2e1..8955c5955a1 100644 --- a/packages/@react-native-windows/tester/src/js/examples/Pressable/PressableExample.windows.js +++ b/packages/@react-native-windows/tester/src/js/examples/Pressable/PressableExample.windows.js @@ -100,6 +100,10 @@ function PressableFeedbackEvents() { testID="pressable_feedback_events_button" accessibilityLabel="pressable feedback events" accessibilityRole="button" + // [Windows + onHoverIn={() => appendEvent('hover in')} + onHoverOut={() => appendEvent('hover out')} + // Windows] onPress={() => appendEvent('press')} onPressIn={() => appendEvent('pressIn')} onPressOut={() => appendEvent('pressOut')} diff --git a/packages/react-native-win32-tester/src/js/examples-win32/Pressable/PressableExample.win32.js b/packages/react-native-win32-tester/src/js/examples-win32/Pressable/PressableExample.win32.js new file mode 100644 index 00000000000..c7f26108b41 --- /dev/null +++ b/packages/react-native-win32-tester/src/js/examples-win32/Pressable/PressableExample.win32.js @@ -0,0 +1,679 @@ +/** + * 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. + * + * @format + * @flow strict-local + */ + +import * as React from 'react'; +import { + Animated, + Image, + Pressable, + StyleSheet, + Text, + TouchableHighlight, + Platform, + View, + Switch, +} from 'react-native'; + +const {useEffect, useRef, useState} = React; + +const forceTouchAvailable = + (Platform.OS === 'ios' && Platform.constants.forceTouchAvailable) || false; + +function ContentPress() { + const [timesPressed, setTimesPressed] = useState(0); + + let textLog = ''; + if (timesPressed > 1) { + textLog = timesPressed + 'x onPress'; + } else if (timesPressed > 0) { + textLog = 'onPress'; + } + + return ( + <> + + { + setTimesPressed(current => current + 1); + }}> + {({pressed}) => ( + {pressed ? 'Pressed!' : 'Press Me'} + )} + + + + {textLog} + + + ); +} + +function TextOnPressBox() { + const [timesPressed, setTimesPressed] = useState(0); + + let textLog = ''; + if (timesPressed > 1) { + textLog = timesPressed + 'x text onPress'; + } else if (timesPressed > 0) { + textLog = 'text onPress'; + } + + return ( + <> + { + setTimesPressed(prev => prev + 1); + }}> + Text has built-in onPress handling + + + {textLog} + + + ); +} + +function PressableFeedbackEvents() { + const [eventLog, setEventLog] = useState([]); + + function appendEvent(eventName) { + const limit = 6; + setEventLog(current => { + return [eventName].concat(current.slice(0, limit - 1)); + }); + } + + return ( + + + appendEvent('hover in')} + onHoverOut={() => appendEvent('hover out')} + // Windows] + onPress={() => appendEvent('press')} + onPressIn={() => appendEvent('pressIn')} + onPressOut={() => appendEvent('pressOut')} + onLongPress={() => appendEvent('longPress')}> + Press Me + + + + {eventLog.map((e, ii) => ( + {e} + ))} + + + ); +} + +function PressableDelayEvents() { + const [eventLog, setEventLog] = useState([]); + + function appendEvent(eventName) { + const limit = 6; + const newEventLog = eventLog.slice(0, limit - 1); + newEventLog.unshift(eventName); + setEventLog(newEventLog); + } + + return ( + + + appendEvent('press')} + onPressIn={() => appendEvent('pressIn')} + onPressOut={() => appendEvent('pressOut')} + delayLongPress={800} + onLongPress={() => appendEvent('longPress - 800ms delay')}> + Press Me + + + + {eventLog.map((e, ii) => ( + {e} + ))} + + + ); +} + +function ForceTouchExample() { + const [force, setForce] = useState(0); + + const consoleText = forceTouchAvailable + ? 'Force: ' + force.toFixed(3) + : '3D Touch is not available on this device'; + + return ( + + + {consoleText} + + + true} + onResponderMove={event => setForce(event.nativeEvent?.force || 1)} + onResponderRelease={event => setForce(0)}> + Press Me + + + + ); +} + +function PressableHitSlop() { + const [timesPressed, setTimesPressed] = useState(0); + + let log = ''; + if (timesPressed > 1) { + log = timesPressed + 'x onPress'; + } else if (timesPressed > 0) { + log = 'onPress'; + } + + return ( + + + setTimesPressed(num => num + 1)} + style={styles.hitSlopWrapper} + hitSlop={{top: 30, bottom: 30, left: 60, right: 60}} + testID="pressable_hit_slop_button"> + Press Outside This View + + + + {log} + + + ); +} + +function PressableNativeMethods() { + const [status, setStatus] = useState(null); + const ref = useRef(null); + + useEffect(() => { + setStatus(ref.current != null && typeof ref.current.measure === 'function'); + }, []); + + return ( + <> + + + + + + {status == null + ? 'Missing Ref!' + : status === true + ? 'Native Methods Exist' + : 'Native Methods Missing!'} + + + + ); +} + +function PressableDisabled() { + return ( + <> + + Disabled Pressable + + + [ + {opacity: pressed ? 0.5 : 1}, + styles.row, + styles.block, + ]}> + Enabled Pressable + + + ); +} + +function PressableFocusCallbacks() { + const [timesPressed, setTimesPressed] = useState(0); + + let textLog = ''; + if (timesPressed > 1) { + textLog = timesPressed + 'x onPress'; + } else if (timesPressed > 0) { + textLog = 'onPress'; + } + const viewRef = useRef | null>(null); + + const focusViewPressed = () => { + viewRef.current.focus(); + }; + + return ( + <> + + console.log('Pressable onFocus')} + onBlur={() => console.log('Pressable onBlur')} + onPress={() => { + setTimesPressed(current => current + 1); + }}> + {({pressed}) => ( + {pressed ? 'Pressed!' : 'Press Me'} + )} + + + + {textLog} + + + + Click to focus textbox + + + + ); +} + +function PressWithOnKeyDown() { + const [timesPressed, setTimesPressed] = useState(0); + const [text, setText] = useState('defaultText'); + + let textLog = ''; + if (timesPressed > 1) { + textLog = timesPressed + 'x onPress'; + } else if (timesPressed > 0) { + textLog = 'onPress'; + } + + const [shouldPreventDefault, setShouldPreventDefault] = useState(false); + const toggleSwitch = () => + setShouldPreventDefault(previousState => !previousState); + + function myKeyDown(event) { + console.log('keyDown - ' + event.nativeEvent.code); + setText(event.nativeEvent.code); + if (shouldPreventDefault) { + event.preventDefault(); + } + } + function myKeyUp(event) { + console.log('keyUp - ' + event.nativeEvent.code); + setText(event.nativeEvent.code); + if (shouldPreventDefault) { + event.preventDefault(); + } + } + + return ( + <> + + myKeyDown(event)} + onKeyUp={event => myKeyUp(event)} + onPress={() => { + setTimesPressed(current => current + 1); + }}> + {({pressed}) => ( + {pressed ? 'Pressed!' : 'Press Me'} + )} + + + + + {textLog} + {text} + + + ); +} + +function PressWithKeyCapture() { + const [eventLog, setEventLog] = useState([]); + const [timesPressed, setTimesPressed] = useState(0); + + function logEvent(eventName) { + const limit = 6; + setEventLog(current => { + return [eventName].concat(current.slice(0, limit - 1)); + }); + console.log(eventName); + } + + return ( + <> + logEvent('outer keyDown ' + event.nativeEvent.code)} + onKeyDownCapture={event => + logEvent('outer keyDownCapture ' + event.nativeEvent.code) + }> + logEvent('keyDown ' + event.nativeEvent.code)} + onKeyDownCapture={event => + logEvent('keyDownCapture ' + event.nativeEvent.code) + } + onPress={() => { + setTimesPressed(current => current + 1); + logEvent('pressed ' + timesPressed); + }}> + {({pressed}) => ( + {pressed ? 'Pressed!' : 'Press Me'} + )} + + + + + {eventLog.map((e, ii) => ( + {e} + ))} + + + ); +} + +const styles = StyleSheet.create({ + row: { + justifyContent: 'center', + flexDirection: 'row', + }, + centered: { + justifyContent: 'center', + }, + text: { + fontSize: 16, + }, + block: { + padding: 10, + }, + button: { + color: '#007AFF', + }, + disabledButton: { + color: '#007AFF', + opacity: 0.5, + }, + hitSlopButton: { + color: 'white', + }, + wrapper: { + borderRadius: 8, + }, + wrapperCustom: { + borderRadius: 8, + padding: 6, + }, + hitSlopWrapper: { + backgroundColor: 'red', + marginVertical: 30, + }, + logBox: { + padding: 20, + margin: 10, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#f0f0f0', + backgroundColor: '#f9f9f9', + }, + eventLogBox: { + padding: 10, + margin: 10, + height: 120, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#f0f0f0', + backgroundColor: '#f9f9f9', + }, + forceTouchBox: { + padding: 10, + margin: 10, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#f0f0f0', + backgroundColor: '#f9f9f9', + alignItems: 'center', + }, + textBlock: { + fontWeight: '500', + color: 'blue', + }, + image: { + height: 100, + width: 100, + }, +}); + +exports.displayName = (undefined: ?string); +exports.description = 'Component for making views pressable.'; +exports.title = 'Pressable'; +exports.category = 'UI'; +exports.documentationURL = 'https://reactnative.dev/docs/pressable'; +exports.examples = [ + { + title: 'Change content based on Press', + render(): React.Node { + return ; + }, + }, + { + title: 'Change style based on Press', + render(): React.Node { + return ( + + [ + { + backgroundColor: pressed ? 'rgb(210, 230, 255)' : 'white', + }, + styles.wrapperCustom, + ]}> + Press Me + + + ); + }, + }, + { + title: 'Pressable feedback events', + description: (' components accept onPress, onPressIn, ' + + 'onPressOut, and onLongPress as props.': string), + render: function(): React.Node { + return ; + }, + }, + { + title: 'Pressable with Ripple and Animated child', + description: ('Pressable can have an AnimatedComponent as a direct child.': string), + platform: 'android', + render: function(): React.Node { + const mScale = new Animated.Value(1); + Animated.timing(mScale, { + toValue: 0.3, + duration: 1000, + useNativeDriver: false, + }).start(); + const style = { + backgroundColor: 'rgb(180, 64, 119)', + width: 200, + height: 100, + transform: [{scale: mScale}], + }; + return ( + + + + + + ); + }, + }, + { + title: 'Pressable with custom Ripple', + description: ("Pressable can specify ripple's radius, color and borderless params": string), + platform: 'android', + render: function(): React.Node { + const nativeFeedbackButton = { + textAlign: 'center', + margin: 10, + }; + return ( + + + + + + radius 30 + + + + + + + + radius 150 + + + + + + + + radius 70, with border + + + + + + + + + with border, default color and radius + + + + + + + + + use foreground + + + ); + }, + }, + { + title: ' with highlight', + render: function(): React.Node { + return ; + }, + }, + { + title: 'Pressable delay for events', + description: (' also accept delayPressIn, ' + + 'delayPressOut, and delayLongPress as props. These props impact the ' + + 'timing of feedback events.': string), + render: function(): React.Node { + return ; + }, + }, + { + title: '3D Touch / Force Touch', + description: + 'iPhone 8 and 8 plus support 3D touch, which adds a force property to touches', + render: function(): React.Node { + return ; + }, + platform: 'ios', + }, + { + title: 'Pressable Hit Slop', + description: (' components accept hitSlop prop which extends the touch area ' + + 'without changing the view bounds.': string), + render: function(): React.Node { + return ; + }, + }, + { + title: 'Pressable Native Methods', + description: (' components expose native methods like `measure`.': string), + render: function(): React.Node { + return ; + }, + }, + { + title: 'Disabled Pressable', + description: (' components accept disabled prop which prevents ' + + 'any interaction with component': string), + render: function(): React.Node { + return ; + }, + }, + { + title: 'Focusability in Pressable', + description: (' components can be receive focus by calling the focus() and blur() methods on them.' + + 'They also expose onFocus and onBlur callbacks to hadle incoming native events.': string), + render: function(): React.Node { + return ; + }, + }, + { + title: 'OnKeyDown/OnKeyUp callbacks on Pressable', + description: (' components can be respond to keyDown/keyUp native events.' + + ' Additionally, they can be activated by pressing Space or Enter as if they were clicked with the mouse, triggering onPress' + + ' - this behavior can be suppressed by calling e.preventDefault() on the event (can be toggled with the switch).': string), + render: function(): React.Node { + return ; + }, + }, + { + title: 'OnKeyDownCapture on Pressable (View)', + description: ('You can intercept routed KeyDown/KeyUp events by specifying the onKeyDownCapture/onKeyUpCapture callbacks.' + + " In the example below, a is wrapper in a , and each specifies onKeyDown and onKeyDownCapture callbacks. Set focus to the 'Press me' element by tabbing into it, and start pressing letters on the keyboard to observe the event log below." + + " Additionally, because the keyDownEvents prop is specified - keyDownEvents={[{code: 'KeyW', handledEventPhase: 3}, {code: 'KeyE', handledEventPhase: 1}]} - " + + 'for these keys the event routing will be interrupted (by a call to event.stopPropagation()) at the phase specified (3 - bubbling, 1 - capturing) to match processing on the native side.': string), + render: function(): React.Node { + return ; + }, + }, +]; diff --git a/packages/react-native-win32-tester/src/js/utils/RNTesterList.win32.js b/packages/react-native-win32-tester/src/js/utils/RNTesterList.win32.js index 2568772ccd3..63ffe491523 100644 --- a/packages/react-native-win32-tester/src/js/utils/RNTesterList.win32.js +++ b/packages/react-native-win32-tester/src/js/utils/RNTesterList.win32.js @@ -46,8 +46,9 @@ const ComponentExamples: Array = [ { key: 'PressableExample', category: 'UI', - module: require('../examples/Pressable/PressableExample'), + module: require('../examples-win32/Pressable/PressableExample'), }, + /* { key: 'TouchableWin32Example', module: require('@office-iss/react-native-win32/Libraries/Components/Touchable/Tests/TouchableWin32Test'), @@ -61,8 +62,7 @@ const ComponentExamples: Array = [ key: 'SectionListExample', category: 'ListView', module: require('../examples/SectionList/SectionListExample'), - }*/, - { + }*/ { key: 'SwitchExample', module: require('../examples/Switch/SwitchExample'), }, diff --git a/packages/react-native-win32/.flowconfig b/packages/react-native-win32/.flowconfig index a7972e4ae9c..3d73fb0a9fe 100644 --- a/packages/react-native-win32/.flowconfig +++ b/packages/react-native-win32/.flowconfig @@ -12,6 +12,7 @@ /Libraries/Alert/Alert.js /Libraries/Components/AccessibilityInfo/NativeAccessibilityInfo.js /Libraries/Components/Picker/Picker.js +/Libraries/Components/Pressable/Pressable.js /Libraries/Components/SafeAreaView/SafeAreaView.js /Libraries/Components/TextInput/TextInput.js /Libraries/Components/TextInput/TextInputState.js @@ -19,11 +20,15 @@ /Libraries/Components/View/ReactNativeViewAttributes.js /Libraries/Components/View/ReactNativeViewViewConfig.js /Libraries/Components/View/View.js +/Libraries/Components/View/ViewPropTypes.js /Libraries/Image/Image.js /Libraries/Inspector/Inspector.js /Libraries/Inspector/InspectorOverlay.js /Libraries/Network/RCTNetworking.js +/Libraries/Pressability/Pressability.js +/Libraries/Pressability/HoverState.js /Libraries/StyleSheet/StyleSheet.js +/Libraries/Types/CoreEventTypes.js /Libraries/Utilities/DeviceInfo.js /Libraries/Utilities/Dimensions.js diff --git a/packages/react-native-win32/overrides.json b/packages/react-native-win32/overrides.json index d15d667bf84..1560ddf23c8 100644 --- a/packages/react-native-win32/overrides.json +++ b/packages/react-native-win32/overrides.json @@ -3,10 +3,10 @@ ".flowconfig", "src/**" ], - "baseVersion": "0.64.0", "excludePatterns": [ "src/Libraries/Lists/__tests__/**" ], + "baseVersion": "0.64.0", "overrides": [ { "type": "derived", @@ -33,12 +33,6 @@ "baseHash": "85eea8e9510b516c12e19fcfedc4553b990feb11", "issue": 5807 }, - { - "type": "patch", - "file": "src/Libraries/ReactNative/PaperUIManager.win32.js", - "baseFile": "Libraries/ReactNative/PaperUIManager.js", - "baseHash": "a72811cd104bb575d0ab2659343dc2ea11c6b674" - }, { "type": "derived", "file": "src/Libraries/Components/AccessibilityInfo/AccessibilityInfo.win32.js", @@ -109,6 +103,13 @@ "baseHash": "0c6bf0751e053672123cbad30d67851ba0007af6", "issue": 4378 }, + { + "type": "patch", + "file": "src/Libraries/Components/Pressable/Pressable.win32.js", + "baseFile": "Libraries/Components/Pressable/Pressable.js", + "baseHash": "53bc7996e0fa83128b846a04d0bcec972a406133", + "issue": 6240 + }, { "type": "copy", "file": "src/Libraries/Components/ProgressBarAndroid/ProgressBarAndroid.js", @@ -221,6 +222,13 @@ "baseHash": "147459dc889f04f2405db051445833f791726895", "issue": 5843 }, + { + "type": "patch", + "file": "src/Libraries/Components/View/ViewPropTypes.win32.js", + "baseFile": "Libraries/Components/View/ViewPropTypes.js", + "baseHash": "2b12228aa1ab0e12844996a99ff5d38fbff689a1", + "issue": 6240 + }, { "type": "platform", "file": "src/Libraries/Components/View/ViewWin32.Props.ts" @@ -342,6 +350,20 @@ "type": "platform", "file": "src/Libraries/PersonaCoin/PersonaCoinTypes.ts" }, + { + "type": "patch", + "file": "src/Libraries/Pressability/HoverState.win32.js", + "baseFile": "Libraries/Pressability/HoverState.js", + "baseHash": "c78372cfc9f0b66109848beb20895e199c5431b8", + "issue": 6240 + }, + { + "type": "patch", + "file": "src/Libraries/Pressability/Pressability.win32.js", + "baseFile": "Libraries/Pressability/Pressability.js", + "baseHash": "e1418d31343ca11ce165a87f82224ccc8d801052", + "issue": 6240 + }, { "type": "platform", "file": "src/Libraries/QuirkSettings/CachingNativeQuirkSettings.js" @@ -358,6 +380,12 @@ "type": "platform", "file": "src/Libraries/QuirkSettings/QuirkSettings.js" }, + { + "type": "patch", + "file": "src/Libraries/ReactNative/PaperUIManager.win32.js", + "baseFile": "Libraries/ReactNative/PaperUIManager.js", + "baseHash": "a72811cd104bb575d0ab2659343dc2ea11c6b674" + }, { "type": "derived", "file": "src/Libraries/Settings/Settings.win32.js", @@ -389,6 +417,13 @@ "baseHash": "d316f87ebc8899c3b99802f684b6a333970d737c", "issue": 7080 }, + { + "type": "patch", + "file": "src/Libraries/Types/CoreEventTypes.win32.js", + "baseFile": "Libraries/Types/CoreEventTypes.js", + "baseHash": "e8f8ce02645228b423fdda317e5d1d9e69b54e6d", + "issue": 6240 + }, { "type": "copy", "file": "src/Libraries/Utilities/BackHandler.win32.js", diff --git a/packages/react-native-win32/src/Libraries/Components/Pressable/Pressable.win32.js b/packages/react-native-win32/src/Libraries/Components/Pressable/Pressable.win32.js new file mode 100644 index 00000000000..6d82cc93d88 --- /dev/null +++ b/packages/react-native-win32/src/Libraries/Components/Pressable/Pressable.win32.js @@ -0,0 +1,333 @@ +/** + * 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 * as React from 'react'; +import {useMemo, useState, useRef, useImperativeHandle} from 'react'; +import useAndroidRippleForView, { + type RippleConfig, +} from './useAndroidRippleForView'; +import type { + AccessibilityActionEvent, + AccessibilityActionInfo, + AccessibilityRole, + AccessibilityState, + AccessibilityValue, +} from '../View/ViewAccessibility'; +import {PressabilityDebugView} from '../../Pressability/PressabilityDebug'; +import usePressability from '../../Pressability/usePressability'; +import {normalizeRect, type RectOrSize} from '../../StyleSheet/Rect'; +import type { + LayoutEvent, + PressEvent, + // [Windows + MouseEvent, + BlurEvent, + FocusEvent, // Windows] +} from '../../Types/CoreEventTypes'; +import View from '../View/View'; +import TextInputState from '../TextInput/TextInputState'; + +type ViewStyleProp = $ElementType, 'style'>; + +export type StateCallbackType = $ReadOnly<{| + pressed: boolean, +|}>; + +type Props = $ReadOnly<{| + /** + * Accessibility. + */ + accessibilityActions?: ?$ReadOnlyArray, + accessibilityElementsHidden?: ?boolean, + accessibilityHint?: ?Stringish, + accessibilityIgnoresInvertColors?: ?boolean, + accessibilityLabel?: ?Stringish, + accessibilityLiveRegion?: ?('none' | 'polite' | 'assertive'), + accessibilityRole?: ?AccessibilityRole, + accessibilityState?: ?AccessibilityState, + accessibilityValue?: ?AccessibilityValue, + accessibilityViewIsModal?: ?boolean, + accessible?: ?boolean, + focusable?: ?boolean, + importantForAccessibility?: ?('auto' | 'yes' | 'no' | 'no-hide-descendants'), + onAccessibilityAction?: ?(event: AccessibilityActionEvent) => mixed, + + /** + * Either children or a render prop that receives a boolean reflecting whether + * the component is currently pressed. + */ + children: React.Node | ((state: StateCallbackType) => React.Node), + + /** + * Duration to wait after hover in before calling `onHoverIn`. + */ + delayHoverIn?: ?number, + + /** + * Duration to wait after hover out before calling `onHoverOut`. + */ + delayHoverOut?: ?number, + + /** + * Duration (in milliseconds) from `onPressIn` before `onLongPress` is called. + */ + delayLongPress?: ?number, + + /** + * Whether the press behavior is disabled. + */ + disabled?: ?boolean, + + /** + * Additional distance outside of this view in which a press is detected. + */ + hitSlop?: ?RectOrSize, + + /** + * Additional distance outside of this view in which a touch is considered a + * press before `onPressOut` is triggered. + */ + pressRetentionOffset?: ?RectOrSize, + + /** + * Called when this view's layout changes. + */ + onLayout?: ?(event: LayoutEvent) => void, + + /** + * Called when the hover is activated to provide visual feedback. + */ + onHoverIn?: ?(event: MouseEvent) => mixed, + + /** + * Called when the hover is deactivated to undo visual feedback. + */ + onHoverOut?: ?(event: MouseEvent) => mixed, + + /** + * Called when a long-tap gesture is detected. + */ + onLongPress?: ?(event: PressEvent) => void, + + /** + * Called when a single tap gesture is detected. + */ + onPress?: ?(event: PressEvent) => void, + + /** + * Called when a touch is engaged before `onPress`. + */ + onPressIn?: ?(event: PressEvent) => void, + + /** + * Called when a touch is released before `onPress`. + */ + onPressOut?: ?(event: PressEvent) => void, + + /** + * Called after the element loses focus. + */ + onBlur?: ?(event: BlurEvent) => mixed, + + /** + * Called after the element is focused. + */ + onFocus?: ?(event: FocusEvent) => mixed, + + /** + * Either view styles or a function that receives a boolean reflecting whether + * the component is currently pressed and returns view styles. + */ + style?: ViewStyleProp | ((state: StateCallbackType) => ViewStyleProp), + + /** + * Identifier used to find this view in tests. + */ + testID?: ?string, + + /** + * If true, doesn't play system sound on touch. + */ + android_disableSound?: ?boolean, + + /** + * Enables the Android ripple effect and configures its color. + */ + android_ripple?: ?RippleConfig, + + /** + * Used only for documentation or testing (e.g. snapshot testing). + */ + testOnly_pressed?: ?boolean, + + /** + * Duration to wait after press down before calling `onPressIn`. + */ + unstable_pressDelay?: ?number, +|}>; + +/** + * 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, + android_disableSound, + android_ripple, + children, + delayHoverIn, + delayHoverOut, + delayLongPress, + disabled, + focusable, + onHoverIn, + onHoverOut, + onLongPress, + onPress, + onPressIn, + onPressOut, + // [Windows + onBlur, + onFocus, + // Windows] + pressRetentionOffset, + style, + testOnly_pressed, + unstable_pressDelay, + ...restProps + } = props; + + const viewRef = useRef | null>(null); + useImperativeHandle(forwardedRef, () => viewRef.current); + + // [Windows + const _onBlur = (event: BlurEvent) => { + TextInputState.blurInput(viewRef.current); + if (props.onBlur) { + props.onBlur(event); + } + }; + + const _onFocus = (event: FocusEvent) => { + TextInputState.focusInput(viewRef.current); + if (props.onFocus) { + props.onFocus(event); + } + }; + // Windows] + + const android_rippleConfig = useAndroidRippleForView(android_ripple, viewRef); + + const [pressed, setPressed] = usePressState(testOnly_pressed === true); + + const hitSlop = normalizeRect(props.hitSlop); + + const restPropsWithDefaults: React.ElementConfig = { + ...restProps, + ...android_rippleConfig?.viewProps, + accessible: accessible !== false, + focusable: focusable !== false, + hitSlop, + }; + + const config = useMemo( + () => ({ + disabled, + hitSlop, + pressRectOffset: pressRetentionOffset, + android_disableSound, + delayHoverIn, + delayHoverOut, + delayLongPress, + delayPressIn: unstable_pressDelay, + onHoverIn, + onHoverOut, + onLongPress, + onPress, + onPressIn(event: PressEvent): void { + if (android_rippleConfig != null) { + android_rippleConfig.onPressIn(event); + } + setPressed(true); + if (onPressIn != null) { + onPressIn(event); + } + }, + onPressMove: android_rippleConfig?.onPressMove, + onPressOut(event: PressEvent): void { + if (android_rippleConfig != null) { + android_rippleConfig.onPressOut(event); + } + setPressed(false); + if (onPressOut != null) { + onPressOut(event); + } + }, + // [Windows + onBlur, + onFocus, + // Windows] + }), + [ + android_disableSound, + android_rippleConfig, + delayHoverIn, + delayHoverOut, + delayLongPress, + disabled, + hitSlop, + onHoverIn, + onHoverOut, + onLongPress, + onPress, + onPressIn, + onPressOut, + // [Windows + onBlur, + onFocus, + // Windows] + pressRetentionOffset, + setPressed, + unstable_pressDelay, + ], + ); + const eventHandlers = usePressability(config); + + return ( + + {typeof children === 'function' ? children({pressed}) : children} + {__DEV__ ? : null} + + ); +} + +function usePressState(forcePressed: boolean): [boolean, (boolean) => void] { + const [pressed, setPressed] = useState(false); + return [pressed || forcePressed, setPressed]; +} + +const MemoedPressable = React.memo(React.forwardRef(Pressable)); +MemoedPressable.displayName = 'Pressable'; + +export default (MemoedPressable: React.AbstractComponent< + Props, + React.ElementRef, +>); diff --git a/packages/react-native-win32/src/Libraries/Components/View/View.win32.js b/packages/react-native-win32/src/Libraries/Components/View/View.win32.js index 47b95ca84bf..aa075c3c9ac 100644 --- a/packages/react-native-win32/src/Libraries/Components/View/View.win32.js +++ b/packages/react-native-win32/src/Libraries/Components/View/View.win32.js @@ -15,7 +15,6 @@ import type {ViewProps} from './ViewPropTypes'; const React = require('react'); import ViewNativeComponent from './ViewNativeComponent'; const TextAncestor = require('../../Text/TextAncestor'); -import warnOnce from '../../Utilities/warnOnce'; // [Windows] import invariant from 'invariant'; // [Windows] export type Props = ViewProps; @@ -31,16 +30,14 @@ const View: React.AbstractComponent< ViewProps, React.ElementRef, > = React.forwardRef((props: ViewProps, forwardedRef) => { - // [Win32 Intercept props to warn about them going away + // [Windows + invariant( + // $FlowFixMe Wanting to catch untyped usages + !props || props.acceptsKeyboardFocus === undefined, + 'Support for the "acceptsKeyboardFocus" property has been removed in favor of "focusable"', + ); + // Windows] - // $FlowFixMe react-native-win32 doesn't have forked types in Flow yet - if (props.acceptsKeyboardFocus !== undefined) { - warnOnce( - 'deprecated-acceptsKeyboardFocus', - '"acceptsKeyboardFocus" has been deprecated in favor of "focusable" and will be removed soon', - ); - } - // Win32] return ( // [Windows // In core this is a TextAncestor.Provider value={false} See @@ -52,10 +49,10 @@ const View: React.AbstractComponent< !hasTextAncestor, 'Nesting of within is not currently supported.', ); - return ; }} + // Windows] ); }); diff --git a/packages/react-native-win32/src/Libraries/Components/View/ViewPropTypes.win32.js b/packages/react-native-win32/src/Libraries/Components/View/ViewPropTypes.win32.js new file mode 100644 index 00000000000..b72ea29c1e5 --- /dev/null +++ b/packages/react-native-win32/src/Libraries/Components/View/ViewPropTypes.win32.js @@ -0,0 +1,544 @@ +/** + * 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. + * + * @format + * @flow strict-local + */ + +'use strict'; + +import type { + BlurEvent, + FocusEvent, + MouseEvent, + PressEvent, + Layout, + LayoutEvent, + KeyEvent, // [Windows] +} from '../../Types/CoreEventTypes'; +import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType'; +import type {Node} from 'react'; +import type {ViewStyleProp} from '../../StyleSheet/StyleSheet'; +import type { + AccessibilityRole, + AccessibilityState, + AccessibilityValue, + AccessibilityActionEvent, + AccessibilityActionInfo, +} from './ViewAccessibility'; + +export type ViewLayout = Layout; +export type ViewLayoutEvent = LayoutEvent; + +type BubblingEventProps = $ReadOnly<{| + onBlur?: ?(event: BlurEvent) => mixed, + onFocus?: ?(event: FocusEvent) => mixed, +|}>; + +type DirectEventProps = $ReadOnly<{| + /** + * When `accessible` is true, the system will try to invoke this function + * when the user performs an accessibility custom action. + * + */ + onAccessibilityAction?: ?(event: AccessibilityActionEvent) => mixed, + + /** + * When `accessible` is true, the system will try to invoke this function + * when the user performs accessibility tap gesture. + * + * See https://reactnative.dev/docs/view.html#onaccessibilitytap + */ + onAccessibilityTap?: ?() => mixed, + + /** + * Invoked on mount and layout changes with: + * + * `{nativeEvent: { layout: {x, y, width, height}}}` + * + * This event is fired immediately once the layout has been calculated, but + * the new layout may not yet be reflected on the screen at the time the + * event is received, especially if a layout animation is in progress. + * + * See https://reactnative.dev/docs/view.html#onlayout + */ + onLayout?: ?(event: LayoutEvent) => mixed, + + /** + * When `accessible` is `true`, the system will invoke this function when the + * user performs the magic tap gesture. + * + * See https://reactnative.dev/docs/view.html#onmagictap + */ + onMagicTap?: ?() => mixed, + + /** + * When `accessible` is `true`, the system will invoke this function when the + * user performs the escape gesture. + * + * See https://reactnative.dev/docs/view.html#onaccessibilityescape + */ + onAccessibilityEscape?: ?() => mixed, +|}>; + +type MouseEventProps = $ReadOnly<{| + onMouseEnter?: (event: MouseEvent) => void, + onMouseLeave?: (event: MouseEvent) => void, +|}>; + +type TouchEventProps = $ReadOnly<{| + onTouchCancel?: ?(e: PressEvent) => void, + onTouchCancelCapture?: ?(e: PressEvent) => void, + onTouchEnd?: ?(e: PressEvent) => void, + onTouchEndCapture?: ?(e: PressEvent) => void, + onTouchMove?: ?(e: PressEvent) => void, + onTouchMoveCapture?: ?(e: PressEvent) => void, + onTouchStart?: ?(e: PressEvent) => void, + onTouchStartCapture?: ?(e: PressEvent) => void, +|}>; + +/** + * For most touch interactions, you'll simply want to wrap your component in + * `TouchableHighlight` or `TouchableOpacity`. Check out `Touchable.js`, + * `ScrollResponder.js` and `ResponderEventPlugin.js` for more discussion. + */ +type GestureResponderEventProps = $ReadOnly<{| + /** + * Does this view want to "claim" touch responsiveness? This is called for + * every touch move on the `View` when it is not the responder. + * + * `View.props.onMoveShouldSetResponder: (event) => [true | false]`, where + * `event` is a synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onmoveshouldsetresponder + */ + onMoveShouldSetResponder?: ?(e: PressEvent) => boolean, + + /** + * If a parent `View` wants to prevent a child `View` from becoming responder + * on a move, it should have this handler which returns `true`. + * + * `View.props.onMoveShouldSetResponderCapture: (event) => [true | false]`, + * where `event` is a synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onMoveShouldsetrespondercapture + */ + onMoveShouldSetResponderCapture?: ?(e: PressEvent) => boolean, + + /** + * The View is now responding for touch events. This is the time to highlight + * and show the user what is happening. + * + * `View.props.onResponderGrant: (event) => {}`, where `event` is a synthetic + * touch event as described above. + * + * PanResponder includes a note `// TODO: t7467124 investigate if this can be removed` that + * should help fixing this return type. + * + * See https://reactnative.dev/docs/view.html#onrespondergrant + */ + onResponderGrant?: ?(e: PressEvent) => void | boolean, + + /** + * The user is moving their finger. + * + * `View.props.onResponderMove: (event) => {}`, where `event` is a synthetic + * touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onrespondermove + */ + onResponderMove?: ?(e: PressEvent) => void, + + /** + * Another responder is already active and will not release it to that `View` + * asking to be the responder. + * + * `View.props.onResponderReject: (event) => {}`, where `event` is a + * synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onresponderreject + */ + onResponderReject?: ?(e: PressEvent) => void, + + /** + * Fired at the end of the touch. + * + * `View.props.onResponderRelease: (event) => {}`, where `event` is a + * synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onresponderrelease + */ + onResponderRelease?: ?(e: PressEvent) => void, + + onResponderStart?: ?(e: PressEvent) => void, + onResponderEnd?: ?(e: PressEvent) => void, + + /** + * The responder has been taken from the `View`. Might be taken by other + * views after a call to `onResponderTerminationRequest`, or might be taken + * by the OS without asking (e.g., happens with control center/ notification + * center on iOS) + * + * `View.props.onResponderTerminate: (event) => {}`, where `event` is a + * synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onresponderterminate + */ + onResponderTerminate?: ?(e: PressEvent) => void, + + /** + * Some other `View` wants to become responder and is asking this `View` to + * release its responder. Returning `true` allows its release. + * + * `View.props.onResponderTerminationRequest: (event) => {}`, where `event` + * is a synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onresponderterminationrequest + */ + onResponderTerminationRequest?: ?(e: PressEvent) => boolean, + + /** + * Does this view want to become responder on the start of a touch? + * + * `View.props.onStartShouldSetResponder: (event) => [true | false]`, where + * `event` is a synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onstartshouldsetresponder + */ + onStartShouldSetResponder?: ?(e: PressEvent) => boolean, + + /** + * If a parent `View` wants to prevent a child `View` from becoming responder + * on a touch start, it should have this handler which returns `true`. + * + * `View.props.onStartShouldSetResponderCapture: (event) => [true | false]`, + * where `event` is a synthetic touch event as described above. + * + * See https://reactnative.dev/docs/view.html#onstartshouldsetrespondercapture + */ + onStartShouldSetResponderCapture?: ?(e: PressEvent) => boolean, +|}>; + +type AndroidDrawableThemeAttr = $ReadOnly<{| + type: 'ThemeAttrAndroid', + attribute: string, +|}>; + +type AndroidDrawableRipple = $ReadOnly<{| + type: 'RippleAndroid', + color?: ?number, + borderless?: ?boolean, + rippleRadius?: ?number, +|}>; + +type AndroidDrawable = AndroidDrawableThemeAttr | AndroidDrawableRipple; + +type AndroidViewProps = $ReadOnly<{| + nativeBackgroundAndroid?: ?AndroidDrawable, + nativeForegroundAndroid?: ?AndroidDrawable, + + /** + * Whether this `View` should render itself (and all of its children) into a + * single hardware texture on the GPU. + * + * @platform android + * + * See https://reactnative.dev/docs/view.html#rendertohardwaretextureandroid + */ + renderToHardwareTextureAndroid?: ?boolean, + + /** + * Whether this `View` needs to rendered offscreen and composited with an + * alpha in order to preserve 100% correct colors and blending behavior. + * + * @platform android + * + * See https://reactnative.dev/docs/view.html#needsoffscreenalphacompositing + */ + needsOffscreenAlphaCompositing?: ?boolean, + + /** + * Indicates to accessibility services whether the user should be notified + * when this view changes. Works for Android API >= 19 only. + * + * @platform android + * + * See https://reactnative.dev/docs/view.html#accessibilityliveregion + */ + accessibilityLiveRegion?: ?('none' | 'polite' | 'assertive'), + + /** + * Controls how view is important for accessibility which is if it + * fires accessibility events and if it is reported to accessibility services + * that query the screen. Works for Android only. + * + * @platform android + * + * See https://reactnative.dev/docs/view.html#importantforaccessibility + */ + importantForAccessibility?: ?('auto' | 'yes' | 'no' | 'no-hide-descendants'), + + /** + * Whether to force the Android TV focus engine to move focus to this view. + * + * @platform android + */ + hasTVPreferredFocus?: ?boolean, + + /** + * TV next focus down (see documentation for the View component). + * + * @platform android + */ + nextFocusDown?: ?number, + + /** + * TV next focus forward (see documentation for the View component). + * + * @platform android + */ + nextFocusForward?: ?number, + + /** + * TV next focus left (see documentation for the View component). + * + * @platform android + */ + nextFocusLeft?: ?number, + + /** + * TV next focus right (see documentation for the View component). + * + * @platform android + */ + nextFocusRight?: ?number, + + /** + * TV next focus up (see documentation for the View component). + * + * @platform android + */ + nextFocusUp?: ?number, + + /** + * Whether this `View` should be focusable with a non-touch input device, eg. receive focus with a hardware keyboard. + * + * @platform android + */ + focusable?: boolean, + + /** + * The action to perform when this `View` is clicked on by a non-touch click, eg. enter key on a hardware keyboard. + * + * @platform android + */ + onClick?: ?(event: PressEvent) => mixed, +|}>; + +type IOSViewProps = $ReadOnly<{| + /** + * Prevents view from being inverted if set to true and color inversion is turned on. + * + * @platform ios + */ + accessibilityIgnoresInvertColors?: ?boolean, + + /** + * A value indicating whether VoiceOver should ignore the elements + * within views that are siblings of the receiver. + * Default is `false`. + * + * @platform ios + * + * See https://reactnative.dev/docs/view.html#accessibilityviewismodal + */ + accessibilityViewIsModal?: ?boolean, + + /** + * A value indicating whether the accessibility elements contained within + * this accessibility element are hidden. + * + * @platform ios + * + * See https://reactnative.dev/docs/view.html#accessibilityElementsHidden + */ + accessibilityElementsHidden?: ?boolean, + + /** + * Whether this `View` should be rendered as a bitmap before compositing. + * + * @platform ios + * + * See https://reactnative.dev/docs/view.html#shouldrasterizeios + */ + shouldRasterizeIOS?: ?boolean, +|}>; + +// [Windows + +type HandledKeyboardEvent = $ReadOnly<{| + altKey?: ?boolean, + ctrlKey?: ?boolean, + metaKey?: ?boolean, + shiftKey?: ?boolean, + code: string, + handledEventPhase?: number, +|}>; + +type WindowsViewProps = $ReadOnly<{| + /** + * Key up event + * + * @platform windows + */ + onKeyUp?: ?(e: KeyEvent) => void, + keyUpEvents?: ?$ReadOnlyArray, + + onKeyDown?: ?(e: KeyEvent) => void, + keyDownEvents?: ?$ReadOnlyArray, + /** + * Specifies the Tooltip for the view + * @platform windows + */ + tooltip?: ?string, + + tabIndex?: ?number, + + accessibilityPosInSet?: ?number, + accessibilitySetSize?: ?number, + + /** + * Specifies if the control should show System focus visuals + */ + enableFocusRing?: ?boolean, + + onFocus?: ?(event: FocusEvent) => mixed, + onBlur?: ?(event: FocusEvent) => mixed, + onMouseLeave?: ?(event: MouseEvent) => mixed, + onMouseEnter?: ?(event: MouseEvent) => mixed, +|}>; +// Windows] + +export type ViewProps = $ReadOnly<{| + ...BubblingEventProps, + ...DirectEventProps, + ...GestureResponderEventProps, + ...MouseEventProps, + ...TouchEventProps, + ...AndroidViewProps, + ...IOSViewProps, + ...WindowsViewProps, // [Windows] + + children?: Node, + style?: ?ViewStyleProp, + + /** + * When `true`, indicates that the view is an accessibility element. + * By default, all the touchable elements are accessible. + * + * See https://reactnative.dev/docs/view.html#accessible + */ + accessible?: ?boolean, + + /** + * Overrides the text that's read by the screen reader when the user interacts + * with the element. By default, the label is constructed by traversing all + * the children and accumulating all the `Text` nodes separated by space. + * + * See https://reactnative.dev/docs/view.html#accessibilitylabel + */ + accessibilityLabel?: ?Stringish, + + /** + * An accessibility hint helps users understand what will happen when they perform + * an action on the accessibility element when that result is not obvious from the + * accessibility label. + * + * + * See https://reactnative.dev/docs/view.html#accessibilityHint + */ + accessibilityHint?: ?Stringish, + + /** + * Indicates to accessibility services to treat UI component like a specific role. + */ + accessibilityRole?: ?AccessibilityRole, + + /** + * Indicates to accessibility services that UI Component is in a specific State. + */ + accessibilityState?: ?AccessibilityState, + accessibilityValue?: ?AccessibilityValue, + + /** + * Provides an array of custom actions available for accessibility. + * + */ + accessibilityActions?: ?$ReadOnlyArray, + + /** + * Views that are only used to layout their children or otherwise don't draw + * anything may be automatically removed from the native hierarchy as an + * optimization. Set this property to `false` to disable this optimization and + * ensure that this `View` exists in the native view hierarchy. + * + * @platform android + * In Fabric, this prop is used in ios as well. + * + * See https://reactnative.dev/docs/view.html#collapsable + */ + collapsable?: ?boolean, + + /** + * Used to locate this view in end-to-end tests. + * + * > This disables the 'layout-only view removal' optimization for this view! + * + * See https://reactnative.dev/docs/view.html#testid + */ + testID?: ?string, + + /** + * Used to locate this view from native classes. + * + * > This disables the 'layout-only view removal' optimization for this view! + * + * See https://reactnative.dev/docs/view.html#nativeid + */ + nativeID?: ?string, + + /** + * This defines how far a touch event can start away from the view. + * Typical interface guidelines recommend touch targets that are at least + * 30 - 40 points/density-independent pixels. + * + * > The touch area never extends past the parent view bounds and the Z-index + * > of sibling views always takes precedence if a touch hits two overlapping + * > views. + * + * See https://reactnative.dev/docs/view.html#hitslop + */ + hitSlop?: ?EdgeInsetsProp, + + /** + * Controls whether the `View` can be the target of touch events. + * + * See https://reactnative.dev/docs/view.html#pointerevents + */ + pointerEvents?: ?('auto' | 'box-none' | 'box-only' | 'none'), + + /** + * This is a special performance property exposed by `RCTView` and is useful + * for scrolling content when there are many subviews, most of which are + * offscreen. For this property to be effective, it must be applied to a + * view that contains many subviews that extend outside its bound. The + * subviews must also have `overflow: hidden`, as should the containing view + * (or one of its superviews). + * + * See https://reactnative.dev/docs/view.html#removeclippedsubviews + */ + removeClippedSubviews?: ?boolean, +|}>; diff --git a/packages/react-native-win32/src/Libraries/Pressability/HoverState.win32.js b/packages/react-native-win32/src/Libraries/Pressability/HoverState.win32.js new file mode 100644 index 00000000000..c2543820928 --- /dev/null +++ b/packages/react-native-win32/src/Libraries/Pressability/HoverState.win32.js @@ -0,0 +1,60 @@ +/** + * 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 + * @format + */ + +import Platform from '../Utilities/Platform'; + +let isEnabled = false; + +if (Platform.OS === 'web') { + const canUseDOM = Boolean( + typeof window !== 'undefined' && + window.document && + window.document.createElement, + ); + + if (canUseDOM) { + /** + * Web browsers emulate mouse events (and hover states) after touch events. + * This code infers when the currently-in-use modality supports hover + * (including for multi-modality devices) and considers "hover" to be enabled + * if a mouse movement occurs more than 1 second after the last touch event. + * This threshold is long enough to account for longer delays between the + * browser firing touch and mouse events on low-powered devices. + */ + const HOVER_THRESHOLD_MS = 1000; + let lastTouchTimestamp = 0; + + const enableHover = () => { + if (isEnabled || Date.now() - lastTouchTimestamp < HOVER_THRESHOLD_MS) { + return; + } + isEnabled = true; + }; + + const disableHover = () => { + lastTouchTimestamp = Date.now(); + if (isEnabled) { + isEnabled = false; + } + }; + + document.addEventListener('touchstart', disableHover, true); + document.addEventListener('touchmove', disableHover, true); + document.addEventListener('mousemove', enableHover, true); + } + // [Windows +} else if (Platform.OS === 'windows' || Platform.OS === 'win32') { + isEnabled = true; + // Windows] +} + +export function isHoverEnabled(): boolean { + return isEnabled; +} diff --git a/packages/react-native-win32/src/Libraries/Pressability/Pressability.win32.js b/packages/react-native-win32/src/Libraries/Pressability/Pressability.win32.js new file mode 100644 index 00000000000..3e362e9fa19 --- /dev/null +++ b/packages/react-native-win32/src/Libraries/Pressability/Pressability.win32.js @@ -0,0 +1,944 @@ +/** + * 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 {isHoverEnabled} from './HoverState'; +import invariant from 'invariant'; +import SoundManager from '../Components/Sound/SoundManager'; +import {normalizeRect, type RectOrSize} from '../StyleSheet/Rect'; +import type { + BlurEvent, + FocusEvent, + PressEvent, + MouseEvent, + KeyEvent, // [Windows] +} from '../Types/CoreEventTypes'; +import Platform from '../Utilities/Platform'; +import UIManager from '../ReactNative/UIManager'; +import type {HostComponent} from '../Renderer/shims/ReactNativeTypes'; +import * as React from 'react'; + +export type PressabilityConfig = $ReadOnly<{| + /** + * Whether a press gesture can be interrupted by a parent gesture such as a + * scroll event. Defaults to true. + */ + cancelable?: ?boolean, + + /** + * Whether to disable initialization of the press gesture. + */ + disabled?: ?boolean, + + /** + * Amount to extend the `VisualRect` by to create `HitRect`. + */ + hitSlop?: ?RectOrSize, + + /** + * Amount to extend the `HitRect` by to create `PressRect`. + */ + pressRectOffset?: ?RectOrSize, + + /** + * Whether to disable the systemm sound when `onPress` fires on Android. + **/ + android_disableSound?: ?boolean, + + /** + * Duration to wait after hover in before calling `onHoverIn`. + */ + delayHoverIn?: ?number, + + /** + * Duration to wait after hover out before calling `onHoverOut`. + */ + delayHoverOut?: ?number, + + /** + * Duration (in addition to `delayPressIn`) 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 `onPressIn`. + */ + delayPressIn?: ?number, + + /** + * Duration to wait after letting up before calling `onPressOut`. + */ + delayPressOut?: ?number, + + /** + * Minimum duration to wait between calling `onPressIn` and `onPressOut`. + */ + minPressDuration?: ?number, + + /** + * Called after the element loses focus. + */ + onBlur?: ?(event: BlurEvent) => mixed, + + /** + * Called after the element is focused. + */ + onFocus?: ?(event: FocusEvent) => mixed, + + /** + * Called when the hover is activated to provide visual feedback. + */ + onHoverIn?: ?(event: MouseEvent) => mixed, + + /** + * Called when the hover is deactivated to undo visual feedback. + */ + onHoverOut?: ?(event: MouseEvent) => mixed, + + /** + * Called when a long press gesture has been triggered. + */ + onLongPress?: ?(event: PressEvent) => mixed, + + /** + * Called when a press gestute has been triggered. + */ + onPress?: ?(event: PressEvent) => mixed, + + /** + * Called when the press is activated to provide visual feedback. + */ + onPressIn?: ?(event: PressEvent) => mixed, + + /** + * Called when the press location moves. (This should rarely be used.) + */ + onPressMove?: ?(event: PressEvent) => mixed, + + /** + * Called when the press is deactivated to undo visual feedback. + */ + onPressOut?: ?(event: PressEvent) => mixed, + + /** + * Returns whether a long press gesture should cancel the press gesture. + * Defaults to true. + */ + onLongPressShouldCancelPress_DEPRECATED?: ?() => boolean, + + /** + * If `cancelable` is set, this will be ignored. + * + * Returns whether to yield to a lock termination request (e.g. if a native + * scroll gesture attempts to steal the responder lock). + */ + onResponderTerminationRequest_DEPRECATED?: ?() => boolean, + + /** + * If `disabled` is set, this will be ignored. + * + * Returns whether to start a press gesture. + * + * @deprecated + */ + onStartShouldSetResponder_DEPRECATED?: ?() => boolean, + + // [Windows + /** + * Raw handler for onMouseEnter that will be preferred if set over hover + * events. This is to preserve compatibility with pre-0.62 behavior which + * allowed attaching mouse event handlers to Touchables + */ + onMouseEnter?: ?(event: MouseEvent) => mixed, + + /** + * Raw handler for onMouseLeave that will be preferred if set over hover + * events. This is to preserve compatibility with pre-0.62 behavior which + * allowed attaching mouse event handlers to Touchables + */ + onMouseLeave?: ?(event: MouseEvent) => mixed, + // Windows] +|}>; + +export type EventHandlers = $ReadOnly<{| + onBlur: (event: BlurEvent) => void, + onClick: (event: PressEvent) => void, + onFocus: (event: FocusEvent) => void, + onMouseEnter?: (event: MouseEvent) => void, + onMouseLeave?: (event: MouseEvent) => void, + onResponderGrant: (event: PressEvent) => void, + onResponderMove: (event: PressEvent) => void, + onResponderRelease: (event: PressEvent) => void, + onResponderTerminate: (event: PressEvent) => void, + onResponderTerminationRequest: () => boolean, + onStartShouldSetResponder: () => boolean, + // [Windows + onKeyUp: (event: KeyEvent) => void, + onKeyDown: (event: KeyEvent) => void, + // Windows] +|}>; + +type TouchState = + | 'NOT_RESPONDER' + | 'RESPONDER_INACTIVE_PRESS_IN' + | 'RESPONDER_INACTIVE_PRESS_OUT' + | 'RESPONDER_ACTIVE_PRESS_IN' + | 'RESPONDER_ACTIVE_PRESS_OUT' + | 'RESPONDER_ACTIVE_LONG_PRESS_IN' + | 'RESPONDER_ACTIVE_LONG_PRESS_OUT' + | 'ERROR'; + +type TouchSignal = + | 'DELAY' + | 'RESPONDER_GRANT' + | 'RESPONDER_RELEASE' + | 'RESPONDER_TERMINATED' + | 'ENTER_PRESS_RECT' + | 'LEAVE_PRESS_RECT' + | 'LONG_PRESS_DETECTED'; + +const Transitions = Object.freeze({ + NOT_RESPONDER: { + DELAY: 'ERROR', + RESPONDER_GRANT: 'RESPONDER_INACTIVE_PRESS_IN', + RESPONDER_RELEASE: 'ERROR', + RESPONDER_TERMINATED: 'ERROR', + ENTER_PRESS_RECT: 'ERROR', + LEAVE_PRESS_RECT: 'ERROR', + LONG_PRESS_DETECTED: 'ERROR', + }, + RESPONDER_INACTIVE_PRESS_IN: { + DELAY: 'RESPONDER_ACTIVE_PRESS_IN', + RESPONDER_GRANT: 'ERROR', + RESPONDER_RELEASE: 'NOT_RESPONDER', + RESPONDER_TERMINATED: 'NOT_RESPONDER', + ENTER_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_IN', + LEAVE_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_OUT', + LONG_PRESS_DETECTED: 'ERROR', + }, + RESPONDER_INACTIVE_PRESS_OUT: { + DELAY: 'RESPONDER_ACTIVE_PRESS_OUT', + RESPONDER_GRANT: 'ERROR', + RESPONDER_RELEASE: 'NOT_RESPONDER', + RESPONDER_TERMINATED: 'NOT_RESPONDER', + ENTER_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_IN', + LEAVE_PRESS_RECT: 'RESPONDER_INACTIVE_PRESS_OUT', + LONG_PRESS_DETECTED: 'ERROR', + }, + RESPONDER_ACTIVE_PRESS_IN: { + DELAY: 'ERROR', + RESPONDER_GRANT: 'ERROR', + RESPONDER_RELEASE: 'NOT_RESPONDER', + RESPONDER_TERMINATED: 'NOT_RESPONDER', + ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_IN', + LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_OUT', + LONG_PRESS_DETECTED: 'RESPONDER_ACTIVE_LONG_PRESS_IN', + }, + RESPONDER_ACTIVE_PRESS_OUT: { + DELAY: 'ERROR', + RESPONDER_GRANT: 'ERROR', + RESPONDER_RELEASE: 'NOT_RESPONDER', + RESPONDER_TERMINATED: 'NOT_RESPONDER', + ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_IN', + LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_PRESS_OUT', + LONG_PRESS_DETECTED: 'ERROR', + }, + RESPONDER_ACTIVE_LONG_PRESS_IN: { + DELAY: 'ERROR', + RESPONDER_GRANT: 'ERROR', + RESPONDER_RELEASE: 'NOT_RESPONDER', + RESPONDER_TERMINATED: 'NOT_RESPONDER', + ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_IN', + LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_OUT', + LONG_PRESS_DETECTED: 'RESPONDER_ACTIVE_LONG_PRESS_IN', + }, + RESPONDER_ACTIVE_LONG_PRESS_OUT: { + DELAY: 'ERROR', + RESPONDER_GRANT: 'ERROR', + RESPONDER_RELEASE: 'NOT_RESPONDER', + RESPONDER_TERMINATED: 'NOT_RESPONDER', + ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_IN', + LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_LONG_PRESS_OUT', + LONG_PRESS_DETECTED: 'ERROR', + }, + ERROR: { + DELAY: 'NOT_RESPONDER', + RESPONDER_GRANT: 'RESPONDER_INACTIVE_PRESS_IN', + RESPONDER_RELEASE: 'NOT_RESPONDER', + RESPONDER_TERMINATED: 'NOT_RESPONDER', + ENTER_PRESS_RECT: 'NOT_RESPONDER', + LEAVE_PRESS_RECT: 'NOT_RESPONDER', + LONG_PRESS_DETECTED: 'NOT_RESPONDER', + }, +}); + +const isActiveSignal = signal => + signal === 'RESPONDER_ACTIVE_PRESS_IN' || + signal === 'RESPONDER_ACTIVE_LONG_PRESS_IN'; + +const isActivationSignal = signal => + signal === 'RESPONDER_ACTIVE_PRESS_OUT' || + signal === 'RESPONDER_ACTIVE_PRESS_IN'; + +const isPressInSignal = signal => + signal === 'RESPONDER_INACTIVE_PRESS_IN' || + signal === 'RESPONDER_ACTIVE_PRESS_IN' || + signal === 'RESPONDER_ACTIVE_LONG_PRESS_IN'; + +const isTerminalSignal = signal => + signal === 'RESPONDER_TERMINATED' || signal === 'RESPONDER_RELEASE'; + +const DEFAULT_LONG_PRESS_DELAY_MS = 500; +const DEFAULT_PRESS_RECT_OFFSETS = { + bottom: 30, + left: 20, + right: 20, + top: 20, +}; +const DEFAULT_MIN_PRESS_DURATION = 130; + +/** + * Pressability implements press handling capabilities. + * + * =========================== Pressability Tutorial =========================== + * + * The `Pressability` 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 `Pressability`, do the following: + * + * 1. Instantiate `Pressability` and store it on your component's state. + * + * state = { + * pressability: new Pressability({ + * // ... + * }), + * }; + * + * 2. Choose the rendered component who should collect the press events. On that + * element, spread `pressability.getEventHandlers()` into its props. + * + * return ( + * + * ); + * + * 3. Reset `Pressability` when your component unmounts. + * + * componentWillUnmount() { + * this.state.pressability.reset(); + * } + * + * ==================== Pressability Implementation Details ==================== + * + * `Pressability` 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`, which + * │ │ ┌────────────┐ │ │ is expanded via the prop `hitSlop`. + * │ │ │ VisualRect │ │ │ + * │ │ └────────────┘ │ │ - When pressed down for sufficient amount of time + * │ │ HitRect │ │ before letting up, `VisualRect` activates for + * │ └──────────────────┘ │ as long as the press stays within `PressRect`. + * │ PressRect o │ + * └────────────────────│───┘ + * Out Region └────── `PressRect`, which is expanded via the prop + * `pressRectOffset`, allows presses to move + * beyond `HitRect` while maintaining activation + * and being 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_IN ├────────▶ │ PRESS_IN ├────────────▶ │ LONG_PRESS_IN │ + * └─┬───────────────────┘ └─┬─────────────────┘ └─┬─────────────────┘ + * │ ▲ │ ▲ │ ▲ + * │LEAVE_ │ │LEAVE_ │ │LEAVE_ │ + * │PRESS_RECT │ENTER_ │PRESS_RECT │ENTER_ │PRESS_RECT │ENTER_ + * │ │PRESS_RECT │ │PRESS_RECT │ │PRESS_RECT + * ▼ │ ▼ │ ▼ │ + * ┌─────────────┴───────┐ ┌─────────────┴─────┐ ┌─────────────┴─────┐ + * │ RESPONDER_INACTIVE_ │ DELAY │ RESPONDER_ACTIVE_ │ │ RESPONDER_ACTIVE_ │ + * │ PRESS_OUT ├────────▶ │ PRESS_OUT │ │ LONG_PRESS_OUT │ + * └─────────────────────┘ └───────────────────┘ └───────────────────┘ + * + * T + DELAY => LONG_PRESS_DELAY + DELAY + * + * Not drawn are the side effects of each transition. The most important side + * effect is the invocation of `onPress` and `onLongPress` that occur when a + * responder is release while in the "press in" states. + */ +export default class Pressability { + _config: PressabilityConfig; + _eventHandlers: ?EventHandlers = null; + _hoverInDelayTimeout: ?TimeoutID = null; + _hoverOutDelayTimeout: ?TimeoutID = null; + _isHovered: boolean = false; + _longPressDelayTimeout: ?TimeoutID = null; + _pressDelayTimeout: ?TimeoutID = null; + _pressOutDelayTimeout: ?TimeoutID = null; + _responderID: ?number | React.ElementRef> = null; + _responderRegion: ?$ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}> = null; + _touchActivatePosition: ?$ReadOnly<{| + pageX: number, + pageY: number, + |}>; + _touchActivateTime: ?number; + _touchState: TouchState = 'NOT_RESPONDER'; + + constructor(config: PressabilityConfig) { + this.configure(config); + } + + configure(config: PressabilityConfig): void { + this._config = config; + } + + /** + * Resets any pending timers. This should be called on unmount. + */ + reset(): void { + this._cancelHoverInDelayTimeout(); + this._cancelHoverOutDelayTimeout(); + this._cancelLongPressDelayTimeout(); + this._cancelPressDelayTimeout(); + this._cancelPressOutDelayTimeout(); + + // Ensure that, if any async event handlers are fired after unmount + // due to a race, we don't call any configured callbacks. + this._config = Object.freeze({}); + } + + /** + * 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 focusEventHandlers = { + onBlur: (event: BlurEvent): void => { + const {onBlur} = this._config; + if (onBlur != null) { + onBlur(event); + } + }, + onFocus: (event: FocusEvent): void => { + const {onFocus} = this._config; + if (onFocus != null) { + onFocus(event); + } + }, + }; + + const responderEventHandlers = { + onStartShouldSetResponder: (): boolean => { + const {disabled} = this._config; + if (disabled == null) { + const {onStartShouldSetResponder_DEPRECATED} = this._config; + return onStartShouldSetResponder_DEPRECATED == null + ? true + : onStartShouldSetResponder_DEPRECATED(); + } + return !disabled; + }, + + onResponderGrant: (event: PressEvent): void => { + event.persist(); + + this._cancelPressOutDelayTimeout(); + + this._responderID = event.currentTarget; + this._touchState = 'NOT_RESPONDER'; + this._receiveSignal('RESPONDER_GRANT', event); + + const delayPressIn = normalizeDelay(this._config.delayPressIn); + if (delayPressIn > 0) { + this._pressDelayTimeout = setTimeout(() => { + this._receiveSignal('DELAY', event); + }, delayPressIn); + } else { + this._receiveSignal('DELAY', event); + } + + const delayLongPress = normalizeDelay( + this._config.delayLongPress, + 10, + DEFAULT_LONG_PRESS_DELAY_MS - delayPressIn, + ); + this._longPressDelayTimeout = setTimeout(() => { + this._handleLongPress(event); + }, delayLongPress + delayPressIn); + }, + + onResponderMove: (event: PressEvent): void => { + if (this._config.onPressMove != null) { + this._config.onPressMove(event); + } + + // Region may not have finished being measured, yet. + const responderRegion = this._responderRegion; + if (responderRegion == null) { + return; + } + + const touch = getTouchFromPressEvent(event); + if (touch == null) { + this._cancelLongPressDelayTimeout(); + this._receiveSignal('LEAVE_PRESS_RECT', event); + return; + } + + 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(); + } + } + + if (this._isTouchWithinResponderRegion(touch, responderRegion)) { + this._receiveSignal('ENTER_PRESS_RECT', event); + } else { + this._cancelLongPressDelayTimeout(); + this._receiveSignal('LEAVE_PRESS_RECT', event); + } + }, + + onResponderRelease: (event: PressEvent): void => { + this._receiveSignal('RESPONDER_RELEASE', event); + }, + + onResponderTerminate: (event: PressEvent): void => { + this._receiveSignal('RESPONDER_TERMINATED', event); + }, + + onResponderTerminationRequest: (): boolean => { + const {cancelable} = this._config; + if (cancelable == null) { + const {onResponderTerminationRequest_DEPRECATED} = this._config; + return onResponderTerminationRequest_DEPRECATED == null + ? true + : onResponderTerminationRequest_DEPRECATED(); + } + return cancelable; + }, + + onClick: (event: PressEvent): void => { + const {onPress} = this._config; + if (onPress != null) { + onPress(event); + } + }, + }; + + if (process.env.NODE_ENV === 'test') { + // We are setting this in order to find this node in ReactNativeTestTools + responderEventHandlers.onStartShouldSetResponder.testOnly_pressabilityConfig = () => + this._config; + } + + const mouseEventHandlers = + Platform.OS === 'ios' || Platform.OS === 'android' + ? null + : { + onMouseEnter: (event: MouseEvent): void => { + // [Windows Add attached raw mouse event handler for compat + if (this._config.onMouseEnter) { + this._config.onMouseEnter(event); + } + // Windows] + + if (isHoverEnabled()) { + this._isHovered = true; + this._cancelHoverOutDelayTimeout(); + const {onHoverIn} = this._config; + if (onHoverIn != null) { + const delayHoverIn = normalizeDelay( + this._config.delayHoverIn, + ); + if (delayHoverIn > 0) { + event.persist(); + this._hoverInDelayTimeout = setTimeout(() => { + onHoverIn(event); + }, delayHoverIn); + } else { + onHoverIn(event); + } + } + } + }, + + onMouseLeave: (event: MouseEvent): void => { + // [Windows Add attached raw mouse event handler for compat + if (this._config.onMouseLeave) { + this._config.onMouseLeave(event); + } + // Windows] + + if (this._isHovered) { + this._isHovered = false; + this._cancelHoverInDelayTimeout(); + const {onHoverOut} = this._config; + if (onHoverOut != null) { + const delayHoverOut = normalizeDelay( + this._config.delayHoverOut, + ); + if (delayHoverOut > 0) { + event.persist(); + this._hoverInDelayTimeout = setTimeout(() => { + onHoverOut(event); + }, delayHoverOut); + } else { + onHoverOut(event); + } + } + } + }, + }; + + // [Windows + const keyboardEventHandlers = { + onKeyUp: (event: KeyEvent): void => { + if ( + event.nativeEvent.code === 'Space' || + event.nativeEvent.code === 'Enter' || + event.nativeEvent.code === 'GamepadA' + ) { + const {onPressOut, onPress} = this._config; + + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of passing KeyEvents instead + onPressOut && onPressOut(event); + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of passing KeyEvents instead + onPress && onPress(event); + } + }, + onKeyDown: (event: KeyEvent): void => { + if ( + event.nativeEvent.code === 'Space' || + event.nativeEvent.code === 'Enter' || + event.nativeEvent.code === 'GamepadA' + ) { + const {onPressIn} = this._config; + + // $FlowFixMe: PressEvents don't mesh with keyboarding APIs. Keep legacy behavior of passing KeyEvents instead + onPressIn && onPressIn(event); + } + }, + }; + // Windows] + + return { + ...focusEventHandlers, + ...responderEventHandlers, + ...mouseEventHandlers, + ...keyboardEventHandlers, // [Windows] + }; + } + + /** + * 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: PressEvent): void { + const prevState = this._touchState; + const nextState = Transitions[prevState]?.[signal]; + if (this._responderID == null && signal === 'RESPONDER_RELEASE') { + return; + } + invariant( + nextState != null && nextState !== 'ERROR', + 'Pressability: Invalid signal `%s` for state `%s` on responder: %s', + signal, + prevState, + typeof this._responderID === 'number' + ? 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: PressEvent, + ): void { + if (isTerminalSignal(signal)) { + this._touchActivatePosition = null; + this._cancelLongPressDelayTimeout(); + } + + const isInitialTransition = + prevState === 'NOT_RESPONDER' && + nextState === 'RESPONDER_INACTIVE_PRESS_IN'; + + const isActivationTransiton = + !isActivationSignal(prevState) && isActivationSignal(nextState); + + if (isInitialTransition || isActivationTransiton) { + this._measureResponderRegion(); + } + + if (isPressInSignal(prevState) && signal === 'LONG_PRESS_DETECTED') { + const {onLongPress} = this._config; + if (onLongPress != null) { + onLongPress(event); + } + } + + const isPrevActive = isActiveSignal(prevState); + const isNextActive = isActiveSignal(nextState); + + if (!isPrevActive && isNextActive) { + this._activate(event); + } else if (isPrevActive && !isNextActive) { + this._deactivate(event); + } + + if (isPressInSignal(prevState) && signal === 'RESPONDER_RELEASE') { + // If we never activated (due to delays), activate and deactivate now. + if (!isNextActive && !isPrevActive) { + this._activate(event); + this._deactivate(event); + } + const {onLongPress, onPress, android_disableSound} = this._config; + if (onPress != null) { + const isPressCanceledByLongPress = + onLongPress != null && + prevState === 'RESPONDER_ACTIVE_LONG_PRESS_IN' && + this._shouldLongPressCancelPress(); + if (!isPressCanceledByLongPress) { + if (Platform.OS === 'android' && android_disableSound !== true) { + SoundManager.playTouchSound(); + } + onPress(event); + } + } + } + + this._cancelPressDelayTimeout(); + } + + _activate(event: PressEvent): void { + const {onPressIn} = this._config; + const touch = getTouchFromPressEvent(event); + this._touchActivatePosition = { + pageX: touch.pageX, + pageY: touch.pageY, + }; + this._touchActivateTime = Date.now(); + if (onPressIn != null) { + onPressIn(event); + } + } + + _deactivate(event: PressEvent): void { + const {onPressOut} = this._config; + if (onPressOut != null) { + const minPressDuration = normalizeDelay( + this._config.minPressDuration, + 0, + DEFAULT_MIN_PRESS_DURATION, + ); + const pressDuration = Date.now() - (this._touchActivateTime ?? 0); + const delayPressOut = Math.max( + minPressDuration - pressDuration, + normalizeDelay(this._config.delayPressOut), + ); + if (delayPressOut > 0) { + event.persist(); + this._pressOutDelayTimeout = setTimeout(() => { + onPressOut(event); + }, delayPressOut); + } else { + onPressOut(event); + } + } + this._touchActivateTime = null; + } + + _measureResponderRegion(): void { + if (this._responderID == null) { + return; + } + + if (typeof this._responderID === 'number') { + UIManager.measure(this._responderID, this._measureCallback); + } else { + this._responderID.measure(this._measureCallback); + } + } + + _measureCallback = (left, top, width, height, pageX, pageY) => { + if (!left && !top && !width && !height && !pageX && !pageY) { + return; + } + this._responderRegion = { + bottom: pageY + height, + left: pageX, + right: pageX + width, + top: pageY, + }; + }; + + _isTouchWithinResponderRegion( + touch: $PropertyType, + responderRegion: $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}>, + ): boolean { + const hitSlop = normalizeRect(this._config.hitSlop); + const pressRectOffset = normalizeRect(this._config.pressRectOffset); + + let regionBottom = responderRegion.bottom; + let regionLeft = responderRegion.left; + let regionRight = responderRegion.right; + let regionTop = responderRegion.top; + + if (hitSlop != null) { + if (hitSlop.bottom != null) { + regionBottom += hitSlop.bottom; + } + if (hitSlop.left != null) { + regionLeft -= hitSlop.left; + } + if (hitSlop.right != null) { + regionRight += hitSlop.right; + } + if (hitSlop.top != null) { + regionTop -= hitSlop.top; + } + } + + regionBottom += + pressRectOffset?.bottom ?? DEFAULT_PRESS_RECT_OFFSETS.bottom; + regionLeft -= pressRectOffset?.left ?? DEFAULT_PRESS_RECT_OFFSETS.left; + regionRight += pressRectOffset?.right ?? DEFAULT_PRESS_RECT_OFFSETS.right; + regionTop -= pressRectOffset?.top ?? DEFAULT_PRESS_RECT_OFFSETS.top; + + return ( + touch.pageX > regionLeft && + touch.pageX < regionRight && + touch.pageY > regionTop && + touch.pageY < regionBottom + ); + } + + _handleLongPress(event: PressEvent): void { + if ( + this._touchState === 'RESPONDER_ACTIVE_PRESS_IN' || + this._touchState === 'RESPONDER_ACTIVE_LONG_PRESS_IN' + ) { + this._receiveSignal('LONG_PRESS_DETECTED', event); + } + } + + _shouldLongPressCancelPress(): boolean { + return ( + this._config.onLongPressShouldCancelPress_DEPRECATED == null || + this._config.onLongPressShouldCancelPress_DEPRECATED() + ); + } + + _cancelHoverInDelayTimeout(): void { + if (this._hoverInDelayTimeout != null) { + clearTimeout(this._hoverInDelayTimeout); + this._hoverInDelayTimeout = null; + } + } + + _cancelHoverOutDelayTimeout(): void { + if (this._hoverOutDelayTimeout != null) { + clearTimeout(this._hoverOutDelayTimeout); + this._hoverOutDelayTimeout = null; + } + } + + _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); +} + +const getTouchFromPressEvent = (event: PressEvent) => { + 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-win32/src/Libraries/Types/CoreEventTypes.win32.js b/packages/react-native-win32/src/Libraries/Types/CoreEventTypes.win32.js new file mode 100644 index 00000000000..c32577c908c --- /dev/null +++ b/packages/react-native-win32/src/Libraries/Types/CoreEventTypes.win32.js @@ -0,0 +1,193 @@ +/** + * 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 * as React from 'react'; +import type {HostComponent} from '../Renderer/shims/ReactNativeTypes'; + +export type SyntheticEvent = $ReadOnly<{| + bubbles: ?boolean, + cancelable: ?boolean, + currentTarget: number | React.ElementRef>, + defaultPrevented: ?boolean, + dispatchConfig: $ReadOnly<{| + registrationName: string, + |}>, + eventPhase: ?number, + preventDefault: () => void, + isDefaultPrevented: () => boolean, + stopPropagation: () => void, + isPropagationStopped: () => boolean, + isTrusted: ?boolean, + nativeEvent: T, + persist: () => void, + target: ?number | React.ElementRef>, + timeStamp: number, + type: ?string, +|}>; + +export type ResponderSyntheticEvent = $ReadOnly<{| + ...SyntheticEvent, + touchHistory: $ReadOnly<{| + indexOfSingleActiveTouch: number, + mostRecentTimeStamp: number, + numberActiveTouches: number, + touchBank: $ReadOnlyArray< + $ReadOnly<{| + touchActive: boolean, + startPageX: number, + startPageY: number, + startTimeStamp: number, + currentPageX: number, + currentPageY: number, + currentTimeStamp: number, + previousPageX: number, + previousPageY: number, + previousTimeStamp: number, + |}>, + >, + |}>, +|}>; + +export type Layout = $ReadOnly<{| + x: number, + y: number, + width: number, + height: number, +|}>; + +export type TextLayout = $ReadOnly<{| + ...Layout, + ascender: number, + capHeight: number, + descender: number, + text: string, + xHeight: number, +|}>; + +export type LayoutEvent = SyntheticEvent< + $ReadOnly<{| + layout: Layout, + |}>, +>; + +export type TextLayoutEvent = SyntheticEvent< + $ReadOnly<{| + lines: Array, + |}>, +>; + +export type PressEvent = ResponderSyntheticEvent< + $ReadOnly<{| + altKey: ?boolean, // TODO(macOS) + button: ?number, // TODO(macOS) + changedTouches: $ReadOnlyArray<$PropertyType>, + ctrlKey: ?boolean, // TODO(macOS) + force?: number, + identifier: number, + locationX: number, + locationY: number, + metaKey: ?boolean, // TODO(macOS) + pageX: number, + pageY: number, + shiftKey: ?boolean, // TODO(macOS) + target: ?number, + timestamp: number, + touches: $ReadOnlyArray<$PropertyType>, + |}>, +>; + +export type ScrollEvent = SyntheticEvent< + $ReadOnly<{| + contentInset: $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}>, + contentOffset: $ReadOnly<{| + y: number, + x: number, + |}>, + contentSize: $ReadOnly<{| + height: number, + width: number, + |}>, + layoutMeasurement: $ReadOnly<{| + height: number, + width: number, + |}>, + targetContentOffset?: $ReadOnly<{| + y: number, + x: number, + |}>, + velocity?: $ReadOnly<{| + y: number, + x: number, + |}>, + zoomScale?: number, + responderIgnoreScroll?: boolean, + key?: string, // TODO(macOS) + |}>, +>; + +export type BlurEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + |}>, +>; + +export type FocusEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + |}>, +>; + +// [Windows Mouse events on Windows don't match up with the version in core +// introduced for react-native-web. Replace typings with our values to catch +// anything dependent on react-native-web specific values +export type MouseEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + identifier: number, + pageX: number, + pageY: number, + locationX: number, + locationY: number, + timestamp: number, + pointerType: string, + force: number, + isLeftButton: boolean, + isRightButton: boolean, + isMiddleButton: boolean, + isBarrelButtonPressed: boolean, + isHorizontalScrollWheel: boolean, + isEraser: boolean, + shiftKey: boolean, + ctrlKey: boolean, + altKey: boolean, + |}>, +>; +// Windows] + +// [Windows +export type KeyEvent = SyntheticEvent< + $ReadOnly<{| + altKey: boolean, + ctrlKey: boolean, + metaKey: boolean, + shiftKey: boolean, + key: string, + code: string, + eventPhase: number, + |}>, +>; +// Windows] diff --git a/vnext/overrides.json b/vnext/overrides.json index eb4388321fe..4a3b08aebfa 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -347,6 +347,13 @@ "baseFile": "Libraries/NewAppScreen/components/ReloadInstructions.js", "baseHash": "39326801da6c9ce8c350aa8ba971be4a386499bc" }, + { + "type": "patch", + "file": "src/Libraries/Pressability/HoverState.js", + "baseFile": "Libraries/Pressability/HoverState.js", + "baseHash": "c78372cfc9f0b66109848beb20895e199c5431b8", + "issue": 6240 + }, { "type": "patch", "file": "src/Libraries/Pressability/Pressability.windows.js", diff --git a/vnext/src/Libraries/Components/Pressable/Pressable.windows.js b/vnext/src/Libraries/Components/Pressable/Pressable.windows.js index 9000db782ea..6d82cc93d88 100644 --- a/vnext/src/Libraries/Components/Pressable/Pressable.windows.js +++ b/vnext/src/Libraries/Components/Pressable/Pressable.windows.js @@ -29,6 +29,7 @@ import type { LayoutEvent, PressEvent, // [Windows + MouseEvent, BlurEvent, FocusEvent, // Windows] } from '../../Types/CoreEventTypes'; @@ -66,6 +67,16 @@ type Props = $ReadOnly<{| */ children: React.Node | ((state: StateCallbackType) => React.Node), + /** + * Duration to wait after hover in before calling `onHoverIn`. + */ + delayHoverIn?: ?number, + + /** + * Duration to wait after hover out before calling `onHoverOut`. + */ + delayHoverOut?: ?number, + /** * Duration (in milliseconds) from `onPressIn` before `onLongPress` is called. */ @@ -92,6 +103,16 @@ type Props = $ReadOnly<{| */ onLayout?: ?(event: LayoutEvent) => void, + /** + * Called when the hover is activated to provide visual feedback. + */ + onHoverIn?: ?(event: MouseEvent) => mixed, + + /** + * Called when the hover is deactivated to undo visual feedback. + */ + onHoverOut?: ?(event: MouseEvent) => mixed, + /** * Called when a long-tap gesture is detected. */ @@ -164,9 +185,13 @@ function Pressable(props: Props, forwardedRef): React.Node { android_disableSound, android_ripple, children, + delayHoverIn, + delayHoverOut, delayLongPress, disabled, focusable, + onHoverIn, + onHoverOut, onLongPress, onPress, onPressIn, @@ -221,8 +246,12 @@ function Pressable(props: Props, forwardedRef): React.Node { hitSlop, pressRectOffset: pressRetentionOffset, android_disableSound, + delayHoverIn, + delayHoverOut, delayLongPress, delayPressIn: unstable_pressDelay, + onHoverIn, + onHoverOut, onLongPress, onPress, onPressIn(event: PressEvent): void { @@ -252,9 +281,13 @@ function Pressable(props: Props, forwardedRef): React.Node { [ android_disableSound, android_rippleConfig, + delayHoverIn, + delayHoverOut, delayLongPress, disabled, hitSlop, + onHoverIn, + onHoverOut, onLongPress, onPress, onPressIn, diff --git a/vnext/src/Libraries/Pressability/HoverState.js b/vnext/src/Libraries/Pressability/HoverState.js new file mode 100644 index 00000000000..28e0c9fb03c --- /dev/null +++ b/vnext/src/Libraries/Pressability/HoverState.js @@ -0,0 +1,60 @@ +/** + * 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 + * @format + */ + +import Platform from '../Utilities/Platform'; + +let isEnabled = false; + +if (Platform.OS === 'web') { + const canUseDOM = Boolean( + typeof window !== 'undefined' && + window.document && + window.document.createElement, + ); + + if (canUseDOM) { + /** + * Web browsers emulate mouse events (and hover states) after touch events. + * This code infers when the currently-in-use modality supports hover + * (including for multi-modality devices) and considers "hover" to be enabled + * if a mouse movement occurs more than 1 second after the last touch event. + * This threshold is long enough to account for longer delays between the + * browser firing touch and mouse events on low-powered devices. + */ + const HOVER_THRESHOLD_MS = 1000; + let lastTouchTimestamp = 0; + + const enableHover = () => { + if (isEnabled || Date.now() - lastTouchTimestamp < HOVER_THRESHOLD_MS) { + return; + } + isEnabled = true; + }; + + const disableHover = () => { + lastTouchTimestamp = Date.now(); + if (isEnabled) { + isEnabled = false; + } + }; + + document.addEventListener('touchstart', disableHover, true); + document.addEventListener('touchmove', disableHover, true); + document.addEventListener('mousemove', enableHover, true); + } + // [Windows +} else if (Platform.OS === 'windows') { + isEnabled = true; + // Windows] +} + +export function isHoverEnabled(): boolean { + return isEnabled; +}