diff --git a/Examples/UIExplorer/ImageExample.js b/Examples/UIExplorer/ImageExample.js index 0c374b19e..905c17f4c 100644 --- a/Examples/UIExplorer/ImageExample.js +++ b/Examples/UIExplorer/ImageExample.js @@ -156,6 +156,63 @@ var ImageSizeExample = React.createClass({ }, }); +var MultipleSourcesExample = React.createClass({ + getInitialState: function() { + return { + width: 30, + height: 30, + }; + }, + render: function() { + return ( + + + + Decrease image size + + + Increase image size + + + Container image size: {this.state.width}x{this.state.height} + + + + + ); + }, + increaseImageSize: function() { + if (this.state.width >= 100) { + return; + } + this.setState({ + width: this.state.width + 10, + height: this.state.height + 10, + }); + }, + decreaseImageSize: function() { + if (this.state.width <= 10) { + return; + } + this.setState({ + width: this.state.width - 10, + height: this.state.height - 10, + }); + }, +}); + exports.displayName = (undefined: ?string); exports.framework = 'React'; exports.title = ''; @@ -510,6 +567,16 @@ exports.examples = [ return ; }, }, + { + title: 'MultipleSourcesExample', + description: + 'The `source` prop allows passing in an array of uris, so that native to choose which image ' + + 'to diplay based on the size of the of the target image', + render: function() { + return ; + }, + platform: 'android', + }, ]; var fullImage = {uri: 'http://facebook.github.io/react/img/logo_og.png'}; @@ -567,4 +634,8 @@ var styles = StyleSheet.create({ height: 50, resizeMode: 'contain', }, + touchableText: { + fontWeight: '500', + color: 'blue', + }, }); diff --git a/Libraries/Image/Image.android.js b/Libraries/Image/Image.android.js index 91dee727a..dc267f8fa 100644 --- a/Libraries/Image/Image.android.js +++ b/Libraries/Image/Image.android.js @@ -71,6 +71,9 @@ var Image = React.createClass({ * `uri` is a string representing the resource identifier for the image, which * could be an http address, a local file path, or a static image * resource (which should be wrapped in the `require('./path/to/image.png')` function). + * This prop can also contain several remote `uri`, specified together with + * their width and height. The native side will then choose the best `uri` to display + * based on the measured size of the image container. */ source: PropTypes.oneOfType([ PropTypes.shape({ @@ -78,6 +81,13 @@ var Image = React.createClass({ }), // Opaque type returned by require('./image.jpg') PropTypes.number, + // Multiple sources + PropTypes.arrayOf( + PropTypes.shape({ + uri: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + })) ]), /** * similarly to `source`, this property represents the resource used to render @@ -176,11 +186,11 @@ var Image = React.createClass({ }, render: function() { - var source = resolveAssetSource(this.props.source); - var loadingIndicatorSource = resolveAssetSource(this.props.loadingIndicatorSource); + const source = resolveAssetSource(this.props.source); + const loadingIndicatorSource = resolveAssetSource(this.props.loadingIndicatorSource); - // As opposed to the ios version, here it render `null` - // when no source or source.uri... so let's not break that. + // As opposed to the ios version, here we render `null` when there is no source, source.uri + // or source array. if (source && source.uri === '') { console.warn('source.uri should not be an empty string'); @@ -190,21 +200,29 @@ var Image = React.createClass({ console.warn('The component requires a `source` property rather than `src`.'); } - if (source && source.uri) { - var {width, height} = source; - var style = flattenStyle([{width, height}, styles.base, this.props.style]); - var {onLoadStart, onLoad, onLoadEnd} = this.props; + if (source && (source.uri || Array.isArray(source))) { + let style; + let sources; + if (source.uri) { + const {width, height} = source; + style = flattenStyle([{width, height}, styles.base, this.props.style]); + sources = [{uri: source.uri}]; + } else { + style = flattenStyle([styles.base, this.props.style]); + sources = source; + } - var nativeProps = merge(this.props, { + const {onLoadStart, onLoad, onLoadEnd} = this.props; + const nativeProps = merge(this.props, { style, shouldNotifyLoadEvents: !!(onLoadStart || onLoad || onLoadEnd), - src: source.uri, + src: sources, loadingIndicatorSrc: loadingIndicatorSource ? loadingIndicatorSource.uri : null, }); if (nativeProps.children) { // TODO(6033040): Consider implementing this as a separate native component - var imageProps = merge(nativeProps, { + const imageProps = merge(nativeProps, { style: styles.absoluteImage, children: undefined, }); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java index fbd98355c..518ea9e0e 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java @@ -19,13 +19,14 @@ import android.graphics.PorterDuff.Mode; import com.facebook.csslayout.CSSConstants; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.controller.AbstractDraweeControllerBuilder; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.common.MapBuilder; -import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.annotations.ReactPropGroup; import com.facebook.react.uimanager.SimpleViewManager; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.ViewProps; +import com.facebook.react.uimanager.annotations.ReactProp; +import com.facebook.react.uimanager.annotations.ReactPropGroup; public class ReactImageManager extends SimpleViewManager { @@ -75,10 +76,10 @@ public class ReactImageManager extends SimpleViewManager { mResourceDrawableIdHelper); } - // In JS this is Image.props.source.uri + // In JS this is Image.props.source @ReactProp(name = "src") - public void setSource(ReactImageView view, @Nullable String source) { - view.setSource(source); + public void setSource(ReactImageView view, @Nullable ReadableArray sources) { + view.setSource(sources); } // In JS this is Image.props.loadingIndicatorSource.uri diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java index d7d093868..7d029f0e4 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java @@ -12,6 +12,8 @@ package com.facebook.react.views.image; import javax.annotation.Nullable; import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; import android.content.Context; import android.graphics.Bitmap; @@ -48,6 +50,8 @@ import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import com.facebook.imagepipeline.request.Postprocessor; import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; import com.facebook.react.common.SystemClock; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.UIManagerModule; @@ -136,6 +140,7 @@ public class ReactImageView extends GenericDraweeView { } private final ResourceDrawableIdHelper mResourceDrawableIdHelper; + private final Map mSources; private @Nullable Uri mUri; private @Nullable Drawable mLoadingImageDrawable; @@ -173,6 +178,7 @@ public class ReactImageView extends GenericDraweeView { mRoundedCornerPostprocessor = new RoundedCornerPostprocessor(); mCallerContext = callerContext; mResourceDrawableIdHelper = resourceDrawableIdHelper; + mSources = new HashMap<>(); } public void setShouldNotifyLoadEvents(boolean shouldNotify) { @@ -259,23 +265,19 @@ public class ReactImageView extends GenericDraweeView { mIsDirty = true; } - public void setSource(@Nullable String source) { - mUri = null; - if (source != null) { - try { - mUri = Uri.parse(source); - // Verify scheme is set, so that relative uri (used by static resources) are not handled. - if (mUri.getScheme() == null) { - mUri = null; - } - } catch (Exception e) { - // ignore malformed uri, then attempt to extract resource ID. - } - if (mUri == null) { - mUri = mResourceDrawableIdHelper.getResourceDrawableUri(getContext(), source); - mIsLocalImage = true; + public void setSource(@Nullable ReadableArray sources) { + mSources.clear(); + if (sources != null && sources.size() != 0) { + // Optimize for the case where we have just one uri, case in which we don't need the sizes + if (sources.size() == 1) { + mSources.put(sources.getMap(0).getString("uri"), 0.0); } else { - mIsLocalImage = false; + for (int idx = 0; idx < sources.size(); idx++) { + ReadableMap source = sources.getMap(idx); + mSources.put( + source.getString("uri"), + source.getDouble("width") * source.getDouble("height")); + } } } mIsDirty = true; @@ -312,6 +314,16 @@ public class ReactImageView extends GenericDraweeView { return; } + if (hasMultipleSources() && (getWidth() <= 0 || getHeight() <= 0)) { + // If we need to choose from multiple uris but the size is not yet set, wait for layout pass + return; + } + + computeSourceUri(); + if (mUri == null) { + return; + } + boolean doResize = shouldResize(mUri); if (doResize && (getWidth() <= 0 || getHeight() <= 0)) { // If need a resize and the size is not yet set, wait until the layout pass provides one @@ -398,6 +410,7 @@ public class ReactImageView extends GenericDraweeView { protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (w > 0 && h > 0) { + mIsDirty = mIsDirty || hasMultipleSources(); maybeUpdateView(); } } @@ -410,10 +423,65 @@ public class ReactImageView extends GenericDraweeView { return false; } - private static boolean shouldResize(@Nullable Uri uri) { + private boolean hasMultipleSources() { + return mSources.size() > 1; + } + + private void computeSourceUri() { + mUri = null; + if (mSources.isEmpty()) { + return; + } + if (hasMultipleSources()) { + setUriFromMultipleSources(); + return; + } + + final String singleSource = mSources.keySet().iterator().next(); + setUriFromSingleSource(singleSource); + } + + private void setUriFromSingleSource(String source) { + try { + mUri = Uri.parse(source); + // Verify scheme is set, so that relative uri (used by static resources) are not handled. + if (mUri.getScheme() == null) { + mUri = null; + } + } catch (Exception e) { + // ignore malformed uri, then attempt to extract resource ID. + } + if (mUri == null) { + mUri = mResourceDrawableIdHelper.getResourceDrawableUri(getContext(), source); + mIsLocalImage = true; + } else { + mIsLocalImage = false; + } + } + + /** + * Chooses the uri with the size closest to the target image size. Must be called only after the + * layout pass when the sizes of the target image have been computed, and when there are at least + * two sources to choose from. + */ + private void setUriFromMultipleSources() { + final double targetImageSize = getWidth() * getHeight(); + double bestPrecision = Double.MAX_VALUE; + String bestUri = null; + for (Map.Entry source : mSources.entrySet()) { + final double precision = Math.abs(1.0 - (source.getValue()) / targetImageSize); + if (precision < bestPrecision) { + bestPrecision = precision; + bestUri = source.getKey(); + } + } + setUriFromSingleSource(bestUri); + } + + private static boolean shouldResize(Uri uri) { // Resizing is inferior to scaling. See http://frescolib.org/docs/resizing-rotating.html#_ // We resize here only for images likely to be from the device's camera, where the app developer // has no control over the original size - return uri != null && (UriUtil.isLocalContentUri(uri) || UriUtil.isLocalFileUri(uri)); + return UriUtil.isLocalContentUri(uri) || UriUtil.isLocalFileUri(uri); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/frescosupport/FrescoBasedReactTextInlineImageShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/frescosupport/FrescoBasedReactTextInlineImageShadowNode.java index b1d3c012a..21e6f2384 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/frescosupport/FrescoBasedReactTextInlineImageShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/frescosupport/FrescoBasedReactTextInlineImageShadowNode.java @@ -20,6 +20,7 @@ import android.net.Uri; import com.facebook.common.util.UriUtil; import com.facebook.csslayout.CSSNode; import com.facebook.drawee.controller.AbstractDraweeControllerBuilder; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.views.text.ReactTextInlineImageShadowNode; import com.facebook.react.views.text.TextInlineImageSpan; @@ -43,7 +44,9 @@ public class FrescoBasedReactTextInlineImageShadowNode extends ReactTextInlineIm } @ReactProp(name = "src") - public void setSource(@Nullable String source) { + public void setSource(@Nullable ReadableArray sources) { + final String source = + (sources == null || sources.size() == 0) ? null : sources.getMap(0).getString("uri"); Uri uri = null; if (source != null) { try { diff --git a/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.java b/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.java index 186e4dca4..0e051ab79 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.java @@ -14,6 +14,7 @@ import android.util.DisplayMetrics; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.react.bridge.CatalystInstance; +import com.facebook.react.bridge.JavaOnlyArray; import com.facebook.react.bridge.ReactTestHelper; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReactApplicationContext; @@ -82,7 +83,9 @@ public class ReactImagePropertyTest { public void testBorderColor() { ReactImageManager viewManager = new ReactImageManager(); ReactImageView view = viewManager.createViewInstance(mThemeContext); - viewManager.updateProperties(view, buildStyles("src", "http://mysite.com/mypic.jpg")); + viewManager.updateProperties( + view, + buildStyles("src", JavaOnlyArray.of(JavaOnlyMap.of("uri", "http://mysite.com/mypic.jpg")))); viewManager.updateProperties(view, buildStyles("borderColor", Color.argb(0, 0, 255, 255))); int borderColor = view.getHierarchy().getRoundingParams().getBorderColor(); @@ -110,7 +113,9 @@ public class ReactImagePropertyTest { public void testRoundedCorners() { ReactImageManager viewManager = new ReactImageManager(); ReactImageView view = viewManager.createViewInstance(mThemeContext); - viewManager.updateProperties(view, buildStyles("src", "http://mysite.com/mypic.jpg")); + viewManager.updateProperties( + view, + buildStyles("src", JavaOnlyArray.of(JavaOnlyMap.of("uri", "http://mysite.com/mypic.jpg")))); // We can't easily verify if rounded corner was honored or not, this tests simply verifies // we're not crashing..