From 2d60711b4e50cf5c773a88d67dd6ea0900e078de Mon Sep 17 00:00:00 2001 From: Scott Beca Date: Thu, 20 Jun 2019 17:26:09 +1000 Subject: [PATCH] Add the ability to downsample images --- .gitignore | 3 + README.md | 26 ++++- .../RCTImageSequenceManager.java | 22 ++++ .../imageSequence/RCTImageSequenceView.java | 106 ++++++++++++++---- index.js | 27 +++-- .../RCTImageSequenceManager.m | 2 + ios/RCTImageSequence/RCTImageSequenceView.m | 73 ++++++++++-- typings/index.d.ts | 7 +- 8 files changed, 225 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index dcb0a80..c87bae8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ android.iml .gradle local.properties +# VS Code +.vscode + # node.js # node_modules/* diff --git a/README.md b/README.md index 5c3d8da..d1b53b2 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ const centerIndex = Math.round(images.length / 2); + style={{width: 50, height: 50}} +/> ``` ### Change animation speed @@ -37,7 +38,7 @@ You can change the speed of the animation by setting the `framesPerSecond` prope +/> ``` ### Looping @@ -48,5 +49,24 @@ You can change if animation loops indefinitely by setting the `loop` property. images={images} framesPerSecond={24} loop={false} - /> +/> ``` + +### Downsampling +Loading and using an image with a higher resolution than the size of the image display area does not provide any visible benefit, but still takes up precious memory and incurs additional performance overhead due to additional on the fly scaling. So choosing to downsample an image before rendering saves memory and CPU time during the rendering process, but costs more CPU time during the image loading process. + +You can set the images to be downsampled by setting both the `downsampleWidth` and `downsampleHeight` properties. Both properties must be set to positive numbers to enable downsampling. + +```javascript + +``` + +IMPORTANT: The final image width and height will not necessarily match `downsampleWidth` and `downsampleHeight` but will just be a target for the per-platform logic on how to downsample. + +On Android, the logic for how to downsample is taken from [here](https://developer.android.com/topic/performance/graphics/load-bitmap). The image's aspect ratio will stay consistent after downsampling. + +On iOS, the max value of `downsampleWidth` and `downsampleHeight` will be used as the max pixel count for both dimensions in the final image. The image's aspect ratio will stay consistent after downsampling. diff --git a/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceManager.java b/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceManager.java index 3516bf0..c4c18c7 100644 --- a/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceManager.java +++ b/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceManager.java @@ -56,4 +56,26 @@ public void setImages(final RCTImageSequenceView view, ReadableArray images) { public void setLoop(final RCTImageSequenceView view, Boolean loop) { view.setLoop(loop); } + + /** + * sets the width for optional downsampling. + * + * @param view + * @param downsampleWidth + */ + @ReactProp(name = "downsampleWidth") + public void setDownsampleWidth(final RCTImageSequenceView view, Integer downsampleWidth) { + view.setDownsampleWidth(downsampleWidth); + } + + /** + * sets the height for optional downsampling. + * + * @param view + * @param downsampleHeight + */ + @ReactProp(name = "downsampleHeight") + public void setDownsampleHeight(final RCTImageSequenceView view, Integer downsampleHeight) { + view.setDownsampleHeight(downsampleHeight); + } } diff --git a/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceView.java b/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceView.java index a072809..4c05302 100644 --- a/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceView.java +++ b/android/src/main/java/dk/madslee/imageSequence/RCTImageSequenceView.java @@ -1,12 +1,14 @@ package dk.madslee.imageSequence; import android.content.Context; +import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.AnimationDrawable; import android.graphics.drawable.BitmapDrawable; -import android.util.Log; import android.os.AsyncTask; +import android.os.Handler; +import android.util.Log; import android.widget.ImageView; import java.io.IOException; @@ -18,10 +20,14 @@ public class RCTImageSequenceView extends ImageView { + private final Handler handler = new Handler(); + private Integer framesPerSecond = 24; private Boolean loop = true; - private ArrayList activeTasks; - private HashMap bitmaps; + private Integer downsampleWidth = -1; + private Integer downsampleHeight = -1; + private ArrayList activeTasks = null; + private HashMap bitmaps = null; private RCTResourceDrawableIdHelper resourceDrawableIdHelper; public RCTImageSequenceView(Context context) { @@ -50,9 +56,26 @@ protected Bitmap doInBackground(String... params) { return this.loadBitmapByLocalResource(this.uri); } - private Bitmap loadBitmapByLocalResource(String uri) { - return BitmapFactory.decodeResource(this.context.getResources(), resourceDrawableIdHelper.getResourceDrawableId(this.context, uri)); + Resources res = this.context.getResources(); + int resId = resourceDrawableIdHelper.getResourceDrawableId(this.context, uri); + + if (downsampleWidth <= 0 || downsampleHeight <= 0) { + // Downsampling is not set so just decode normally + return BitmapFactory.decodeResource(res, resId); + } + + // First decode with inJustDecodeBounds=true to check dimensions + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeResource(res, resId, options); + + // Calculate inSampleSize + options.inSampleSize = RCTImageSequenceView.calculateInSampleSize(options, downsampleWidth, downsampleHeight); + + // Decode bitmap with inSampleSize set + options.inJustDecodeBounds = false; + return BitmapFactory.decodeResource(res, resId, options); } private Bitmap loadBitmapByExternalURL(String uri) { @@ -90,27 +113,64 @@ private void onTaskCompleted(DownloadImageTask downloadImageTask, Integer index, } } - public void setImages(ArrayList uris) { + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + final int halfHeight = height / 2; + final int halfWidth = width / 2; + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while ((halfHeight / inSampleSize) >= reqHeight + && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2; + } + } + + return inSampleSize; + } + + public void setImages(final ArrayList uris) { + // Cancel any previously queued Runnables + handler.removeCallbacksAndMessages(null); + if (isLoading()) { - // cancel ongoing tasks (if still loading previous images) + // Cancel ongoing tasks (if still loading previous images) for (int index = 0; index < activeTasks.size(); index++) { activeTasks.get(index).cancel(true); } } - activeTasks = new ArrayList<>(uris.size()); - bitmaps = new HashMap<>(uris.size()); - - for (int index = 0; index < uris.size(); index++) { - DownloadImageTask task = new DownloadImageTask(index, uris.get(index), getContext()); - activeTasks.add(task); - - try { - task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } catch (RejectedExecutionException e){ - Log.e("react-native-image-sequence", "DownloadImageTask failed" + e.getMessage()); - break; + activeTasks = null; + bitmaps = null; + + final Runnable r = new Runnable() { + public void run() { + activeTasks = new ArrayList<>(uris.size()); + bitmaps = new HashMap<>(uris.size()); + + for (int index = 0; index < uris.size(); index++) { + DownloadImageTask task = new DownloadImageTask(index, uris.get(index), getContext()); + activeTasks.add(task); + + try { + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } catch (RejectedExecutionException e){ + Log.e("react-native-image-sequence", "DownloadImageTask failed" + e.getMessage()); + break; + } + } } + }; + + // Delay for 1ms to make sure that all the props have been set properly before starting processing + final boolean added = handler.postDelayed(r, 1); + if (!added) { + Log.e("react-native-image-sequence", "Failed to place Runnable in to the message queue"); } } @@ -132,6 +192,14 @@ public void setLoop(Boolean loop) { } } + public void setDownsampleWidth(Integer downsampleWidth) { + this.downsampleWidth = downsampleWidth; + } + + public void setDownsampleHeight(Integer downsampleHeight) { + this.downsampleHeight = downsampleHeight; + } + private boolean isLoaded() { return !isLoading() && bitmaps != null && !bitmaps.isEmpty(); } diff --git a/index.js b/index.js index 4b7632c..cf08a22 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,5 @@ import React, { Component } from 'react'; import { - View, requireNativeComponent, ViewPropTypes } from 'react-native'; @@ -9,16 +8,22 @@ import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource' class ImageSequence extends Component { render() { - let normalized = this.props.images.map(resolveAssetSource); + const { + startFrameIndex, + images, + ...otherProps + } = this.props; + + let normalized = images.map(resolveAssetSource); // reorder elements if start-index is different from 0 (beginning) - if (this.props.startFrameIndex !== 0) { - normalized = [...normalized.slice(this.props.startFrameIndex), ...normalized.slice(0, this.props.startFrameIndex)]; + if (startFrameIndex !== 0) { + normalized = [...normalized.slice(startFrameIndex), ...normalized.slice(0, startFrameIndex)]; } return ( ); } @@ -27,14 +32,18 @@ class ImageSequence extends Component { ImageSequence.defaultProps = { startFrameIndex: 0, framesPerSecond: 24, - loop: true + loop: true, + downsampleWidth: -1, + downsampleHeight: -1 }; ImageSequence.propTypes = { startFrameIndex: number, images: array.isRequired, framesPerSecond: number, - loop: bool + loop: bool, + downsampleWidth: number, + downsampleHeight: number }; const RCTImageSequence = requireNativeComponent('RCTImageSequence', { @@ -44,7 +53,9 @@ const RCTImageSequence = requireNativeComponent('RCTImageSequence', { uri: string.isRequired })).isRequired, framesPerSecond: number, - loop: bool + loop: bool, + downsampleWidth: number, + downsampleHeight: number }, }); diff --git a/ios/RCTImageSequence/RCTImageSequenceManager.m b/ios/RCTImageSequence/RCTImageSequenceManager.m index 437b037..36a93e2 100644 --- a/ios/RCTImageSequence/RCTImageSequenceManager.m +++ b/ios/RCTImageSequence/RCTImageSequenceManager.m @@ -13,6 +13,8 @@ @implementation RCTImageSequenceManager { RCT_EXPORT_VIEW_PROPERTY(images, NSArray); RCT_EXPORT_VIEW_PROPERTY(framesPerSecond, NSUInteger); RCT_EXPORT_VIEW_PROPERTY(loop, BOOL); +RCT_EXPORT_VIEW_PROPERTY(downsampleWidth, NSInteger); +RCT_EXPORT_VIEW_PROPERTY(downsampleHeight, NSInteger); - (UIView *)view { return [RCTImageSequenceView new]; diff --git a/ios/RCTImageSequence/RCTImageSequenceView.m b/ios/RCTImageSequence/RCTImageSequenceView.m index 9038f5f..e80330c 100644 --- a/ios/RCTImageSequence/RCTImageSequenceView.m +++ b/ios/RCTImageSequence/RCTImageSequenceView.m @@ -5,11 +5,16 @@ #import "RCTImageSequenceView.h" +#import +#import + @implementation RCTImageSequenceView { NSUInteger _framesPerSecond; NSMutableDictionary *_activeTasks; NSMutableDictionary *_imagesLoaded; BOOL _loop; + NSInteger _downsampleWidth; + NSInteger _downsampleHeight; } - (void)setImages:(NSArray *)images { @@ -23,21 +28,61 @@ - (void)setImages:(NSArray *)images { for (NSUInteger index = 0; index < images.count; index++) { NSDictionary *item = images[index]; - #ifdef DEBUG - NSString *url = item[@"uri"]; - #else - NSString *url = [NSString stringWithFormat:@"file://%@", item[@"uri"]]; // when not in debug, the paths are "local paths" (because resources are bundled in app) - #endif +#ifdef DEBUG + NSString *urlString = item[@"uri"]; +#else + // when not in debug, the paths are "local paths" (because resources are bundled in app) + NSString *urlString = [NSString stringWithFormat:@"file://%@", item[@"uri"]]; +#endif dispatch_async(dispatch_queue_create("dk.mads-lee.ImageSequence.Downloader", NULL), ^{ - UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:url]]]; - dispatch_async(dispatch_get_main_queue(), ^{ - [weakSelf onImageLoadTaskAtIndex:index image:image]; - }); + // Sleep for 1ms to make sure that all the props have been set properly before starting processing + [NSThread sleepForTimeInterval:0.001]; + + NSURL *url = [NSURL URLWithString:urlString]; + + if (_downsampleWidth <= 0 || _downsampleHeight <= 0) { + // Downsampling is not set so just return normally + UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:url]]; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf onImageLoadTaskAtIndex:index image:image]; + }); + } else { + // Downsampling is set so we need to downsample + UIImage *image = [weakSelf resizedImage:url width:_downsampleWidth height:_downsampleHeight]; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf onImageLoadTaskAtIndex:index image:image]; + }); + } }); - _activeTasks[@(index)] = url; + _activeTasks[@(index)] = urlString; + } +} + +- (UIImage *)resizedImage:(NSURL *)url width:(NSInteger)width height:(NSInteger)height { + CFURLRef cfurl = (__bridge CFURLRef)url; + CGImageSourceRef imageSourceRef = CGImageSourceCreateWithURL(cfurl, nil); + if (!imageSourceRef) { + return nil; + } + + NSDictionary* d = @{ + (id)kCGImageSourceShouldAllowFloat: (id)kCFBooleanTrue, + (id)kCGImageSourceCreateThumbnailWithTransform: (id)kCFBooleanTrue, + (id)kCGImageSourceCreateThumbnailFromImageAlways: (id)kCFBooleanTrue, + (id)kCGImageSourceThumbnailMaxPixelSize: @((int)(width > height ? width : height)) + }; + CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(imageSourceRef, 0, (__bridge CFDictionaryRef)d); + CFRelease(imageSourceRef); + if (!imageRef) { + return nil; } + + UIImage* scaledImage = [UIImage imageWithCGImage:imageRef scale:1 orientation:UIImageOrientationUp]; + CFRelease(imageRef); + + return scaledImage; } - (void)onImageLoadTaskAtIndex:(NSUInteger)index image:(UIImage *)image { @@ -84,4 +129,12 @@ - (void)setLoop:(NSUInteger)loop { self.animationRepeatCount = _loop ? 0 : 1; } +- (void)setDownsampleWidth:(NSInteger)downsampleWidth { + _downsampleWidth = downsampleWidth; +} + +- (void)setDownsampleHeight:(NSInteger)downsampleHeight { + _downsampleHeight = downsampleHeight; +} + @end diff --git a/typings/index.d.ts b/typings/index.d.ts index c413d58..d24591b 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,6 +1,7 @@ import { Component } from 'react'; +import { ViewProps } from 'react-native'; -interface ImageSequenceProps { +interface ImageSequenceProps extends ViewProps { /** An array of source images. Each element of the array should be the result of a call to require(imagePath). */ images: any[]; /** Which index of the images array should the sequence start at. Default: 0 */ @@ -9,6 +10,10 @@ interface ImageSequenceProps { framesPerSecond?: number; /** Should the sequence loop. Default: true */ loop?: boolean; + /** The width to use for optional downsampling. Both `downsampleWidth` and `downsampleHeight` must be set to a positive number to enable downsampling. Default: -1 */ + downsampleWidth?: number; + /** The height to use for optional downsampling. Both `downsampleWidth` and `downsampleHeight` must be set to a positive number to enable downsampling. Default: -1 */ + downsampleHeight?: number; } declare class ImageSequence extends Component {