From 3153cd8213f393bd6e8f22a027b278b40293778e Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 24 May 2018 20:31:21 -0700 Subject: [PATCH] [add] Image support for blurRadius, tintColor, and shadows Use CSS filters to implement React Native image styles. Ref #362 Ref #548 --- .../src/exports/Image/ImageStylePropTypes.js | 2 +- .../__snapshots__/index-test.js.snap | 6 +- .../src/exports/Image/__tests__/index-test.js | 26 ++++++++- .../src/exports/Image/index.js | 55 +++++++++++++++++-- .../exports/StyleSheet/createReactDOMStyle.js | 12 +--- .../exports/StyleSheet/resolveShadowValue.js | 27 +++++++++ .../1-components/Image/ImageScreen.js | 26 ++++++++- 7 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 packages/react-native-web/src/exports/StyleSheet/resolveShadowValue.js diff --git a/packages/react-native-web/src/exports/Image/ImageStylePropTypes.js b/packages/react-native-web/src/exports/Image/ImageStylePropTypes.js index 7a32aa8d..9e219f80 100644 --- a/packages/react-native-web/src/exports/Image/ImageStylePropTypes.js +++ b/packages/react-native-web/src/exports/Image/ImageStylePropTypes.js @@ -22,11 +22,11 @@ const ImageStylePropTypes = { backgroundColor: ColorPropType, opacity: number, resizeMode: oneOf(Object.keys(ImageResizeMode)), + tintColor: ColorPropType, /** * @platform unsupported */ overlayColor: string, - tintColor: ColorPropType, /** * @platform web */ diff --git a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap index a4cfd27d..bdd491de 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`components/Image prop "blurRadius" 1`] = `"blur(5px)"`; + exports[`components/Image prop "defaultSource" sets background image when value is a string 1`] = `"url(\\"https://google.com/favicon.ico\\")"`; exports[`components/Image prop "defaultSource" sets background image when value is an object 1`] = `"url(\\"https://google.com/favicon.ico\\")"`; @@ -14,4 +16,6 @@ exports[`components/Image prop "resizeMode" value "stretch" 1`] = `"100% 100%"`; exports[`components/Image prop "resizeMode" value "undefined" 1`] = `"cover"`; -exports[`components/Image prop "style" correctly supports "resizeMode" property 1`] = `"contain"`; +exports[`components/Image prop "style" supports "resizeMode" property 1`] = `"contain"`; + +exports[`components/Image prop "style" supports "shadow" properties (convert to filter) 1`] = `"drop-shadow(1px 1px 0px rgba(255,0,0,1.00))"`; diff --git a/packages/react-native-web/src/exports/Image/__tests__/index-test.js b/packages/react-native-web/src/exports/Image/__tests__/index-test.js index c7f81d9b..6477ae75 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Image/__tests__/index-test.js @@ -36,6 +36,12 @@ describe('components/Image', () => { expect(component.prop('accessible')).toBe(false); }); + test('prop "blurRadius"', () => { + const defaultSource = { uri: 'https://google.com/favicon.ico' }; + const component = shallow(); + expect(findImageSurfaceStyle(component).filter).toMatchSnapshot(); + }); + describe('prop "defaultSource"', () => { test('sets background image when value is an object', () => { const defaultSource = { uri: 'https://google.com/favicon.ico' }; @@ -207,11 +213,29 @@ describe('components/Image', () => { }); describe('prop "style"', () => { - test('correctly supports "resizeMode" property', () => { + test('supports "resizeMode" property', () => { const component = shallow(); expect(findImageSurfaceStyle(component).backgroundSize).toMatchSnapshot(); }); + test('supports "shadow" properties (convert to filter)', () => { + const component = shallow( + + ); + expect(findImageSurfaceStyle(component).filter).toMatchSnapshot(); + }); + + test('supports "tintcolor" property (convert to filter)', () => { + const defaultSource = { uri: 'https://google.com/favicon.ico' }; + const component = shallow( + + ); + // filter + expect(findImageSurfaceStyle(component).filter).toContain('url(#tint-'); + // svg + expect(component.childAt(2).type()).toBe('svg'); + }); + test('removes other unsupported View styles', () => { const component = shallow(); expect(component.props().style.overlayColor).toBeUndefined(); diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 8e4216b5..77061c42 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -11,6 +11,7 @@ import applyNativeMethods from '../../modules/applyNativeMethods'; import createElement from '../createElement'; import { getAssetByID } from '../../modules/AssetRegistry'; +import resolveShadowValue from '../StyleSheet/resolveShadowValue'; import ImageLoader from '../../modules/ImageLoader'; import ImageResizeMode from './ImageResizeMode'; import ImageSourcePropType from './ImageSourcePropType'; @@ -73,6 +74,20 @@ const resolveAssetUri = source => { return uri; }; +let filterId = 0; + +const createTintColorSVG = (tintColor, id) => + tintColor && id != null ? ( + + + + + + + + + ) : null; + type State = { shouldDisplaySource: boolean }; @@ -86,6 +101,7 @@ class Image extends Component<*, State> { static propTypes = { ...ViewPropTypes, + blurRadius: number, defaultSource: ImageSourcePropType, draggable: bool, onError: func, @@ -98,7 +114,6 @@ class Image extends Component<*, State> { style: StyleSheetPropType(ImageStylePropTypes), // compatibility with React Native /* eslint-disable react/sort-prop-types */ - blurRadius: number, capInsets: shape({ top: number, left: number, bottom: number, right: number }), resizeMethod: oneOf(['auto', 'resize', 'scale']) /* eslint-enable react/sort-prop-types */ @@ -118,6 +133,7 @@ class Image extends Component<*, State> { static resizeMode = ImageResizeMode; + _filterId = 0; _imageRef = null; _imageRequestId = null; _imageState = null; @@ -130,6 +146,8 @@ class Image extends Component<*, State> { const shouldDisplaySource = ImageUriCache.has(uri); this.state = { shouldDisplaySource }; this._imageState = getImageState(uri, shouldDisplaySource); + this._filterId = filterId; + filterId++; } componentDidMount() { @@ -170,13 +188,13 @@ class Image extends Component<*, State> { const { accessibilityLabel, accessible, + blurRadius, defaultSource, draggable, onLayout, source, testID, /* eslint-disable */ - blurRadius, capInsets, onError, onLoad, @@ -206,10 +224,35 @@ class Image extends Component<*, State> { const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; const flatStyle = { ...StyleSheet.flatten(this.props.style) }; const finalResizeMode = resizeMode || flatStyle.resizeMode || ImageResizeMode.cover; - // View doesn't support these styles + + // CSS filters + const filters = []; + const tintColor = flatStyle.tintColor; + if (flatStyle.filter) { + filters.push(flatStyle.filter); + } + if (blurRadius) { + filters.push(`blur(${blurRadius}px)`); + } + if (flatStyle.shadowOffset) { + const shadowString = resolveShadowValue(flatStyle); + if (shadowString) { + filters.push(`drop-shadow(${shadowString})`); + } + } + if (flatStyle.tintColor) { + filters.push(`url(#tint-${this._filterId})`); + } + + // these styles were converted to filters + delete flatStyle.shadowColor; + delete flatStyle.shadowOpacity; + delete flatStyle.shadowOffset; + delete flatStyle.shadowRadius; + delete flatStyle.tintColor; + // these styles are not supported on View delete flatStyle.overlayColor; delete flatStyle.resizeMode; - delete flatStyle.tintColor; // Accessibility image allows users to trigger the browser's image context menu const hiddenImage = displayImageUri @@ -240,10 +283,12 @@ class Image extends Component<*, State> { style={[ styles.image, resizeModeStyles[finalResizeMode], - backgroundImage && { backgroundImage } + backgroundImage && { backgroundImage }, + filters.length > 0 && { filter: filters.join(' ') } ]} /> {hiddenImage} + {createTintColorSVG(tintColor, this._filterId)} ); } diff --git a/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js b/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js index a50472f7..399ba6dc 100644 --- a/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js +++ b/packages/react-native-web/src/exports/StyleSheet/createReactDOMStyle.js @@ -9,6 +9,7 @@ import normalizeColor from '../../modules/normalizeColor'; import normalizeValue from './normalizeValue'; +import resolveShadowValue from './resolveShadowValue'; /** * The browser implements the CSS cascade, where the order of properties is a @@ -82,16 +83,9 @@ const defaultOffset = { height: 0, width: 0 }; * Shadow */ -// TODO: add inset and spread support const resolveShadow = (resolvedStyle, style) => { - const { boxShadow, shadowColor, shadowOffset, shadowOpacity, shadowRadius } = style; - const { height, width } = shadowOffset || defaultOffset; - const offsetX = normalizeValue(null, width); - const offsetY = normalizeValue(null, height); - const blurRadius = normalizeValue(null, shadowRadius || 0); - const color = normalizeColor(shadowColor || 'black', shadowOpacity); - - const shadow = `${offsetX} ${offsetY} ${blurRadius} ${color}`; + const { boxShadow } = style; + const shadow = resolveShadowValue(style); resolvedStyle.boxShadow = boxShadow ? `${boxShadow}, ${shadow}` : shadow; }; diff --git a/packages/react-native-web/src/exports/StyleSheet/resolveShadowValue.js b/packages/react-native-web/src/exports/StyleSheet/resolveShadowValue.js new file mode 100644 index 00000000..2126d597 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/resolveShadowValue.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2018-present, Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import normalizeColor from '../../modules/normalizeColor'; +import normalizeValue from './normalizeValue'; + +const defaultOffset = { height: 0, width: 0 }; + +const resolveShadowValue = (style: Object) => { + const { shadowColor, shadowOffset, shadowOpacity, shadowRadius } = style; + const { height, width } = shadowOffset || defaultOffset; + const offsetX = normalizeValue(null, width); + const offsetY = normalizeValue(null, height); + const blurRadius = normalizeValue(null, shadowRadius || 0); + const color = normalizeColor(shadowColor || 'black', shadowOpacity); + if (color) { + return `${offsetX} ${offsetY} ${blurRadius} ${color}`; + } +}; + +export default resolveShadowValue; diff --git a/packages/website/storybook/1-components/Image/ImageScreen.js b/packages/website/storybook/1-components/Image/ImageScreen.js index 8662a814..34efc807 100644 --- a/packages/website/storybook/1-components/Image/ImageScreen.js +++ b/packages/website/storybook/1-components/Image/ImageScreen.js @@ -21,6 +21,7 @@ import UIExplorer, { Description, DocItem, Section, + StyleList, storiesOf } from '../../ui-explorer'; @@ -34,6 +35,12 @@ const ImageScreen = () => (
+ + ( }} /> - + } + />
@@ -168,4 +179,17 @@ const ImageScreen = () => ( ); +const stylePropTypes = [ + { + name: '...View#style' + }, + { + name: 'resizeMode' + }, + { + name: 'tintColor', + typeInfo: 'color' + } +]; + storiesOf('Components', module).add('Image', ImageScreen);