From 50613a700d6f044590f7c30fb8b232fd5951dd1e Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Fri, 21 Aug 2026 15:06:31 +0200 Subject: [PATCH] fix: make elevation shadows on iOS & Web match android more closely --- example/src/Examples/SurfaceExample.tsx | 92 +++++++++--- src/components/FAB/useVisibility.ts | 7 +- src/components/__tests__/Surface.test.tsx | 135 +++++++++++++----- .../__snapshots__/Banner.test.tsx.snap | 72 +++++----- .../__tests__/__snapshots__/FAB.test.tsx.snap | 76 +++++----- .../__snapshots__/FABExtended.test.tsx.snap | 34 ++--- .../__snapshots__/FABMenu.test.tsx.snap | 40 +++--- .../__snapshots__/Menu.test.tsx.snap | 24 ++-- .../__snapshots__/Snackbar.test.tsx.snap | 48 +++---- src/theme/tokens/sys/elevation.ts | 125 ++++++++++++---- 10 files changed, 416 insertions(+), 237 deletions(-) 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], }; }