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 <Pressable> Component to make it easier to create touchable elements

Reviewed By: yungsters

Differential Revision: D19674480

fbshipit-source-id: 765d657f023caea459f02da25376e4d5a2efff8b
This commit is contained in:
Eli White 2020-02-20 19:22:51 -08:00 коммит произвёл Facebook Github Bot
Родитель 6239ace5a3
Коммит 3212f7dfe8
8 изменённых файлов: 852 добавлений и 0 удалений

Просмотреть файл

@ -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<React.ElementConfig<typeof View>, 'style'>;
export type StateCallbackType = $ReadOnly<{|
pressed: boolean,
|}>;
type Props = $ReadOnly<{|
/**
* Accessibility.
*/
accessibilityActions?: ?$ReadOnlyArray<AccessibilityActionInfo>,
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<React.ElementRef<typeof View> | 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 (
<View
{...restProps}
{...eventHandlers}
{...android_ripple?.viewProps}
accessible={accessible !== false}
focusable={focusable !== false}
hitSlop={hitSlop}
ref={viewRef}
style={typeof style === 'function' ? style({pressed}) : style}>
{typeof children === 'function' ? children({pressed}) : children}
</View>
);
}
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<typeof View>,
>);

Просмотреть файл

@ -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('<Pressable />', () => {
it('should render as expected', () => {
expectRendersMatchingSnapshot(
'Pressable',
() => (
<Pressable>
<View />
</Pressable>
),
() => {
jest.dontMock('../Pressable');
},
);
});
});

Просмотреть файл

@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Pressable /> should render as expected: should deep render when mocked (please verify output manually) 1`] = `
<View
accessible={true}
focusable={true}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View />
</View>
`;
exports[`<Pressable /> should render as expected: should deep render when not mocked (please verify output manually) 1`] = `
<View
accessible={true}
focusable={true}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View />
</View>
`;
exports[`<Pressable /> should render as expected: should shallow render as <Pressable /> when mocked 1`] = `
<Memo(Pressable)>
<View />
</Memo(Pressable)>
`;
exports[`<Pressable /> should render as expected: should shallow render as <Pressable /> when not mocked 1`] = `
<Memo(Pressable)>
<View />
</Memo(Pressable)>
`;

Просмотреть файл

@ -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<typeof View>|},
): ?$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]);
}

Просмотреть файл

@ -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 (
<>
<View style={styles.row}>
<Pressable
onPress={() => {
setTimesPressed(current => current + 1);
}}>
{({pressed}) => (
<Text style={styles.text}>{pressed ? 'Pressed!' : 'Press Me'}</Text>
)}
</Pressable>
</View>
<View style={styles.logBox}>
<Text testID="pressable_press_console">{textLog}</Text>
</View>
</>
);
}
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 (
<>
<Text
style={styles.textBlock}
testID="tappable_text"
onPress={() => {
setTimesPressed(prev => prev + 1);
}}>
Text has built-in onPress handling
</Text>
<View style={styles.logBox}>
<Text testID="tappable_text_console">{textLog}</Text>
</View>
</>
);
}
function PressableFeedbackEvents() {
const [eventLog, setEventLog] = useState([]);
function appendEvent(eventName) {
const limit = 6;
setEventLog(current => {
return [eventName].concat(current.slice(0, limit - 1));
});
}
return (
<View testID="pressable_feedback_events">
<View style={[styles.row, styles.centered]}>
<Pressable
style={styles.wrapper}
testID="pressable_feedback_events_button"
accessibilityLabel="pressable feedback events"
accessibilityRole="button"
onPress={() => appendEvent('press')}
onPressIn={() => appendEvent('pressIn')}
onPressOut={() => appendEvent('pressOut')}
onLongPress={() => appendEvent('longPress')}>
<Text style={styles.button}>Press Me</Text>
</Pressable>
</View>
<View
testID="pressable_feedback_events_console"
style={styles.eventLogBox}>
{eventLog.map((e, ii) => (
<Text key={ii}>{e}</Text>
))}
</View>
</View>
);
}
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 (
<View testID="pressable_delay_events">
<View style={[styles.row, styles.centered]}>
<Pressable
style={styles.wrapper}
testID="pressable_delay_events_button"
onPress={() => appendEvent('press')}
onPressIn={() => appendEvent('pressIn')}
onPressOut={() => appendEvent('pressOut')}
delayLongPress={800}
onLongPress={() => appendEvent('longPress - 800ms delay')}>
<Text style={styles.button}>Press Me</Text>
</Pressable>
</View>
<View style={styles.eventLogBox} testID="pressable_delay_events_console">
{eventLog.map((e, ii) => (
<Text key={ii}>{e}</Text>
))}
</View>
</View>
);
}
function ForceTouchExample() {
const [force, setForce] = useState(0);
const consoleText = forceTouchAvailable
? 'Force: ' + force.toFixed(3)
: '3D Touch is not available on this device';
return (
<View testID="pressable_3dtouch_event">
<View style={styles.forceTouchBox} testID="pressable_3dtouch_output">
<Text>{consoleText}</Text>
</View>
<View style={[styles.row, styles.centered]}>
<View
style={styles.wrapper}
testID="pressable_3dtouch_button"
onStartShouldSetResponder={() => true}
onResponderMove={event => setForce(event.nativeEvent.force)}
onResponderRelease={event => setForce(0)}>
<Text style={styles.button}>Press Me</Text>
</View>
</View>
</View>
);
}
function PressableHitSlop() {
const [timesPressed, setTimesPressed] = useState(0);
let log = '';
if (timesPressed > 1) {
log = timesPressed + 'x onPress';
} else if (timesPressed > 0) {
log = 'onPress';
}
return (
<View testID="pressable_hit_slop">
<View style={[styles.row, styles.centered]}>
<Pressable
onPress={() => setTimesPressed(num => num + 1)}
style={styles.hitSlopWrapper}
hitSlop={{top: 30, bottom: 30, left: 60, right: 60}}
testID="pressable_hit_slop_button">
<Text style={styles.hitSlopButton}>Press Outside This View</Text>
</Pressable>
</View>
<View style={styles.logBox}>
<Text>{log}</Text>
</View>
</View>
);
}
function PressableNativeMethods() {
const [status, setStatus] = useState<?boolean>(null);
const ref = useRef(null);
useEffect(() => {
setStatus(ref.current != null && typeof ref.current.measure === 'function');
}, []);
return (
<>
<View style={[styles.row, styles.block]}>
<Pressable ref={ref}>
<View />
</Pressable>
<Text>
{status == null
? 'Missing Ref!'
: status === true
? 'Native Methods Exist'
: 'Native Methods Missing!'}
</Text>
</View>
</>
);
}
function PressableDisabled() {
return (
<>
<Pressable disabled={true} style={[styles.row, styles.block]}>
<Text style={styles.disabledButton}>Disabled Pressable</Text>
</Pressable>
<Pressable
disabled={false}
style={({pressed}) => [
{opacity: pressed ? 0.5 : 1},
styles.row,
styles.block,
]}>
<Text style={styles.button}>Enabled Pressable</Text>
</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 = '<Pressable>';
exports.examples = [
{
title: 'Change content based on Press',
render(): React.Node {
return <ContentPress />;
},
},
{
title: 'Change style based on Press',
render(): React.Node {
return (
<View style={styles.row}>
<Pressable
style={({pressed}) => [
{
backgroundColor: pressed ? 'rgb(210, 230, 255)' : 'white',
},
styles.wrapperCustom,
]}>
<Text style={styles.text}>Press Me</Text>
</Pressable>
</View>
);
},
},
{
title: 'Pressable feedback events',
description: ('<Pressable> components accept onPress, onPressIn, ' +
'onPressOut, and onLongPress as props.': string),
render: function(): React.Node {
return <PressableFeedbackEvents />;
},
},
{
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 (
<View style={styles.row}>
<Pressable android_rippleColor="green">
<Animated.View style={style} />
</Pressable>
</View>
);
},
},
{
title: '<Text onPress={fn}> with highlight',
render: function(): React.Node {
return <TextOnPressBox />;
},
},
{
title: 'Pressable delay for events',
description: ('<Pressable> also accept delayPressIn, ' +
'delayPressOut, and delayLongPress as props. These props impact the ' +
'timing of feedback events.': string),
render: function(): React.Node {
return <PressableDelayEvents />;
},
},
{
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 <ForceTouchExample />;
},
platform: 'ios',
},
{
title: 'Pressable Hit Slop',
description: ('<Pressable> components accept hitSlop prop which extends the touch area ' +
'without changing the view bounds.': string),
render: function(): React.Node {
return <PressableHitSlop />;
},
},
{
title: 'Pressable Native Methods',
description: ('<Pressable> components expose native methods like `measure`.': string),
render: function(): React.Node {
return <PressableNativeMethods />;
},
},
{
title: 'Disabled Pressable',
description: ('<Pressable> components accept disabled prop which prevents ' +
'any interaction with component': string),
render: function(): React.Node {
return <PressableDisabled />;
},
},
];

Просмотреть файл

@ -57,6 +57,10 @@ const ComponentExamples: Array<RNTesterExample> = [
key: 'PickerExample',
module: require('../examples/Picker/PickerExample'),
},
{
key: 'PressableExample',
module: require('../examples/Pressable/PressableExample'),
},
{
key: 'ProgressBarAndroidExample',
module: require('../examples/ProgressBarAndroid/ProgressBarAndroidExample'),

Просмотреть файл

@ -87,6 +87,11 @@ const ComponentExamples: Array<RNTesterExample> = [
module: require('../examples/Picker/PickerIOSExample'),
supportsTVOS: false,
},
{
key: 'PressableExample',
module: require('../examples/Pressable/PressableExample'),
supportsTVOS: true,
},
{
key: 'ProgressViewIOSExample',
module: require('../examples/ProgressViewIOS/ProgressViewIOSExample'),

Просмотреть файл

@ -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',