[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
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
style="position: absolute; height: 0px; visibility: hidden; width: 0px;"
>
<defs>
<filter
id="tint-33"
id="tint-53"
>
<feflood
flood-color="blue"
@@ -366,7 +366,7 @@ exports[`components/Image prop "style" supports "tintcolor" property (convert to
>
<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"
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
alt=""
@@ -379,7 +379,7 @@ exports[`components/Image prop "style" supports "tintcolor" property (convert to
>
<defs>
<filter
id="tint-32"
id="tint-52"
>
<feflood
flood-color="red"
@@ -1,10 +1,10 @@
/* eslint-env jasmine, jest */
/* eslint-disable react/jsx-no-bind */
import { act } from 'react-dom/test-utils';
import * as AssetRegistry from '../../../modules/AssetRegistry';
import Image from '../';
import ImageLoader from '../../../modules/ImageLoader';
import ImageUriCache from '../ImageUriCache';
import ImageLoader, { ImageUriCache } from '../../../modules/ImageLoader';
import PixelRatio from '../../PixelRatio';
import React from 'react';
import { render } from '@testing-library/react';
@@ -89,10 +89,18 @@ describe('components/Image', () => {
ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => {
onLoad();
});
const onLoadStartStub = 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();
expect(ImageLoader.load).toBeCalled();
expect(onLoadStub).toBeCalled();
});
@@ -101,32 +109,74 @@ describe('components/Image', () => {
ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => {
onLoad();
});
const onLoadStartStub = jest.fn();
const onLoadStub = jest.fn();
const onLoadEndStub = jest.fn();
const uri = 'https://test.com/img.jpg';
ImageUriCache.add(uri);
render(<Image onLoad={onLoadStub} source={uri} />);
render(
<Image
onLoad={onLoadStub}
onLoadEnd={onLoadEndStub}
onLoadStart={onLoadStartStub}
source={uri}
/>
);
jest.runOnlyPendingTimers();
expect(ImageLoader.load).not.toBeCalled();
expect(onLoadStub).toBeCalled();
ImageUriCache.remove(uri);
});
test('is called on update if "uri" is different', () => {
const onLoadStartStub = jest.fn();
const onLoadStub = jest.fn();
const onLoadEndStub = jest.fn();
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(onLoadEndStub.mock.calls.length).toBe(2);
});
test('is not called on update if "uri" is the same', () => {
const onLoadStartStub = jest.fn();
const onLoadStub = jest.fn();
const onLoadEndStub = jest.fn();
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(onLoadEndStub.mock.calls.length).toBe(1);
});
});
@@ -178,15 +228,19 @@ describe('components/Image', () => {
ImageUriCache.remove(uriOne);
expect(container.firstChild).toMatchSnapshot();
// props update
rerender(<Image source={{ uri: uriTwo }} />);
ImageUriCache.remove(uriTwo);
act(() => {
rerender(<Image source={{ uri: uriTwo }} />);
ImageUriCache.remove(uriTwo);
});
expect(container.firstChild).toMatchSnapshot();
});
test('is correctly updated when missing in initial render', () => {
const uri = 'https://testing.com/img.jpg';
const { container, rerender } = render(<Image />);
rerender(<Image source={{ uri }} />);
act(() => {
rerender(<Image source={{ uri }} />);
});
expect(container.firstChild).toMatchSnapshot();
});
@@ -199,7 +253,9 @@ describe('components/Image', () => {
});
const { container } = render(<Image defaultSource={{ uri: defaultUri }} source={{ uri }} />);
expect(container.firstChild).toMatchSnapshot();
loadCallback();
act(() => {
loadCallback();
});
expect(container.firstChild).toMatchSnapshot();
});
@@ -215,8 +271,10 @@ describe('components/Image', () => {
let { container } = render(<Image source={1} />);
expect(container.querySelector('img').src).toBe('http://localhost/static/img.png');
PixelRatio.get = jest.fn(() => 2.2);
({ container } = render(<Image source={1} />));
act(() => {
PixelRatio.get = jest.fn(() => 2.2);
({ container } = render(<Image source={1} />));
});
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 { ResizeMode, Source, Style } from './types';
import applyNativeMethods from '../../modules/applyNativeMethods';
import createElement from '../createElement';
import css from '../StyleSheet/css';
import { getAssetByID } from '../../modules/AssetRegistry';
import resolveShadowValue from '../StyleSheet/resolveShadowValue';
import ImageLoader from '../../modules/ImageLoader';
import ImageUriCache from './ImageUriCache';
import PixelRatio from '../PixelRatio';
import StyleSheet from '../StyleSheet';
import TextAncestorContext from '../Text/TextAncestorContext';
import View from '../View';
import React from 'react';
import React, { forwardRef, useContext, useEffect, useRef, useState } from 'react';
export type ImageProps = {
...ViewProps,
@@ -40,34 +38,84 @@ export type ImageProps = {
style?: Style
};
type State = {
layout: Object,
shouldDisplaySource: boolean
};
const ERRORED = 'ERRORED';
const LOADED = 'LOADED';
const LOADING = 'LOADING';
const IDLE = 'IDLE';
const STATUS_ERRORED = 'ERRORED';
const STATUS_LOADED = 'LOADED';
const STATUS_LOADING = 'LOADING';
const STATUS_PENDING = 'PENDING';
const STATUS_IDLE = 'IDLE';
let _filterId = 0;
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
const getImageState = (uri, shouldDisplaySource) => {
return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE;
};
function createTintColorSVG(tintColor, id) {
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') {
const { height, width } = getAssetByID(source);
return { height, width };
} else if (typeof source === 'object') {
} else if (source != null && typeof source === 'object') {
const { height, width } = source;
return { height, width };
}
};
}
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
const resolveAssetUri = source => {
let uri = '';
function resolveAssetUri(source): ?string {
let uri = null;
if (typeof source === 'number') {
// get the URI from the packager
const asset = getAssetByID(source);
@@ -98,306 +146,193 @@ const resolveAssetUri = source => {
}
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) =>
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;
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 });
if (process.env.NODE_ENV !== 'production') {
if (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.'
);
}
}
componentDidUpdate(prevProps) {
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 [state, updateState] = useState(() => {
const uri = resolveAssetUri(source);
this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError);
this._onLoadStart();
}
_destroyImageLoader() {
if (this._imageRequestId) {
ImageLoader.abort(this._imageRequestId);
this._imageRequestId = null;
if (uri != null) {
const isLoaded = ImageLoader.has(uri);
if (isLoaded) {
return LOADED;
}
}
}
return IDLE;
});
_createLayoutHandler = resizeMode => {
const { onLayout } = this.props;
if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) {
return e => {
const { layout } = e.nativeEvent;
onLayout && onLayout(e);
this.setState(() => ({ layout }));
};
}
};
const [layout, updateLayout] = useState({});
const hasTextAncestor = useContext(TextAncestorContext);
const hiddenImageRef = useRef(null);
const filterRef = useRef(_filterId++);
const requestRef = useRef(null);
const shouldDisplaySource = state === LOADED || (state === LOADING && defaultSource == null);
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 => {
if (this._imageRef && (resizeMode === 'center' || resizeMode === 'repeat')) {
const { naturalHeight, naturalWidth } = this._imageRef;
const { height, width } = this.state.layout;
// 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: 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) {
const scaleFactor = Math.min(1, width / naturalWidth, height / naturalHeight);
const x = Math.ceil(scaleFactor * naturalWidth);
const y = Math.ceil(scaleFactor * naturalHeight);
return {
backgroundSize: `${x}px ${y}px`
};
return `${x}px ${y}px`;
}
}
};
}
_onError = () => {
const { onError, source } = this.props;
this._updateImageState(STATUS_ERRORED);
if (onError) {
onError({
nativeEvent: {
error: `Failed to load resource ${resolveAssetUri(source)} (404)`
function handleLayout(e) {
if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) {
const { layout } = e.nativeEvent;
onLayout && onLayout(e);
updateLayout(layout);
}
}
// 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 => {
const { onLoad, source } = this.props;
const event = { nativeEvent: e };
ImageUriCache.add(resolveAssetUri(source));
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 }));
function abortPendingRequest() {
if (requestRef.current != null) {
ImageLoader.abort(requestRef.current);
requestRef.current = null;
}
}
}
}
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({
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,
backgroundClip?: string,
backgroundColor?: ColorValue,
backgroundImage?: string,
backgroundImage?: ?string,
backgroundOrigin?: 'border-box' | 'content-box' | 'padding-box',
backgroundPosition?: string,
backgroundRepeat?: string,
backgroundSize?: string,
boxShadow?: string,
clip?: string,
filter?: string,
filter?: ?string,
opacity?: number,
outlineColor?: ColorValue,
outlineOffset?: string | number,
+93 -7
View File
@@ -4,9 +4,71 @@
* This source code is licensed under the MIT license found in the
* 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;
const requests = {};
@@ -14,11 +76,13 @@ const ImageLoader = {
abort(requestId: number) {
let image = requests[`${requestId}`];
if (image) {
image.onerror = image.onload = image = null;
image.onerror = null;
image.onload = null;
image = null;
delete requests[`${requestId}`];
}
},
getSize(uri, success, failure) {
getSize(uri: string, success: Function, failure: Function) {
let complete = false;
const interval = setInterval(callback, 16);
const requestId = ImageLoader.load(uri, callback, errorCallback);
@@ -46,13 +110,16 @@ const ImageLoader = {
clearInterval(interval);
}
},
load(uri, onLoad, onError): number {
has(uri: string) {
return ImageUriCache.has(uri);
},
load(uri: string, onLoad: Function, onError: Function): number {
id += 1;
const image = new window.Image();
image.onerror = onError;
image.onload = e => {
// avoid blocking the main thread
const onDecode = () => onLoad(e);
const onDecode = () => onLoad();
if (typeof image.decode === 'function') {
// Safari currently throws exceptions when decoding svgs.
// We want to catch that error and allow the load handler
@@ -66,10 +133,29 @@ const ImageLoader = {
requests[`${id}`] = image;
return id;
},
prefetch(uri): Promise {
prefetch(uri: string): Promise<*> {
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);
}
};