diff --git a/Libraries/Components/Pressable/Pressable.js b/Libraries/Components/Pressable/Pressable.js new file mode 100644 index 00000000000000..a4f264dd4d098f --- /dev/null +++ b/Libraries/Components/Pressable/Pressable.js @@ -0,0 +1,236 @@ +/** + * 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 from './useAndroidRippleForView.js'; +import type { + AccessibilityActionEvent, + AccessibilityActionInfo, + AccessibilityRole, + AccessibilityState, + AccessibilityValue, +} from '../View/ViewAccessibility.js'; +import usePressability from '../../Pressability/usePressability.js'; +import {normalizeRect, type RectOrSize} from '../../StyleSheet/Rect.js'; +import type {ColorValue} from '../../StyleSheet/StyleSheetTypes.js'; +import type {LayoutEvent, PressEvent} from '../../Types/CoreEventTypes.js'; +import View from '../View/View'; + +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 (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. + */ + pressRectOffset?: ?RectOrSize, + + /** + * Called when this view's layout changes. + */ + onLayout?: ?(event: LayoutEvent) => void, + + /** + * 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, + + /** + * 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_rippleColor?: ?ColorValue, + + /** + * 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, + android_disableSound, + android_rippleColor, + children, + delayLongPress, + disabled, + focusable, + onLongPress, + onPress, + onPressIn, + onPressOut, + pressRectOffset, + style, + testOnly_pressed, + ...restProps + } = props; + + const viewRef = useRef | null>(null); + useImperativeHandle(forwardedRef, () => viewRef.current); + + const android_ripple = useAndroidRippleForView(android_rippleColor, viewRef); + + const [pressed, setPressed] = usePressState(testOnly_pressed === true); + + const hitSlop = normalizeRect(props.hitSlop); + + const config = useMemo( + () => ({ + disabled, + hitSlop, + pressRectOffset, + android_disableSound, + delayLongPress, + onLongPress, + onPress, + onPressIn(event: PressEvent): void { + if (android_ripple != null) { + android_ripple.onPressIn(event); + } + setPressed(true); + if (onPressIn != null) { + onPressIn(event); + } + }, + onPressMove: android_ripple?.onPressMove, + onPressOut(event: PressEvent): void { + if (android_ripple != null) { + android_ripple.onPressOut(event); + } + setPressed(false); + if (onPressOut != null) { + onPressOut(event); + } + }, + }), + [ + android_disableSound, + android_ripple, + delayLongPress, + disabled, + hitSlop, + onLongPress, + onPress, + onPressIn, + onPressOut, + pressRectOffset, + setPressed, + ], + ); + const eventHandlers = usePressability(config); + + return ( + + {typeof children === 'function' ? children({pressed}) : children} + + ); +} + +function usePressState(forcePressed: boolean): [boolean, (boolean) => void] { + const [pressed, setPressed] = useState(false); + return [pressed || forcePressed, setPressed]; +} + +const MemodPressable = React.memo(React.forwardRef(Pressable)); +MemodPressable.displayName = 'Pressable'; + +export default (MemodPressable: React.AbstractComponent< + Props, + React.ElementRef, +>); diff --git a/Libraries/Components/Pressable/__tests__/Pressable-test.js b/Libraries/Components/Pressable/__tests__/Pressable-test.js new file mode 100644 index 00000000000000..a8c5af535e8518 --- /dev/null +++ b/Libraries/Components/Pressable/__tests__/Pressable-test.js @@ -0,0 +1,34 @@ +/** + * 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 + * @emails oncall+react_native + * @flow strict-local + */ + +'use strict'; + +import * as React from 'react'; + +import Pressable from '../Pressable'; +import View from '../../View/View'; +import {expectRendersMatchingSnapshot} from '../../../Utilities/ReactNativeTestTools'; + +describe('', () => { + it('should render as expected', () => { + expectRendersMatchingSnapshot( + 'Pressable', + () => ( + + + + ), + () => { + jest.dontMock('../Pressable'); + }, + ); + }); +}); diff --git a/Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap b/Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap new file mode 100644 index 00000000000000..5c82f9ab1c7e67 --- /dev/null +++ b/Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render as expected: should deep render when mocked (please verify output manually) 1`] = ` + + + +`; + +exports[` should render as expected: should deep render when not mocked (please verify output manually) 1`] = ` + + + +`; + +exports[` should render as expected: should shallow render as when mocked 1`] = ` + + + +`; + +exports[` should render as expected: should shallow render as when not mocked 1`] = ` + + + +`; diff --git a/Libraries/Components/Pressable/useAndroidRippleForView.js b/Libraries/Components/Pressable/useAndroidRippleForView.js new file mode 100644 index 00000000000000..d584ffff2c01d9 --- /dev/null +++ b/Libraries/Components/Pressable/useAndroidRippleForView.js @@ -0,0 +1,94 @@ +/** + * 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 'invariant'; +import {Commands} from '../View/ViewNativeComponent.js'; +import type {ColorValue} from '../../StyleSheet/StyleSheetTypes.js'; +import type {PressEvent} from '../../Types/CoreEventTypes.js'; +import {Platform, View, processColor} from 'react-native'; +import * as React from 'react'; +import {useMemo} from 'react'; + +type NativeBackgroundProp = $ReadOnly<{| + type: 'RippleAndroid', + color: ?number, + borderless: boolean, +|}>; + +/** + * Provides the event handlers and props for configuring the ripple effect on + * supported versions of Android. + */ +export default function useAndroidRippleForView( + rippleColor: ?ColorValue, + viewRef: {|current: null | React.ElementRef|}, +): ?$ReadOnly<{| + onPressIn: (event: PressEvent) => void, + onPressMove: (event: PressEvent) => void, + onPressOut: (event: PressEvent) => void, + viewProps: $ReadOnly<{| + nativeBackgroundAndroid: NativeBackgroundProp, + |}>, +|}> { + return useMemo(() => { + if ( + Platform.OS === 'android' && + Platform.Version >= 21 && + rippleColor != null + ) { + const processedColor = processColor(rippleColor); + invariant( + processedColor == null || typeof processedColor === 'number', + 'Unexpected color given for Ripple color', + ); + + return { + viewProps: { + // Consider supporting `nativeForegroundAndroid` and `borderless`. + nativeBackgroundAndroid: { + type: 'RippleAndroid', + color: processedColor, + borderless: false, + }, + }, + onPressIn(event: PressEvent): void { + const view = viewRef.current; + if (view != null) { + Commands.setPressed(view, true); + Commands.hotspotUpdate( + view, + event.nativeEvent.locationX ?? 0, + event.nativeEvent.locationY ?? 0, + ); + } + }, + onPressMove(event: PressEvent): void { + const view = viewRef.current; + if (view != null) { + Commands.hotspotUpdate( + view, + event.nativeEvent.locationX ?? 0, + event.nativeEvent.locationY ?? 0, + ); + } + }, + onPressOut(event: PressEvent): void { + const view = viewRef.current; + if (view != null) { + Commands.setPressed(view, false); + } + }, + }; + } + return null; + }, [rippleColor, viewRef]); +} diff --git a/RNTester/js/examples/Pressable/PressableExample.js b/RNTester/js/examples/Pressable/PressableExample.js new file mode 100644 index 00000000000000..d2155ebc48a36f --- /dev/null +++ b/RNTester/js/examples/Pressable/PressableExample.js @@ -0,0 +1,426 @@ +/** + * 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 * as React from 'react'; +import { + Animated, + Pressable, + StyleSheet, + Text, + Platform, + View, +} 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('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)} + 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 + + + ); +} + +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', + }, +}); + +exports.displayName = (undefined: ?string); +exports.description = 'Component for making views pressable.'; +exports.title = ''; +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: ' 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 ; + }, + }, +]; diff --git a/RNTester/js/utils/RNTesterList.android.js b/RNTester/js/utils/RNTesterList.android.js index 10fcbe09a4e377..d4300da1d8f379 100644 --- a/RNTester/js/utils/RNTesterList.android.js +++ b/RNTester/js/utils/RNTesterList.android.js @@ -57,6 +57,10 @@ const ComponentExamples: Array = [ key: 'PickerExample', module: require('../examples/Picker/PickerExample'), }, + { + key: 'PressableExample', + module: require('../examples/Pressable/PressableExample'), + }, { key: 'ProgressBarAndroidExample', module: require('../examples/ProgressBarAndroid/ProgressBarAndroidExample'), diff --git a/RNTester/js/utils/RNTesterList.ios.js b/RNTester/js/utils/RNTesterList.ios.js index 3419e5398423e7..31c9751754e6e7 100644 --- a/RNTester/js/utils/RNTesterList.ios.js +++ b/RNTester/js/utils/RNTesterList.ios.js @@ -87,6 +87,11 @@ const ComponentExamples: Array = [ module: require('../examples/Picker/PickerIOSExample'), supportsTVOS: false, }, + { + key: 'PressableExample', + module: require('../examples/Pressable/PressableExample'), + supportsTVOS: true, + }, { key: 'ProgressViewIOSExample', module: require('../examples/ProgressViewIOS/ProgressViewIOSExample'), diff --git a/index.js b/index.js index a8cdf3a5dc02ba..bc185e7f178152 100644 --- a/index.js +++ b/index.js @@ -25,6 +25,7 @@ import typeof MaskedViewIOS from './Libraries/Components/MaskedView/MaskedViewIO import typeof Modal from './Libraries/Modal/Modal'; import typeof Picker from './Libraries/Components/Picker/Picker'; import typeof PickerIOS from './Libraries/Components/Picker/PickerIOS'; +import typeof Pressable from './Libraries/Components/Pressable/Pressable'; import typeof ProgressBarAndroid from './Libraries/Components/ProgressBarAndroid/ProgressBarAndroid'; import typeof ProgressViewIOS from './Libraries/Components/ProgressViewIOS/ProgressViewIOS'; import typeof SafeAreaView from './Libraries/Components/SafeAreaView/SafeAreaView'; @@ -183,6 +184,9 @@ module.exports = { ); return require('./Libraries/Components/Picker/PickerIOS'); }, + get Pressable(): Pressable { + return require('./Libraries/Components/Pressable/Pressable').default; + }, get ProgressBarAndroid(): ProgressBarAndroid { warnOnce( 'progress-bar-android-moved',