Skip to content

Commit

Permalink
Merge pull request #151 from jthure/fix-android-scaling-issue
Browse files Browse the repository at this point in the history
Fix bitmap crop/scaling for android
  • Loading branch information
retyui authored Mar 22, 2024
2 parents 16b6ee4 + cb3e58c commit 9813b2c
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,10 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
BitmapRegionDecoder.newInstance(it)
} else {
@Suppress("DEPRECATION") BitmapRegionDecoder.newInstance(it, false)
}
} ?: throw Error("Could not create bitmap decoder. Uri: $uri")

val imageHeight: Int = decoder!!.height
val imageWidth: Int = decoder!!.width
val imageHeight: Int = decoder.height
val imageWidth: Int = decoder.width
val orientation = getOrientation(reactContext, Uri.parse(uri))

val (left, top) =
Expand All @@ -229,9 +229,9 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {

return@use try {
val rect = Rect(left, top, right, bottom)
decoder!!.decodeRegion(rect, outOptions)
decoder.decodeRegion(rect, outOptions)
} finally {
decoder!!.recycle()
decoder.recycle()
}
}
}
Expand Down Expand Up @@ -262,68 +262,79 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
): Bitmap? {
Assertions.assertNotNull(outOptions)

// Loading large bitmaps efficiently:
// http://developer.android.com/training/displaying-bitmaps/load-bitmap.html

// This uses scaling mode COVER

// Where would the crop rect end up within the scaled bitmap?

val bitmap =
openBitmapInputStream(uri, headers)?.use {
// This can use significantly less memory than decoding the full-resolution bitmap
BitmapFactory.decodeStream(it, null, outOptions)
} ?: return null
return openBitmapInputStream(uri, headers)?.use {
// Efficiently crops image without loading full resolution into memory
// https://developer.android.com/reference/android/graphics/BitmapRegionDecoder.html
val decoder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(it)
} else {
@Suppress("DEPRECATION") BitmapRegionDecoder.newInstance(it, false)
} ?: throw Error("Could not create bitmap decoder. Uri: $uri")

val orientation = getOrientation(reactContext, Uri.parse(uri))
val (x, y) =
when (orientation) {
90 -> yPos to bitmap.height - rectWidth - xPos
270 -> bitmap.width - rectHeight - yPos to xPos
180 -> bitmap.width - rectWidth - xPos to bitmap.height - rectHeight - yPos
else -> xPos to yPos
}
val orientation = getOrientation(reactContext, Uri.parse(uri))
val (x, y) =
when (orientation) {
90 -> yPos to decoder.height - rectWidth - xPos
270 -> decoder.width - rectHeight - yPos to xPos
180 -> decoder.width - rectWidth - xPos to decoder.height - rectHeight - yPos
else -> xPos to yPos
}

val (width, height) =
when (orientation) {
90,
270 -> rectHeight to rectWidth
else -> rectWidth to rectHeight
}
val (targetWidth, targetHeight) =
when (orientation) {
90,
270 -> outputHeight to outputWidth
else -> outputWidth to outputHeight
}
val (width, height) =
when (orientation) {
90,
270 -> rectHeight to rectWidth
else -> rectWidth to rectHeight
}
val (targetWidth, targetHeight) =
when (orientation) {
90,
270 -> outputHeight to outputWidth
else -> outputWidth to outputHeight
}

val cropRectRatio = width / height.toFloat()
val targetRatio = targetWidth / targetHeight.toFloat()
val isCropRatioLargerThanTargetRatio = cropRectRatio > targetRatio
val newWidth =
if (isCropRatioLargerThanTargetRatio) height * targetRatio else width.toFloat()
val newHeight =
if (isCropRatioLargerThanTargetRatio) height.toFloat() else width / targetRatio
val newX = if (isCropRatioLargerThanTargetRatio) x + (width - newWidth) / 2 else x.toFloat()
val newY =
if (isCropRatioLargerThanTargetRatio) y.toFloat() else y + (height - newHeight) / 2
val scale =
if (isCropRatioLargerThanTargetRatio) targetHeight / height.toFloat()
else targetWidth / width.toFloat()

// Decode the bitmap. We have to open the stream again, like in the example linked above.
// Is there a way to just continue reading from the stream?
outOptions.inSampleSize = getDecodeSampleSize(width, height, targetWidth, targetHeight)

val cropX = (newX / outOptions.inSampleSize.toFloat()).roundToInt()
val cropY = (newY / outOptions.inSampleSize.toFloat()).roundToInt()
val cropWidth = (newWidth / outOptions.inSampleSize.toFloat()).roundToInt()
val cropHeight = (newHeight / outOptions.inSampleSize.toFloat()).roundToInt()
val cropScale = scale * outOptions.inSampleSize
val scaleMatrix = Matrix().apply { setScale(cropScale, cropScale) }
val filter = true

return Bitmap.createBitmap(bitmap, cropX, cropY, cropWidth, cropHeight, scaleMatrix, filter)
val cropRectRatio = width / height.toFloat()
val targetRatio = targetWidth / targetHeight.toFloat()
val isCropRatioLargerThanTargetRatio = cropRectRatio > targetRatio
val newWidth =
if (isCropRatioLargerThanTargetRatio) height * targetRatio else width.toFloat()
val newHeight =
if (isCropRatioLargerThanTargetRatio) height.toFloat() else width / targetRatio
val newX =
if (isCropRatioLargerThanTargetRatio) x + (width - newWidth) / 2 else x.toFloat()
val newY =
if (isCropRatioLargerThanTargetRatio) y.toFloat() else y + (height - newHeight) / 2
val scale =
if (isCropRatioLargerThanTargetRatio) targetHeight / height.toFloat()
else targetWidth / width.toFloat()

// Decode the bitmap. We have to open the stream again, like in the example linked
// above.
// Is there a way to just continue reading from the stream?
outOptions.inSampleSize = getDecodeSampleSize(width, height, targetWidth, targetHeight)

val cropX = (newX / outOptions.inSampleSize.toFloat()).roundToInt()
val cropY = (newY / outOptions.inSampleSize.toFloat()).roundToInt()
val cropWidth = (newWidth / outOptions.inSampleSize.toFloat()).roundToInt()
val cropHeight = (newHeight / outOptions.inSampleSize.toFloat()).roundToInt()
val cropScale = scale * outOptions.inSampleSize
val scaleMatrix = Matrix().apply { setScale(cropScale, cropScale) }
val filter = true

val rect = Rect(0, 0, decoder.width, decoder.height)
val bitmap = decoder.decodeRegion(rect, outOptions)

return Bitmap.createBitmap(
bitmap,
cropX,
cropY,
cropWidth,
cropHeight,
scaleMatrix,
filter
)
}
}

private fun openBitmapInputStream(uri: String, headers: HashMap<String, Any?>?): InputStream? {
Expand Down
24 changes: 22 additions & 2 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,22 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-slider (4.5.0):
- RCT-Folly (= 2021.07.22.00)
- RCTRequired
- RCTTypeSafety
- React-Codegen
- React-Core
- React-debug
- React-Fabric
- React-graphics
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-utils
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- React-NativeModulesApple (0.72.6):
- React-callinvoker
- React-Core
Expand Down Expand Up @@ -985,6 +1001,7 @@ DEPENDENCIES:
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- "react-native-image-editor (from `../node_modules/@react-native-community/image-editor`)"
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
Expand Down Expand Up @@ -1060,6 +1077,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/logger"
react-native-image-editor:
:path: "../node_modules/@react-native-community/image-editor"
react-native-slider:
:path: "../node_modules/@react-native-community/slider"
React-NativeModulesApple:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-perflogger:
Expand Down Expand Up @@ -1130,11 +1149,12 @@ SPEC CHECKSUMS:
React-jsinspector: 194e32c6aab382d88713ad3dd0025c5f5c4ee072
React-logger: cebf22b6cf43434e471dc561e5911b40ac01d289
react-native-image-editor: 6491eca6c084de724a9a144056323cb00848b68c
react-native-slider: 69ccddffd41798b325247b9c4c09a0927e3b7cec
React-NativeModulesApple: 63505fb94b71e2469cab35bdaf36cca813cb5bfd
React-perflogger: e3596db7e753f51766bceadc061936ef1472edc3
React-RCTActionSheet: 17ab132c748b4471012abbcdcf5befe860660485
React-RCTAnimation: c8bbaab62be5817d2a31c36d5f2571e3f7dcf099
React-RCTAppDelegate: 100a4f479c664e4f63d4cfd3ff8acbd3915e31e7
React-RCTAppDelegate: 16245b0c3a216dd600db53209812f8d470643a57
React-RCTBlob: 86ab788db3fcc1af0d186a6625e7d0956ffeea5b
React-RCTFabric: 87e15f0ad21f7bf2642d4e78afaf162d349eb221
React-RCTImage: 670a3486b532292649b1aef3ffddd0b495a5cee4
Expand All @@ -1156,4 +1176,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: c464aabec59351f1e87d676c093bc6f524e21681

COCOAPODS: 1.13.0
COCOAPODS: 1.14.3
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@react-native-community/image-editor": "link:..",
"@react-native-community/slider": "^4.5.0",
"react": "18.2.0",
"react-native": "0.72.6"
},
Expand Down
37 changes: 35 additions & 2 deletions example/src/SquareImageCropper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SafeAreaView,
} from 'react-native';
import ImageEditor from '@react-native-community/image-editor';
import Slider from '@react-native-community/slider';

import type { LayoutChangeEvent } from 'react-native';
import { DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT } from './constants';
Expand All @@ -19,6 +20,7 @@ interface State {
croppedImageURI: string | null;
cropError: Error | null;
measuredSize: ImageSize | null;
cropScale: number;
}
interface Props {
// noop
Expand All @@ -41,6 +43,7 @@ export class SquareImageCropper extends Component<Props, State> {
measuredSize: null,
croppedImageURI: null,
cropError: null,
cropScale: 1,
};
}

Expand Down Expand Up @@ -89,6 +92,17 @@ export class SquareImageCropper extends Component<Props, State> {
style={[styles.imageCropper, measuredSize]}
onTransformDataChange={this._onTransformDataChange}
/>
<View style={styles.scaleSliderContainer}>
<Text>Scale {this.state.cropScale.toFixed(2)}</Text>
<Slider
style={styles.scaleSlider}
minimumValue={0}
maximumValue={1}
onValueChange={(cropScale) => this.setState({ cropScale })}
value={this.state.cropScale}
/>
</View>

<TouchableHighlight
accessibilityRole="button"
style={styles.cropButtonTouchable}
Expand Down Expand Up @@ -131,9 +145,20 @@ export class SquareImageCropper extends Component<Props, State> {
if (!this._transformData) {
return;
}
const displaySize =
this.state.cropScale !== 1
? {
width: this._transformData?.size.width * this.state.cropScale,
height: this._transformData?.size.height * this.state.cropScale,
}
: undefined;
const cropData: ImageCropData = {
...this._transformData,
displaySize,
};
const { uri } = await ImageEditor.cropImage(
this.state.photo.uri,
this._transformData
cropData
);
if (uri) {
this.setState({ croppedImageURI: uri });
Expand All @@ -146,7 +171,7 @@ export class SquareImageCropper extends Component<Props, State> {
};

_reset = () => {
this.setState({ croppedImageURI: null, cropError: null });
this.setState({ croppedImageURI: null, cropError: null, cropScale: 1 });
};
}

Expand Down Expand Up @@ -186,4 +211,12 @@ const styles = StyleSheet.create({
fontSize: 16,
marginBottom: 10,
},
scaleSlider: {
width: '100%',
},
scaleSliderContainer: {
width: '80%',
alignSelf: 'center',
alignItems: 'center',
},
});
5 changes: 5 additions & 0 deletions example/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,11 @@
version "0.0.0"
uid ""

"@react-native-community/slider@^4.5.0":
version "4.5.0"
resolved "https://registry.yarnpkg.com/@react-native-community/slider/-/slider-4.5.0.tgz#5c55488ee30060cd87100fb746b9d8655dbab04e"
integrity sha512-pyUvNTvu5IfCI5abzqRfO/dd3A009RC66RXZE6t0gyOwI/j0QDlq9VZRv3rjkpuIvNTnsYj+m5BHlh0DkSYUyA==

"@react-native/assets-registry@^0.72.0":
version "0.72.0"
resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.72.0.tgz#c82a76a1d86ec0c3907be76f7faf97a32bbed05d"
Expand Down

0 comments on commit 9813b2c

Please sign in to comment.