From 617a38d9846fee81b3d94d1e9d06fd74ecb5abe1 Mon Sep 17 00:00:00 2001 From: Andrei Coman Date: Mon, 13 Jun 2016 14:04:19 -0700 Subject: [PATCH] Support multi sources for images Summary: This adds support for specifying multiple sources for an image component, so that native can choose the best one based on the flexbox-computed size of the image. The API is as follows: the image component receives in the `source` prop an array of objects of the type `{uri, width, height}`. On the native side, the native component will wait for the layout pass to receive the width and height of the image, and then parse the array to find the best fitting one. For now, this does not support local resources, but it will be added soon. To see how this works and play with it, there's an example called `MultipleSourcesExample` under `ImageExample` In UIExplorer. Reviewed By: foghina Differential Revision: D3364550 fbshipit-source-id: 66c5aeb2794f2ffeff8da39a9c0b95155fb2d41f --- Examples/UIExplorer/ImageExample.js | 71 ++++++++++++ Libraries/Image/Image.android.js | 40 +++++-- .../react/views/image/ReactImageManager.java | 11 +- .../react/views/image/ReactImageView.java | 104 +++++++++++++++--- ...coBasedReactTextInlineImageShadowNode.java | 5 +- .../views/image/ReactImagePropertyTest.java | 9 +- 6 files changed, 203 insertions(+), 37 deletions(-) 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..