Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/6.x/docs/guides/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ The theme types were renamed too:
+const color = Palette.tertiary70;
```

#### `stateLayerPressed`

The `stateLayerPressed` color token has been removed from `theme.colors`. The Material 3 pressed state layer opacity (`0.10`) is now applied automatically by `TouchableRipple` (via `android_ripple.alpha` on Android, `color.alpha()` on Web, and underlay opacity on iOS) rather than being baked into a color token.

If your code referenced `theme.colors.stateLayerPressed`, use `theme.colors.onSurface` (or the appropriate on-color for the container) with the desired opacity or alpha.

#### `roundness`

The `roundness` theme property was removed. Corner radii are now set in `theme.shapes.corner`, a scale of roles from `extraSmall` (4) to `extraExtraLarge` (48), and each component uses one fixed role. Paper ignores a `roundness` left in your theme, and TypeScript only reports it in a theme object written inline in a `theme` prop.
Expand Down Expand Up @@ -933,3 +939,10 @@ A standalone `Checkbox` has no visible label, so it now warns in development whe
+ theme={{ colors: { primary: 'tomato' } }}
+/>
```

### TouchableRipple

- `TouchableRipple` now automatically bakes the Material 3 pressed state layer opacity (`0.10`) into the ripple and default iOS underlay.
- The default `rippleColor` is now `theme.colors.onSurface` (instead of the removed `theme.colors.stateLayerPressed`), with the pressed opacity applied automatically.
- On Android, `TouchableRipple` provides `alpha: 0.1` in `android_ripple` to `Pressable`. When passing a custom `background` prop, the pressed opacity is filled into `alpha` if not already specified.
- On iOS, `TouchableRipple` applies `opacity: 0.1` to the default underlay. Passing an explicit `underlayColor` preserves that color without forcing underlay opacity.
7 changes: 4 additions & 3 deletions src/components/FAB/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ const MenuItem = ({
>
<TouchableRipple
borderless
rippleColor={colors.stateLayer}
onPress={onPress}
onFocus={onFocus}
onBlur={onBlur}
Expand Down Expand Up @@ -448,9 +449,9 @@ const MorphingTrigger = ({
>
<Shell
size={size}
variant={triggerVariant}
containerColor={triggerContainerColor}
contentColor={triggerContentColor}
variant={expanded ? closeVariant : triggerVariant}
containerColor={expanded ? undefined : triggerContainerColor}
contentColor={expanded ? undefined : triggerContentColor}
visible={visible}
onPress={onPress}
aria-label={ariaLabel}
Expand Down
1 change: 1 addition & 0 deletions src/components/FAB/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ const Shell = ({
<TouchableRipple
borderless
background={background}
rippleColor={colors.stateLayer}
onPress={onPress}
onFocus={onFocus}
onBlur={onBlur}
Expand Down
23 changes: 19 additions & 4 deletions src/components/FAB/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,39 @@ const stateElevation = {
} as const satisfies Record<string, Elevation>;

const variants = {
primary: { container: 'primary', content: 'onPrimary' },
secondary: { container: 'secondary', content: 'onSecondary' },
tertiary: { container: 'tertiary', content: 'onTertiary' },
primary: {
container: 'primary',
content: 'onPrimary',
stateLayer: 'onPrimary',
},
secondary: {
container: 'secondary',
content: 'onSecondary',
stateLayer: 'onSecondary',
},
tertiary: {
container: 'tertiary',
content: 'onTertiary',
stateLayer: 'onTertiary',
},
tonalPrimary: {
container: 'primaryContainer',
content: 'onPrimaryContainer',
stateLayer: 'onPrimaryContainer',
},
tonalSecondary: {
container: 'secondaryContainer',
content: 'onSecondaryContainer',
stateLayer: 'onSecondaryContainer',
},
tonalTertiary: {
container: 'tertiaryContainer',
content: 'onTertiaryContainer',
stateLayer: 'onTertiaryContainer',
},
} as const satisfies Record<
Variant,
{ container: ColorRole; content: ColorRole }
{ container: ColorRole; content: ColorRole; stateLayer: ColorRole }
>;

export const Tokens = {
Expand Down
14 changes: 10 additions & 4 deletions src/components/FAB/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import type { ShapeToken } from '../../theme/utils/shape';
export type ResolvedColors = {
container: ColorValue;
content: ColorValue;
stateLayer: ColorValue;
};

/**
* Resolve container + content colors. Explicit overrides win; when only
* `containerColor` is set, the content color is derived via
* `contentColorFor`.
* Resolve container + content + state-layer colors. Explicit overrides win;
* when only `containerColor` is set, the content color is derived via
* `contentColorFor`. The state layer always follows the container (it ignores
* a `contentColor` override) so the ripple keeps contrasting the surface.
*/
export const resolveColors = ({
theme,
Expand All @@ -35,7 +37,11 @@ export const resolveColors = ({
(containerColor != null
? contentColorFor(theme, container)
: theme.colors[roles.content]);
return { container, content };
const stateLayer =
containerColor != null
? contentColorFor(theme, container)
: theme.colors[roles.stateLayer];
return { container, content, stateLayer };
};

export type Dimensions = {
Expand Down
63 changes: 33 additions & 30 deletions src/components/TouchableRipple/TouchableRipple.native.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,28 @@
import * as React from 'react';
import { Platform, StyleSheet, View } from 'react-native';
import type {
PressableAndroidRippleConfig,
StyleProp,
ViewStyle,
GestureResponderEvent,
ColorValue,
} from 'react-native';
import type { StyleProp, ViewStyle } from 'react-native';

import type { PressableProps } from './Pressable';
import { Pressable } from './Pressable';
import type { TouchableRippleCommonProps } from './types';
import { getTouchableRippleColors } from './utils';
import { SettingsContext } from '../../core/settings';
import type { Settings } from '../../core/settings';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../theme/types';
import { isPlatformColorSentinel } from '../../theme/provider';
import { Palette } from '../../theme/tokens';
import { state } from '../../theme/tokens/sys/state';
import hasTouchHandler from '../../utils/hasTouchHandler';

const ANDROID_VERSION_LOLLIPOP = 21;
const ANDROID_VERSION_PIE = 28;

export type Props = PressableProps & {
borderless?: boolean;
background?: PressableAndroidRippleConfig;
centered?: boolean;
disabled?: boolean;
onPress?: (e: GestureResponderEvent) => void | null;
onLongPress?: (e: GestureResponderEvent) => void;
onPressIn?: (e: GestureResponderEvent) => void;
onPressOut?: (e: GestureResponderEvent) => void;
rippleColor?: ColorValue;
underlayColor?: string;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
ref?: React.Ref<React.ComponentRef<typeof View>>;
theme?: ThemeProp;
};
export type Props = Omit<PressableProps, 'children' | 'style'> &
TouchableRippleCommonProps & {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
ref?: React.Ref<React.ComponentRef<typeof View>>;
};

const TouchableRipple = ({
style,
Expand Down Expand Up @@ -80,11 +67,21 @@ const TouchableRipple = ({

if (TouchableRipple.supported) {
const androidRipple = rippleEffectEnabled
? (background ?? {
color: calculatedRippleColor,
borderless,
foreground: useForeground,
})
? background
? {
alpha: state.opacity.pressed,
...background,
...(background.color &&
isPlatformColorSentinel(background.color) && {
color: theme.dark ? Palette.neutral90 : Palette.neutral10,
}),
}
: {
color: calculatedRippleColor,
borderless,
foreground: useForeground,
alpha: state.opacity.pressed,
}
: undefined;

return (
Expand All @@ -111,9 +108,15 @@ const TouchableRipple = ({
<>
{pressed && rippleEffectEnabled && (
<View
testID="touchable-ripple-underlay"
style={[
styles.underlay,
{ backgroundColor: calculatedUnderlayColor },
{
backgroundColor: calculatedUnderlayColor,
...(underlayColor == null && {
opacity: state.opacity.pressed,
}),
},
]}
/>
)}
Expand Down
101 changes: 27 additions & 74 deletions src/components/TouchableRipple/TouchableRipple.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,22 @@
import * as React from 'react';
import { Platform, StyleSheet, View } from 'react-native';
import type {
ColorValue,
GestureResponderEvent,
StyleProp,
ViewStyle,
} from 'react-native';

import color from 'color';

import type { PressableProps, PressableStateCallbackType } from './Pressable';
import type { PressableProps } from './Pressable';
import { Pressable } from './Pressable';
import type { TouchableRippleCommonProps } from './types';
import { getTouchableRippleColors } from './utils';
import { SettingsContext } from '../../core/settings';
import type { Settings } from '../../core/settings';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../theme/types';
import { state } from '../../theme/tokens/sys/state';
import hasTouchHandler from '../../utils/hasTouchHandler';

export type Props = PressableProps & {
/**
* Whether to render the ripple outside the view bounds.
*/
borderless?: boolean;
/**
* Type of background drawable to display the feedback (Android).
* https://reactnative.dev/docs/pressable#rippleconfig
*/
background?: Object;
/**
* Whether to start the ripple at the center (Web).
*/
centered?: boolean;
/**
* Whether to prevent interaction with the touchable.
*/
disabled?: boolean;
/**
* Function to execute on press. If not set, will cause the touchable to be disabled.
*/
onPress?: (e: GestureResponderEvent) => void;
/**
* Function to execute on long press.
*/
onLongPress?: (e: GestureResponderEvent) => void;
/**
* Function to execute immediately when a touch is engaged, before `onPressOut` and `onPress`.
*/
onPressIn?: (e: GestureResponderEvent) => void;
/**
* Function to execute when a touch is released.
*/
onPressOut?: (e: GestureResponderEvent) => void;
/**
* Color of the ripple effect (Android >= 5.0 and Web).
*/
rippleColor?: ColorValue;
/**
* Color of the underlay for the highlight effect (Android < 5.0 and iOS).
*/
underlayColor?: string;
/**
* Content of the `TouchableRipple`.
*/
children:
| ((state: PressableStateCallbackType) => React.ReactNode)
| React.ReactNode;
style?:
| StyleProp<ViewStyle>
| ((state: PressableStateCallbackType) => StyleProp<ViewStyle>)
| undefined;
ref?: React.Ref<React.ComponentRef<typeof View>>;
/**
* @optional
*/
theme?: ThemeProp;
};
export type Props = PressableProps &
TouchableRippleCommonProps & {
ref?: React.Ref<React.ComponentRef<typeof View>>;
};

/**
* A wrapper for views that should respond to touches.
Expand Down Expand Up @@ -119,12 +60,24 @@ const TouchableRipple = ({
theme,
rippleColor,
});
// Web-only style. PlatformColor doesn't exist on web, so the calculated
// ripple color is effectively always a string here.
const hoverColor =
typeof calculatedRippleColor === 'string'
? color(calculatedRippleColor).fade(0.5).rgb().string()
: calculatedRippleColor;
// Web-only styles. PlatformColor doesn't exist on web, so the calculated
// ripple color is effectively always a string here, and alpha can be baked
// into it. We multiply into the color's own alpha (matching Android's
// android_ripple.alpha), so an opaque color settles at the pressed opacity
// and a transparent color stays invisible. Hover uses the lighter hover
// state-layer opacity.
const multiplyAlpha = (factor: number) => {
if (typeof calculatedRippleColor !== 'string') {
return calculatedRippleColor;
}
const base = color(calculatedRippleColor);
return base
.alpha(base.alpha() * factor)
.rgb()
.string();
};
const rippleStateLayer = multiplyAlpha(state.opacity.pressed);
const hoverColor = multiplyAlpha(state.opacity.hovered);
const { rippleEffectEnabled } = React.useContext<Settings>(SettingsContext);

const { onPress, onLongPress, onPressIn, onPressOut } = rest;
Expand Down Expand Up @@ -187,7 +140,7 @@ const TouchableRipple = ({
Object.assign(ripple.style, {
position: 'absolute',
pointerEvents: 'none',
backgroundColor: calculatedRippleColor,
backgroundColor: rippleStateLayer,
borderRadius: '50%',

/* Transition configuration */
Expand Down Expand Up @@ -224,7 +177,7 @@ const TouchableRipple = ({
});
}
},
[onPressIn, rest, rippleEffectEnabled, calculatedRippleColor]
[onPressIn, rest, rippleEffectEnabled, rippleStateLayer]
);

const handlePressOut = React.useCallback(
Expand Down
Loading
Loading