Last active
June 3, 2025 04:05
-
-
Save stevengoldberg/857dda61967f9571d9f6251880117f46 to your computer and use it in GitHub Desktop.
Expo Module wrapper for Mantis iOS cropping library
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function CropperExample({ | |
image, | |
cropperRef, | |
isCroppingLocked, | |
setIsCroppingLocked, | |
setCroppingRatio, | |
setIsCropResettable, | |
}) { | |
return ( | |
<CropperView | |
uri={image.localUri} | |
style={{ | |
width: image.width, | |
height: image.height, | |
}} | |
initialCropInfo={initialCropInfo} | |
ref={cropperRef} | |
isCroppingLocked={isCroppingLocked} | |
setIsCroppingLocked={setIsCroppingLocked} | |
setIsCropResettable={setIsCropResettable} | |
setCroppingRatio={setCroppingRatio} | |
/> | |
) | |
} | |
export default CropperExample |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
A Swift Expo Module wrapping the Mantis functionality. Includes a "headless" cropping function (not yet supported by the library) which crops via an offscreen view. | |
*/ | |
import ExpoModulesCore | |
import Mantis | |
import Photos | |
import UIKit | |
public class MantisModule: Module, CropViewControllerDelegate { | |
// MARK: – Properties for headless crop | |
private var headlessPromise: Promise? | |
private var offscreenWindow: UIWindow? | |
public func definition() -> ModuleDefinition { | |
Name("MantisModule") | |
View(MantisView.self) { | |
Prop("uri") { (view: MantisView, uri: String) in | |
view.uri = uri | |
} | |
Prop("initialCropInfo") { (view: MantisView, initialCropInfo: [String: Any]?) in | |
if let dict = initialCropInfo, | |
let t = view.parseTransformation(dict: dict) | |
{ | |
view.initialTransformation = t | |
} else { | |
view.initialTransformation = nil | |
} | |
} | |
AsyncFunction("cropAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.performCrop { result in | |
switch result { | |
case .success(let data): promise.resolve(data) | |
case .failure(let error): promise.reject(error) | |
} | |
} | |
} | |
} | |
AsyncFunction("cancelAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.cancelCrop { _ in } | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("rotateCounterClockwiseAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.rotateCounterClockwise() | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("rotateClockwiseAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.rotateClockwise() | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("resetAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.reset() | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("horizontallyFlipAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.horizontallyFlip() | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("verticallyFlipAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.verticallyFlip() | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("setFixedRatioAsync") { (view: MantisView, ratio: Double, promise: Promise) in | |
Task { @MainActor in | |
view.setFixedRatio(ratio) | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("setFreeRatioAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.setFreeRatio() | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("alterCropper90DegreeAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
view.alterCropper90Degree() | |
promise.resolve(nil) | |
} | |
} | |
AsyncFunction("getCurrentAspectRatioAsync") { (view: MantisView, promise: Promise) in | |
Task { @MainActor in | |
promise.resolve(view.getCurrentAspectRatio()) | |
} | |
} | |
AsyncFunction("autoAdjustAsync") { (view: MantisView, isActive: Bool, promise: Promise) in | |
Task { @MainActor in | |
view.autoAdjust(isActive: isActive) | |
promise.resolve(nil) | |
} | |
} | |
Events("onResizeStart", "onBecomeResettable") | |
} | |
// headless, module‐level crop function — | |
AsyncFunction("cropWithTransformAsync") { ( | |
uri: String, | |
transformDict: [String: Any], | |
promise: Promise | |
) in | |
Task { @MainActor in | |
// 1) parse the saved transform | |
guard let transform = self.parseTransformation(dict: transformDict) else { | |
return promise.reject("E_INVALID_TRANSFORM", "Malformed transform") | |
} | |
// 2) load the full-res UIImage | |
guard let url = URL(string: uri), | |
let data = try? Data(contentsOf: url), | |
let image = UIImage(data: data) | |
else { | |
return promise.reject("E_LOAD_IMAGE", "Could not load image at \(uri)") | |
} | |
// 3) configure a CropViewController with your transform | |
var config = Config() | |
config.cropViewConfig.presetTransformationType = .presetInfo(info: transform) | |
let ratio = transform.maskFrame.width / transform.maskFrame.height | |
config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: ratio) | |
let cropVC = Mantis.cropViewController(image: image, config: config) | |
cropVC.delegate = self | |
// 4) stick it into an offscreen window so layout actually runs | |
let window = UIWindow(frame: UIScreen.main.bounds) | |
window.isHidden = false | |
let host = UIViewController() | |
window.rootViewController = host | |
host.view.addSubview(cropVC.view) | |
cropVC.view.frame = host.view.bounds | |
window.layoutIfNeeded() | |
self.offscreenWindow = window | |
// 5) stash the promise and fire‐and‐forget the crop call | |
self.headlessPromise = promise | |
cropVC.crop() | |
} | |
} | |
} | |
// MARK: – CropViewControllerDelegate | |
public func cropViewControllerDidCrop( | |
_ cropViewController: CropViewController, | |
cropped: UIImage, | |
transformation: Transformation, | |
cropInfo: CropInfo | |
) { | |
guard let promise = headlessPromise else { return } | |
headlessPromise = nil | |
// tear down our offscreen window | |
offscreenWindow?.isHidden = true | |
offscreenWindow = nil | |
// encode & save the JPEG | |
guard let data = cropped.jpegData(compressionQuality: 1.0) else { | |
return promise.reject("E_ENCODE_FAILED", "Failed to encode crop") | |
} | |
let docs = FileManager.default | |
.urls(for: .documentDirectory, in: .userDomainMask)[0] | |
let outURL = docs.appendingPathComponent(UUID().uuidString + ".jpg") | |
do { | |
try data.write(to: outURL) | |
let r = cropInfo.cropRegion | |
promise.resolve([ | |
"uri": outURL.absoluteString, | |
"width": Int(cropped.size.width), | |
"height": Int(cropped.size.height), | |
"transformation": buildTransformationDict(transformation), | |
"cropRegion": [ | |
"topLeft": ["x": r.topLeft.x, "y": r.topLeft.y], | |
"topRight": ["x": r.topRight.x, "y": r.topRight.y], | |
"bottomLeft": ["x": r.bottomLeft.x, "y": r.bottomLeft.y], | |
"bottomRight": ["x": r.bottomRight.x,"y": r.bottomRight.y] | |
] | |
]) | |
} catch { | |
promise.reject("E_SAVE_FAILED", "Failed to save cropped image") | |
} | |
} | |
public func cropViewControllerDidFailToCrop( | |
_ cropViewController: CropViewController, | |
original: UIImage | |
) { | |
if let promise = headlessPromise { | |
headlessPromise = nil | |
offscreenWindow?.isHidden = true | |
offscreenWindow = nil | |
promise.reject("E_CROP_FAILED", "Mantis failed to crop") | |
} | |
} | |
public func cropViewControllerDidCancel( | |
_ cropViewController: CropViewController, | |
original: UIImage | |
) { | |
if let promise = headlessPromise { | |
headlessPromise = nil | |
offscreenWindow?.isHidden = true | |
offscreenWindow = nil | |
promise.reject("E_USER_CANCELLED", "User cancelled crop") | |
} | |
} | |
// MARK: – Transformation helpers | |
func parseTransformation(dict: [String: Any]) -> Transformation? { | |
guard | |
let offsetX = dict["offsetX"] as? CGFloat, | |
let offsetY = dict["offsetY"] as? CGFloat, | |
let rotation = dict["rotation"] as? CGFloat, | |
let scale = dict["scale"] as? CGFloat, | |
let isManual = dict["isManuallyZoomed"] as? Bool, | |
let imf = dict["initialMaskFrame"] as? [String: CGFloat], | |
let mf = dict["maskFrame"] as? [String: CGFloat], | |
let cb = dict["cropWorkbenchViewBounds"] as? [String: CGFloat], | |
let hf = dict["horizontallyFlipped"] as? Bool, | |
let vf = dict["verticallyFlipped"] as? Bool | |
else { | |
return nil | |
} | |
return Transformation( | |
offset: CGPoint(x: offsetX, y: offsetY), | |
rotation: rotation, | |
scale: scale, | |
isManuallyZoomed: isManual, | |
initialMaskFrame: rectFromDict(imf), | |
maskFrame: rectFromDict(mf), | |
cropWorkbenchViewBounds: rectFromDict(cb), | |
horizontallyFlipped: hf, | |
verticallyFlipped: vf | |
) | |
} | |
func buildTransformationDict(_ t: Transformation) -> [String: Any] { | |
return [ | |
"offsetX": t.offset.x, | |
"offsetY": t.offset.y, | |
"rotation": t.rotation, | |
"scale": t.scale, | |
"isManuallyZoomed": t.isManuallyZoomed, | |
"initialMaskFrame": rectToDict(t.initialMaskFrame), | |
"maskFrame": rectToDict(t.maskFrame), | |
"cropWorkbenchViewBounds": rectToDict(t.cropWorkbenchViewBounds), | |
"horizontallyFlipped": t.horizontallyFlipped, | |
"verticallyFlipped": t.verticallyFlipped | |
] | |
} | |
private func rectFromDict(_ d: [String: CGFloat]) -> CGRect { | |
return CGRect( | |
x: d["x"] ?? 0, | |
y: d["y"] ?? 0, | |
width: d["width"] ?? 0, | |
height: d["height"] ?? 0 | |
) | |
} | |
private func rectToDict(_ r: CGRect) -> [String: CGFloat] { | |
return [ | |
"x": r.origin.x, | |
"y": r.origin.y, | |
"width": r.size.width, | |
"height": r.size.height | |
] | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* TypeScript module exposing the Mantis functions */ | |
import { requireNativeModule } from 'expo-modules-core' | |
import * as MantisTypes from './Mantis.types' | |
const MantisModule = requireNativeModule('MantisModule') | |
export async function cropAsync(viewTag: number) { | |
return await MantisModule.cropAsync(viewTag) | |
} | |
export async function cancelAsync(viewTag: number) { | |
return await MantisModule.cancelAsync(viewTag) | |
} | |
export function rotateCounterClockwise(viewTag: number) { | |
return MantisModule.rotateCounterClockwise(viewTag) | |
} | |
export function rotateClockwise(viewTag: number) { | |
return MantisModule.rotateClockwise(viewTag) | |
} | |
export function reset(viewTag: number) { | |
return MantisModule.reset(viewTag) | |
} | |
export function horizontallyFlip(viewTag: number) { | |
return MantisModule.horizontallyFlip(viewTag) | |
} | |
export function verticallyFlip(viewTag: number) { | |
return MantisModule.verticallyFlip(viewTag) | |
} | |
export function setFixedRatio(viewTag: number, ratio: number) { | |
return MantisModule.setFixedRatio(viewTag, ratio) | |
} | |
export function setFreeRatio(viewTag: number) { | |
return MantisModule.setFreeRatio(viewTag) | |
} | |
export function alterCropper90Degree(viewTag: number) { | |
return MantisModule.alterCropper90Degree(viewTag) | |
} | |
export function getCurrentAspectRatio(viewTag: number): number { | |
return MantisModule.getCurrentAspectRatio(viewTag) | |
} | |
export async function autoAdjustAsync(viewTag: number, isActive: boolean) { | |
return await MantisModule.autoAdjustAsync(viewTag, isActive) | |
} | |
export async function cropWithTransformAsync( | |
uri: string, | |
transformation: MantisModule.Transformation | |
) { | |
return await MantisModule.cropWithTransformAsync(uri, transformation) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* React Native view wrapping the Mantis view */ | |
import { requireNativeViewManager } from 'expo-modules-core' | |
import { forwardRef, useImperativeHandle, useRef } from 'react' | |
import { View, TouchableOpacity } from 'react-native' | |
import LockIcon from 'components/icons/lock' | |
import { useTheme } from 'helpers/hooks' | |
const NativeCropperView = requireNativeViewManager('MantisModule') | |
export const CropperView = forwardRef( | |
( | |
{ | |
isCroppingLocked, | |
setIsCroppingLocked, | |
uri, | |
setIsCropResettable, | |
setCroppingRatio, | |
...props | |
}, | |
ref | |
) => { | |
const nativeRef = useRef(null) | |
const { colors } = useTheme() | |
const handleLock = async () => { | |
if (isCroppingLocked) { | |
setIsCroppingLocked(false) | |
await nativeRef.current.setFreeRatioAsync() | |
} else { | |
const currentRatio = | |
await nativeRef.current.getCurrentAspectRatioAsync() | |
await nativeRef.current.setFixedRatioAsync(currentRatio) | |
setIsCroppingLocked(true) | |
} | |
} | |
const onResizeStart = () => { | |
if (!isCroppingLocked) { | |
setCroppingRatio(null) | |
} | |
setIsCropResettable(true) | |
} | |
useImperativeHandle(ref, () => ({ | |
crop: async () => { | |
try { | |
if (!nativeRef.current) { | |
console.error('No nativeRef.current available') | |
return | |
} | |
return await nativeRef.current.cropAsync() | |
} catch (error) { | |
console.error('Crop error:', error) | |
throw error | |
} | |
}, | |
cancel: async () => { | |
if (!nativeRef.current) return null | |
return await nativeRef.current.cancelAsync() | |
}, | |
rotateCounterClockwise: async () => { | |
if (!nativeRef.current) return | |
setIsCropResettable(true) | |
return await nativeRef.current.rotateCounterClockwiseAsync() | |
}, | |
rotateClockwise: async () => { | |
if (!nativeRef.current) return | |
setIsCropResettable(true) | |
return await nativeRef.current.rotateClockwiseAsync() | |
}, | |
reset: async () => { | |
if (!nativeRef.current) return | |
setIsCroppingLocked(true) | |
setIsCropResettable(false) | |
await nativeRef.current.resetAsync() | |
const currentRatio = | |
await nativeRef.current.getCurrentAspectRatioAsync() | |
const roundedRatio = Math.round(currentRatio * 100) / 100 | |
setCroppingRatio(roundedRatio) | |
}, | |
horizontallyFlip: async () => { | |
if (!nativeRef.current) return | |
setIsCropResettable(true) | |
return await nativeRef.current.horizontallyFlipAsync() | |
}, | |
verticallyFlip: async () => { | |
if (!nativeRef.current) return | |
setIsCropResettable(true) | |
return await nativeRef.current.verticallyFlipAsync() | |
}, | |
setFixedRatio: async (ratio) => { | |
if (!nativeRef.current) return | |
setIsCroppingLocked(true) | |
setIsCropResettable(true) | |
return await nativeRef.current.setFixedRatioAsync(ratio) | |
}, | |
setFreeRatio: async () => { | |
if (!nativeRef.current) return | |
setIsCroppingLocked(false) | |
return await nativeRef.current.setFreeRatioAsync() | |
}, | |
alterCropper90Degree: async () => { | |
if (!nativeRef.current) return | |
setIsCropResettable(true) | |
return await nativeRef.current.alterCropper90DegreeAsync() | |
}, | |
getCurrentAspectRatio: async () => { | |
if (!nativeRef.current) return 1.0 | |
return await nativeRef.current.getCurrentAspectRatioAsync() | |
}, | |
autoAdjust: async (isActive) => { | |
if (!nativeRef.current) return | |
return await nativeRef.current.autoAdjustAsync(isActive) | |
}, | |
})) | |
return ( | |
<View style={{ flex: 1 }}> | |
<NativeCropperView | |
uri={uri} | |
{...props} | |
ref={nativeRef} | |
onResizeStart={onResizeStart} | |
/> | |
<TouchableOpacity | |
onPress={() => handleLock()} | |
type="transparent" | |
style={{ | |
position: 'absolute', | |
top: 6, | |
right: 6, | |
borderRadius: 8, | |
backgroundColor: colors.cream['50'], | |
padding: 6, | |
borderWidth: 1, | |
borderColor: colors.offBlack['100'], | |
justifyContent: 'center', | |
alignItems: 'center', | |
opacity: 0.6, | |
}} | |
> | |
<LockIcon | |
isLocked={isCroppingLocked} | |
size={20} | |
color={ | |
isCroppingLocked | |
? colors.button.dark | |
: colors.button.light | |
} | |
/> | |
</TouchableOpacity> | |
</View> | |
) | |
} | |
) | |
CropperView.displayName = 'CropperView' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* Swift Expo View used for embedding the Mantis cropper into an RN app */ | |
import UIKit | |
import Mantis | |
import ExpoModulesCore | |
import Photos | |
public class MantisView: ExpoView, CropViewControllerDelegate { | |
private var cropViewController: CropViewController? | |
private var originalImage: UIImage? | |
private var pendingCropCompletion: ((Result<[String: Any], Error>) -> Void)? | |
private var pendingImage: UIImage? | |
private var currentCropInfo: CropInfo? | |
private var originalAspectRatio: CGFloat = 0 | |
public var initialTransformation: Transformation? { | |
didSet { | |
if let img = pendingImage { | |
setupCropper(with: img) | |
} | |
} | |
} | |
@objc | |
var uri: String = "" { | |
didSet { | |
loadImage() | |
} | |
} | |
@objc | |
var initialCropInfo: [String: Any]? { | |
didSet { | |
if let dict = initialCropInfo { | |
initialTransformation = parseTransformation(dict: dict) | |
} | |
} | |
} | |
public let onResizeStart = EventDispatcher() | |
public let onBecomeResettable = EventDispatcher() | |
private func loadImage() { | |
guard let url = URL(string: uri) else { | |
return | |
} | |
DispatchQueue.global(qos: .userInitiated).async { | |
if let data = try? Data(contentsOf: url), | |
let image = UIImage(data: data) { | |
DispatchQueue.main.async { [weak self] in | |
self?.originalImage = image | |
self?.setupCropper(with: image) | |
} | |
} | |
} | |
} | |
private func setupCropper(with img: UIImage) { | |
// Convert to 8-bit if necessary | |
var imageToUse = img | |
if let cgImage = img.cgImage, cgImage.bitsPerComponent != 8 { | |
if let converted = convertTo8Bit(image: img) { | |
imageToUse = converted | |
} | |
} | |
// Remove existing crop view controller if any | |
cropViewController?.view.removeFromSuperview() | |
cropViewController = nil | |
let buttonColorLight = UIColor( | |
red: 33 / 255, | |
green: 185 / 255, | |
blue: 255 / 255, | |
alpha: 1 | |
) | |
let buttonColorStandard = UIColor( | |
red: 0.0 / 255.0, | |
green: 148.0 / 255.0, | |
blue: 217.0 / 255.0, | |
alpha: 0.75 | |
) | |
let cream50 = UIColor( | |
red: 251.0 / 255.0, | |
green: 251.0 / 255.0, | |
blue: 249.0 / 255.0, | |
alpha: 1.0 | |
) | |
let cream100 = UIColor( | |
red: 234 / 255, | |
green: 232 / 255, | |
blue: 227 / 255, | |
alpha: 0.7 | |
) | |
var config = Config() | |
var rotationDialConfig = RotationDialConfig() | |
rotationDialConfig.smallScaleColor = cream50 | |
rotationDialConfig.bigScaleColor = buttonColorStandard | |
rotationDialConfig.numberColor = buttonColorLight | |
config.cropViewConfig.builtInRotationControlViewType = .rotationDial(config: rotationDialConfig) | |
config.cropToolbarConfig.mode = .embedded | |
config.showAttachedCropToolbar = false | |
config.cropViewConfig.cropAuxiliaryIndicatorConfig.borderNormalColor = cream100 | |
config.cropViewConfig.cropAuxiliaryIndicatorConfig.borderHintColor = buttonColorLight | |
config.cropViewConfig.cropAuxiliaryIndicatorConfig.cornerHandleColor = buttonColorStandard | |
config.cropViewConfig.cropAuxiliaryIndicatorConfig.gridMainColor = cream100 | |
// Calculate the aspect ratio to use | |
let aspectRatioToUse: CGFloat | |
if let transform = initialTransformation { | |
config.cropViewConfig.presetTransformationType = .presetInfo(info: transform) | |
// Use the aspect ratio from the initial transformation's mask frame | |
aspectRatioToUse = transform.maskFrame.width / transform.maskFrame.height | |
} else { | |
config.cropViewConfig.presetTransformationType = .none | |
// Use the original image's aspect ratio | |
aspectRatioToUse = img.size.width / img.size.height | |
} | |
// Store the aspect ratio we're using | |
self.originalAspectRatio = aspectRatioToUse | |
// Lock the cropper to this aspect ratio | |
config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: aspectRatioToUse) | |
let cropVC = Mantis.cropViewController( | |
image: imageToUse, | |
config: config | |
) | |
cropVC.delegate = self | |
self.cropViewController = cropVC | |
// Ensure we're on the main thread | |
DispatchQueue.main.async { [weak self] in | |
guard let self = self else { return } | |
// Add the crop view controller's view | |
self.addSubview(cropVC.view) | |
cropVC.view.translatesAutoresizingMaskIntoConstraints = false | |
NSLayoutConstraint.activate([ | |
cropVC.view.topAnchor.constraint(equalTo: self.topAnchor), | |
cropVC.view.bottomAnchor.constraint(equalTo: self.bottomAnchor), | |
cropVC.view.leadingAnchor.constraint(equalTo: self.leadingAnchor), | |
cropVC.view.trailingAnchor.constraint(equalTo: self.trailingAnchor) | |
]) | |
// Force layout update | |
self.layoutIfNeeded() | |
} | |
} | |
@MainActor | |
public func performCrop(completion: @escaping (Result<[String: Any], Error>) -> Void) { | |
guard let cropVC = cropViewController else { | |
completion(.failure(CropError.noCropViewController)) | |
return | |
} | |
pendingCropCompletion = completion | |
// Before calling crop | |
if let image = self.originalImage { | |
print("Original image size: \(image.size.width) x \(image.size.height)") | |
print("Image orientation: \(image.imageOrientation.rawValue)") | |
} | |
// If you have access to the crop rect or transformation: | |
print("Initial transformation: \(String(describing: self.initialTransformation))") | |
cropVC.crop() | |
} | |
@MainActor | |
public func cancelCrop(completion: @escaping (Result<[String: Any], Error>) -> Void) { | |
pendingCropCompletion = completion | |
cropViewController?.didSelectCancel() | |
} | |
// MARK: - CropViewControllerDelegate | |
public func cropViewControllerDidCrop( | |
_ cropViewController: CropViewController, | |
cropped: UIImage, | |
transformation: Transformation, | |
cropInfo: CropInfo | |
) { | |
// 1. Convert the cropped image to JPEG | |
guard let data = cropped.jpegData(compressionQuality: 1.0) else { | |
pendingCropCompletion?(.failure(CropError.failedToEncode)) | |
pendingCropCompletion = nil | |
return | |
} | |
// 2. Save to documents directory (not temp directory) | |
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! | |
let filePath = docsDir.appendingPathComponent(UUID().uuidString + ".jpg") | |
do { | |
try data.write(to: filePath) | |
let cropRegion = cropInfo.cropRegion | |
// 3. Build result object with the same structure as the old module | |
let result: [String: Any] = [ | |
"uri": filePath.absoluteString, | |
"width": Int(cropped.size.width), | |
"height": Int(cropped.size.height), | |
"transformation": buildTransformationDict(transformation), | |
"cropRegion": [ | |
"topLeft": ["x": cropRegion.topLeft.x, "y": cropRegion.topLeft.y], | |
"topRight": ["x": cropRegion.topRight.x, "y": cropRegion.topRight.y], | |
"bottomLeft": ["x": cropRegion.bottomLeft.x, "y": cropRegion.bottomLeft.y], | |
"bottomRight": ["x": cropRegion.bottomRight.x, "y": cropRegion.bottomRight.y], | |
] | |
] | |
pendingCropCompletion?(.success(result)) | |
} catch { | |
pendingCropCompletion?(.failure(CropError.failedToSave)) | |
} | |
pendingCropCompletion = nil | |
} | |
public func cropViewControllerDidCancel( | |
_ cropViewController: CropViewController, | |
original: UIImage | |
) { | |
print("cropViewControllerDidCancel called") | |
pendingCropCompletion?(.failure(CropError.userCancelled)) | |
pendingCropCompletion = nil | |
} | |
public func cropViewControllerDidFailToCrop( | |
_ cropViewController: CropViewController, | |
original: UIImage | |
) { | |
print("cropViewControllerDidFailToCrop called") | |
pendingCropCompletion?(.failure(CropError.failedToCrop)) | |
pendingCropCompletion = nil | |
} | |
public func cropViewControllerDidEndResize(_ cropViewController: CropViewController, original: UIImage, cropInfo: CropInfo) { | |
currentCropInfo = cropInfo | |
} | |
public func cropViewControllerDidBeginResize(_ cropViewController: CropViewController) { | |
print("Swift: resize start") | |
onResizeStart([:]) | |
} | |
public func cropViewController(_ cropViewController: CropViewController, didBecomeResettable resettable: Bool) { | |
onBecomeResettable(["resettable": resettable]) | |
} | |
// MARK: - Transformation Helpers | |
public func parseTransformation(dict: [String: Any]) -> Transformation? { | |
guard | |
let offsetX = dict["offsetX"] as? CGFloat, | |
let offsetY = dict["offsetY"] as? CGFloat, | |
let rotation = dict["rotation"] as? CGFloat, | |
let scale = dict["scale"] as? CGFloat, | |
let isManual = dict["isManuallyZoomed"] as? Bool, | |
let initialMaskFrameDict = dict["initialMaskFrame"] as? [String: CGFloat], | |
let maskFrameDict = dict["maskFrame"] as? [String: CGFloat], | |
let workbenchDict = dict["cropWorkbenchViewBounds"] as? [String: CGFloat], | |
let horizontallyFlipped = dict["horizontallyFlipped"] as? Bool, | |
let verticallyFlipped = dict["verticallyFlipped"] as? Bool | |
else { | |
return nil | |
} | |
let offset = CGPoint(x: offsetX, y: offsetY) | |
let initialFrame = rectFromDict(initialMaskFrameDict) | |
let maskFrame = rectFromDict(maskFrameDict) | |
let workbenchFrame = rectFromDict(workbenchDict) | |
return Transformation( | |
offset: offset, | |
rotation: rotation, | |
scale: scale, | |
isManuallyZoomed: isManual, | |
initialMaskFrame: initialFrame, | |
maskFrame: maskFrame, | |
cropWorkbenchViewBounds: workbenchFrame, | |
horizontallyFlipped: horizontallyFlipped, | |
verticallyFlipped: verticallyFlipped | |
) | |
} | |
private func rectFromDict(_ dict: [String: CGFloat]) -> CGRect { | |
let x = dict["x"] ?? 0 | |
let y = dict["y"] ?? 0 | |
let w = dict["width"] ?? 0 | |
let h = dict["height"] ?? 0 | |
return CGRect(x: x, y: y, width: w, height: h) | |
} | |
private func buildTransformationDict(_ t: Transformation) -> [String: Any] { | |
return [ | |
"offsetX": t.offset.x, | |
"offsetY": t.offset.y, | |
"rotation": t.rotation, | |
"scale": t.scale, | |
"isManuallyZoomed": t.isManuallyZoomed, | |
"initialMaskFrame": rectToDict(t.initialMaskFrame), | |
"maskFrame": rectToDict(t.maskFrame), | |
"cropWorkbenchViewBounds": rectToDict(t.cropWorkbenchViewBounds), | |
"horizontallyFlipped": t.horizontallyFlipped, | |
"verticallyFlipped": t.verticallyFlipped | |
] | |
} | |
private func rectToDict(_ rect: CGRect) -> [String: CGFloat] { | |
return [ | |
"x": rect.origin.x, | |
"y": rect.origin.y, | |
"width": rect.size.width, | |
"height": rect.size.height | |
] | |
} | |
@MainActor | |
public func rotateCounterClockwise() { | |
cropViewController?.didSelectCounterClockwiseRotate() | |
} | |
@MainActor | |
public func rotateClockwise() { | |
cropViewController?.didSelectClockwiseRotate() | |
} | |
@MainActor | |
public func reset() { | |
// First restore the original aspect ratio | |
cropViewController?.didSelectRatio(ratio: originalAspectRatio) | |
// Then perform the reset | |
cropViewController?.didSelectReset() | |
} | |
@MainActor | |
public func horizontallyFlip() { | |
cropViewController?.didSelectHorizontallyFlip() | |
} | |
@MainActor | |
public func verticallyFlip() { | |
cropViewController?.didSelectVerticallyFlip() | |
} | |
@MainActor | |
public func setFixedRatio(_ ratio: Double) { | |
cropViewController?.didSelectRatio(ratio: ratio) | |
} | |
@MainActor | |
public func setFreeRatio() { | |
cropViewController?.didSelectFreeRatio() | |
} | |
@MainActor | |
public func alterCropper90Degree() { | |
cropViewController?.didSelectAlterCropper90Degree() | |
} | |
@MainActor | |
public func getCurrentAspectRatio() -> Double { | |
guard let cropInfo = currentCropInfo else { return 1.0 } | |
return Double(cropInfo.cropSize.width / cropInfo.cropSize.height) | |
} | |
@MainActor | |
public func autoAdjust(isActive: Bool) { | |
cropViewController?.didSelectAutoAdjust(nil, isActive: isActive) | |
} | |
func convertTo8Bit(image: UIImage) -> UIImage? { | |
guard let cgImage = image.cgImage else { return nil } | |
let width = cgImage.width | |
let height = cgImage.height | |
let colorSpace = CGColorSpaceCreateDeviceRGB() | |
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | |
let context = CGContext( | |
data: nil, | |
width: width, | |
height: height, | |
bitsPerComponent: 8, | |
bytesPerRow: 0, | |
space: colorSpace, | |
bitmapInfo: bitmapInfo | |
) | |
guard let ctx = context else { return nil } | |
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) | |
guard let newCGImage = ctx.makeImage() else { return nil } | |
return UIImage(cgImage: newCGImage) | |
} | |
} | |
enum CropError: Error { | |
case failedToEncode | |
case failedToSave | |
case userCancelled | |
case noCropViewController | |
case failedToCrop | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment