Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/components/FAB/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ const Shell = forwardRef<View, ShellProps>(
<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 @@ -9,12 +9,14 @@ import type { InternalTheme } from '../../types';
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 @@ -34,7 +36,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
52 changes: 20 additions & 32 deletions src/components/TouchableRipple/TouchableRipple.native.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,24 @@
import * as React from 'react';
import {
PressableAndroidRippleConfig,
StyleProp,
Platform,
ViewStyle,
StyleSheet,
GestureResponderEvent,
View,
ColorValue,
} from 'react-native';
import { Platform, StyleProp, StyleSheet, View, ViewStyle } from 'react-native';

import type { PressableProps } from './Pressable';
import { Pressable } from './Pressable';
import type { TouchableRippleCommonProps } from './types';
import { getTouchableRippleColors } from './utils';
import { Settings, SettingsContext } from '../../core/settings';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../types';
import { state } from '../../theme/tokens/sys/state';
import { forwardRef } from '../../utils/forwardRef';
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>;
theme?: ThemeProp;
};
export type Props = Omit<PressableProps, 'children' | 'style'> &
TouchableRippleCommonProps & {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
};

const TouchableRipple = (
{
Expand Down Expand Up @@ -83,11 +65,14 @@ const TouchableRipple = (

if (TouchableRipple.supported) {
const androidRipple = rippleEffectEnabled
? background ?? {
color: calculatedRippleColor,
borderless,
foreground: useForeground,
}
? background
? { alpha: state.opacity.pressed, ...background }
: {
color: calculatedRippleColor,
borderless,
foreground: useForeground,
alpha: state.opacity.pressed,
}
: undefined;

return (
Expand Down Expand Up @@ -117,7 +102,10 @@ const TouchableRipple = (
testID="touchable-ripple-underlay"
style={[
styles.underlay,
{ backgroundColor: calculatedUnderlayColor },
{
backgroundColor: calculatedUnderlayColor,
opacity: state.opacity.pressed,
},
]}
/>
)}
Expand Down
101 changes: 25 additions & 76 deletions src/components/TouchableRipple/TouchableRipple.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,19 @@
import * as React from 'react';
import {
ColorValue,
GestureResponderEvent,
Platform,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native';
import { Platform, StyleSheet, View } 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 { Settings, SettingsContext } from '../../core/settings';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../types';
import { state } from '../../theme/tokens/sys/state';
import { forwardRef } from '../../utils/forwardRef';
import hasTouchHandler from '../../utils/hasTouchHandler';

export type Props = PressableProps & {
/**
* Whether to render the ripple outside the view bounds.
*/
borderless?: boolean;
/**
* Type of background drawabale 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;
/**
* @optional
*/
theme?: ThemeProp;
};
export type Props = PressableProps & TouchableRippleCommonProps;

/**
* A wrapper for views that should respond to touches.
Expand Down Expand Up @@ -122,12 +59,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 @@ -190,7 +139,7 @@ const TouchableRipple = (
Object.assign(ripple.style, {
position: 'absolute',
pointerEvents: 'none',
backgroundColor: calculatedRippleColor,
backgroundColor: rippleStateLayer,
borderRadius: '50%',

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

const handlePressOut = React.useCallback(
Expand Down
64 changes: 64 additions & 0 deletions src/components/TouchableRipple/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type {
ColorValue,
GestureResponderEvent,
PressableAndroidRippleConfig,
} from 'react-native';

import type { ThemeProp } from '../../types';

/**
* Props shared by the web and native `TouchableRipple` implementations.
* `children` and `style` are intentionally excluded: web allows render-prop
* children and function styles, while native requires a single element.
*/
export type TouchableRippleCommonProps = {
/**
* Whether to render the ripple outside the view bounds.
*/
borderless?: boolean;
/**
* Ripple configuration passed straight to the underlying Pressable (Android).
* When set, it overrides the computed ripple; the MD3 pressed opacity fills in
* its `alpha` only if the config doesn't set one.
* https://reactnative.dev/docs/pressable#rippleconfig
*/
background?: PressableAndroidRippleConfig;
/**
* 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;
/**
* Base color of the ripple effect (Android >= 5.0 and Web). Treated as an
* opaque color; the MD3 pressed state-layer opacity is applied automatically,
* multiplied into the color's own alpha (so a transparent color stays hidden).
*/
rippleColor?: ColorValue;
/**
* Color of the underlay for the highlight effect (Android < 5.0 and iOS).
*/
underlayColor?: ColorValue;
/**
* @optional
*/
theme?: ThemeProp;
};
6 changes: 3 additions & 3 deletions src/components/TouchableRipple/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const getUnderlayColor = ({
underlayColor,
}: {
calculatedRippleColor: ColorValue;
underlayColor?: string;
underlayColor?: ColorValue;
}) => {
if (underlayColor != null) {
return underlayColor;
Expand All @@ -27,7 +27,7 @@ const getRippleColor = ({
return rippleColor;
}

return theme.colors.stateLayerPressed;
return theme.colors.onSurface;
};

export const getTouchableRippleColors = ({
Expand All @@ -37,7 +37,7 @@ export const getTouchableRippleColors = ({
}: {
theme: InternalTheme;
rippleColor?: ColorValue;
underlayColor?: string;
underlayColor?: ColorValue;
}) => {
const calculatedRippleColor = getRippleColor({ theme, rippleColor });
return {
Expand Down
Loading
Loading