From 7552b9e515b055b63720d8873b45ee54dfddefe4 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Mon, 12 Mar 2018 16:05:40 -0700 Subject: [PATCH] Support Image resizeMode=repeat on Android Summary: `` for Android, matching the iOS implementation (#7968). (Non-goal: changing the component's API for finer-grained control / feature parity with CSS - this would be nice in the future) As requested in e.g. #14158. Given https://github.com/facebook/fresco/issues/1575, and lacking the context to follow the specific recommendations in https://github.com/facebook/fresco/issues/1575#issuecomment-267004303, I've opted for a minimal change within RN itself. It's likely that performance can be improved by offloading this work to Fresco in some clever way; but I'm assuming that the present naive approach is still an improvement over a userland implementation with `onLayout` and multiple `` instances. - Picking up on a TODO note in the existing code, I implemented `MultiPostprocessor` to allow arbitrary chaining of Fresco-compatible postprocessors inside `ReactImageView`. - Rather than extensively refactor `ImageResizeMode`, `ReactImageManager` and `ReactImageView`, I mostly preserved the existing API that maps `resizeMode` values to [`ScaleType`](http://frescolib.org/javadoc/reference/com/facebook/drawee/drawable/ScalingUtils.ScaleType.html) instances, and simply added a second mapping, to [`TileMode`](https://developer.android.com/reference/android/graphics/Shader.TileMode.html). - To match the iOS rendering exactly for oversized images, I found that scaling with a custom `ScaleType` was required - a kind of combination of `CENTER_INSIDE` and `FIT_START` which Fresco doesn't provide - so I implemented that as `ScaleTypeStartInside`. (This is, frankly, questionable as the default behaviour on iOS to begin with - but I am aiming for parity here) - `resizeMode="repeat"` is therefore unpacked by the view manager to the effect of: ```js view.setScaleType(ScaleTypeStartInside.INSTANCE); view.setTileMode(Shader.TileMode.REPEAT); ``` And the added postprocessing in the view (in case of a non-`CLAMP` tile mode) consists of waiting for layout, allocating a destination bitmap and painting the source bitmap with the requested tile mode and scale type. Note that as in https://github.com/facebook/react-native/pull/17398#issue-285235247, I have neither updated nor tested the "Flat" UI implementation - everything compiles but I've taken [this comment](https://github.com/facebook/react-native/issues/12770#issuecomment-294052694) to mean there's no point in trying to wade through it on my own right now; I'm happy to tackle it if given some pointers. Also, I'm happy to address any code style issues or other feedback; I'm new to this codebase and a very infrequent Android/Java coder. Tested by enabling the relevant case in RNTester on Android. | iOS | Android | |-|-| | | | Docs update: https://github.com/facebook/react-native-website/pull/106 [ANDROID] [FEATURE] [Image] - Implement resizeMode=repeat Closes https://github.com/facebook/react-native/pull/17404 Reviewed By: achen1 Differential Revision: D7070329 Pulled By: mdvacca fbshipit-source-id: 6a72fcbdcc7c7c2daf293dc1d8b6728f54ad0249 --- Libraries/Image/Image.android.js | 2 +- RNTester/js/ImageExample.js | 22 +++--- .../react/views/image/ImageResizeMode.java | 32 ++++++++ .../react/views/image/MultiPostprocessor.java | 79 +++++++++++++++++++ .../react/views/image/ReactImageManager.java | 1 + .../react/views/image/ReactImageView.java | 70 ++++++++++++++-- .../views/image/ScaleTypeStartInside.java | 40 ++++++++++ 7 files changed, 227 insertions(+), 19 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/image/MultiPostprocessor.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/image/ScaleTypeStartInside.java diff --git a/Libraries/Image/Image.android.js b/Libraries/Image/Image.android.js index bdb29ab8fe8965..4e218487c9b505 100644 --- a/Libraries/Image/Image.android.js +++ b/Libraries/Image/Image.android.js @@ -119,7 +119,7 @@ var Image = createReactClass({ * * See https://facebook.github.io/react-native/docs/image.html#resizemode */ - resizeMode: PropTypes.oneOf(['cover', 'contain', 'stretch', 'center']), + resizeMode: PropTypes.oneOf(['cover', 'contain', 'stretch', 'repeat', 'center']), }, statics: { diff --git a/RNTester/js/ImageExample.js b/RNTester/js/ImageExample.js index d30a24ca7c93b2..37f36b697de32a 100644 --- a/RNTester/js/ImageExample.js +++ b/RNTester/js/ImageExample.js @@ -558,18 +558,16 @@ exports.examples = [ source={image} /> - { Platform.OS === 'ios' ? - - - Repeat - - - - : null } + + + Repeat + + + Center diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/ImageResizeMode.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/ImageResizeMode.java index 3cff8b9667a292..3f98058feedd0c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/image/ImageResizeMode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/ImageResizeMode.java @@ -9,6 +9,7 @@ import javax.annotation.Nullable; +import android.graphics.Shader; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.drawee.drawable.ScalingUtils; @@ -34,6 +35,10 @@ public static ScalingUtils.ScaleType toScaleType(@Nullable String resizeModeValu if ("center".equals(resizeModeValue)) { return ScalingUtils.ScaleType.CENTER_INSIDE; } + if ("repeat".equals(resizeModeValue)) { + // Handled via a combination of ScaleType and TileMode + return ScaleTypeStartInside.INSTANCE; + } if (resizeModeValue == null) { // Use the default. Never use null. return defaultValue(); @@ -42,6 +47,29 @@ public static ScalingUtils.ScaleType toScaleType(@Nullable String resizeModeValu "Invalid resize mode: '" + resizeModeValue + "'"); } + /** + * Converts JS resize modes into {@code Shader.TileMode}. + * See {@code ImageResizeMode.js}. + */ + public static Shader.TileMode toTileMode(@Nullable String resizeModeValue) { + if ("contain".equals(resizeModeValue) + || "cover".equals(resizeModeValue) + || "stretch".equals(resizeModeValue) + || "center".equals(resizeModeValue)) { + return Shader.TileMode.CLAMP; + } + if ("repeat".equals(resizeModeValue)) { + // Handled via a combination of ScaleType and TileMode + return Shader.TileMode.REPEAT; + } + if (resizeModeValue == null) { + // Use the default. Never use null. + return defaultTileMode(); + } + throw new JSApplicationIllegalArgumentException( + "Invalid resize mode: '" + resizeModeValue + "'"); + } + /** * This is the default as per web and iOS. * We want to be consistent across platforms. @@ -49,4 +77,8 @@ public static ScalingUtils.ScaleType toScaleType(@Nullable String resizeModeValu public static ScalingUtils.ScaleType defaultValue() { return ScalingUtils.ScaleType.CENTER_CROP; } + + public static Shader.TileMode defaultTileMode() { + return Shader.TileMode.CLAMP; + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/MultiPostprocessor.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/MultiPostprocessor.java new file mode 100644 index 00000000000000..d6cf6a3ca8d6c4 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/MultiPostprocessor.java @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.views.image; + +import android.graphics.Bitmap; + +import com.facebook.cache.common.CacheKey; +import com.facebook.cache.common.MultiCacheKey; +import com.facebook.common.references.CloseableReference; +import com.facebook.imagepipeline.bitmaps.PlatformBitmapFactory; +import com.facebook.imagepipeline.request.Postprocessor; + +import java.util.LinkedList; +import java.util.List; + +public class MultiPostprocessor implements Postprocessor { + private final List mPostprocessors; + + public static Postprocessor from(List postprocessors) { + switch (postprocessors.size()) { + case 0: + return null; + case 1: + return postprocessors.get(0); + default: + return new MultiPostprocessor(postprocessors); + } + } + + private MultiPostprocessor(List postprocessors) { + mPostprocessors = new LinkedList<>(postprocessors); + } + + @Override + public String getName () { + StringBuilder name = new StringBuilder(); + for (Postprocessor p: mPostprocessors) { + if (name.length() > 0) { + name.append(","); + } + name.append(p.getName()); + } + name.insert(0, "MultiPostProcessor ("); + name.append(")"); + return name.toString(); + } + + @Override + public CacheKey getPostprocessorCacheKey () { + LinkedList keys = new LinkedList<>(); + for (Postprocessor p: mPostprocessors) { + keys.push(p.getPostprocessorCacheKey()); + } + return new MultiCacheKey(keys); + } + + @Override + public CloseableReference process(Bitmap sourceBitmap, PlatformBitmapFactory bitmapFactory) { + CloseableReference prevBitmap = null, nextBitmap = null; + + try { + for (Postprocessor p : mPostprocessors) { + nextBitmap = p.process(prevBitmap != null ? prevBitmap.get() : sourceBitmap, bitmapFactory); + CloseableReference.closeSafely(prevBitmap); + prevBitmap = nextBitmap.clone(); + } + return nextBitmap.clone(); + } finally { + CloseableReference.closeSafely(nextBitmap); + } + } +} 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 21014c5c7b0fd5..938524d3012156 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 @@ -139,6 +139,7 @@ public void setBorderRadius(ReactImageView view, int index, float borderRadius) @ReactProp(name = ViewProps.RESIZE_MODE) public void setResizeMode(ReactImageView view, @Nullable String resizeMode) { view.setScaleType(ImageResizeMode.toScaleType(resizeMode)); + view.setTileMode(ImageResizeMode.toTileMode(resizeMode)); } @ReactProp(name = ViewProps.RESIZE_METHOD) 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 3fa2e66eb82e6d..9396cac1c5c1f2 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 @@ -22,6 +22,7 @@ import android.graphics.drawable.Drawable; import android.net.Uri; import android.widget.Toast; +import com.facebook.common.references.CloseableReference; import com.facebook.common.util.UriUtil; import com.facebook.drawee.controller.AbstractDraweeControllerBuilder; import com.facebook.drawee.controller.BaseControllerListener; @@ -33,6 +34,7 @@ import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.RoundingParams; import com.facebook.drawee.view.GenericDraweeView; +import com.facebook.imagepipeline.bitmaps.PlatformBitmapFactory; import com.facebook.imagepipeline.common.ResizeOptions; import com.facebook.imagepipeline.image.ImageInfo; import com.facebook.imagepipeline.postprocessors.IterativeBoxBlurPostProcessor; @@ -49,6 +51,7 @@ import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.UIManagerModule; import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.views.image.ImageResizeMode; import com.facebook.react.views.imagehelper.ImageSource; import com.facebook.react.views.imagehelper.MultiSourceHelper; import com.facebook.react.views.imagehelper.MultiSourceHelper.MultiSourceResult; @@ -141,6 +144,40 @@ public void process(Bitmap output, Bitmap source) { } } + // Fresco lacks support for repeating images, see https://github.com/facebook/fresco/issues/1575 + // We implement it here as a postprocessing step. + private static final Matrix sTileMatrix = new Matrix(); + + private class TilePostprocessor extends BasePostprocessor { + @Override + public CloseableReference process(Bitmap source, PlatformBitmapFactory bitmapFactory) { + final Rect destRect = new Rect(0, 0, getWidth(), getHeight()); + + mScaleType.getTransform( + sTileMatrix, + destRect, + source.getWidth(), + source.getHeight(), + 0.0f, + 0.0f); + + Paint paint = new Paint(); + paint.setAntiAlias(true); + Shader shader = new BitmapShader(source, mTileMode, mTileMode); + shader.setLocalMatrix(sTileMatrix); + paint.setShader(shader); + + CloseableReference output = bitmapFactory.createBitmap(getWidth(), getHeight()); + try { + Canvas canvas = new Canvas(output.get()); + canvas.drawRect(destRect, paint); + return output.clone(); + } finally { + CloseableReference.closeSafely(output); + } + } + } + private final List mSources; private @Nullable ImageSource mImageSource; @@ -152,9 +189,11 @@ public void process(Bitmap output, Bitmap source) { private float mBorderRadius = YogaConstants.UNDEFINED; private @Nullable float[] mBorderCornerRadii; private ScalingUtils.ScaleType mScaleType; + private Shader.TileMode mTileMode = ImageResizeMode.defaultTileMode(); private boolean mIsDirty; private final AbstractDraweeControllerBuilder mDraweeControllerBuilder; private final RoundedCornerPostprocessor mRoundedCornerPostprocessor; + private final TilePostprocessor mTilePostprocessor; private @Nullable IterativeBoxBlurPostProcessor mIterativeBoxBlurPostProcessor; private @Nullable ControllerListener mControllerListener; private @Nullable ControllerListener mControllerForTesting; @@ -180,6 +219,7 @@ public ReactImageView( mScaleType = ImageResizeMode.defaultValue(); mDraweeControllerBuilder = draweeControllerBuilder; mRoundedCornerPostprocessor = new RoundedCornerPostprocessor(); + mTilePostprocessor = new TilePostprocessor(); mGlobalImageLoadListener = globalImageLoadListener; mCallerContext = callerContext; mSources = new LinkedList<>(); @@ -275,6 +315,11 @@ public void setScaleType(ScalingUtils.ScaleType scaleType) { mIsDirty = true; } + public void setTileMode(Shader.TileMode tileMode) { + mTileMode = tileMode; + mIsDirty = true; + } + public void setResizeMethod(ImageResizeMethod resizeMethod) { mResizeMethod = resizeMethod; mIsDirty = true; @@ -362,6 +407,11 @@ public void maybeUpdateView() { return; } + if (isTiled() && (getWidth() <= 0 || getHeight() <= 0)) { + // If need to tile and the size is not yet set, wait until the layout pass provides one + return; + } + GenericDraweeHierarchy hierarchy = getHierarchy(); hierarchy.setActualImageScaleType(mScaleType); @@ -396,13 +446,17 @@ public void maybeUpdateView() { ? mFadeDurationMs : mImageSource.isResource() ? 0 : REMOTE_IMAGE_FADE_DURATION_MS); - // TODO: t13601664 Support multiple PostProcessors - Postprocessor postprocessor = null; + List postprocessors = new LinkedList<>(); if (usePostprocessorScaling) { - postprocessor = mRoundedCornerPostprocessor; - } else if (mIterativeBoxBlurPostProcessor != null) { - postprocessor = mIterativeBoxBlurPostProcessor; + postprocessors.add(mRoundedCornerPostprocessor); + } + if (mIterativeBoxBlurPostProcessor != null) { + postprocessors.add(mIterativeBoxBlurPostProcessor); + } + if (isTiled()) { + postprocessors.add(mTilePostprocessor); } + Postprocessor postprocessor = MultiPostprocessor.from(postprocessors); ResizeOptions resizeOptions = doResize ? new ResizeOptions(getWidth(), getHeight()) : null; @@ -468,7 +522,7 @@ public void setControllerListener(ControllerListener controllerListener) { 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(); + mIsDirty = mIsDirty || hasMultipleSources() || isTiled(); maybeUpdateView(); } } @@ -485,6 +539,10 @@ private boolean hasMultipleSources() { return mSources.size() > 1; } + private boolean isTiled() { + return mTileMode != Shader.TileMode.CLAMP; + } + private void setSourceImage() { mImageSource = null; if (mSources.isEmpty()) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/ScaleTypeStartInside.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/ScaleTypeStartInside.java new file mode 100644 index 00000000000000..e2b902b2c5248f --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/ScaleTypeStartInside.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.views.image; + +import android.graphics.Matrix; +import android.graphics.Rect; +import com.facebook.drawee.drawable.ScalingUtils; + +public class ScaleTypeStartInside extends ScalingUtils.AbstractScaleType { + public static final ScalingUtils.ScaleType INSTANCE = new ScaleTypeStartInside(); + + @Override + public void getTransformImpl( + Matrix outTransform, + Rect parentRect, + int childWidth, + int childHeight, + float focusX, + float focusY, + float scaleX, + float scaleY) { + float scale = Math.min(Math.min(scaleX, scaleY), 1.0f); + float dx = parentRect.left; + float dy = parentRect.top; + outTransform.setScale(scale, scale); + outTransform.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f)); + } + + @Override + public String toString() { + return "start_inside"; + } +}