Skip to content

Commit

Permalink
Support Image resizeMode=repeat on Android
Browse files Browse the repository at this point in the history
Summary:
`<Image resizeMode="repeat" />` for Android, matching the iOS implementation (facebook#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. facebook#14158.

Given facebook/fresco#1575, and lacking the context to follow the specific recommendations in facebook/fresco#1575 (comment), 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 `<Image>` 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 facebook#17398 (comment), I have neither updated nor tested the "Flat" UI implementation - everything compiles but I've taken [this comment](facebook#12770 (comment)) 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 |
|-|-|
| <img src=https://user-images.githubusercontent.com/2246565/34461897-4e12008e-ee2f-11e7-8581-1dc0cc8f2779.png width=300>| <img src=https://user-images.githubusercontent.com/2246565/34461894-40b2c8ec-ee2f-11e7-8a8f-96704f3c8caa.png width=300> |

Docs update: facebook/react-native-website#106

[ANDROID] [FEATURE] [Image] - Implement resizeMode=repeat
Closes facebook#17404

Reviewed By: achen1

Differential Revision: D7070329

Pulled By: mdvacca

fbshipit-source-id: 6a72fcbdcc7c7c2daf293dc1d8b6728f54ad0249
  • Loading branch information
motiz88 authored and LukeDurrant committed Apr 11, 2018
1 parent e87a740 commit b69fbd3
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 19 deletions.
2 changes: 1 addition & 1 deletion Libraries/Image/Image.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
22 changes: 10 additions & 12 deletions RNTester/js/ImageExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,18 +558,16 @@ exports.examples = [
source={image}
/>
</View>
{ Platform.OS === 'ios' ?
<View style={styles.leftMargin}>
<Text style={[styles.resizeModeText]}>
Repeat
</Text>
<Image
style={styles.resizeMode}
resizeMode={Image.resizeMode.repeat}
source={image}
/>
</View>
: null }
<View style={styles.leftMargin}>
<Text style={[styles.resizeModeText]}>
Repeat
</Text>
<Image
style={styles.resizeMode}
resizeMode={Image.resizeMode.repeat}
source={image}
/>
</View>
<View style={styles.leftMargin}>
<Text style={[styles.resizeModeText]}>
Center
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import javax.annotation.Nullable;

import android.graphics.Shader;
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
import com.facebook.drawee.drawable.ScalingUtils;

Expand All @@ -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();
Expand All @@ -42,11 +47,38 @@ 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.
*/
public static ScalingUtils.ScaleType defaultValue() {
return ScalingUtils.ScaleType.CENTER_CROP;
}

public static Shader.TileMode defaultTileMode() {
return Shader.TileMode.CLAMP;
}
}
Original file line number Diff line number Diff line change
@@ -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<Postprocessor> mPostprocessors;

public static Postprocessor from(List<Postprocessor> postprocessors) {
switch (postprocessors.size()) {
case 0:
return null;
case 1:
return postprocessors.get(0);
default:
return new MultiPostprocessor(postprocessors);
}
}

private MultiPostprocessor(List<Postprocessor> 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<CacheKey> keys = new LinkedList<>();
for (Postprocessor p: mPostprocessors) {
keys.push(p.getPostprocessorCacheKey());
}
return new MultiCacheKey(keys);
}

@Override
public CloseableReference<Bitmap> process(Bitmap sourceBitmap, PlatformBitmapFactory bitmapFactory) {
CloseableReference<Bitmap> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Bitmap> 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<Bitmap> 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<ImageSource> mSources;

private @Nullable ImageSource mImageSource;
Expand All @@ -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;
Expand All @@ -180,6 +219,7 @@ public ReactImageView(
mScaleType = ImageResizeMode.defaultValue();
mDraweeControllerBuilder = draweeControllerBuilder;
mRoundedCornerPostprocessor = new RoundedCornerPostprocessor();
mTilePostprocessor = new TilePostprocessor();
mGlobalImageLoadListener = globalImageLoadListener;
mCallerContext = callerContext;
mSources = new LinkedList<>();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<Postprocessor> 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;

Expand Down Expand Up @@ -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();
}
}
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
}
}

0 comments on commit b69fbd3

Please sign in to comment.