diff --git a/example/src/Examples/SurfaceExample.tsx b/example/src/Examples/SurfaceExample.tsx
index a246b3c63c..fe420830ce 100644
--- a/example/src/Examples/SurfaceExample.tsx
+++ b/example/src/Examples/SurfaceExample.tsx
@@ -1,30 +1,67 @@
-import { ScrollView, StyleSheet, View } from 'react-native';
+import * as React from 'react';
+import { Animated, ScrollView, StyleSheet, View } from 'react-native';
-import { Surface, Text, Palette, List } from 'react-native-paper';
+import { Surface, Text, Palette, List, IconButton } from 'react-native-paper';
import type { Elevation } from 'react-native-paper';
import ScreenWrapper from '../ScreenWrapper';
+const elevationLevels: Elevation[] = [0, 1, 2, 3, 4, 5];
+
+const AnimatedSurface = () => {
+ const [index, setIndex] = React.useState(3);
+
+ const level = elevationLevels[index];
+ const elevation = React.useRef(new Animated.Value(level)).current;
+
+ React.useEffect(() => {
+ Animated.timing(elevation, {
+ toValue: level,
+ duration: 250,
+ useNativeDriver: false,
+ }).start();
+ }, [elevation, level]);
+
+ return (
+
+
+ {`Elevation ${level}`}
+
+
+ setIndex(index - 1)}
+ />
+ setIndex(index + 1)}
+ />
+
+
+ );
+};
+
const SurfaceExample = () => {
const elevationValues: Elevation[] = [0, 1, 2, 3, 4, 5];
const renderSurface = (index: Elevation, mode: 'flat' | 'elevated') => (
-
-
- {`Elevation ${index === 1 ? '(default)' : ''} ${index}`}
-
+
+ {`Elevation ${index}`}
);
return (
-
+
{elevationValues.map((elevation) =>
renderSurface(elevation, 'elevated')
)}
@@ -32,11 +69,19 @@ const SurfaceExample = () => {
-
+
{elevationValues.map((elevation) => renderSurface(elevation, 'flat'))}
+
+
+
+
@@ -68,21 +113,22 @@ const styles = StyleSheet.create({
padding: 24,
alignItems: 'center',
},
+ scroll: {
+ gap: 24,
+ paddingHorizontal: 16,
+ paddingVertical: 24,
+ },
surface: {
- margin: 24,
- height: 80,
- width: 80,
+ height: 120,
+ width: 120,
+ borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
- v3Surface: {
- borderRadius: 16,
- height: 200,
- width: 200,
- alignItems: 'center',
- justifyContent: 'center',
+ actions: {
+ flexDirection: 'row',
+ gap: 16,
},
-
horizontalSurfacesContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
diff --git a/src/components/FAB/useVisibility.ts b/src/components/FAB/useVisibility.ts
index 12914e7f64..f6c4b2e645 100644
--- a/src/components/FAB/useVisibility.ts
+++ b/src/components/FAB/useVisibility.ts
@@ -11,8 +11,6 @@ import {
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import {
- IOS_SHADOW_RADIUS_FACTOR,
- SHADOW_OPACITY,
androidElevationLevels,
shadow,
shadowLayers,
@@ -79,6 +77,7 @@ export function useVisibility({
const restingElevationDp = androidElevationLevels[elevation];
const shadowOffsetHeight = shadowLayers[0].height[elevation];
const shadowRadius = shadowLayers[0].shadowRadius[elevation];
+ const shadowOpacity = elevation ? shadowLayers[0].shadowOpacity : 0;
const shadowColor = theme.colors.shadow;
const webShadow =
@@ -93,9 +92,9 @@ export function useVisibility({
}
return {
shadowColor,
- shadowOpacity: alpha.value * (elevation ? SHADOW_OPACITY : 0),
+ shadowOpacity: alpha.value * shadowOpacity,
shadowOffset: { width: 0, height: shadowOffsetHeight },
- shadowRadius: shadowRadius * IOS_SHADOW_RADIUS_FACTOR,
+ shadowRadius,
};
});
diff --git a/src/components/__tests__/Surface.test.tsx b/src/components/__tests__/Surface.test.tsx
index 1a9ebc8a45..d0a0437f65 100644
--- a/src/components/__tests__/Surface.test.tsx
+++ b/src/components/__tests__/Surface.test.tsx
@@ -2,7 +2,14 @@ import type { ViewStyle } from 'react-native';
import { StyleSheet } from 'react-native';
import { Platform } from 'react-native';
-import { describe, expect, it } from '@jest/globals';
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
import { getTheme } from '../../core/theming';
import { render, screen } from '../../test-utils';
@@ -13,20 +20,31 @@ type StyleCase = {
value: ViewStyle[keyof ViewStyle];
};
+const SPOT_SHADOW_OPACITY = 0.19;
+const AMBIENT_SHADOW_OPACITY = 0.039;
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
describe('Surface', () => {
it('should properly render passed props', async () => {
- const testID = 'surface-container';
await render(
-
+
{null}
);
// eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(screen.getByTestId(testID).props.pointerEvents).toBe('box-none');
+ expect(screen.getByTestId('surface-container').props.pointerEvents).toBe(
+ 'box-none'
+ );
});
describe('on iOS', () => {
- Platform.OS = 'ios';
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', 'ios');
+ });
+
const styles = StyleSheet.create({
absoluteStyles: {
bottom: 10,
@@ -59,23 +77,34 @@ describe('Surface', () => {
);
- expect(screen.getByTestId('surface-test')).not.toHaveStyle({
- shadowColor: '#000',
- shadowOpacity: 0.3,
- shadowOffset: { width: 0, height: 4 },
- shadowRadius: 4,
- });
+ // @ts-expect-error
expect(screen.getByTestId('surface-test-outer-layer')).not.toHaveStyle({
- shadowColor: '#000',
- shadowOpacity: 0.15,
- shadowOffset: { width: 0, height: 8 },
- shadowRadius: 12,
+ shadowOpacity: expect.any(Number),
+ });
+ // @ts-expect-error
+ expect(screen.getByTestId('surface-test')).not.toHaveStyle({
+ shadowOpacity: expect.any(Number),
});
expect(screen.getByTestId('surface-test')).toHaveStyle({
backgroundColor: getTheme().colors.surfaceContainerHighest,
});
});
+ it('should render a spot shadow over an ambient shadow, if mode is elevated', async () => {
+ await render(
+
+ {null}
+
+ );
+
+ expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle({
+ shadowOpacity: SPOT_SHADOW_OPACITY,
+ });
+ expect(screen.getByTestId('surface-test')).toHaveStyle({
+ shadowOpacity: AMBIENT_SHADOW_OPACITY,
+ });
+ });
+
it.each([
{ property: 'opacity', value: 0.7 },
{ property: 'transform', value: [{ scale: 1.02 }] },
@@ -170,43 +199,37 @@ describe('Surface', () => {
describe('outer layer', () => {
it('should not render rest style', async () => {
- const testID = 'surface-test';
-
await render(
-
+
{null}
);
- expect(screen.getByTestId(`${testID}-outer-layer`)).not.toHaveStyle(
+ expect(screen.getByTestId('surface-test-outer-layer')).not.toHaveStyle(
styles.restStyle
);
});
it('should render absolute position properties on outer layer', async () => {
- const testID = 'surface-test';
-
await render(
-
+
{null}
);
- expect(screen.getByTestId(`${testID}-outer-layer`)).toHaveStyle(
+ expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle(
styles.absoluteStyles
);
});
it('should render absolute position properties on the outer layer', async () => {
- const testID = 'surface-test';
-
await render(
-
+
{null}
);
- expect(screen.getByTestId(`${testID}-outer-layer`)).toHaveStyle(
+ expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle(
styles.absoluteStyles
);
});
@@ -214,15 +237,13 @@ describe('Surface', () => {
describe('inner layer', () => {
it('should render inner layer styles on the inner layer', async () => {
- const testID = 'surface-test';
-
await render(
-
+
{null}
);
- expect(screen.getByTestId(testID)).toHaveStyle(
+ expect(screen.getByTestId('surface-test')).toHaveStyle(
styles.innerLayerViewStyle
);
});
@@ -246,39 +267,75 @@ describe('Surface', () => {
describe('children wrapper', () => {
it('should render rest styles', async () => {
- const testID = 'surface-test';
const combinedStyles = [styles.innerLayerViewStyle, styles.restStyle];
await render(
-
+
{null}
);
- expect(screen.getByTestId(testID)).toHaveStyle(combinedStyles);
+ expect(screen.getByTestId('surface-test')).toHaveStyle(combinedStyles);
});
});
});
describe('on Android', () => {
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', 'android');
+ });
+
it('should render Surface with appropriate bg color but without shadow, if mode is set to "flat"', async () => {
- Platform.OS = 'android';
- const testID = 'surface-container';
await render(
{null}
);
- expect(screen.getByTestId(testID)).not.toHaveStyle({ elevation: 5 });
- expect(screen.getByTestId(testID)).toHaveStyle({
+ // @ts-expect-error
+ expect(screen.getByTestId('surface-container')).not.toHaveStyle({
+ elevation: expect.any(Number),
+ });
+ expect(screen.getByTestId('surface-container')).toHaveStyle({
backgroundColor: getTheme().colors.surfaceContainerHighest,
});
});
+
+ it('should render the dp value for the elevation level, if mode is elevated', async () => {
+ await render(
+
+ {null}
+
+ );
+
+ expect(screen.getByTestId('surface-container')).toHaveStyle({
+ elevation: 12,
+ });
+ });
+ });
+
+ describe('on Web', () => {
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ });
+
+ it('should render both shadows in one box shadow, if mode is elevated', async () => {
+ await render(
+
+ {null}
+
+ );
+
+ expect(screen.getByTestId('surface-container')).toHaveStyle({
+ boxShadow:
+ `0px 6.75px 19.22px rgba(0, 0, 0, ${SPOT_SHADOW_OPACITY}), ` +
+ `0px 0px 6px rgba(0, 0, 0, ${AMBIENT_SHADOW_OPACITY})`,
+ });
+ });
});
});
diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap
index a6fb8c8dee..4e75db8cc7 100644
--- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap
@@ -9,11 +9,11 @@ exports[`render visible banner, with custom theme 1`] = `
"opacity": 1,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0.55,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 3,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 0.79,
}
}
testID="surface-outer-layer"
@@ -26,11 +26,11 @@ exports[`render visible banner, with custom theme 1`] = `
"flex": undefined,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.25,
}
}
testID="surface"
@@ -285,11 +285,11 @@ exports[`renders hidden banner, without action buttons and without image 1`] = `
"opacity": 0,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0.55,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 3,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 0.79,
}
}
testID="surface-outer-layer"
@@ -302,11 +302,11 @@ exports[`renders hidden banner, without action buttons and without image 1`] = `
"flex": undefined,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.25,
}
}
testID="surface"
@@ -420,11 +420,11 @@ exports[`renders visible banner, with action buttons and with image 1`] = `
"opacity": 1,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0.55,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 3,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 0.79,
}
}
testID="surface-outer-layer"
@@ -437,11 +437,11 @@ exports[`renders visible banner, with action buttons and with image 1`] = `
"flex": undefined,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.25,
}
}
testID="surface"
@@ -718,11 +718,11 @@ exports[`renders visible banner, with action buttons and without image 1`] = `
"opacity": 1,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0.55,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 3,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 0.79,
}
}
testID="surface-outer-layer"
@@ -735,11 +735,11 @@ exports[`renders visible banner, with action buttons and without image 1`] = `
"flex": undefined,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.25,
}
}
testID="surface"
@@ -1146,11 +1146,11 @@ exports[`renders visible banner, without action buttons and with image 1`] = `
"opacity": 1,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0.55,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 3,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 0.79,
}
}
testID="surface-outer-layer"
@@ -1163,11 +1163,11 @@ exports[`renders visible banner, without action buttons and with image 1`] = `
"flex": undefined,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.25,
}
}
testID="surface"
@@ -1291,11 +1291,11 @@ exports[`renders visible banner, without action buttons and without image 1`] =
"opacity": 1,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0.55,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 3,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 0.79,
}
}
testID="surface-outer-layer"
@@ -1308,11 +1308,11 @@ exports[`renders visible banner, without action buttons and without image 1`] =
"flex": undefined,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.25,
}
}
testID="surface"
diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
index 348a6bba64..cec33bb556 100644
--- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
@@ -23,11 +23,11 @@ exports[`renders FAB large size 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -195,11 +195,11 @@ exports[`renders FAB medium size 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -367,11 +367,11 @@ exports[`renders FAB transitioning to not visible 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
"shadowOpacity": 0,
- "shadowRadius": 4,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "none",
@@ -539,11 +539,11 @@ exports[`renders FAB transitioning to visible 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -711,11 +711,11 @@ exports[`renders FAB with aria-label 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -884,11 +884,11 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -1056,11 +1056,11 @@ exports[`renders FAB with containerColor override 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -1228,11 +1228,11 @@ exports[`renders FAB with default props 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -1400,11 +1400,11 @@ exports[`renders FAB with primary variant 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -1572,11 +1572,11 @@ exports[`renders FAB with secondary variant 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -1744,11 +1744,11 @@ exports[`renders FAB with tertiary variant 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -1916,11 +1916,11 @@ exports[`renders FAB with tonalSecondary variant 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -2088,11 +2088,11 @@ exports[`renders FAB with tonalTertiary variant 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap
index 2ceb981d60..d159f82912 100644
--- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap
@@ -24,11 +24,11 @@ exports[`renders extended FAB collapsed 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -263,11 +263,11 @@ exports[`renders extended FAB expanded 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -502,11 +502,11 @@ exports[`renders extended FAB large size 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -741,11 +741,11 @@ exports[`renders extended FAB medium size 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -980,11 +980,11 @@ exports[`renders extended FAB not visible 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
"shadowOpacity": 0,
- "shadowRadius": 4,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "none",
@@ -1219,11 +1219,11 @@ exports[`renders extended FAB transitioning to collapsed 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
diff --git a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap
index 726f4ef1b2..d48d6e3666 100644
--- a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap
@@ -424,11 +424,11 @@ exports[`renders FAB.Menu closed 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -1109,11 +1109,11 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
"shadowOpacity": 0,
- "shadowRadius": 4,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "none",
@@ -1794,11 +1794,11 @@ exports[`renders FAB.Menu open 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -3139,11 +3139,11 @@ exports[`renders FAB.Menu with 6 items 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -3825,11 +3825,11 @@ exports[`renders FAB.Menu with center alignment 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -4568,11 +4568,11 @@ exports[`renders FAB.Menu with items having icons 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
@@ -5253,11 +5253,11 @@ exports[`renders FAB.Menu with start alignment 1`] = `
{
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 4,
+ "height": 3.33,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 4,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 4.75,
},
{
"pointerEvents": "auto",
diff --git a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap
index 11d5aea821..3e027106ae 100644
--- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap
@@ -269,11 +269,11 @@ exports[`renders menu with content styles 1`] = `
"opacity": 0,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 2,
+ "height": 1.66,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 6,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 2.36,
"transform": [
{
"scaleX": 0,
@@ -299,11 +299,11 @@ exports[`renders menu with content styles 1`] = `
"paddingVertical": 8,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.75,
}
}
testID="menu-surface"
@@ -998,11 +998,11 @@ exports[`renders visible menu 1`] = `
"opacity": 0,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 2,
+ "height": 1.66,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 6,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 2.36,
"transform": [
{
"scaleX": 0,
@@ -1026,11 +1026,11 @@ exports[`renders visible menu 1`] = `
"paddingVertical": 8,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.75,
}
}
testID="menu-surface"
diff --git a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap
index 4857580e83..76d4857c89 100644
--- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap
@@ -30,11 +30,11 @@ exports[`renders snackbar with Text as a child 1`] = `
"opacity": 0,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 2,
+ "height": 1.66,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 6,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 2.36,
"transform": [
{
"scale": 0.9,
@@ -58,11 +58,11 @@ exports[`renders snackbar with Text as a child 1`] = `
"minHeight": 48,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.75,
}
}
testID="surface"
@@ -118,11 +118,11 @@ exports[`renders snackbar with View & Text as a child 1`] = `
"opacity": 0,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 2,
+ "height": 1.66,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 6,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 2.36,
"transform": [
{
"scale": 0.9,
@@ -146,11 +146,11 @@ exports[`renders snackbar with View & Text as a child 1`] = `
"minHeight": 48,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.75,
}
}
testID="surface"
@@ -232,11 +232,11 @@ exports[`renders snackbar with action button 1`] = `
"opacity": 0,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 2,
+ "height": 1.66,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 6,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 2.36,
"transform": [
{
"scale": 0.9,
@@ -260,11 +260,11 @@ exports[`renders snackbar with action button 1`] = `
"minHeight": 48,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.75,
}
}
testID="surface"
@@ -503,11 +503,11 @@ exports[`renders snackbar with content 1`] = `
"opacity": 0,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 2,
+ "height": 1.66,
"width": 0,
},
- "shadowOpacity": 0.15,
- "shadowRadius": 6,
+ "shadowOpacity": 0.19,
+ "shadowRadius": 2.36,
"transform": [
{
"scale": 0.9,
@@ -531,11 +531,11 @@ exports[`renders snackbar with content 1`] = `
"minHeight": 48,
"shadowColor": "rgba(0, 0, 0, 1)",
"shadowOffset": {
- "height": 1,
+ "height": 0,
"width": 0,
},
- "shadowOpacity": 0.3,
- "shadowRadius": 2,
+ "shadowOpacity": 0.039,
+ "shadowRadius": 0.75,
}
}
testID="surface"
diff --git a/src/theme/tokens/sys/elevation.ts b/src/theme/tokens/sys/elevation.ts
index de797183d4..18e92dcb63 100644
--- a/src/theme/tokens/sys/elevation.ts
+++ b/src/theme/tokens/sys/elevation.ts
@@ -43,21 +43,90 @@ export const elevationInputRange: Elevation[] = Object.values(defaultElevation);
export const androidElevationLevels = [0, 1, 3, 6, 8, 12];
-export const SHADOW_OPACITY = 0.3;
-// iOS shadowRadius is a Gaussian sigma; CSS blur-radius = 2*sigma.
-// Dividing by this factor normalizes the spread to match Web.
-export const IOS_SHADOW_RADIUS_FACTOR = 0.5;
+/**
+ * Android draws two shadows for an elevated view:
+ * - Spot shadow cast by a light source above the top edge of the window
+ * - Ambient shadow centered on the view
+ *
+ * The shadow is dynamic based on how far the view is from the light source.
+ * But we can't express this dynamic value in a static shadow.
+ *
+ * So we use an approximation assuming:
+ * - Light is at the platform's default height and radius
+ * - Screen is the size of a phone, 400dp wide and 800dp tall
+ * - View is halfway down the window, and centered horizontally
+ */
+
+// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/res/res/values/dimens.xml#752
+const LIGHT_Z = 500;
+const LIGHT_RADIUS = 800;
+const SPOT_SHADOW_ALPHA = 0.19;
+const AMBIENT_SHADOW_ALPHA = 0.039;
+
+// https://skia.googlesource.com/skia/+/refs/heads/main/src/core/SkDrawShadowInfo.h#51
+const AMBIENT_HEIGHT_FACTOR = 1 / 128;
+const AMBIENT_GEOM_FACTOR = 64;
+
+// Approximate blur based on how Skia draws the shadow
+// https://skia.googlesource.com/skia/+/refs/heads/main/src/opts/SkRasterPipeline_opts.h#5326
+const SOFT_EDGE_BLUR_FACTOR = 0.94;
+const SOFT_EDGE_OFFSET_FACTOR = 0.17;
+
+// Since we can't express the dynamic shadow statically
+// We use these device dimensions to calculate as an approximation
+const WINDOW_WIDTH = 400;
+const WINDOW_HEIGHT = 800;
+
+// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/view/ThreadedRenderer.java#602
+const LIGHT_HEIGHT = LIGHT_Z * ((WINDOW_WIDTH / 450 + 2) / 3);
+
+// Sub-pixel precision is enough for a shadow, and keeps the values readable.
+const round = (value: number) => Math.round(value * 100) / 100;
+
+// How far the view has moved towards the light
+// This controls the height and softness of the shadow
+const getLightRatio = (dp: number) => dp / (LIGHT_HEIGHT - dp);
+
+// How wide the shadow fades out over
+// A larger light casts a softer shadow
+const getSoftEdge = (dp: number) => getLightRatio(dp) * LIGHT_RADIUS;
+
+const spotBlurRadius = androidElevationLevels.map((dp) =>
+ round(getSoftEdge(dp) * SOFT_EDGE_BLUR_FACTOR)
+);
+
+// Android's ambient shadow starts dark at the view’s edge and fades outward.
+// A regular blur is already half faded at that edge because half of it is hidden behind the view.
+// Using the same fade distance gives both shadows a similar size, but our shadow starts lighter.
+// https://skia.googlesource.com/skia/+/refs/heads/main/src/utils/SkShadowTessellator.cpp#923
+const ambientBlurRadius = androidElevationLevels.map((dp) =>
+ round(dp * AMBIENT_HEIGHT_FACTOR * AMBIENT_GEOM_FACTOR)
+);
+
+// On iOS, we need to use half the blur radius to draw the approximately same blur in `shadowRadius`
+// https://github.com/react/react-native/blob/main/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm#L64-L67
+const IOS_SHADOW_RADIUS_FACTOR = 0.5;
+
+const getShadowRadius = (blurRadius: number[]) =>
+ blurRadius.map((radius) => round(radius * IOS_SHADOW_RADIUS_FACTOR));
export const shadowLayers = [
{
- shadowOpacity: 0.15,
- height: [0, 1, 2, 4, 6, 8],
- shadowRadius: [0, 3, 6, 8, 10, 12],
+ height: androidElevationLevels.map((dp) =>
+ round(
+ getLightRatio(dp) * (WINDOW_HEIGHT / 2) -
+ getSoftEdge(dp) * SOFT_EDGE_OFFSET_FACTOR
+ )
+ ),
+ blurRadius: spotBlurRadius,
+ shadowOpacity: SPOT_SHADOW_ALPHA,
+ shadowRadius: getShadowRadius(spotBlurRadius),
},
{
- shadowOpacity: SHADOW_OPACITY,
- height: [0, 1, 1, 1, 2, 4],
- shadowRadius: [0, 2, 2, 3, 3, 4],
+ height: androidElevationLevels.map(() => 0),
+ blurRadius: ambientBlurRadius,
+ shadowOpacity: AMBIENT_SHADOW_ALPHA,
+ shadowRadius: getShadowRadius(ambientBlurRadius),
},
];
@@ -71,8 +140,13 @@ const getShadowColor = (shadowColor: ColorValue, shadowOpacity: number) => {
return color(shadowColor).alpha(shadowOpacity).rgb().string();
};
-const getBoxShadowValue = (elevation: number, shadowColor: string) =>
- `0px ${shadowLayers[0].height[elevation]}px ${shadowLayers[0].shadowRadius[elevation]}px ${shadowColor}`;
+const getBoxShadowValue = (elevation: number, layerColors: readonly string[]) =>
+ shadowLayers
+ .map(
+ (layer, index) =>
+ `0px ${layer.height[elevation]}px ${layer.blurRadius[elevation]}px ${layerColors[index]}`
+ )
+ .join(', ');
export function shadow(elevation: number, shadowColor: ColorValue): ViewStyle;
// eslint-disable-next-line no-redeclare
@@ -91,24 +165,30 @@ export function shadow(
shadowColor: ColorValue
): ViewStyle | AnimatedShadowStyle {
if (Platform.OS === 'web') {
- const webShadowColor = getShadowColor(shadowColor, SHADOW_OPACITY);
+ const layerColors = shadowLayers.map((layer) =>
+ getShadowColor(shadowColor, layer.shadowOpacity)
+ );
if (isAnimatedValue(elevation)) {
return {
boxShadow: elevation.interpolate({
inputRange: elevationInputRange,
outputRange: elevationInputRange.map((value) =>
- getBoxShadowValue(value, webShadowColor)
+ getBoxShadowValue(value, layerColors)
),
}),
};
}
return {
- boxShadow: getBoxShadowValue(elevation, webShadowColor),
+ boxShadow: getBoxShadowValue(elevation, layerColors),
};
}
+ // For a single view, we can only draw one shadow
+ // So we pick the spot shadow, as it shows the depth
+ const [spotShadow] = shadowLayers;
+
if (isAnimatedValue(elevation)) {
return {
shadowColor,
@@ -116,31 +196,28 @@ export function shadow(
width: new Animated.Value(0),
height: elevation.interpolate({
inputRange: elevationInputRange,
- outputRange: shadowLayers[0].height,
+ outputRange: spotShadow.height,
}),
},
shadowOpacity: elevation.interpolate({
inputRange: [0, 1],
- outputRange: [0, SHADOW_OPACITY],
+ outputRange: [0, spotShadow.shadowOpacity],
extrapolate: 'clamp',
}),
shadowRadius: elevation.interpolate({
inputRange: elevationInputRange,
- outputRange: shadowLayers[0].shadowRadius.map(
- (r) => r * IOS_SHADOW_RADIUS_FACTOR
- ),
+ outputRange: spotShadow.shadowRadius,
}),
};
}
return {
shadowColor,
- shadowOpacity: elevation ? SHADOW_OPACITY : 0,
+ shadowOpacity: elevation ? spotShadow.shadowOpacity : 0,
shadowOffset: {
width: 0,
- height: shadowLayers[0].height[elevation],
+ height: spotShadow.height[elevation],
},
- shadowRadius:
- shadowLayers[0].shadowRadius[elevation] * IOS_SHADOW_RADIUS_FACTOR,
+ shadowRadius: spotShadow.shadowRadius[elevation],
};
}