Skip to content

Commit b2bbb49

Browse files
authored
Forward borderless and foreground from android_ripple (#4442)
## Description Follow-up to #4411. `StatefulPressable` and the legacy `Pressable` only forwarded `color` and `radius` from `android_ripple` to the button, so `borderless` and `foreground` silently did nothing on those engines - the ripple stayed bounded and drew under the children. `PressableWithTouchable` already passed all four fields, which made the behavior depend on whether a relation prop (`simultaneousWith`/`requireToFail`/`block`) was present. Both engines now pass the whole config through. The button props for the two flags already existed, only the Pressable side dropped them. ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import React from 'react'; import { Pressable as RNPressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import { LegacyPressable, Pressable } from 'react-native-gesture-handler'; import type { PressableProps } from 'react-native-gesture-handler'; // Android-only check for `android_ripple.borderless` / `.foreground`, which the // StatefulPressable and legacy engines used to drop (only color + radius were // forwarded to the button). // // borderless: the ripple is a circle that spills outside the box. // foreground: the ripple draws over the opaque child instead of under it. // // Every column must look the same. Nothing to see on iOS - no native ripple there. const RIPPLE_COLOR = '#1565c0'; const VARIANTS = [ { label: 'baseline\n{ color }', ripple: { color: RIPPLE_COLOR }, covered: false, }, { label: 'borderless\n{ borderless: true }', ripple: { color: RIPPLE_COLOR, borderless: true }, covered: false, }, { // Control for the row below: a background ripple hides under the child. label: 'covered, control\n{ color }\nno ripple expected', ripple: { color: RIPPLE_COLOR }, covered: true, }, { label: 'foreground\n{ foreground: true }\nripple over the child', ripple: { color: RIPPLE_COLOR, foreground: true }, covered: true, }, { label: 'both + radius\n{ borderless, foreground, radius: 70 }', ripple: { color: RIPPLE_COLOR, borderless: true, foreground: true, radius: 70, }, covered: true, }, ] as const; // A relation prop is what routes the public `Pressable` to the Stateful engine. function StatefulPressable(props: PressableProps) { return <Pressable {...props} simultaneousWith={[]} />; } const ENGINES = [ { label: 'v3\nTouchable', Component: Pressable }, { label: 'v3\nStateful', Component: StatefulPressable }, { label: 'legacy\nRNGH', Component: LegacyPressable }, { label: 'RN\ncontrol', Component: RNPressable }, ] as const; export default function EmptyExample() { return ( <ScrollView contentContainerStyle={styles.container}> <View style={styles.row}> <View style={styles.variantLabel} /> {ENGINES.map((engine) => ( <Text key={engine.label} style={styles.engineLabel}> {engine.label} </Text> ))} </View> {VARIANTS.map((variant) => ( <View key={variant.label} style={styles.row}> <Text style={[styles.variantLabel, styles.variantText]}> {variant.label} </Text> {ENGINES.map(({ label, Component }) => ( <View key={label} style={styles.cell}> <Component android_ripple={variant.ripple} style={styles.button}> {variant.covered ? <View style={styles.cover} /> : null} </Component> </View> ))} </View> ))} </ScrollView> ); } const styles = StyleSheet.create({ container: { padding: 12, paddingTop: 40, gap: 20, }, row: { flexDirection: 'row', alignItems: 'center', }, variantLabel: { width: 100, }, variantText: { fontSize: 11, fontFamily: 'monospace', color: '#37474f', }, engineLabel: { flex: 1, fontSize: 11, textAlign: 'center', color: '#607d8b', }, cell: { flex: 1, alignItems: 'center', }, button: { width: 54, height: 54, borderRadius: 6, backgroundColor: '#eceff1', }, cover: { flex: 1, backgroundColor: '#cfd8dc', }, }); ``` </details>
1 parent 00acc10 commit b2bbb49

3 files changed

Lines changed: 76 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { render, screen } from '@testing-library/react-native';
2+
import React from 'react';
3+
import type { Platform as PlatformModule } from 'react-native';
4+
import { Text } from 'react-native';
5+
6+
import GestureHandlerRootView from '../components/GestureHandlerRootView';
7+
import { Pressable } from '../v3/components';
8+
9+
// The Touchable engine reads `Platform.OS` once, at import time, to decide
10+
// whether the native ripple applies — so the platform has to be swapped before
11+
// the component module loads.
12+
jest.mock('react-native/Libraries/Utilities/Platform', () => {
13+
const actual = jest.requireActual<{ default: typeof PlatformModule }>(
14+
'react-native/Libraries/Utilities/Platform'
15+
).default;
16+
return {
17+
__esModule: true,
18+
default: {
19+
...actual,
20+
OS: 'android',
21+
select: (spec: Record<string, unknown>) =>
22+
'android' in spec ? spec.android : (spec.native ?? spec.default),
23+
},
24+
};
25+
});
26+
27+
// Both engines have to forward the whole `android_ripple` config to the button —
28+
// dropping `borderless`/`foreground` made the two flags silently do nothing on
29+
// the engine that omitted them.
30+
const RIPPLE = {
31+
color: 'red',
32+
radius: 20,
33+
borderless: true,
34+
foreground: true,
35+
} as const;
36+
37+
const expectRippleOnButton = () => {
38+
const button = screen.getByTestId('pressable');
39+
40+
expect(button.props.rippleColor).toBe(RIPPLE.color);
41+
expect(button.props.rippleRadius).toBe(RIPPLE.radius);
42+
expect(button.props.borderless).toBe(true);
43+
expect(button.props.foreground).toBe(true);
44+
};
45+
46+
test('StatefulPressable forwards the whole android_ripple config', () => {
47+
render(
48+
<GestureHandlerRootView>
49+
{/* A relation prop routes `Pressable` to the `StatefulPressable` engine. */}
50+
<Pressable
51+
testID="pressable"
52+
simultaneousWith={[]}
53+
android_ripple={RIPPLE}>
54+
<Text>Press Me</Text>
55+
</Pressable>
56+
</GestureHandlerRootView>
57+
);
58+
59+
expectRippleOnButton();
60+
});
61+
62+
test('PressableWithTouchable forwards the whole android_ripple config', () => {
63+
render(
64+
<GestureHandlerRootView>
65+
<Pressable testID="pressable" android_ripple={RIPPLE}>
66+
<Text>Press Me</Text>
67+
</Pressable>
68+
</GestureHandlerRootView>
69+
);
70+
71+
expectRippleOnButton();
72+
});

‎packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,8 @@ const LegacyPressable = (props: LegacyPressableProps) => {
395395
touchSoundDisabled={android_disableSound ?? undefined}
396396
rippleColor={rippleColor}
397397
rippleRadius={android_ripple?.radius ?? undefined}
398+
borderless={android_ripple?.borderless ?? undefined}
399+
foreground={android_ripple?.foreground ?? undefined}
398400
style={[pointerStyle, styleProp]}
399401
testOnly_onPress={IS_TEST_ENV ? onPress : undefined}
400402
testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined}

‎packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,8 @@ const StatefulPressable = (props: PressableProps) => {
459459
touchSoundDisabled={android_disableSound ?? undefined}
460460
rippleColor={rippleColor}
461461
rippleRadius={android_ripple?.radius ?? undefined}
462+
borderless={android_ripple?.borderless ?? undefined}
463+
foreground={android_ripple?.foreground ?? undefined}
462464
style={[pointerStyle, styleProp]}
463465
testOnly_onPress={IS_TEST_ENV ? onPress : undefined}
464466
testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined}

0 commit comments

Comments
 (0)