Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix bitmap crop/scaling for android #151

Merged
merged 2 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
croppedImageURI: string | null;
cropError: Error | null;
measuredSize: ImageSize | null;
cropScale: number;
}
interface Props {
// noop
Expand All @@ -41,6 +43,7 @@
measuredSize: null,
croppedImageURI: null,
cropError: null,
cropScale: 1,
};
}

Expand Down Expand Up @@ -89,6 +92,17 @@
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 @@
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,12 +171,12 @@
};

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

const styles = StyleSheet.create({
container: {

Check warning on line 179 in example/src/SquareImageCropper.tsx

View workflow job for this annotation

GitHub Actions / Code Quality

Color literal: { backgroundColor: 'white' }
flex: 1,
backgroundColor: 'white',
paddingTop: 20,
Expand All @@ -160,7 +185,7 @@
alignSelf: 'center',
marginTop: 12,
},
cropButtonTouchable: {

Check warning on line 188 in example/src/SquareImageCropper.tsx

View workflow job for this annotation

GitHub Actions / Code Quality

Color literal: { backgroundColor: 'royalblue' }
alignSelf: 'center',
marginBottom: 10,
marginTop: 'auto',
Expand All @@ -170,20 +195,28 @@
cropButton: {
padding: 12,
},
cropButtonLabel: {

Check warning on line 198 in example/src/SquareImageCropper.tsx

View workflow job for this annotation

GitHub Actions / Code Quality

Color literal: { color: 'white' }
color: 'white',
fontSize: 18,
fontWeight: '500',
},
text: {

Check warning on line 203 in example/src/SquareImageCropper.tsx

View workflow job for this annotation

GitHub Actions / Code Quality

Color literal: { color: 'black' }
color: 'black',
textAlign: 'center',
fontSize: 16,
},
errorText: {

Check warning on line 208 in example/src/SquareImageCropper.tsx

View workflow job for this annotation

GitHub Actions / Code Quality

Color literal: { color: 'red' }
color: 'red',
textAlign: 'center',
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
Loading