417 lines
15 KiB
JavaScript
417 lines
15 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and 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
|
|
*/
|
|
|
|
import type {GestureResponderEvent} from '../../Types/CoreEventTypes';
|
|
import type {TouchableWithoutFeedbackProps} from './TouchableWithoutFeedback';
|
|
|
|
import View from '../../Components/View/View';
|
|
import Pressability, {
|
|
type PressabilityConfig,
|
|
} from '../../Pressability/Pressability';
|
|
import {PressabilityDebugView} from '../../Pressability/PressabilityDebug';
|
|
import {findHostInstance_DEPRECATED} from '../../ReactNative/RendererProxy';
|
|
import processColor from '../../StyleSheet/processColor';
|
|
import Platform from '../../Utilities/Platform';
|
|
import {Commands} from '../View/ViewNativeComponent';
|
|
import invariant from 'invariant';
|
|
import * as React from 'react';
|
|
import {cloneElement} from 'react';
|
|
|
|
type TouchableNativeFeedbackTVProps = {
|
|
/**
|
|
* *(Apple TV only)* TV preferred focus (see documentation for the View component).
|
|
*
|
|
* @platform ios
|
|
* @deprecated Use `focusable` instead
|
|
*/
|
|
hasTVPreferredFocus?: ?boolean,
|
|
|
|
/**
|
|
* Designates the next view to receive focus when the user navigates down. See the Android documentation.
|
|
*
|
|
* @platform android
|
|
*/
|
|
nextFocusDown?: ?number,
|
|
|
|
/**
|
|
* Designates the next view to receive focus when the user navigates forward. See the Android documentation.
|
|
*
|
|
* @platform android
|
|
*/
|
|
nextFocusForward?: ?number,
|
|
|
|
/**
|
|
* Designates the next view to receive focus when the user navigates left. See the Android documentation.
|
|
*
|
|
* @platform android
|
|
*/
|
|
nextFocusLeft?: ?number,
|
|
|
|
/**
|
|
* Designates the next view to receive focus when the user navigates right. See the Android documentation.
|
|
*
|
|
* @platform android
|
|
*/
|
|
nextFocusRight?: ?number,
|
|
|
|
/**
|
|
* Designates the next view to receive focus when the user navigates up. See the Android documentation.
|
|
*
|
|
* @platform android
|
|
*/
|
|
nextFocusUp?: ?number,
|
|
};
|
|
|
|
export type TouchableNativeFeedbackProps = $ReadOnly<{
|
|
...TouchableWithoutFeedbackProps,
|
|
...TouchableNativeFeedbackTVProps,
|
|
/**
|
|
* Determines the type of background drawable that's going to be used to display feedback.
|
|
* It takes an object with type property and extra data depending on the type.
|
|
* It's recommended to use one of the following static methods to generate that dictionary:
|
|
* 1) TouchableNativeFeedback.SelectableBackground() - will create object that represents android theme's
|
|
* default background for selectable elements (?android:attr/selectableItemBackground)
|
|
* 2) TouchableNativeFeedback.SelectableBackgroundBorderless() - will create object that represent android
|
|
* theme's default background for borderless selectable elements
|
|
* (?android:attr/selectableItemBackgroundBorderless). Available on android API level 21+
|
|
* 3) TouchableNativeFeedback.Ripple(color, borderless) - will create object that represents ripple drawable
|
|
* with specified color (as a string). If property borderless evaluates to true the ripple will render
|
|
* outside of the view bounds (see native actionbar buttons as an example of that behavior). This background
|
|
* type is available on Android API level 21+
|
|
*/
|
|
background?: ?(
|
|
| $ReadOnly<{
|
|
type: 'ThemeAttrAndroid',
|
|
attribute:
|
|
| 'selectableItemBackground'
|
|
| 'selectableItemBackgroundBorderless',
|
|
rippleRadius: ?number,
|
|
}>
|
|
| $ReadOnly<{
|
|
type: 'RippleAndroid',
|
|
color: ?number,
|
|
borderless: boolean,
|
|
rippleRadius: ?number,
|
|
}>
|
|
),
|
|
/**
|
|
* Set to true to add the ripple effect to the foreground of the view, instead
|
|
* of the background. This is useful if one of your child views has a
|
|
* background of its own, or you're e.g. displaying images, and you don't want
|
|
* the ripple to be covered by them.
|
|
*
|
|
* Check TouchableNativeFeedback.canUseNativeForeground() first, as this is
|
|
* only available on Android 6.0 and above. If you try to use this on older
|
|
* versions, this will fallback to background.
|
|
*/
|
|
useForeground?: ?boolean,
|
|
}>;
|
|
|
|
type TouchableNativeFeedbackState = $ReadOnly<{
|
|
pressability: Pressability,
|
|
}>;
|
|
|
|
/**
|
|
* A wrapper for making views respond properly to touches (Android only).
|
|
* On Android this component uses native state drawable to display touch feedback.
|
|
* At the moment it only supports having a single View instance as a child node,
|
|
* as it's implemented by replacing that View with another instance of RCTView node with some additional properties set.
|
|
*
|
|
* Background drawable of native feedback touchable can be customized with background property.
|
|
*
|
|
* @see https://reactnative.dev/docs/touchablenativefeedback#content
|
|
*/
|
|
class TouchableNativeFeedback extends React.Component<
|
|
TouchableNativeFeedbackProps,
|
|
TouchableNativeFeedbackState,
|
|
> {
|
|
/**
|
|
* Creates an object that represents android theme's default background for
|
|
* selectable elements (?android:attr/selectableItemBackground).
|
|
*
|
|
* @param rippleRadius The radius of ripple effect
|
|
*/
|
|
static SelectableBackground: (rippleRadius?: ?number) => $ReadOnly<{
|
|
attribute: 'selectableItemBackground',
|
|
type: 'ThemeAttrAndroid',
|
|
rippleRadius: ?number,
|
|
}> = (rippleRadius?: ?number) => ({
|
|
type: 'ThemeAttrAndroid',
|
|
attribute: 'selectableItemBackground',
|
|
rippleRadius,
|
|
});
|
|
|
|
/**
|
|
* Creates an object that represent android theme's default background for borderless
|
|
* selectable elements (?android:attr/selectableItemBackgroundBorderless).
|
|
* Available on android API level 21+.
|
|
*
|
|
* @param rippleRadius The radius of ripple effect
|
|
*/
|
|
static SelectableBackgroundBorderless: (rippleRadius?: ?number) => $ReadOnly<{
|
|
attribute: 'selectableItemBackgroundBorderless',
|
|
type: 'ThemeAttrAndroid',
|
|
rippleRadius: ?number,
|
|
}> = (rippleRadius?: ?number) => ({
|
|
type: 'ThemeAttrAndroid',
|
|
attribute: 'selectableItemBackgroundBorderless',
|
|
rippleRadius,
|
|
});
|
|
|
|
/**
|
|
* Creates an object that represents ripple drawable with specified color (as a
|
|
* string). If property `borderless` evaluates to true the ripple will
|
|
* render outside of the view bounds (see native actionbar buttons as an
|
|
* example of that behavior). This background type is available on Android
|
|
* API level 21+.
|
|
*
|
|
* @param color The ripple color
|
|
* @param borderless If the ripple can render outside it's bounds
|
|
* @param rippleRadius The radius of ripple effect
|
|
*/
|
|
static Ripple: (
|
|
color: string,
|
|
borderless: boolean,
|
|
rippleRadius?: ?number,
|
|
) => $ReadOnly<{
|
|
borderless: boolean,
|
|
color: ?number,
|
|
rippleRadius: ?number,
|
|
type: 'RippleAndroid',
|
|
}> = (color: string, borderless: boolean, rippleRadius?: ?number) => {
|
|
const processedColor = processColor(color);
|
|
invariant(
|
|
processedColor == null || typeof processedColor === 'number',
|
|
'Unexpected color given for Ripple color',
|
|
);
|
|
return {
|
|
type: 'RippleAndroid',
|
|
// $FlowFixMe[incompatible-type]
|
|
color: processedColor,
|
|
borderless,
|
|
rippleRadius,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Whether `useForeground` is supported.
|
|
*/
|
|
static canUseNativeForeground: () => boolean = () =>
|
|
Platform.OS === 'android';
|
|
|
|
state: TouchableNativeFeedbackState = {
|
|
pressability: new Pressability(this._createPressabilityConfig()),
|
|
};
|
|
|
|
_createPressabilityConfig(): PressabilityConfig {
|
|
const accessibilityStateDisabled =
|
|
this.props['aria-disabled'] ?? this.props.accessibilityState?.disabled;
|
|
return {
|
|
cancelable: !this.props.rejectResponderTermination,
|
|
disabled:
|
|
this.props.disabled != null
|
|
? this.props.disabled
|
|
: accessibilityStateDisabled,
|
|
hitSlop: this.props.hitSlop,
|
|
delayLongPress: this.props.delayLongPress,
|
|
delayPressIn: this.props.delayPressIn,
|
|
delayPressOut: this.props.delayPressOut,
|
|
minPressDuration: 0,
|
|
pressRectOffset: this.props.pressRetentionOffset,
|
|
android_disableSound: this.props.touchSoundDisabled,
|
|
onLongPress: this.props.onLongPress,
|
|
onPress: this.props.onPress,
|
|
onPressIn: event => {
|
|
if (Platform.OS === 'android') {
|
|
this._dispatchHotspotUpdate(event);
|
|
this._dispatchPressedStateChange(true);
|
|
}
|
|
if (this.props.onPressIn != null) {
|
|
this.props.onPressIn(event);
|
|
}
|
|
},
|
|
onPressMove: event => {
|
|
if (Platform.OS === 'android') {
|
|
this._dispatchHotspotUpdate(event);
|
|
}
|
|
},
|
|
onPressOut: event => {
|
|
if (Platform.OS === 'android') {
|
|
this._dispatchPressedStateChange(false);
|
|
}
|
|
if (this.props.onPressOut != null) {
|
|
this.props.onPressOut(event);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
_dispatchPressedStateChange(pressed: boolean): void {
|
|
if (Platform.OS === 'android') {
|
|
const hostComponentRef = findHostInstance_DEPRECATED<$FlowFixMe>(this);
|
|
if (hostComponentRef == null) {
|
|
console.warn(
|
|
'Touchable: Unable to find HostComponent instance. ' +
|
|
'Has your Touchable component been unmounted?',
|
|
);
|
|
} else {
|
|
Commands.setPressed(hostComponentRef, pressed);
|
|
}
|
|
}
|
|
}
|
|
|
|
_dispatchHotspotUpdate(event: GestureResponderEvent): void {
|
|
if (Platform.OS === 'android') {
|
|
const {locationX, locationY} = event.nativeEvent;
|
|
const hostComponentRef = findHostInstance_DEPRECATED<$FlowFixMe>(this);
|
|
if (hostComponentRef == null) {
|
|
console.warn(
|
|
'Touchable: Unable to find HostComponent instance. ' +
|
|
'Has your Touchable component been unmounted?',
|
|
);
|
|
} else {
|
|
Commands.hotspotUpdate(
|
|
hostComponentRef,
|
|
locationX ?? 0,
|
|
locationY ?? 0,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
render(): React.Node {
|
|
const element = React.Children.only<$FlowFixMe>(this.props.children);
|
|
const children: Array<React.Node> = [element.props.children];
|
|
if (__DEV__) {
|
|
if (element.type === View) {
|
|
children.push(
|
|
<PressabilityDebugView color="brown" hitSlop={this.props.hitSlop} />,
|
|
);
|
|
}
|
|
}
|
|
|
|
// BACKWARD-COMPATIBILITY: Focus and blur events were never supported before
|
|
// adopting `Pressability`, so preserve that behavior.
|
|
const {onBlur, onFocus, ...eventHandlersWithoutBlurAndFocus} =
|
|
this.state.pressability.getEventHandlers();
|
|
|
|
let _accessibilityState = {
|
|
busy: this.props['aria-busy'] ?? this.props.accessibilityState?.busy,
|
|
checked:
|
|
this.props['aria-checked'] ?? this.props.accessibilityState?.checked,
|
|
disabled:
|
|
this.props['aria-disabled'] ?? this.props.accessibilityState?.disabled,
|
|
expanded:
|
|
this.props['aria-expanded'] ?? this.props.accessibilityState?.expanded,
|
|
selected:
|
|
this.props['aria-selected'] ?? this.props.accessibilityState?.selected,
|
|
};
|
|
|
|
_accessibilityState =
|
|
this.props.disabled != null
|
|
? {
|
|
..._accessibilityState,
|
|
disabled: this.props.disabled,
|
|
}
|
|
: _accessibilityState;
|
|
|
|
const accessibilityValue = {
|
|
max: this.props['aria-valuemax'] ?? this.props.accessibilityValue?.max,
|
|
min: this.props['aria-valuemin'] ?? this.props.accessibilityValue?.min,
|
|
now: this.props['aria-valuenow'] ?? this.props.accessibilityValue?.now,
|
|
text: this.props['aria-valuetext'] ?? this.props.accessibilityValue?.text,
|
|
};
|
|
|
|
const accessibilityLiveRegion =
|
|
this.props['aria-live'] === 'off'
|
|
? 'none'
|
|
: this.props['aria-live'] ?? this.props.accessibilityLiveRegion;
|
|
|
|
const accessibilityLabel =
|
|
this.props['aria-label'] ?? this.props.accessibilityLabel;
|
|
return cloneElement(
|
|
element,
|
|
{
|
|
...eventHandlersWithoutBlurAndFocus,
|
|
...getBackgroundProp(
|
|
this.props.background === undefined
|
|
? TouchableNativeFeedback.SelectableBackground()
|
|
: this.props.background,
|
|
this.props.useForeground === true,
|
|
),
|
|
accessible: this.props.accessible !== false,
|
|
accessibilityHint: this.props.accessibilityHint,
|
|
accessibilityLanguage: this.props.accessibilityLanguage,
|
|
accessibilityLabel: accessibilityLabel,
|
|
accessibilityRole: this.props.accessibilityRole,
|
|
accessibilityState: _accessibilityState,
|
|
accessibilityActions: this.props.accessibilityActions,
|
|
onAccessibilityAction: this.props.onAccessibilityAction,
|
|
accessibilityValue: accessibilityValue,
|
|
importantForAccessibility:
|
|
this.props['aria-hidden'] === true
|
|
? 'no-hide-descendants'
|
|
: this.props.importantForAccessibility,
|
|
accessibilityViewIsModal:
|
|
this.props['aria-modal'] ?? this.props.accessibilityViewIsModal,
|
|
accessibilityLiveRegion: accessibilityLiveRegion,
|
|
accessibilityElementsHidden:
|
|
this.props['aria-hidden'] ?? this.props.accessibilityElementsHidden,
|
|
hasTVPreferredFocus: this.props.hasTVPreferredFocus,
|
|
hitSlop: this.props.hitSlop,
|
|
focusable:
|
|
this.props.focusable !== false &&
|
|
this.props.onPress !== undefined &&
|
|
!this.props.disabled,
|
|
nativeID: this.props.id ?? this.props.nativeID,
|
|
nextFocusDown: this.props.nextFocusDown,
|
|
nextFocusForward: this.props.nextFocusForward,
|
|
nextFocusLeft: this.props.nextFocusLeft,
|
|
nextFocusRight: this.props.nextFocusRight,
|
|
nextFocusUp: this.props.nextFocusUp,
|
|
onLayout: this.props.onLayout,
|
|
testID: this.props.testID,
|
|
},
|
|
...children,
|
|
);
|
|
}
|
|
|
|
componentDidUpdate(
|
|
prevProps: TouchableNativeFeedbackProps,
|
|
prevState: TouchableNativeFeedbackState,
|
|
) {
|
|
this.state.pressability.configure(this._createPressabilityConfig());
|
|
}
|
|
|
|
componentDidMount(): mixed {
|
|
this.state.pressability.configure(this._createPressabilityConfig());
|
|
}
|
|
|
|
componentWillUnmount(): void {
|
|
this.state.pressability.reset();
|
|
}
|
|
}
|
|
|
|
const getBackgroundProp =
|
|
Platform.OS === 'android'
|
|
? /* $FlowFixMe[missing-local-annot] The type annotation(s) required by
|
|
* Flow's LTI update could not be added via codemod */
|
|
(background, useForeground: boolean) =>
|
|
useForeground && TouchableNativeFeedback.canUseNativeForeground()
|
|
? {nativeForegroundAndroid: background}
|
|
: {nativeBackgroundAndroid: background}
|
|
: /* $FlowFixMe[missing-local-annot] The type annotation(s) required by
|
|
* Flow's LTI update could not be added via codemod */
|
|
(background, useForeground: boolean) => null;
|
|
|
|
TouchableNativeFeedback.displayName = 'TouchableNativeFeedback';
|
|
|
|
export default TouchableNativeFeedback;
|