[add] Image support for blurRadius, tintColor, and shadows

Use CSS filters to implement React Native image styles.

Ref #362
Ref #548
This commit is contained in:
Nicolas Gallagher
2018-05-24 20:31:21 -07:00
parent 026a92fd53
commit 3153cd8213
7 changed files with 136 additions and 18 deletions

View File

@@ -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
*/

View File

@@ -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))"`;

View File

@@ -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(<Image blurRadius={5} defaultSource={defaultSource} />);
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(<Image style={{ resizeMode: Image.resizeMode.contain }} />);
expect(findImageSurfaceStyle(component).backgroundSize).toMatchSnapshot();
});
test('supports "shadow" properties (convert to filter)', () => {
const component = shallow(
<Image style={{ shadowColor: 'red', shadowOffset: { width: 1, height: 1 } }} />
);
expect(findImageSurfaceStyle(component).filter).toMatchSnapshot();
});
test('supports "tintcolor" property (convert to filter)', () => {
const defaultSource = { uri: 'https://google.com/favicon.ico' };
const component = shallow(
<Image defaultSource={defaultSource} style={{ tintColor: 'red' }} />
);
// 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(<Image style={{ overlayColor: 'red', tintColor: 'blue' }} />);
expect(component.props().style.overlayColor).toBeUndefined();

View File

@@ -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 ? (
<svg style={{ position: 'absolute', height: 0, visibility: 'hidden', width: 0 }}>
<defs>
<filter id={`tint-${id}`}>
<feFlood floodColor={`${tintColor}`} />
<feComposite in2="SourceAlpha" operator="atop" />
</filter>
</defs>
</svg>
) : 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)}
</View>
);
}

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -21,6 +21,7 @@ import UIExplorer, {
Description,
DocItem,
Section,
StyleList,
storiesOf
} from '../../ui-explorer';
@@ -34,6 +35,12 @@ const ImageScreen = () => (
<Section title="Props">
<DocItem name="...View props" />
<DocItem
name="blurRadius"
typeInfo="?number"
description="The blur radius of the blur filter added to the image"
/>
<DocItem
name="defaultSource"
typeInfo="?object"
@@ -122,7 +129,11 @@ const ImageScreen = () => (
}}
/>
<DocItem name="style" typeInfo="?style" />
<DocItem
name="style"
typeInfo="?style"
description={<StyleList stylePropTypes={stylePropTypes} />}
/>
</Section>
<Section title="Properties">
@@ -168,4 +179,17 @@ const ImageScreen = () => (
</UIExplorer>
);
const stylePropTypes = [
{
name: '...View#style'
},
{
name: 'resizeMode'
},
{
name: 'tintColor',
typeInfo: 'color'
}
];
storiesOf('Components', module).add('Image', ImageScreen);