Skip to content

Commit 0459e4f

Browse files
motiz88facebook-github-bot
authored andcommitted
Support Image resizeMode=repeat on Android
Summary: `<Image resizeMode="repeat" />` 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 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 #17398 (comment), I have neither updated nor tested the "Flat" UI implementation - everything compiles but I've taken [this comment](#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 #17404 Reviewed By: achen1 Differential Revision: D7070329 Pulled By: mdvacca fbshipit-source-id: 6a72fcbdcc7c7c2daf293dc1d8b6728f54ad0249
1 parent 1dde989 commit 0459e4f

File tree

7 files changed

+227
-19
lines changed

7 files changed

+227
-19
lines changed

Libraries/Image/Image.android.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ var Image = createReactClass({
119119
*
120120
* See https://facebook.github.io/react-native/docs/image.html#resizemode
121121
*/
122-
resizeMode: PropTypes.oneOf(['cover', 'contain', 'stretch', 'center']),
122+
resizeMode: PropTypes.oneOf(['cover', 'contain', 'stretch', 'repeat', 'center']),
123123
},
124124

125125
statics: {

RNTester/js/ImageExample.js

+10-12
Original file line numberDiff line numberDiff line change
@@ -558,18 +558,16 @@ exports.examples = [
558558
source={image}
559559
/>
560560
</View>
561-
{ Platform.OS === 'ios' ?
562-
<View style={styles.leftMargin}>
563-
<Text style={[styles.resizeModeText]}>
564-
Repeat
565-
</Text>
566-
<Image
567-
style={styles.resizeMode}
568-
resizeMode={Image.resizeMode.repeat}
569-
source={image}
570-
/>
571-
</View>
572-
: null }
561+
<View style={styles.leftMargin}>
562+
<Text style={[styles.resizeModeText]}>
563+
Repeat
564+
</Text>
565+
<Image
566+
style={styles.resizeMode}
567+
resizeMode={Image.resizeMode.repeat}
568+
source={image}
569+
/>
570+
</View>
573571
<View style={styles.leftMargin}>
574572
<Text style={[styles.resizeModeText]}>
575573
Center

ReactAndroid/src/main/java/com/facebook/react/views/image/ImageResizeMode.java

+32
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import javax.annotation.Nullable;
1111

12+
import android.graphics.Shader;
1213
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
1314
import com.facebook.drawee.drawable.ScalingUtils;
1415

@@ -34,6 +35,10 @@ public static ScalingUtils.ScaleType toScaleType(@Nullable String resizeModeValu
3435
if ("center".equals(resizeModeValue)) {
3536
return ScalingUtils.ScaleType.CENTER_INSIDE;
3637
}
38+
if ("repeat".equals(resizeModeValue)) {
39+
// Handled via a combination of ScaleType and TileMode
40+
return ScaleTypeStartInside.INSTANCE;
41+
}
3742
if (resizeModeValue == null) {
3843
// Use the default. Never use null.
3944
return defaultValue();
@@ -42,11 +47,38 @@ public static ScalingUtils.ScaleType toScaleType(@Nullable String resizeModeValu
4247
"Invalid resize mode: '" + resizeModeValue + "'");
4348
}
4449

50+
/**
51+
* Converts JS resize modes into {@code Shader.TileMode}.
52+
* See {@code ImageResizeMode.js}.
53+
*/
54+
public static Shader.TileMode toTileMode(@Nullable String resizeModeValue) {
55+
if ("contain".equals(resizeModeValue)
56+
|| "cover".equals(resizeModeValue)
57+
|| "stretch".equals(resizeModeValue)
58+
|| "center".equals(resizeModeValue)) {
59+
return Shader.TileMode.CLAMP;
60+
}
61+
if ("repeat".equals(resizeModeValue)) {
62+
// Handled via a combination of ScaleType and TileMode
63+
return Shader.TileMode.REPEAT;
64+
}
65+
if (resizeModeValue == null) {
66+
// Use the default. Never use null.
67+
return defaultTileMode();
68+
}
69+
throw new JSApplicationIllegalArgumentException(
70+
"Invalid resize mode: '" + resizeModeValue + "'");
71+
}
72+
4573
/**
4674
* This is the default as per web and iOS.
4775
* We want to be consistent across platforms.
4876
*/
4977
public static ScalingUtils.ScaleType defaultValue() {
5078
return ScalingUtils.ScaleType.CENTER_CROP;
5179
}
80+
81+
public static Shader.TileMode defaultTileMode() {
82+
return Shader.TileMode.CLAMP;
83+
}
5284
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Copyright (c) 2017-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
package com.facebook.react.views.image;
11+
12+
import android.graphics.Bitmap;
13+
14+
import com.facebook.cache.common.CacheKey;
15+
import com.facebook.cache.common.MultiCacheKey;
16+
import com.facebook.common.references.CloseableReference;
17+
import com.facebook.imagepipeline.bitmaps.PlatformBitmapFactory;
18+
import com.facebook.imagepipeline.request.Postprocessor;
19+
20+
import java.util.LinkedList;
21+
import java.util.List;
22+
23+
public class MultiPostprocessor implements Postprocessor {
24+
private final List<Postprocessor> mPostprocessors;
25+
26+
public static Postprocessor from(List<Postprocessor> postprocessors) {
27+
switch (postprocessors.size()) {
28+
case 0:
29+
return null;
30+
case 1:
31+
return postprocessors.get(0);
32+
default:
33+
return new MultiPostprocessor(postprocessors);
34+
}
35+
}
36+
37+
private MultiPostprocessor(List<Postprocessor> postprocessors) {
38+
mPostprocessors = new LinkedList<>(postprocessors);
39+
}
40+
41+
@Override
42+
public String getName () {
43+
StringBuilder name = new StringBuilder();
44+
for (Postprocessor p: mPostprocessors) {
45+
if (name.length() > 0) {
46+
name.append(",");
47+
}
48+
name.append(p.getName());
49+
}
50+
name.insert(0, "MultiPostProcessor (");
51+
name.append(")");
52+
return name.toString();
53+
}
54+
55+
@Override
56+
public CacheKey getPostprocessorCacheKey () {
57+
LinkedList<CacheKey> keys = new LinkedList<>();
58+
for (Postprocessor p: mPostprocessors) {
59+
keys.push(p.getPostprocessorCacheKey());
60+
}
61+
return new MultiCacheKey(keys);
62+
}
63+
64+
@Override
65+
public CloseableReference<Bitmap> process(Bitmap sourceBitmap, PlatformBitmapFactory bitmapFactory) {
66+
CloseableReference<Bitmap> prevBitmap = null, nextBitmap = null;
67+
68+
try {
69+
for (Postprocessor p : mPostprocessors) {
70+
nextBitmap = p.process(prevBitmap != null ? prevBitmap.get() : sourceBitmap, bitmapFactory);
71+
CloseableReference.closeSafely(prevBitmap);
72+
prevBitmap = nextBitmap.clone();
73+
}
74+
return nextBitmap.clone();
75+
} finally {
76+
CloseableReference.closeSafely(nextBitmap);
77+
}
78+
}
79+
}

ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java

+1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ public void setBorderRadius(ReactImageView view, int index, float borderRadius)
139139
@ReactProp(name = ViewProps.RESIZE_MODE)
140140
public void setResizeMode(ReactImageView view, @Nullable String resizeMode) {
141141
view.setScaleType(ImageResizeMode.toScaleType(resizeMode));
142+
view.setTileMode(ImageResizeMode.toTileMode(resizeMode));
142143
}
143144

144145
@ReactProp(name = ViewProps.RESIZE_METHOD)

ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java

+64-6
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import android.graphics.drawable.Drawable;
2323
import android.net.Uri;
2424
import android.widget.Toast;
25+
import com.facebook.common.references.CloseableReference;
2526
import com.facebook.common.util.UriUtil;
2627
import com.facebook.drawee.controller.AbstractDraweeControllerBuilder;
2728
import com.facebook.drawee.controller.BaseControllerListener;
@@ -33,6 +34,7 @@
3334
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder;
3435
import com.facebook.drawee.generic.RoundingParams;
3536
import com.facebook.drawee.view.GenericDraweeView;
37+
import com.facebook.imagepipeline.bitmaps.PlatformBitmapFactory;
3638
import com.facebook.imagepipeline.common.ResizeOptions;
3739
import com.facebook.imagepipeline.image.ImageInfo;
3840
import com.facebook.imagepipeline.postprocessors.IterativeBoxBlurPostProcessor;
@@ -49,6 +51,7 @@
4951
import com.facebook.react.uimanager.PixelUtil;
5052
import com.facebook.react.uimanager.UIManagerModule;
5153
import com.facebook.react.uimanager.events.EventDispatcher;
54+
import com.facebook.react.views.image.ImageResizeMode;
5255
import com.facebook.react.views.imagehelper.ImageSource;
5356
import com.facebook.react.views.imagehelper.MultiSourceHelper;
5457
import com.facebook.react.views.imagehelper.MultiSourceHelper.MultiSourceResult;
@@ -141,6 +144,40 @@ public void process(Bitmap output, Bitmap source) {
141144
}
142145
}
143146

147+
// Fresco lacks support for repeating images, see https://github.com/facebook/fresco/issues/1575
148+
// We implement it here as a postprocessing step.
149+
private static final Matrix sTileMatrix = new Matrix();
150+
151+
private class TilePostprocessor extends BasePostprocessor {
152+
@Override
153+
public CloseableReference<Bitmap> process(Bitmap source, PlatformBitmapFactory bitmapFactory) {
154+
final Rect destRect = new Rect(0, 0, getWidth(), getHeight());
155+
156+
mScaleType.getTransform(
157+
sTileMatrix,
158+
destRect,
159+
source.getWidth(),
160+
source.getHeight(),
161+
0.0f,
162+
0.0f);
163+
164+
Paint paint = new Paint();
165+
paint.setAntiAlias(true);
166+
Shader shader = new BitmapShader(source, mTileMode, mTileMode);
167+
shader.setLocalMatrix(sTileMatrix);
168+
paint.setShader(shader);
169+
170+
CloseableReference<Bitmap> output = bitmapFactory.createBitmap(getWidth(), getHeight());
171+
try {
172+
Canvas canvas = new Canvas(output.get());
173+
canvas.drawRect(destRect, paint);
174+
return output.clone();
175+
} finally {
176+
CloseableReference.closeSafely(output);
177+
}
178+
}
179+
}
180+
144181
private final List<ImageSource> mSources;
145182

146183
private @Nullable ImageSource mImageSource;
@@ -152,9 +189,11 @@ public void process(Bitmap output, Bitmap source) {
152189
private float mBorderRadius = YogaConstants.UNDEFINED;
153190
private @Nullable float[] mBorderCornerRadii;
154191
private ScalingUtils.ScaleType mScaleType;
192+
private Shader.TileMode mTileMode = ImageResizeMode.defaultTileMode();
155193
private boolean mIsDirty;
156194
private final AbstractDraweeControllerBuilder mDraweeControllerBuilder;
157195
private final RoundedCornerPostprocessor mRoundedCornerPostprocessor;
196+
private final TilePostprocessor mTilePostprocessor;
158197
private @Nullable IterativeBoxBlurPostProcessor mIterativeBoxBlurPostProcessor;
159198
private @Nullable ControllerListener mControllerListener;
160199
private @Nullable ControllerListener mControllerForTesting;
@@ -180,6 +219,7 @@ public ReactImageView(
180219
mScaleType = ImageResizeMode.defaultValue();
181220
mDraweeControllerBuilder = draweeControllerBuilder;
182221
mRoundedCornerPostprocessor = new RoundedCornerPostprocessor();
222+
mTilePostprocessor = new TilePostprocessor();
183223
mGlobalImageLoadListener = globalImageLoadListener;
184224
mCallerContext = callerContext;
185225
mSources = new LinkedList<>();
@@ -275,6 +315,11 @@ public void setScaleType(ScalingUtils.ScaleType scaleType) {
275315
mIsDirty = true;
276316
}
277317

318+
public void setTileMode(Shader.TileMode tileMode) {
319+
mTileMode = tileMode;
320+
mIsDirty = true;
321+
}
322+
278323
public void setResizeMethod(ImageResizeMethod resizeMethod) {
279324
mResizeMethod = resizeMethod;
280325
mIsDirty = true;
@@ -362,6 +407,11 @@ public void maybeUpdateView() {
362407
return;
363408
}
364409

410+
if (isTiled() && (getWidth() <= 0 || getHeight() <= 0)) {
411+
// If need to tile and the size is not yet set, wait until the layout pass provides one
412+
return;
413+
}
414+
365415
GenericDraweeHierarchy hierarchy = getHierarchy();
366416
hierarchy.setActualImageScaleType(mScaleType);
367417

@@ -396,13 +446,17 @@ public void maybeUpdateView() {
396446
? mFadeDurationMs
397447
: mImageSource.isResource() ? 0 : REMOTE_IMAGE_FADE_DURATION_MS);
398448

399-
// TODO: t13601664 Support multiple PostProcessors
400-
Postprocessor postprocessor = null;
449+
List<Postprocessor> postprocessors = new LinkedList<>();
401450
if (usePostprocessorScaling) {
402-
postprocessor = mRoundedCornerPostprocessor;
403-
} else if (mIterativeBoxBlurPostProcessor != null) {
404-
postprocessor = mIterativeBoxBlurPostProcessor;
451+
postprocessors.add(mRoundedCornerPostprocessor);
452+
}
453+
if (mIterativeBoxBlurPostProcessor != null) {
454+
postprocessors.add(mIterativeBoxBlurPostProcessor);
455+
}
456+
if (isTiled()) {
457+
postprocessors.add(mTilePostprocessor);
405458
}
459+
Postprocessor postprocessor = MultiPostprocessor.from(postprocessors);
406460

407461
ResizeOptions resizeOptions = doResize ? new ResizeOptions(getWidth(), getHeight()) : null;
408462

@@ -468,7 +522,7 @@ public void setControllerListener(ControllerListener controllerListener) {
468522
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
469523
super.onSizeChanged(w, h, oldw, oldh);
470524
if (w > 0 && h > 0) {
471-
mIsDirty = mIsDirty || hasMultipleSources();
525+
mIsDirty = mIsDirty || hasMultipleSources() || isTiled();
472526
maybeUpdateView();
473527
}
474528
}
@@ -485,6 +539,10 @@ private boolean hasMultipleSources() {
485539
return mSources.size() > 1;
486540
}
487541

542+
private boolean isTiled() {
543+
return mTileMode != Shader.TileMode.CLAMP;
544+
}
545+
488546
private void setSourceImage() {
489547
mImageSource = null;
490548
if (mSources.isEmpty()) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Copyright (c) 2017-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
package com.facebook.react.views.image;
11+
12+
import android.graphics.Matrix;
13+
import android.graphics.Rect;
14+
import com.facebook.drawee.drawable.ScalingUtils;
15+
16+
public class ScaleTypeStartInside extends ScalingUtils.AbstractScaleType {
17+
public static final ScalingUtils.ScaleType INSTANCE = new ScaleTypeStartInside();
18+
19+
@Override
20+
public void getTransformImpl(
21+
Matrix outTransform,
22+
Rect parentRect,
23+
int childWidth,
24+
int childHeight,
25+
float focusX,
26+
float focusY,
27+
float scaleX,
28+
float scaleY) {
29+
float scale = Math.min(Math.min(scaleX, scaleY), 1.0f);
30+
float dx = parentRect.left;
31+
float dy = parentRect.top;
32+
outTransform.setScale(scale, scale);
33+
outTransform.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f));
34+
}
35+
36+
@Override
37+
public String toString() {
38+
return "start_inside";
39+
}
40+
}

0 commit comments

Comments
 (0)