[change] modernize Image

Rewrite Image to use function components and hooks.

Fix #1322
This commit is contained in:
Nicolas Gallagher
2020-02-24 13:03:42 -08:00
parent 7e616f6d46
commit f4e8b6b194
6 changed files with 409 additions and 400 deletions
@@ -1,70 +0,0 @@
/**
* Copyright (c) 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
*/
const dataUriPattern = /^data:/;
export default class ImageUriCache {
static _maximumEntries: number = 256;
static _entries = {};
static has(uri: string) {
const entries = ImageUriCache._entries;
const isDataUri = dataUriPattern.test(uri);
return isDataUri || Boolean(entries[uri]);
}
static add(uri: string) {
const entries = ImageUriCache._entries;
const lastUsedTimestamp = Date.now();
if (entries[uri]) {
entries[uri].lastUsedTimestamp = lastUsedTimestamp;
entries[uri].refCount += 1;
} else {
entries[uri] = {
lastUsedTimestamp,
refCount: 1
};
}
}
static remove(uri: string) {
const entries = ImageUriCache._entries;
if (entries[uri]) {
entries[uri].refCount -= 1;
}
// Free up entries when the cache is "full"
ImageUriCache._cleanUpIfNeeded();
}
static _cleanUpIfNeeded() {
const entries = ImageUriCache._entries;
const imageUris = Object.keys(entries);
if (imageUris.length + 1 > ImageUriCache._maximumEntries) {
let leastRecentlyUsedKey;
let leastRecentlyUsedEntry;
imageUris.forEach(uri => {
const entry = entries[uri];
if (
(!leastRecentlyUsedEntry ||
entry.lastUsedTimestamp < leastRecentlyUsedEntry.lastUsedTimestamp) &&
entry.refCount === 0
) {
leastRecentlyUsedKey = uri;
leastRecentlyUsedEntry = entry;
}
});
if (leastRecentlyUsedKey) {
delete entries[leastRecentlyUsedKey];
}
}
}
}
@@ -317,14 +317,14 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`]
> >
<div <div
class="css-view-1dbjc4n r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-backgroundSize-4gszlv r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw" class="css-view-1dbjc4n r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-backgroundSize-4gszlv r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw"
style="filter: url(#tint-33);" style="filter: url(#tint-53);"
/> />
<svg <svg
style="position: absolute; height: 0px; visibility: hidden; width: 0px;" style="position: absolute; height: 0px; visibility: hidden; width: 0px;"
> >
<defs> <defs>
<filter <filter
id="tint-33" id="tint-53"
> >
<feflood <feflood
flood-color="blue" flood-color="blue"
@@ -366,7 +366,7 @@ exports[`components/Image prop "style" supports "tintcolor" property (convert to
> >
<div <div
class="css-view-1dbjc4n r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-backgroundSize-4gszlv r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw" class="css-view-1dbjc4n r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-backgroundSize-4gszlv r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw"
style="background-image: url(https://google.com/favicon.ico); filter: url(#tint-32);" style="background-image: url(https://google.com/favicon.ico); filter: url(#tint-52);"
/> />
<img <img
alt="" alt=""
@@ -379,7 +379,7 @@ exports[`components/Image prop "style" supports "tintcolor" property (convert to
> >
<defs> <defs>
<filter <filter
id="tint-32" id="tint-52"
> >
<feflood <feflood
flood-color="red" flood-color="red"
@@ -1,10 +1,10 @@
/* eslint-env jasmine, jest */ /* eslint-env jasmine, jest */
/* eslint-disable react/jsx-no-bind */ /* eslint-disable react/jsx-no-bind */
import { act } from 'react-dom/test-utils';
import * as AssetRegistry from '../../../modules/AssetRegistry'; import * as AssetRegistry from '../../../modules/AssetRegistry';
import Image from '../'; import Image from '../';
import ImageLoader from '../../../modules/ImageLoader'; import ImageLoader, { ImageUriCache } from '../../../modules/ImageLoader';
import ImageUriCache from '../ImageUriCache';
import PixelRatio from '../../PixelRatio'; import PixelRatio from '../../PixelRatio';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
@@ -89,10 +89,18 @@ describe('components/Image', () => {
ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => {
onLoad(); onLoad();
}); });
const onLoadStartStub = jest.fn();
const onLoadStub = jest.fn(); const onLoadStub = jest.fn();
render(<Image onLoad={onLoadStub} source="https://test.com/img.jpg" />); const onLoadEndStub = jest.fn();
render(
<Image
onLoad={onLoadStub}
onLoadEnd={onLoadEndStub}
onLoadStart={onLoadStartStub}
source="https://test.com/img.jpg"
/>
);
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
expect(ImageLoader.load).toBeCalled();
expect(onLoadStub).toBeCalled(); expect(onLoadStub).toBeCalled();
}); });
@@ -101,32 +109,74 @@ describe('components/Image', () => {
ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => {
onLoad(); onLoad();
}); });
const onLoadStartStub = jest.fn();
const onLoadStub = jest.fn(); const onLoadStub = jest.fn();
const onLoadEndStub = jest.fn();
const uri = 'https://test.com/img.jpg'; const uri = 'https://test.com/img.jpg';
ImageUriCache.add(uri); ImageUriCache.add(uri);
render(<Image onLoad={onLoadStub} source={uri} />); render(
<Image
onLoad={onLoadStub}
onLoadEnd={onLoadEndStub}
onLoadStart={onLoadStartStub}
source={uri}
/>
);
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
expect(ImageLoader.load).not.toBeCalled();
expect(onLoadStub).toBeCalled(); expect(onLoadStub).toBeCalled();
ImageUriCache.remove(uri); ImageUriCache.remove(uri);
}); });
test('is called on update if "uri" is different', () => { test('is called on update if "uri" is different', () => {
const onLoadStartStub = jest.fn();
const onLoadStub = jest.fn(); const onLoadStub = jest.fn();
const onLoadEndStub = jest.fn();
const { rerender } = render( const { rerender } = render(
<Image onLoad={onLoadStub} source={'https://test.com/img.jpg'} /> <Image
onLoad={onLoadStub}
onLoadEnd={onLoadEndStub}
onLoadStart={onLoadStartStub}
source={'https://test.com/img.jpg'}
/>
); );
rerender(<Image onLoad={onLoadStub} source={'https://blah.com/img.png'} />); act(() => {
rerender(
<Image
onLoad={onLoadStub}
onLoadEnd={onLoadEndStub}
onLoadStart={onLoadStartStub}
source={'https://blah.com/img.png'}
/>
);
});
expect(onLoadStub.mock.calls.length).toBe(2); expect(onLoadStub.mock.calls.length).toBe(2);
expect(onLoadEndStub.mock.calls.length).toBe(2);
}); });
test('is not called on update if "uri" is the same', () => { test('is not called on update if "uri" is the same', () => {
const onLoadStartStub = jest.fn();
const onLoadStub = jest.fn(); const onLoadStub = jest.fn();
const onLoadEndStub = jest.fn();
const { rerender } = render( const { rerender } = render(
<Image onLoad={onLoadStub} source={'https://test.com/img.jpg'} /> <Image
onLoad={onLoadStub}
onLoadEnd={onLoadEndStub}
onLoadStart={onLoadStartStub}
source={'https://test.com/img.jpg'}
/>
); );
rerender(<Image onLoad={onLoadStub} source={'https://test.com/img.jpg'} />); act(() => {
rerender(
<Image
onLoad={onLoadStub}
onLoadEnd={onLoadEndStub}
onLoadStart={onLoadStartStub}
source={'https://test.com/img.jpg'}
/>
);
});
expect(onLoadStub.mock.calls.length).toBe(1); expect(onLoadStub.mock.calls.length).toBe(1);
expect(onLoadEndStub.mock.calls.length).toBe(1);
}); });
}); });
@@ -178,15 +228,19 @@ describe('components/Image', () => {
ImageUriCache.remove(uriOne); ImageUriCache.remove(uriOne);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
// props update // props update
rerender(<Image source={{ uri: uriTwo }} />); act(() => {
ImageUriCache.remove(uriTwo); rerender(<Image source={{ uri: uriTwo }} />);
ImageUriCache.remove(uriTwo);
});
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
}); });
test('is correctly updated when missing in initial render', () => { test('is correctly updated when missing in initial render', () => {
const uri = 'https://testing.com/img.jpg'; const uri = 'https://testing.com/img.jpg';
const { container, rerender } = render(<Image />); const { container, rerender } = render(<Image />);
rerender(<Image source={{ uri }} />); act(() => {
rerender(<Image source={{ uri }} />);
});
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
}); });
@@ -199,7 +253,9 @@ describe('components/Image', () => {
}); });
const { container } = render(<Image defaultSource={{ uri: defaultUri }} source={{ uri }} />); const { container } = render(<Image defaultSource={{ uri: defaultUri }} source={{ uri }} />);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
loadCallback(); act(() => {
loadCallback();
});
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
}); });
@@ -215,8 +271,10 @@ describe('components/Image', () => {
let { container } = render(<Image source={1} />); let { container } = render(<Image source={1} />);
expect(container.querySelector('img').src).toBe('http://localhost/static/img.png'); expect(container.querySelector('img').src).toBe('http://localhost/static/img.png');
PixelRatio.get = jest.fn(() => 2.2); act(() => {
({ container } = render(<Image source={1} />)); PixelRatio.get = jest.fn(() => 2.2);
({ container } = render(<Image source={1} />));
});
expect(container.querySelector('img').src).toBe('http://localhost/static/img@2x.png'); expect(container.querySelector('img').src).toBe('http://localhost/static/img@2x.png');
}); });
}); });
+236 -301
View File
@@ -11,18 +11,16 @@
import type { ViewProps } from '../View'; import type { ViewProps } from '../View';
import type { ResizeMode, Source, Style } from './types'; import type { ResizeMode, Source, Style } from './types';
import applyNativeMethods from '../../modules/applyNativeMethods';
import createElement from '../createElement'; import createElement from '../createElement';
import css from '../StyleSheet/css'; import css from '../StyleSheet/css';
import { getAssetByID } from '../../modules/AssetRegistry'; import { getAssetByID } from '../../modules/AssetRegistry';
import resolveShadowValue from '../StyleSheet/resolveShadowValue'; import resolveShadowValue from '../StyleSheet/resolveShadowValue';
import ImageLoader from '../../modules/ImageLoader'; import ImageLoader from '../../modules/ImageLoader';
import ImageUriCache from './ImageUriCache';
import PixelRatio from '../PixelRatio'; import PixelRatio from '../PixelRatio';
import StyleSheet from '../StyleSheet'; import StyleSheet from '../StyleSheet';
import TextAncestorContext from '../Text/TextAncestorContext'; import TextAncestorContext from '../Text/TextAncestorContext';
import View from '../View'; import View from '../View';
import React from 'react'; import React, { forwardRef, useContext, useEffect, useRef, useState } from 'react';
export type ImageProps = { export type ImageProps = {
...ViewProps, ...ViewProps,
@@ -40,34 +38,84 @@ export type ImageProps = {
style?: Style style?: Style
}; };
type State = { const ERRORED = 'ERRORED';
layout: Object, const LOADED = 'LOADED';
shouldDisplaySource: boolean const LOADING = 'LOADING';
}; const IDLE = 'IDLE';
const STATUS_ERRORED = 'ERRORED'; let _filterId = 0;
const STATUS_LOADED = 'LOADED'; const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
const STATUS_LOADING = 'LOADING';
const STATUS_PENDING = 'PENDING';
const STATUS_IDLE = 'IDLE';
const getImageState = (uri, shouldDisplaySource) => { function createTintColorSVG(tintColor, id) {
return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE; return 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;
}
const resolveAssetDimensions = source => { function getFlatStyle(style, blurRadius, filterId) {
const flatStyle = { ...StyleSheet.flatten(style) };
const { filter, resizeMode, shadowOffset, tintColor } = flatStyle;
// Add CSS filters
// React Native exposes these features as props and proprietary styles
const filters = [];
let _filter = null;
if (filter) {
filters.push(filter);
}
//
if (blurRadius) {
filters.push(`blur(${blurRadius}px)`);
}
if (shadowOffset) {
const shadowString = resolveShadowValue(flatStyle);
if (shadowString) {
filters.push(`drop-shadow(${shadowString})`);
}
}
if (tintColor && filterId != null) {
filters.push(`url(#tint-${filterId})`);
}
if (filters.length > 0) {
_filter = filters.join(' ');
}
// These styles are converted to CSS filters applied to the
// element displaying the background image.
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;
return [flatStyle, resizeMode, _filter, tintColor];
}
function resolveAssetDimensions(source) {
if (typeof source === 'number') { if (typeof source === 'number') {
const { height, width } = getAssetByID(source); const { height, width } = getAssetByID(source);
return { height, width }; return { height, width };
} else if (typeof source === 'object') { } else if (source != null && typeof source === 'object') {
const { height, width } = source; const { height, width } = source;
return { height, width }; return { height, width };
} }
}; }
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; function resolveAssetUri(source): ?string {
const resolveAssetUri = source => { let uri = null;
let uri = '';
if (typeof source === 'number') { if (typeof source === 'number') {
// get the URI from the packager // get the URI from the packager
const asset = getAssetByID(source); const asset = getAssetByID(source);
@@ -98,306 +146,193 @@ const resolveAssetUri = source => {
} }
return uri; return uri;
}; }
let filterId = 0; const Image = forwardRef<ImageProps, *>((props, ref) => {
const {
accessibilityLabel,
accessibilityRelationship,
accessibilityRole,
accessibilityState,
accessible,
blurRadius,
defaultSource,
draggable,
importantForAccessibility,
nativeID,
onError,
onLayout,
onLoad,
onLoadEnd,
onLoadStart,
pointerEvents,
source,
testID
} = props;
const createTintColorSVG = (tintColor, id) => if (process.env.NODE_ENV !== 'production') {
tintColor && id != null ? ( if (props.children) {
<svg style={{ position: 'absolute', height: 0, visibility: 'hidden', width: 0 }}> throw new Error(
<defs> 'The <Image> component cannot contain children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.'
<filter id={`tint-${id}`}> );
<feFlood floodColor={`${tintColor}`} />
<feComposite in2="SourceAlpha" operator="atop" />
</filter>
</defs>
</svg>
) : null;
class Image extends React.Component<ImageProps, State> {
static displayName = 'Image';
static getSize(uri, success, failure) {
ImageLoader.getSize(uri, success, failure);
}
static prefetch(uri) {
return ImageLoader.prefetch(uri).then(() => {
// Add the uri to the cache so it can be immediately displayed when used
// but also immediately remove it to correctly reflect that it has no active references
ImageUriCache.add(uri);
ImageUriCache.remove(uri);
});
}
static queryCache(uris) {
const result = {};
uris.forEach(u => {
if (ImageUriCache.has(u)) {
result[u] = 'disk/memory';
}
});
return Promise.resolve(result);
}
_filterId = 0;
_imageRef = null;
_imageRequestId = null;
_imageState = null;
_isMounted = false;
constructor(props, context) {
super(props, context);
// If an image has been loaded before, render it immediately
const uri = resolveAssetUri(props.source);
const shouldDisplaySource = ImageUriCache.has(uri);
this.state = { layout: {}, shouldDisplaySource };
this._imageState = getImageState(uri, shouldDisplaySource);
this._filterId = filterId;
filterId++;
}
componentDidMount() {
this._isMounted = true;
if (this._imageState === STATUS_PENDING) {
this._createImageLoader();
} else if (this._imageState === STATUS_LOADED) {
this._onLoad({ target: this._imageRef });
} }
} }
componentDidUpdate(prevProps) { const [state, updateState] = useState(() => {
const prevUri = resolveAssetUri(prevProps.source);
const uri = resolveAssetUri(this.props.source);
const hasDefaultSource = this.props.defaultSource != null;
if (prevUri !== uri) {
ImageUriCache.remove(prevUri);
const isPreviouslyLoaded = ImageUriCache.has(uri);
isPreviouslyLoaded && ImageUriCache.add(uri);
this._updateImageState(getImageState(uri, isPreviouslyLoaded), hasDefaultSource);
} else if (hasDefaultSource && prevProps.defaultSource !== this.props.defaultSource) {
this._updateImageState(this._imageState, hasDefaultSource);
}
if (this._imageState === STATUS_PENDING) {
this._createImageLoader();
}
}
componentWillUnmount() {
const uri = resolveAssetUri(this.props.source);
ImageUriCache.remove(uri);
this._destroyImageLoader();
this._isMounted = false;
}
renderImage(hasTextAncestor) {
const { shouldDisplaySource } = this.state;
const {
accessibilityLabel,
accessibilityRelationship,
accessibilityRole,
accessibilityState,
accessible,
blurRadius,
defaultSource,
draggable,
importantForAccessibility,
nativeID,
pointerEvents,
resizeMode,
source,
testID
} = this.props;
if (process.env.NODE_ENV !== 'production') {
if (this.props.children) {
throw new Error(
'The <Image> component cannot contain children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.'
);
}
}
const selectedSource = shouldDisplaySource ? source : defaultSource;
const displayImageUri = resolveAssetUri(selectedSource);
const imageSizeStyle = resolveAssetDimensions(selectedSource);
const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null;
const flatStyle = { ...StyleSheet.flatten(this.props.style) };
const finalResizeMode = resizeMode || flatStyle.resizeMode || 'cover';
// 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;
// Accessibility image allows users to trigger the browser's image context menu
const hiddenImage = displayImageUri
? createElement('img', {
alt: accessibilityLabel || '',
classList: [classes.accessibilityImage],
draggable: draggable || false,
ref: this._setImageRef,
src: displayImageUri
})
: null;
return (
<View
accessibilityLabel={accessibilityLabel}
accessibilityRelationship={accessibilityRelationship}
accessibilityRole={accessibilityRole}
accessibilityState={accessibilityState}
accessible={accessible}
importantForAccessibility={importantForAccessibility}
nativeID={nativeID}
onLayout={this._createLayoutHandler(finalResizeMode)}
pointerEvents={pointerEvents}
style={[styles.root, hasTextAncestor && styles.inline, imageSizeStyle, flatStyle]}
testID={testID}
>
<View
style={[
styles.image,
resizeModeStyles[finalResizeMode],
this._getBackgroundSize(finalResizeMode),
backgroundImage && { backgroundImage },
filters.length > 0 && { filter: filters.join(' ') }
]}
/>
{hiddenImage}
{createTintColorSVG(tintColor, this._filterId)}
</View>
);
}
render() {
return (
<TextAncestorContext.Consumer>
{hasTextAncestor => this.renderImage(hasTextAncestor)}
</TextAncestorContext.Consumer>
);
}
_createImageLoader() {
const { source } = this.props;
this._destroyImageLoader();
const uri = resolveAssetUri(source); const uri = resolveAssetUri(source);
this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError); if (uri != null) {
this._onLoadStart(); const isLoaded = ImageLoader.has(uri);
} if (isLoaded) {
return LOADED;
_destroyImageLoader() { }
if (this._imageRequestId) {
ImageLoader.abort(this._imageRequestId);
this._imageRequestId = null;
} }
} return IDLE;
});
_createLayoutHandler = resizeMode => { const [layout, updateLayout] = useState({});
const { onLayout } = this.props; const hasTextAncestor = useContext(TextAncestorContext);
if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) { const hiddenImageRef = useRef(null);
return e => { const filterRef = useRef(_filterId++);
const { layout } = e.nativeEvent; const requestRef = useRef(null);
onLayout && onLayout(e); const shouldDisplaySource = state === LOADED || (state === LOADING && defaultSource == null);
this.setState(() => ({ layout })); const [flatStyle, _resizeMode, filter, tintColor] = getFlatStyle(
}; props.style,
} blurRadius,
}; filterRef.current
);
const resizeMode = props.resizeMode || _resizeMode || 'cover';
const selectedSource = shouldDisplaySource ? source : defaultSource;
const displayImageUri = resolveAssetUri(selectedSource);
const imageSizeStyle = resolveAssetDimensions(selectedSource);
const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null;
const backgroundSize = getBackgroundSize();
_getBackgroundSize = resizeMode => { // Accessibility image allows users to trigger the browser's image context menu
if (this._imageRef && (resizeMode === 'center' || resizeMode === 'repeat')) { const hiddenImage = displayImageUri
const { naturalHeight, naturalWidth } = this._imageRef; ? createElement('img', {
const { height, width } = this.state.layout; alt: accessibilityLabel || '',
classList: [classes.accessibilityImage],
draggable: draggable || false,
ref: hiddenImageRef,
src: displayImageUri
})
: null;
function getBackgroundSize(): ?string {
if (hiddenImageRef.current != null && (resizeMode === 'center' || resizeMode === 'repeat')) {
const { naturalHeight, naturalWidth } = hiddenImageRef.current;
const { height, width } = layout;
if (naturalHeight && naturalWidth && height && width) { if (naturalHeight && naturalWidth && height && width) {
const scaleFactor = Math.min(1, width / naturalWidth, height / naturalHeight); const scaleFactor = Math.min(1, width / naturalWidth, height / naturalHeight);
const x = Math.ceil(scaleFactor * naturalWidth); const x = Math.ceil(scaleFactor * naturalWidth);
const y = Math.ceil(scaleFactor * naturalHeight); const y = Math.ceil(scaleFactor * naturalHeight);
return { return `${x}px ${y}px`;
backgroundSize: `${x}px ${y}px`
};
} }
} }
}; }
_onError = () => { function handleLayout(e) {
const { onError, source } = this.props; if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) {
this._updateImageState(STATUS_ERRORED); const { layout } = e.nativeEvent;
if (onError) { onLayout && onLayout(e);
onError({ updateLayout(layout);
nativeEvent: { }
error: `Failed to load resource ${resolveAssetUri(source)} (404)` }
// Image loading
useEffect(() => {
abortPendingRequest();
const uri = resolveAssetUri(source);
if (uri != null) {
updateState(LOADING);
if (onLoadStart) {
onLoadStart();
}
requestRef.current = ImageLoader.load(
uri,
function load(e) {
updateState(LOADED);
if (onLoad) {
onLoad();
}
if (onLoadEnd) {
onLoadEnd();
}
},
function error() {
updateState(ERRORED);
if (onError) {
onError({
nativeEvent: {
error: `Failed to load resource ${uri} (404)`
}
});
}
if (onLoadEnd) {
onLoadEnd();
}
} }
}); );
} }
this._onLoadEnd();
};
_onLoad = e => { function abortPendingRequest() {
const { onLoad, source } = this.props; if (requestRef.current != null) {
const event = { nativeEvent: e }; ImageLoader.abort(requestRef.current);
ImageUriCache.add(resolveAssetUri(source)); requestRef.current = null;
this._updateImageState(STATUS_LOADED);
if (onLoad) {
onLoad(event);
}
this._onLoadEnd();
};
_onLoadEnd() {
const { onLoadEnd } = this.props;
if (onLoadEnd) {
onLoadEnd();
}
}
_onLoadStart() {
const { defaultSource, onLoadStart } = this.props;
this._updateImageState(STATUS_LOADING, defaultSource != null);
if (onLoadStart) {
onLoadStart();
}
}
_setImageRef = ref => {
this._imageRef = ref;
};
_updateImageState(status: ?string, hasDefaultSource: ?boolean = false) {
this._imageState = status;
const shouldDisplaySource =
this._imageState === STATUS_LOADED ||
(this._imageState === STATUS_LOADING && !hasDefaultSource);
// only triggers a re-render when the image is loading and has no default image (to support PJPEG), loaded, or failed
if (shouldDisplaySource !== this.state.shouldDisplaySource) {
if (this._isMounted) {
this.setState(() => ({ shouldDisplaySource }));
} }
} }
}
} return abortPendingRequest;
}, [source, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]);
return (
<View
accessibilityLabel={accessibilityLabel}
accessibilityRelationship={accessibilityRelationship}
accessibilityRole={accessibilityRole}
accessibilityState={accessibilityState}
accessible={accessible}
importantForAccessibility={importantForAccessibility}
nativeID={nativeID}
onLayout={handleLayout}
pointerEvents={pointerEvents}
ref={ref}
style={[styles.root, hasTextAncestor && styles.inline, imageSizeStyle, flatStyle]}
testID={testID}
>
<View
style={[
styles.image,
resizeModeStyles[resizeMode],
{ backgroundImage, filter },
backgroundSize != null && { backgroundSize }
]}
/>
{hiddenImage}
{createTintColorSVG(tintColor, filterRef.current)}
</View>
);
});
Image.displayName = 'Image';
// $FlowFixMe
Image.getSize = function(uri, success, failure) {
ImageLoader.getSize(uri, success, failure);
};
// $FlowFixMe
Image.prefetch = function(uri) {
return ImageLoader.prefetch(uri);
};
// $FlowFixMe
Image.queryCache = function(uris) {
return ImageLoader.queryCache(uris);
};
const classes = css.create({ const classes = css.create({
accessibilityImage: { accessibilityImage: {
@@ -454,4 +389,4 @@ const resizeModeStyles = StyleSheet.create({
} }
}); });
export default applyNativeMethods(Image); export default Image;
+2 -2
View File
@@ -33,14 +33,14 @@ export type ViewStyle = {
backgroundBlendMode?: string, backgroundBlendMode?: string,
backgroundClip?: string, backgroundClip?: string,
backgroundColor?: ColorValue, backgroundColor?: ColorValue,
backgroundImage?: string, backgroundImage?: ?string,
backgroundOrigin?: 'border-box' | 'content-box' | 'padding-box', backgroundOrigin?: 'border-box' | 'content-box' | 'padding-box',
backgroundPosition?: string, backgroundPosition?: string,
backgroundRepeat?: string, backgroundRepeat?: string,
backgroundSize?: string, backgroundSize?: string,
boxShadow?: string, boxShadow?: string,
clip?: string, clip?: string,
filter?: string, filter?: ?string,
opacity?: number, opacity?: number,
outlineColor?: ColorValue, outlineColor?: ColorValue,
outlineOffset?: string | number, outlineOffset?: string | number,
+93 -7
View File
@@ -4,9 +4,71 @@
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
* *
* @noflow * @flow
*/ */
const dataUriPattern = /^data:/;
export class ImageUriCache {
static _maximumEntries: number = 256;
static _entries = {};
static has(uri: string) {
const entries = ImageUriCache._entries;
const isDataUri = dataUriPattern.test(uri);
return isDataUri || Boolean(entries[uri]);
}
static add(uri: string) {
const entries = ImageUriCache._entries;
const lastUsedTimestamp = Date.now();
if (entries[uri]) {
entries[uri].lastUsedTimestamp = lastUsedTimestamp;
entries[uri].refCount += 1;
} else {
entries[uri] = {
lastUsedTimestamp,
refCount: 1
};
}
}
static remove(uri: string) {
const entries = ImageUriCache._entries;
if (entries[uri]) {
entries[uri].refCount -= 1;
}
// Free up entries when the cache is "full"
ImageUriCache._cleanUpIfNeeded();
}
static _cleanUpIfNeeded() {
const entries = ImageUriCache._entries;
const imageUris = Object.keys(entries);
if (imageUris.length + 1 > ImageUriCache._maximumEntries) {
let leastRecentlyUsedKey;
let leastRecentlyUsedEntry;
imageUris.forEach(uri => {
const entry = entries[uri];
if (
(!leastRecentlyUsedEntry ||
entry.lastUsedTimestamp < leastRecentlyUsedEntry.lastUsedTimestamp) &&
entry.refCount === 0
) {
leastRecentlyUsedKey = uri;
leastRecentlyUsedEntry = entry;
}
});
if (leastRecentlyUsedKey) {
delete entries[leastRecentlyUsedKey];
}
}
}
}
let id = 0; let id = 0;
const requests = {}; const requests = {};
@@ -14,11 +76,13 @@ const ImageLoader = {
abort(requestId: number) { abort(requestId: number) {
let image = requests[`${requestId}`]; let image = requests[`${requestId}`];
if (image) { if (image) {
image.onerror = image.onload = image = null; image.onerror = null;
image.onload = null;
image = null;
delete requests[`${requestId}`]; delete requests[`${requestId}`];
} }
}, },
getSize(uri, success, failure) { getSize(uri: string, success: Function, failure: Function) {
let complete = false; let complete = false;
const interval = setInterval(callback, 16); const interval = setInterval(callback, 16);
const requestId = ImageLoader.load(uri, callback, errorCallback); const requestId = ImageLoader.load(uri, callback, errorCallback);
@@ -46,13 +110,16 @@ const ImageLoader = {
clearInterval(interval); clearInterval(interval);
} }
}, },
load(uri, onLoad, onError): number { has(uri: string) {
return ImageUriCache.has(uri);
},
load(uri: string, onLoad: Function, onError: Function): number {
id += 1; id += 1;
const image = new window.Image(); const image = new window.Image();
image.onerror = onError; image.onerror = onError;
image.onload = e => { image.onload = e => {
// avoid blocking the main thread // avoid blocking the main thread
const onDecode = () => onLoad(e); const onDecode = () => onLoad();
if (typeof image.decode === 'function') { if (typeof image.decode === 'function') {
// Safari currently throws exceptions when decoding svgs. // Safari currently throws exceptions when decoding svgs.
// We want to catch that error and allow the load handler // We want to catch that error and allow the load handler
@@ -66,10 +133,29 @@ const ImageLoader = {
requests[`${id}`] = image; requests[`${id}`] = image;
return id; return id;
}, },
prefetch(uri): Promise { prefetch(uri: string): Promise<*> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ImageLoader.load(uri, resolve, reject); ImageLoader.load(
uri,
() => {
// Add the uri to the cache so it can be immediately displayed when used
// but also immediately remove it to correctly reflect that it has no active references
ImageUriCache.add(uri);
ImageUriCache.remove(uri);
resolve();
},
reject
);
}); });
},
queryCache(uris: Array<string>): Object {
const result = {};
uris.forEach(u => {
if (ImageUriCache.has(u)) {
result[u] = 'disk/memory';
}
});
return Promise.resolve(result);
} }
}; };