From 3212f7dfe82d187e27f1410c8c3cb1d9fb9f5094 Mon Sep 17 00:00:00 2001 From: Eli White Date: Thu, 20 Feb 2020 19:22:51 -0800 Subject: [PATCH] Release Pressable! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: *Pressable* is a component which is intended to replace the Touchable* components such as *TouchableWithoutFeedback* and *TouchableOpacity*. The motivation is to make it easier to create custom visual touch feedback so that React Native apps are not easily identified by the “signature opacity fade” touch feedback. We see this component as eventually deprecating all of the existing Touchable components. Changelog: [Added][General] New Component to make it easier to create touchable elements Reviewed By: yungsters Differential Revision: D19674480 fbshipit-source-id: 765d657f023caea459f02da25376e4d5a2efff8b --- Libraries/Components/Pressable/Pressable.js | 236 ++++++++++ .../Pressable/__tests__/Pressable-test.js | 34 ++ .../__snapshots__/Pressable-test.js.snap | 49 ++ .../Pressable/useAndroidRippleForView.js | 94 ++++ .../js/examples/Pressable/PressableExample.js | 426 ++++++++++++++++++ RNTester/js/utils/RNTesterList.android.js | 4 + RNTester/js/utils/RNTesterList.ios.js | 5 + index.js | 4 + 8 files changed, 852 insertions(+) create mode 100644 Libraries/Components/Pressable/Pressable.js create mode 100644 Libraries/Components/Pressable/__tests__/Pressable-test.js create mode 100644 Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap create mode 100644 Libraries/Components/Pressable/useAndroidRippleForView.js create mode 100644 RNTester/js/examples/Pressable/PressableExample.js diff --git a/Libraries/Components/Pressable/Pressable.js b/Libraries/Components/Pressable/Pressable.js new file mode 100644 index 0000000000..a4f264dd4d --- /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 0000000000..a8c5af535e --- /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 0000000000..5c82f9ab1c --- /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 0000000000..d584ffff2c --- /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 0000000000..d2155ebc48 --- /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 10fcbe09a4..d4300da1d8 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 3419e53984..31c9751754 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 a8cdf3a5dc..bc185e7f17 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',