From 4222e4cc77c4a6e7ce56d71d2152075f3e0aa76b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 18 Sep 2026 01:53:25 +0800 Subject: [PATCH 1/6] feat: add image crop picker module Add @onekeyfe/react-native-image-crop-picker, a Nitro replacement for react-native-image-crop-picker 0.51.1. app-monorepo installs it under the react-native-image-crop-picker npm alias, so imports stay the same. It keeps openPicker, openCropper, clean, cleanSingle and the E_* rejection codes that OneKey uses, and drops multiple selection, video and the camera. iOS picks with PHPickerViewController, which runs out of process and needs no photo library permission (OK-48227). The old library asked for full library access first, so after one "Don't Allow" iOS never asked again and every later openPicker call rejected with E_NO_LIBRARY_PERMISSION, which the OneKey ID avatar and hardware wallpaper entries swallowed. Cropping uses a vendored TOCropViewController 3.2.0, up from the 2.8.0 the app carried as a pod override, with the OK-51551 crop box shrink fix re-applied; upstream still has that bug. Android ports the existing flow to Kotlin: the system Photo Picker, which needs no storage or media permission, and uCrop 2.2.11-native. Activity results go through the activity's ActivityResultRegistry instead of an ActivityEventListener. - decode photos at most 4096 px on the long side - scale aspect-locked crops to exactly the requested size - add an example page and the 3.0.146 changelog entry Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 + example/react-native/package.json | 1 + .../pages/ImageCropPickerTestPage.tsx | 156 ++ example/react-native/route.tsx | 14 + .../react-native-image-crop-picker/.gitignore | 89 + .../react-native-image-crop-picker/LICENSE | 21 + .../react-native-image-crop-picker/README.md | 83 + .../ReactNativeImageCropPicker.podspec | 40 + .../android/CMakeLists.txt | 24 + .../android/build.gradle | 144 ++ .../android/gradle.properties | 4 + .../android/src/main/AndroidManifest.xml | 24 + .../android/src/main/cpp/cpp-adapter.cpp | 6 + .../ImageCropPickerException.kt | 45 + .../ImageCropPickerImageProcessor.kt | 308 +++ .../ImageCropPickerSession.kt | 297 +++ .../ReactNativeImageCropPicker.kt | 69 + .../ReactNativeImageCropPickerPackage.kt | 24 + .../babel.config.js | 13 + .../eslint.config.mjs | 5 + .../ios/ImageCropPickerError.swift | 57 + .../ios/ImageCropPickerImageProcessor.swift | 343 ++++ .../ios/ImageCropPickerSession.swift | 407 ++++ .../ios/ReactNativeImageCropPicker.swift | 65 + .../Categories/UIImage+CropRotate.h | 39 + .../Categories/UIImage+CropRotate.m | 94 + .../Constants/TOCropViewConstants.h | 63 + .../ios/TOCropViewController/LICENSE | 21 + .../Models/TOActivityCroppedImageProvider.h | 38 + .../Models/TOActivityCroppedImageProvider.m | 73 + .../TOCropViewControllerAspectRatioPreset.h | 64 + .../TOCropViewControllerAspectRatioPreset.m | 121 ++ .../TOCropViewControllerTransitioning.h | 49 + .../TOCropViewControllerTransitioning.m | 125 ++ .../Models/TOCroppedImageAttributes.h | 38 + .../Models/TOCroppedImageAttributes.m | 45 + .../TOCropViewControllerLocalizable.strings | 8 + .../Resources/PrivacyInfo.xcprivacy | 14 + .../TOCropViewControllerLocalizable.strings | 9 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 9 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 9 + .../TOCropViewControllerLocalizable.strings | 8 + .../TOCropViewControllerLocalizable.strings | 9 + .../TOCropViewControllerLocalizable.strings | 9 + .../TOCropViewControllerLocalizable.strings | 9 + .../TOCropViewController.h | 479 +++++ .../TOCropViewController.m | 1355 +++++++++++++ .../Views/TOCropOverlayView.h | 43 + .../Views/TOCropOverlayView.m | 240 +++ .../Views/TOCropScrollView.h | 39 + .../Views/TOCropScrollView.m | 48 + .../Views/TOCropToolbar.h | 104 + .../Views/TOCropToolbar.m | 783 ++++++++ .../TOCropViewController/Views/TOCropView.h | 311 +++ .../TOCropViewController/Views/TOCropView.m | 1787 +++++++++++++++++ .../lefthook.yml | 10 + .../react-native-image-crop-picker/nitro.json | 23 + .../package.json | 173 ++ .../src/ReactNativeImageCropPicker.nitro.ts | 72 + .../src/index.tsx | 134 ++ .../tsconfig.build.json | 9 + .../tsconfig.json | 30 + .../react-native-image-crop-picker/turbo.json | 17 + yarn.lock | 37 + 86 files changed, 8975 insertions(+) create mode 100644 example/react-native/pages/ImageCropPickerTestPage.tsx create mode 100644 native-modules/react-native-image-crop-picker/.gitignore create mode 100644 native-modules/react-native-image-crop-picker/LICENSE create mode 100644 native-modules/react-native-image-crop-picker/README.md create mode 100644 native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec create mode 100644 native-modules/react-native-image-crop-picker/android/CMakeLists.txt create mode 100644 native-modules/react-native-image-crop-picker/android/build.gradle create mode 100644 native-modules/react-native-image-crop-picker/android/gradle.properties create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/cpp/cpp-adapter.cpp create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerException.kt create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerImageProcessor.kt create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerSession.kt create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPicker.kt create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPickerPackage.kt create mode 100644 native-modules/react-native-image-crop-picker/babel.config.js create mode 100644 native-modules/react-native-image-crop-picker/eslint.config.mjs create mode 100644 native-modules/react-native-image-crop-picker/ios/ImageCropPickerError.swift create mode 100644 native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift create mode 100644 native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift create mode 100644 native-modules/react-native-image-crop-picker/ios/ReactNativeImageCropPicker.swift create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Constants/TOCropViewConstants.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/LICENSE create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/Base.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/PrivacyInfo.xcprivacy create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ar.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ca.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/cs.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/da-DK.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/de.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/en.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/es.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa-IR.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fi.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fr.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/hu.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/id.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/it.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ja.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ko.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ms.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/nl.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pl.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt-BR.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ro.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ru.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/sk.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/tr.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/uk.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/vi.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hans.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hant.lproj/TOCropViewControllerLocalizable.strings create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.m create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.h create mode 100644 native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.m create mode 100644 native-modules/react-native-image-crop-picker/lefthook.yml create mode 100644 native-modules/react-native-image-crop-picker/nitro.json create mode 100644 native-modules/react-native-image-crop-picker/package.json create mode 100644 native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts create mode 100644 native-modules/react-native-image-crop-picker/src/index.tsx create mode 100644 native-modules/react-native-image-crop-picker/tsconfig.build.json create mode 100644 native-modules/react-native-image-crop-picker/tsconfig.json create mode 100644 native-modules/react-native-image-crop-picker/turbo.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a03ee601..048e655b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [3.0.147] - 2026-09-18 + +### Features +- **image-crop-picker (new)**: Add `@onekeyfe/react-native-image-crop-picker`, a Nitro module that replaces `react-native-image-crop-picker` 0.51.1. app-monorepo installs it under the `react-native-image-crop-picker` npm alias, so imports stay the same. It keeps `openPicker`, `openCropper`, `clean`, `cleanSingle` and the `E_*` rejection codes that OneKey uses, and drops multiple selection, video and the camera. + - **iOS**: Pick photos with `PHPickerViewController`, which needs no photo library permission (OK-48227). `react-native-image-crop-picker` requested full library access before showing its picker. After a user tapped "Don't Allow" once, iOS never asked again and every later `openPicker` call rejected with `E_NO_LIBRARY_PERMISSION`. The OneKey ID avatar and hardware wallpaper entries swallowed that rejection, so tapping them did nothing. + - **iOS**: Crop with a vendored TOCropViewController 3.2.0, up from 2.8.0, with the OK-51551 fix that keeps the crop box from shrinking on every rotation re-applied. Upstream 3.2.0 still has that bug. The app no longer needs its TOCropViewController pod override. + - **Android**: Port the existing flow to Kotlin: the system Photo Picker (no storage or media permission) and uCrop 2.2.11-native, the latest release. Activity results go through the activity's `ActivityResultRegistry` instead of an `ActivityEventListener`. + - Decode photos at most 4096 px on the long side, so a 48 MP photo no longer needs about 200 MB of memory before cropping. + +### Chores +- Bump all 41 publishable packages to 3.0.147. + ## [3.0.146] - 2026-09-17 ### Bug Fixes diff --git a/example/react-native/package.json b/example/react-native/package.json index 5acb33227..a9fd10cce 100644 --- a/example/react-native/package.json +++ b/example/react-native/package.json @@ -28,6 +28,7 @@ "@onekeyfe/react-native-dns-lookup": "workspace:*", "@onekeyfe/react-native-get-random-values": "workspace:*", "@onekeyfe/react-native-image": "workspace:*", + "@onekeyfe/react-native-image-crop-picker": "workspace:*", "@onekeyfe/react-native-keychain-module": "workspace:*", "@onekeyfe/react-native-lite-card": "workspace:*", "@onekeyfe/react-native-native-list": "workspace:*", diff --git a/example/react-native/pages/ImageCropPickerTestPage.tsx b/example/react-native/pages/ImageCropPickerTestPage.tsx new file mode 100644 index 000000000..372a0ba73 --- /dev/null +++ b/example/react-native/pages/ImageCropPickerTestPage.tsx @@ -0,0 +1,156 @@ +import { useCallback, useState } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; +import ImageCropPicker, { + ImageCropPickerError, + type Image as PickedImage, +} from '@onekeyfe/react-native-image-crop-picker'; +import { TestButton, TestPageBase, TestResult } from './TestPageBase'; + +// Production consumers import from `react-native-image-crop-picker`, an npm +// alias mapped to @onekeyfe/react-native-image-crop-picker. +export function ImageCropPickerTestPage() { + const [image, setImage] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const run = useCallback(async (task: () => Promise) => { + setResult(null); + setError(null); + try { + const picked = await task(); + if (picked) { + setImage(picked); + // Keep the output readable: base64 payloads are only summarized. + setResult({ + ...picked, + data: picked.data ? `<${picked.data.length} base64 chars>` : undefined, + }); + } else { + setResult('done'); + } + } catch (err) { + setError( + err instanceof ImageCropPickerError + ? `${err.code}: ${err.message}` + : String(err) + ); + } + }, []); + + return ( + + + The picker needs no photo library permission. To check the OK-48227 + regression, deny Photos access for this app in Settings first; the + picker must still open. + + + + run(() => + ImageCropPicker.openPicker({ + width: 500, + height: 500, + cropping: true, + includeBase64: true, + cropperChooseText: 'Confirm', + cropperCancelText: 'Cancel', + }) + ) + } + /> + + run(() => + ImageCropPicker.openPicker({ + width: 240, + height: 240, + cropping: true, + cropperCircleOverlay: true, + compressImageQuality: 0.8, + }) + ) + } + /> + + run(() => + ImageCropPicker.openPicker({ + width: 480, + height: 800, + cropping: true, + includeBase64: true, + }) + ) + } + /> + + run(() => + ImageCropPicker.openPicker({ + compressImageMaxWidth: 1024, + compressImageMaxHeight: 1024, + }) + ) + } + /> + + run(() => + ImageCropPicker.openCropper({ + path: image?.path ?? '', + width: 300, + height: 200, + }) + ) + } + /> + + run(() => + ImageCropPicker.openCropper({ + path: 'file:///does/not/exist.jpg', + width: 100, + height: 100, + }) + ) + } + /> + run(ImageCropPicker.clean)} /> + + {image ? ( + + + + ) : null} + + + + ); +} + +const s = StyleSheet.create({ + hint: { + fontSize: 13, + color: '#636366', + lineHeight: 18, + }, + preview: { + alignItems: 'center', + }, + previewImage: { + width: 240, + backgroundColor: '#e5e5ea', + }, +}); diff --git a/example/react-native/route.tsx b/example/react-native/route.tsx index d1a0b8f3d..5f0be9206 100644 --- a/example/react-native/route.tsx +++ b/example/react-native/route.tsx @@ -45,6 +45,7 @@ import { AppUpdateTestPage } from './pages/AppUpdateTestPage'; import { RangeDownloaderTestPage } from './pages/RangeDownloaderTestPage'; import { BundleCryptoTestPage } from './pages/BundleCryptoTestPage'; import { ZipArchiveTestPage } from './pages/ZipArchiveTestPage'; +import { ImageCropPickerTestPage } from './pages/ImageCropPickerTestPage'; import { OtaPipelineTestPage } from './pages/OtaPipelineTestPage'; import { ApkOtaPipelineTestPage } from './pages/ApkOtaPipelineTestPage'; import { ChartWebViewTestPage } from './pages/ChartWebViewTestPage'; @@ -99,6 +100,7 @@ export type RootStackParamList = { RangeDownloader: undefined; BundleCrypto: undefined; ZipArchive: undefined; + ImageCropPicker: undefined; ScrollGuard: undefined; SegmentSlider: undefined; Skeleton: undefined; @@ -205,6 +207,13 @@ const modules: { 'unzip, getUncompressedSize, isPasswordProtected for OTA bundle archives', icon: '🗜️', }, + { + screen: 'ImageCropPicker', + name: 'Image Crop Picker', + description: + 'Permission-free photo picker with cropping for avatars and wallpapers', + icon: '🖼️', + }, { screen: 'CloudKit', name: 'CloudKit Module', @@ -578,6 +587,11 @@ export function AppNavigator() { component={ZipArchiveTestPage} options={{ title: 'Zip Archive' }} /> + /react-native-image-crop-picker/` on iOS and `/react-native-image-crop-picker/` on Android. `clean()` empties that directory. +- Only one picker or cropper can be open at a time. A second call rejects with `E_PICKER_IN_PROGRESS`. +- On iOS, cancelling the cropper that `openPicker` opened returns to the photo picker. On Android it rejects with `E_PICKER_CANCELLED`. + +## Not supported + +Multiple selection, video, the camera and `includeExif` are not implemented. `mediaType`, `forceJpg` and `sortOrder` are accepted for source compatibility and ignored. + +## Error codes + +| Code | Meaning | +| --- | --- | +| `E_PICKER_CANCELLED` | The user closed the picker or the cropper. | +| `E_PICKER_IN_PROGRESS` | Another picker or cropper is still open. | +| `E_ACTIVITY_DOES_NOT_EXIST` | Android: no current activity. | +| `E_FAILED_TO_SHOW_PICKER` | The picker or cropper could not be presented. | +| `E_NO_IMAGE_DATA_FOUND` | The selected item could not be read as an image. | +| `E_CROPPER_IMAGE_NOT_FOUND` | `openCropper` could not load `path`. | +| `E_CANNOT_SAVE_IMAGE` | The result could not be written. | +| `E_LOW_MEMORY_ERROR` | Android: out of memory while processing. | +| `E_ERROR_WHILE_CLEANING_FILES` | `clean` or `cleanSingle` failed. | +| `E_UNKNOWN` | Any other native error. | + +## License + +MIT. The vendored TOCropViewController in `ios/TOCropViewController` is MIT licensed by Tim Oliver; see its `LICENSE`. diff --git a/native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec b/native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec new file mode 100644 index 000000000..d9682edf5 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec @@ -0,0 +1,40 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "ReactNativeImageCropPicker" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-image-crop-picker.git", :tag => "#{s.version}" } + + s.source_files = [ + "ios/**/*.{swift}", + "ios/**/*.{h,m,mm}", + "cpp/**/*.{hpp,cpp}", + ] + + # Vendored TOCropViewController 3.2.0 with the OK-51551 rotation fix. Its + # headers are public so the Swift sources can use it. It replaces the + # standalone TOCropViewController pod, which must not be installed alongside. + s.public_header_files = ["ios/TOCropViewController/**/*.h"] + # TOCropViewController looks up its strings in a bundle with exactly this name. + s.resource_bundles = { + "TOCropViewControllerBundle" => ["ios/TOCropViewController/Resources/**/*.{lproj,xcprivacy}"], + } + s.frameworks = "PhotosUI", "UniformTypeIdentifiers" + + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + s.dependency 'ReactNativeNativeLogger' + + load 'nitrogen/generated/ios/ReactNativeImageCropPicker+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-image-crop-picker/android/CMakeLists.txt b/native-modules/react-native-image-crop-picker/android/CMakeLists.txt new file mode 100644 index 000000000..15ce320d3 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/CMakeLists.txt @@ -0,0 +1,24 @@ +project(reactnativeimagecroppicker) +cmake_minimum_required(VERSION 3.9.0) + +set(PACKAGE_NAME reactnativeimagecroppicker) +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 20) + +# Define C++ library and add all sources +add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp) + +# Add Nitrogen specs :) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/reactnativeimagecroppicker+autolinking.cmake) + +# Set up local includes +include_directories("src/main/cpp" "../cpp") + +find_library(LOG_LIB log) + +# Link all libraries together +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android # <-- Android core +) diff --git a/native-modules/react-native-image-crop-picker/android/build.gradle b/native-modules/react-native-image-crop-picker/android/build.gradle new file mode 100644 index 000000000..7f35c3b8e --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/build.gradle @@ -0,0 +1,144 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['ReactNativeImageCropPicker_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + +def reactNativeArchitectures() { + def value = rootProject.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply from: '../nitrogen/generated/android/reactnativeimagecroppicker+autolinking.gradle' + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["ReactNativeImageCropPicker_" + name]).toInteger() +} + +android { + namespace "com.margelo.nitro.reactnativeimagecroppicker" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + + externalNativeBuild { + cmake { + cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + abiFilters (*reactNativeArchitectures()) + + buildTypes { + debug { + cppFlags "-O1 -g" + } + release { + cppFlags "-O2" + } + } + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnative.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so" + ] + } + + buildFeatures { + buildConfig true + prefab true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() + // uCrop is only published to JitPack. + maven { + url "https://www.jitpack.io" + content { + includeGroup "com.github.yalantis" + } + } +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation project(":react-native-nitro-modules") + + implementation project(":onekeyfe_react-native-native-logger") + + implementation "androidx.activity:activity:1.10.1" + implementation "androidx.appcompat:appcompat:1.7.1" + implementation "androidx.exifinterface:exifinterface:1.4.1" + // uCrop's JitPack POM omits its runtime dependencies. + implementation "androidx.transition:transition:1.6.0" + implementation "com.github.yalantis:ucrop:2.2.11-native" +} diff --git a/native-modules/react-native-image-crop-picker/android/gradle.properties b/native-modules/react-native-image-crop-picker/android/gradle.properties new file mode 100644 index 000000000..9d95701ab --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/gradle.properties @@ -0,0 +1,4 @@ +ReactNativeImageCropPicker_kotlinVersion=1.9.25 +ReactNativeImageCropPicker_compileSdkVersion=35 +ReactNativeImageCropPicker_targetSdkVersion=35 +ReactNativeImageCropPicker_minSdkVersion=24 diff --git a/native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml b/native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..853ec5d4d --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/native-modules/react-native-image-crop-picker/android/src/main/cpp/cpp-adapter.cpp b/native-modules/react-native-image-crop-picker/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 000000000..33ae35d3e --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,6 @@ +#include +#include "reactnativeimagecroppickerOnLoad.hpp" + +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + return margelo::nitro::reactnativeimagecroppicker::initialize(vm); +} diff --git a/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerException.kt b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerException.kt new file mode 100644 index 000000000..fd028149c --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerException.kt @@ -0,0 +1,45 @@ +package com.margelo.nitro.reactnativeimagecroppicker + +// The JS wrapper parses ": " back into `error.code` / +// `error.message`, matching react-native-image-crop-picker. +internal class ImageCropPickerException( + val code: String, + message: String, + cause: Throwable? = null, +) : Exception("$code: $message", cause) { + companion object { + const val E_PICKER_CANCELLED = "E_PICKER_CANCELLED" + const val E_PICKER_IN_PROGRESS = "E_PICKER_IN_PROGRESS" + const val E_ACTIVITY_DOES_NOT_EXIST = "E_ACTIVITY_DOES_NOT_EXIST" + const val E_FAILED_TO_SHOW_PICKER = "E_FAILED_TO_SHOW_PICKER" + const val E_NO_IMAGE_DATA_FOUND = "E_NO_IMAGE_DATA_FOUND" + const val E_CROPPER_IMAGE_NOT_FOUND = "E_CROPPER_IMAGE_NOT_FOUND" + const val E_CANNOT_SAVE_IMAGE = "E_CANNOT_SAVE_IMAGE" + const val E_LOW_MEMORY_ERROR = "E_LOW_MEMORY_ERROR" + const val E_ERROR_WHILE_CLEANING_FILES = "E_ERROR_WHILE_CLEANING_FILES" + + fun cancelled() = ImageCropPickerException(E_PICKER_CANCELLED, "User cancelled image selection") + + fun inProgress() = + ImageCropPickerException(E_PICKER_IN_PROGRESS, "Another image picker or cropper is already open") + + fun noActivity() = ImageCropPickerException(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist") + + fun failedToShowPicker(cause: Throwable) = + ImageCropPickerException(E_FAILED_TO_SHOW_PICKER, cause.message ?: "Cannot show picker", cause) + + fun noImageData(message: String = "Cannot find image data") = + ImageCropPickerException(E_NO_IMAGE_DATA_FOUND, message) + + fun cropperImageNotFound() = + ImageCropPickerException(E_CROPPER_IMAGE_NOT_FOUND, "Can't find the image at the specified path") + + fun cannotSaveImage(cause: Throwable? = null) = + ImageCropPickerException(E_CANNOT_SAVE_IMAGE, "Cannot save image. Unable to write to tmp location.", cause) + + fun lowMemory(cause: Throwable) = + ImageCropPickerException(E_LOW_MEMORY_ERROR, cause.message ?: "Out of memory", cause) + + fun cleanupFailed() = ImageCropPickerException(E_ERROR_WHILE_CLEANING_FILES, "Error while cleaning up tmp files") + } +} diff --git a/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerImageProcessor.kt b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerImageProcessor.kt new file mode 100644 index 000000000..1239d27b3 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerImageProcessor.kt @@ -0,0 +1,308 @@ +package com.margelo.nitro.reactnativeimagecroppicker + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.util.Base64 +import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.UUID +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +internal data class ImageCropPickerConfig( + val width: Int?, + val height: Int?, + val cropping: Boolean, + val includeBase64: Boolean, + val compressImageQuality: Double?, + val compressImageMaxWidth: Double?, + val compressImageMaxHeight: Double?, + val freeStyleCropEnabled: Boolean, + val cropperCircleOverlay: Boolean, + val cropperToolbarTitle: String?, + val cropperActiveWidgetColor: String?, + val cropperToolbarColor: String?, + val cropperToolbarWidgetColor: String?, + val cropperStatusBarLight: Boolean, + val cropperNavigationBarLight: Boolean, + val showCropGuidelines: Boolean, + val showCropFrame: Boolean, + val enableRotationGesture: Boolean, + val hideBottomControls: Boolean, + val disableCropperColorSetters: Boolean, +) { + companion object { + fun from(options: ImageCropPickerOptions, forceCropping: Boolean) = ImageCropPickerConfig( + width = options.width?.takeIf { it.isFinite() }?.roundToInt()?.takeIf { it > 0 }, + height = options.height?.takeIf { it.isFinite() }?.roundToInt()?.takeIf { it > 0 }, + cropping = forceCropping || options.cropping == true, + includeBase64 = options.includeBase64 == true, + compressImageQuality = options.compressImageQuality, + compressImageMaxWidth = options.compressImageMaxWidth, + compressImageMaxHeight = options.compressImageMaxHeight, + freeStyleCropEnabled = options.freeStyleCropEnabled == true, + cropperCircleOverlay = options.cropperCircleOverlay == true, + cropperToolbarTitle = options.cropperToolbarTitle, + cropperActiveWidgetColor = options.cropperActiveWidgetColor, + cropperToolbarColor = options.cropperToolbarColor, + cropperToolbarWidgetColor = options.cropperToolbarWidgetColor, + cropperStatusBarLight = options.cropperStatusBarLight ?: true, + cropperNavigationBarLight = options.cropperNavigationBarLight ?: false, + showCropGuidelines = options.showCropGuidelines ?: true, + showCropFrame = options.showCropFrame ?: true, + enableRotationGesture = options.enableRotationGesture == true, + hideBottomControls = options.hideBottomControls == true, + disableCropperColorSetters = options.disableCropperColorSetters == true, + ) + } +} + +internal object ImageCropPickerImageProcessor { + // Bounds memory for huge photos. Crop targets are far smaller than this. + private const val MAX_DECODED_PIXEL_SIZE = 4096 + + // Matches react-native-image-crop-picker on Android. + private const val DEFAULT_COMPRESS_QUALITY = 1.0 + private const val TEMPORARY_DIRECTORY_NAME = "react-native-image-crop-picker" + + fun temporaryDirectory(context: Context): File { + val directory = File(context.cacheDir, TEMPORARY_DIRECTORY_NAME) + if (!directory.isDirectory && !directory.mkdirs()) { + throw ImageCropPickerException.cannotSaveImage() + } + return directory + } + + fun createTemporaryFile(context: Context, extension: String): File = + File(temporaryDirectory(context), "${UUID.randomUUID()}.$extension") + + fun cleanTemporaryDirectory(context: Context) { + val directory = File(context.cacheDir, TEMPORARY_DIRECTORY_NAME) + if (directory.exists() && !directory.deleteRecursively()) { + throw ImageCropPickerException.cleanupFailed() + } + } + + fun removeFile(path: String) { + val file = fileFromPath(path) ?: throw ImageCropPickerException.cleanupFailed() + if (!file.delete()) { + throw ImageCropPickerException.cleanupFailed() + } + } + + private fun fileFromPath(path: String): File? = when { + path.startsWith("file://") -> Uri.parse(path).path?.let { File(it) } + path.startsWith("/") -> File(path) + else -> null + } + + // Resolves an openCropper source into a Uri that uCrop can read. Returns the + // temporary file it had to create, if any, so the caller can delete it. + fun resolveCropSource(context: Context, path: String): Pair { + when { + path.startsWith("data:") -> { + val commaIndex = path.indexOf(',') + if (commaIndex < 0) { + throw ImageCropPickerException.cropperImageNotFound() + } + val bytes = try { + Base64.decode(path.substring(commaIndex + 1), Base64.DEFAULT) + } catch (error: IllegalArgumentException) { + throw ImageCropPickerException.cropperImageNotFound() + } + val file = createTemporaryFile(context, "img") + try { + file.writeBytes(bytes) + } catch (error: IOException) { + throw ImageCropPickerException.cannotSaveImage(error) + } + return Uri.fromFile(file) to file + } + // uCrop downloads remote sources and reads content URIs itself. + path.startsWith("http://") || path.startsWith("https://") || path.startsWith("content://") -> + return Uri.parse(path) to null + else -> { + val file = fileFromPath(path) + if (file == null || !file.isFile) { + throw ImageCropPickerException.cropperImageNotFound() + } + return Uri.fromFile(file) to null + } + } + } + + // Decodes an image with its EXIF orientation applied, downsampled so its + // longest side is at most MAX_DECODED_PIXEL_SIZE. + fun decodeBitmap(context: Context, uri: Uri): Bitmap { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + openStream(context, uri).use { BitmapFactory.decodeStream(it, null, bounds) } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + throw ImageCropPickerException.noImageData("Invalid image selected") + } + + var sampleSize = 1 + while (max(bounds.outWidth, bounds.outHeight) / (sampleSize * 2) >= MAX_DECODED_PIXEL_SIZE) { + sampleSize *= 2 + } + val options = BitmapFactory.Options().apply { inSampleSize = sampleSize } + val sampled = try { + openStream(context, uri).use { BitmapFactory.decodeStream(it, null, options) } + } catch (error: OutOfMemoryError) { + throw ImageCropPickerException.lowMemory(error) + } ?: throw ImageCropPickerException.noImageData("Invalid image selected") + + val orientation = try { + openStream(context, uri).use { + ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + } + } catch (error: IOException) { + ExifInterface.ORIENTATION_NORMAL + } + val oriented = applyOrientation(sampled, orientation) + val scale = min( + MAX_DECODED_PIXEL_SIZE.toDouble() / oriented.width, + MAX_DECODED_PIXEL_SIZE.toDouble() / oriented.height, + ) + return if (scale < 1.0) { + scaleBitmap(oriented, floorSize(oriented.width * scale), floorSize(oriented.height * scale)) + } else { + oriented + } + } + + // Scales a cropped image to the requested size; smaller crops are scaled up. + // With a locked aspect ratio the crop can still end up a few pixels off that + // ratio, so the result is scaled to exactly the requested size, as + // react-native-image-crop-picker did. Free-style crops keep their own aspect + // ratio and fit inside it. + fun resizeCroppedBitmap(bitmap: Bitmap, config: ImageCropPickerConfig): Bitmap { + val width = config.width ?: return bitmap + val height = config.height ?: return bitmap + if (!config.freeStyleCropEnabled) { + return scaleBitmap(bitmap, width, height) + } + val widthRatio = width.toDouble() / bitmap.width + val heightRatio = height.toDouble() / bitmap.height + return if (widthRatio < heightRatio) { + scaleBitmap(bitmap, width, roundSize(bitmap.height * widthRatio)) + } else { + scaleBitmap(bitmap, roundSize(bitmap.width * heightRatio), height) + } + } + + fun makeResult( + context: Context, + bitmap: Bitmap, + config: ImageCropPickerConfig, + cropRect: CropRect?, + filename: String?, + ): PickedImage { + var output = bitmap + val maxWidth = config.compressImageMaxWidth + val maxHeight = config.compressImageMaxHeight + val shouldResizeWidth = maxWidth != null && maxWidth < output.width + val shouldResizeHeight = maxHeight != null && maxHeight < output.height + if (shouldResizeWidth || shouldResizeHeight) { + val scale = min( + (maxWidth ?: output.width.toDouble()) / output.width, + (maxHeight ?: output.height.toDouble()) / output.height, + ) + output = scaleBitmap(output, floorSize(output.width * scale), floorSize(output.height * scale)) + } + + val quality = ((config.compressImageQuality ?: DEFAULT_COMPRESS_QUALITY).coerceIn(0.0, 1.0) * 100).roundToInt() + val file = createTemporaryFile(context, "jpg") + try { + FileOutputStream(file).use { stream -> + if (!output.compress(Bitmap.CompressFormat.JPEG, quality, stream)) { + throw ImageCropPickerException.cannotSaveImage() + } + } + } catch (error: IOException) { + file.delete() + throw ImageCropPickerException.cannotSaveImage(error) + } + + val data = if (config.includeBase64) { + Base64.encodeToString(file.readBytes(), Base64.NO_WRAP) + } else { + null + } + return PickedImage( + path = Uri.fromFile(file).toString(), + size = file.length().toDouble(), + width = output.width.toDouble(), + height = output.height.toDouble(), + mime = "image/jpeg", + data = data, + cropRect = cropRect, + filename = filename, + ) + } + + private fun openStream(context: Context, uri: Uri): InputStream = + try { + context.contentResolver.openInputStream(uri) + } catch (error: Exception) { + null + } ?: throw ImageCropPickerException.noImageData() + + private fun floorSize(value: Double): Int = max(1, floor(value).toInt()) + + private fun roundSize(value: Double): Int = max(1, value.roundToInt()) + + private fun scaleBitmap(bitmap: Bitmap, width: Int, height: Int): Bitmap { + if (width == bitmap.width && height == bitmap.height) { + return bitmap + } + val scaled = try { + Bitmap.createScaledBitmap(bitmap, width, height, true) + } catch (error: OutOfMemoryError) { + throw ImageCropPickerException.lowMemory(error) + } + if (scaled !== bitmap) { + bitmap.recycle() + } + return scaled + } + + private fun applyOrientation(bitmap: Bitmap, orientation: Int): Bitmap { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.setRotate(180f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) + else -> return bitmap + } + val oriented = try { + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } catch (error: OutOfMemoryError) { + throw ImageCropPickerException.lowMemory(error) + } + if (oriented !== bitmap) { + bitmap.recycle() + } + return oriented + } +} diff --git a/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerSession.kt b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerSession.kt new file mode 100644 index 000000000..d083ca123 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerSession.kt @@ -0,0 +1,297 @@ +package com.margelo.nitro.reactnativeimagecroppicker + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.OpenableColumns +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import com.margelo.nitro.core.Promise +import com.margelo.nitro.nativelogger.OneKeyLog +import com.yalantis.ucrop.UCrop +import com.yalantis.ucrop.UCropActivity +import java.io.File +import java.util.UUID +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +// One picker or cropper flow. It owns the JS promise until the launched +// activities report back, and settles it exactly once. +internal class ImageCropPickerSession( + private val activity: ComponentActivity, + private val mode: Mode, + private val config: ImageCropPickerConfig, + private val promise: Promise, + private val onFinish: (ImageCropPickerSession) -> Unit, +) { + sealed class Mode { + object Picker : Mode() + + class Cropper(val path: String) : Mode() + } + + // Main thread state. + private val keyPrefix = "onekey-image-crop-picker-${UUID.randomUUID()}" + private val launchers = mutableListOf>() + private val temporaryFiles = mutableListOf() + private var pickedFilename: String? = null + private var isAwaitingResult = false + private var isProcessing = false + private var isFinished = false + + // Whether a launched activity or background work is still pending. + val isActive: Boolean + get() = !isFinished && (isProcessing || (isAwaitingResult && !activity.isDestroyed)) + + fun start() { + when (mode) { + is Mode.Picker -> launchPicker() + is Mode.Cropper -> resolveCropperSource(mode.path) + } + } + + // Settles a session whose activity went away without calling back. + fun abandon() { + finish(Result.failure(ImageCropPickerException.cancelled())) + } + + // The system Photo Picker needs no storage or media permission. On devices + // without it, PickVisualMedia falls back to ACTION_OPEN_DOCUMENT. + private fun launchPicker() { + val launcher = register(ActivityResultContracts.PickVisualMedia()) { uri -> + when { + uri == null -> finish(Result.failure(ImageCropPickerException.cancelled())) + config.cropping -> { + pickedFilename = queryDisplayName(uri) + launchCropper(uri) + } + else -> processPickedImage(uri) + } + } + launch { + launcher.launch( + PickVisualMediaRequest.Builder() + .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly) + .build(), + ) + } + } + + private fun processPickedImage(uri: Uri) { + val filename = queryDisplayName(uri) + runInBackground( + work = { + ImageCropPickerImageProcessor.makeResult( + activity, + ImageCropPickerImageProcessor.decodeBitmap(activity, uri), + config, + null, + filename, + ) + }, + onComplete = ::finish, + ) + } + + private fun resolveCropperSource(path: String) { + runInBackground( + work = { ImageCropPickerImageProcessor.resolveCropSource(activity, path) }, + onComplete = { result -> + result.fold( + onSuccess = { (source, temporaryFile) -> + temporaryFile?.let { temporaryFiles.add(it) } + launchCropper(source) + }, + onFailure = { finish(Result.failure(it)) }, + ) + }, + ) + } + + private fun launchCropper(source: Uri) { + val destination = try { + ImageCropPickerImageProcessor.createTemporaryFile(activity, "jpg") + } catch (error: ImageCropPickerException) { + finish(Result.failure(error)) + return + } + temporaryFiles.add(destination) + + val cropper = UCrop.of(source, Uri.fromFile(destination)).withOptions(buildCropOptions()) + if (config.width != null && config.height != null) { + cropper.withAspectRatio(config.width.toFloat(), config.height.toFloat()) + } + val launcher = register(ActivityResultContracts.StartActivityForResult()) { result -> + handleCropResult(result) + } + launch { launcher.launch(cropper.getIntent(activity)) } + } + + private fun handleCropResult(result: ActivityResult) { + val data = result.data + when (result.resultCode) { + Activity.RESULT_OK -> { + val output = data?.let { UCrop.getOutput(it) } + if (data == null || output == null) { + finish(Result.failure(ImageCropPickerException.noImageData())) + return + } + val cropRect = CropRect( + x = data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_X, -1).toDouble(), + y = data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_Y, -1).toDouble(), + width = data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_WIDTH, -1).toDouble(), + height = data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_HEIGHT, -1).toDouble(), + ) + val filename = pickedFilename + runInBackground( + work = { + val cropped = ImageCropPickerImageProcessor.decodeBitmap(activity, output) + ImageCropPickerImageProcessor.makeResult( + activity, + ImageCropPickerImageProcessor.resizeCroppedBitmap(cropped, config), + config, + cropRect, + filename, + ) + }, + onComplete = ::finish, + ) + } + UCrop.RESULT_ERROR -> { + val message = data?.let { UCrop.getError(it)?.message } ?: "Cannot crop image" + finish(Result.failure(ImageCropPickerException.noImageData(message))) + } + else -> finish(Result.failure(ImageCropPickerException.cancelled())) + } + } + + private fun buildCropOptions(): UCrop.Options = UCrop.Options().apply { + setCompressionFormat(Bitmap.CompressFormat.JPEG) + setCompressionQuality(100) + setCircleDimmedLayer(config.cropperCircleOverlay) + setFreeStyleCropEnabled(config.freeStyleCropEnabled) + setShowCropGrid(config.showCropGuidelines) + setShowCropFrame(config.showCropFrame) + setHideBottomControls(config.hideBottomControls) + config.cropperToolbarTitle?.let { setToolbarTitle(it) } + if (config.enableRotationGesture) { + setAllowedGestures(UCropActivity.ALL, UCropActivity.ALL, UCropActivity.ALL) + } + if (!config.disableCropperColorSetters) { + parseColor(config.cropperActiveWidgetColor)?.let { setActiveControlsWidgetColor(it) } + parseColor(config.cropperToolbarColor)?.let { setToolbarColor(it) } + parseColor(config.cropperToolbarWidgetColor)?.let { setToolbarWidgetColor(it) } + setStatusBarLight(config.cropperStatusBarLight) + setNavigationBarLight(config.cropperNavigationBarLight) + } + } + + private fun register( + contract: ActivityResultContract, + callback: (O) -> Unit, + ): ActivityResultLauncher { + val key = "$keyPrefix-${launchers.size}" + val launcher = activity.activityResultRegistry.register(key, contract) { output -> + isAwaitingResult = false + if (!isFinished) { + callback(output) + } + } + launchers.add(launcher) + return launcher + } + + private fun launch(block: () -> Unit) { + isAwaitingResult = true + try { + block() + } catch (error: Exception) { + isAwaitingResult = false + finish(Result.failure(ImageCropPickerException.failedToShowPicker(error))) + } + } + + private fun runInBackground(work: () -> T, onComplete: (Result) -> Unit) { + isProcessing = true + executor.execute { + val result = try { + Result.success(work()) + } catch (error: ImageCropPickerException) { + Result.failure(error) + } catch (error: OutOfMemoryError) { + Result.failure(ImageCropPickerException.lowMemory(error)) + } catch (error: Exception) { + Result.failure(ImageCropPickerException.noImageData(error.message ?: "Cannot process image")) + } + mainHandler.post { + isProcessing = false + if (!isFinished) { + onComplete(result) + } + } + } + } + + private fun finish(result: Result) { + if (isFinished) { + return + } + isFinished = true + isAwaitingResult = false + + // Unregister outside of the registry's dispatch callback. + val finishedLaunchers = launchers.toList() + launchers.clear() + mainHandler.post { finishedLaunchers.forEach { it.unregister() } } + + val filesToDelete = temporaryFiles.toList() + temporaryFiles.clear() + if (filesToDelete.isNotEmpty()) { + executor.execute { filesToDelete.forEach { it.delete() } } + } + + result.fold( + onSuccess = { promise.resolve(it) }, + onFailure = { error -> + if ((error as? ImageCropPickerException)?.code != ImageCropPickerException.E_PICKER_CANCELLED) { + OneKeyLog.warn(TAG, error.message ?: error.toString()) + } + promise.reject(error) + }, + ) + onFinish(this) + } + + private fun queryDisplayName(uri: Uri): String? { + val displayName = try { + activity.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + } catch (error: Exception) { + null + } + return displayName ?: uri.lastPathSegment + } + + private fun parseColor(value: String?): Int? = + value?.let { + try { + Color.parseColor(it) + } catch (error: IllegalArgumentException) { + null + } + } + + companion object { + private const val TAG = "ImageCropPicker" + private val mainHandler = Handler(Looper.getMainLooper()) + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + } +} diff --git a/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPicker.kt b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPicker.kt new file mode 100644 index 000000000..48481465f --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPicker.kt @@ -0,0 +1,69 @@ +package com.margelo.nitro.reactnativeimagecroppicker + +import android.os.Handler +import android.os.Looper +import androidx.activity.ComponentActivity +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.Promise + +@DoNotStrip +class ReactNativeImageCropPicker : HybridReactNativeImageCropPickerSpec() { + private val mainHandler = Handler(Looper.getMainLooper()) + + // Main thread only. + private var activeSession: ImageCropPickerSession? = null + + override fun openPicker(options: ImageCropPickerOptions): Promise = + startSession( + ImageCropPickerSession.Mode.Picker, + ImageCropPickerConfig.from(options, forceCropping = false), + ) + + override fun openCropper(path: String, options: ImageCropPickerOptions): Promise = + startSession( + ImageCropPickerSession.Mode.Cropper(path), + ImageCropPickerConfig.from(options, forceCropping = true), + ) + + override fun clean(): Promise = + Promise.parallel { + val context = NitroModules.applicationContext ?: throw ImageCropPickerException.cleanupFailed() + ImageCropPickerImageProcessor.cleanTemporaryDirectory(context) + } + + override fun cleanSingle(path: String): Promise = + Promise.parallel { ImageCropPickerImageProcessor.removeFile(path) } + + private fun startSession( + mode: ImageCropPickerSession.Mode, + config: ImageCropPickerConfig, + ): Promise { + val promise = Promise() + mainHandler.post { + activeSession?.let { session -> + if (session.isActive) { + promise.reject(ImageCropPickerException.inProgress()) + return@post + } + // Its activity went away without calling back; don't block new requests on it. + session.abandon() + } + + val activity = NitroModules.applicationContext?.currentActivity as? ComponentActivity + if (activity == null || activity.isFinishing || activity.isDestroyed) { + promise.reject(ImageCropPickerException.noActivity()) + return@post + } + + val session = ImageCropPickerSession(activity, mode, config, promise) { finishedSession -> + if (activeSession === finishedSession) { + activeSession = null + } + } + activeSession = session + session.start() + } + return promise + } +} diff --git a/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPickerPackage.kt b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPickerPackage.kt new file mode 100644 index 000000000..35edeaa3e --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPickerPackage.kt @@ -0,0 +1,24 @@ +package com.margelo.nitro.reactnativeimagecroppicker + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfoProvider + +class ReactNativeImageCropPickerPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { HashMap() } + } + + companion object { + init { + System.loadLibrary("reactnativeimagecroppicker") + } + } +} + + diff --git a/native-modules/react-native-image-crop-picker/babel.config.js b/native-modules/react-native-image-crop-picker/babel.config.js new file mode 100644 index 000000000..d02b2466a --- /dev/null +++ b/native-modules/react-native-image-crop-picker/babel.config.js @@ -0,0 +1,13 @@ +module.exports = { + presets: ['@react-native/babel-preset'], + plugins: [ + [ + 'module-resolver', + { + alias: { + 'react-native-image-crop-picker': './src/index', + }, + }, + ], + ], +}; diff --git a/native-modules/react-native-image-crop-picker/eslint.config.mjs b/native-modules/react-native-image-crop-picker/eslint.config.mjs new file mode 100644 index 000000000..3416cf5cd --- /dev/null +++ b/native-modules/react-native-image-crop-picker/eslint.config.mjs @@ -0,0 +1,5 @@ +import { createEslintConfig } from '@react-native/eslint-config'; + +export default createEslintConfig({ + extends: ['@react-native/eslint-config'], +}); diff --git a/native-modules/react-native-image-crop-picker/ios/ImageCropPickerError.swift b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerError.swift new file mode 100644 index 000000000..4171c9f61 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerError.swift @@ -0,0 +1,57 @@ +import Foundation + +enum ImageCropPickerErrorCode: String { + case pickerCancelled = "E_PICKER_CANCELLED" + case pickerInProgress = "E_PICKER_IN_PROGRESS" + case failedToShowPicker = "E_FAILED_TO_SHOW_PICKER" + case noImageDataFound = "E_NO_IMAGE_DATA_FOUND" + case cropperImageNotFound = "E_CROPPER_IMAGE_NOT_FOUND" + case cannotSaveImage = "E_CANNOT_SAVE_IMAGE" + case cleanupError = "E_ERROR_WHILE_CLEANING_FILES" +} + +// The JS wrapper parses the ": " description back into +// `error.code` / `error.message`, matching react-native-image-crop-picker. +struct ImageCropPickerError: Error, CustomStringConvertible { + let code: ImageCropPickerErrorCode + let message: String + + var description: String { + return "\(code.rawValue): \(message)" + } + + static let cancelled = ImageCropPickerError( + code: .pickerCancelled, + message: "User cancelled image selection" + ) + + static let inProgress = ImageCropPickerError( + code: .pickerInProgress, + message: "Another image picker or cropper is already open" + ) + + static let noViewController = ImageCropPickerError( + code: .failedToShowPicker, + message: "Cannot find a view controller to present from" + ) + + static let noImageData = ImageCropPickerError( + code: .noImageDataFound, + message: "Cannot find image data" + ) + + static let cropperImageNotFound = ImageCropPickerError( + code: .cropperImageNotFound, + message: "Can't find the image at the specified path" + ) + + static let cannotSaveImage = ImageCropPickerError( + code: .cannotSaveImage, + message: "Cannot save image. Unable to write to tmp location." + ) + + static let cleanupFailed = ImageCropPickerError( + code: .cleanupError, + message: "Error while cleaning up tmp files" + ) +} diff --git a/native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift new file mode 100644 index 000000000..30e8a05fc --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift @@ -0,0 +1,343 @@ +import Foundation +import ImageIO +import UIKit + +// Plain Swift copy of the options, so no C++ backed struct outlives the JS call. +struct ImageCropPickerConfig { + let width: Double? + let height: Double? + let cropping: Bool + let includeBase64: Bool + let compressImageQuality: Double? + let compressImageMaxWidth: Double? + let compressImageMaxHeight: Double? + let freeStyleCropEnabled: Bool + let cropperCircleOverlay: Bool + let cropperToolbarTitle: String? + let cropperChooseText: String? + let cropperCancelText: String? + let cropperChooseColor: String? + let cropperCancelColor: String? + let cropperRotateButtonsHidden: Bool + + init(_ options: ImageCropPickerOptions, forceCropping: Bool = false) { + width = options.width + height = options.height + cropping = forceCropping || (options.cropping ?? false) + includeBase64 = options.includeBase64 ?? false + compressImageQuality = options.compressImageQuality + compressImageMaxWidth = options.compressImageMaxWidth + compressImageMaxHeight = options.compressImageMaxHeight + freeStyleCropEnabled = options.freeStyleCropEnabled ?? false + cropperCircleOverlay = options.cropperCircleOverlay ?? false + cropperToolbarTitle = options.cropperToolbarTitle + cropperChooseText = options.cropperChooseText + cropperCancelText = options.cropperCancelText + cropperChooseColor = options.cropperChooseColor + cropperCancelColor = options.cropperCancelColor + cropperRotateButtonsHidden = options.cropperRotateButtonsHidden ?? false + } + + var targetSize: CGSize? { + guard let width, let height, width > 0, height > 0 else { + return nil + } + return CGSize(width: width, height: height) + } +} + +struct DecodedImage { + let image: UIImage + // Source pixels per decoded pixel. Greater than 1 when the source was downsampled. + let sourceScale: CGFloat +} + +enum ImageCropPickerImageProcessor { + // Bounds memory for huge photos (48 MP decodes to ~200 MB). Crop targets are + // far smaller than this, so no visible quality is lost. + static let maxDecodedPixelSize = 4096 + static let defaultCompressQuality = 0.8 + static let temporaryDirectoryName = "react-native-image-crop-picker" + + static func decodeImage(at url: URL) -> DecodedImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { + return nil + } + return decodeImage(source: source) + } + + static func decodeImage(data: Data) -> DecodedImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { + return nil + } + return decodeImage(source: source) + } + + private static func decodeImage(source: CGImageSource) -> DecodedImage? { + guard CGImageSourceGetCount(source) > 0 else { + return nil + } + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + let pixelWidth = (properties?[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue ?? 0 + let pixelHeight = (properties?[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue ?? 0 + let sourceMaxDimension = max(pixelWidth, pixelHeight) + let maxPixelSize = sourceMaxDimension > 0 + ? min(sourceMaxDimension, maxDecodedPixelSize) + : maxDecodedPixelSize + + // The thumbnail API applies the EXIF orientation, so the result is always `.up`. + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + thumbnailOptions as CFDictionary + ) else { + return nil + } + + let decodedMaxDimension = max(cgImage.width, cgImage.height) + let sourceScale = sourceMaxDimension > 0 && decodedMaxDimension > 0 + ? CGFloat(sourceMaxDimension) / CGFloat(decodedMaxDimension) + : 1 + return DecodedImage( + image: UIImage(cgImage: cgImage, scale: 1, orientation: .up), + sourceScale: max(sourceScale, 1) + ) + } + + static func loadImage(fromPath path: String) throws -> DecodedImage { + if path.hasPrefix("data:") { + guard + let commaIndex = path.firstIndex(of: ","), + let data = Data( + base64Encoded: String(path[path.index(after: commaIndex)...]), + options: .ignoreUnknownCharacters + ), + let decoded = decodeImage(data: data) + else { + throw ImageCropPickerError.cropperImageNotFound + } + return decoded + } + + if let url = URL(string: path), let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" { + guard let data = try? downloadData(from: url), let decoded = decodeImage(data: data) else { + throw ImageCropPickerError.cropperImageNotFound + } + return decoded + } + + guard let fileURL = fileURL(fromPath: path), + let decoded = decodeImage(at: fileURL) else { + throw ImageCropPickerError.cropperImageNotFound + } + return decoded + } + + static func fileURL(fromPath path: String) -> URL? { + if path.hasPrefix("file://") { + if let url = URL(string: path), url.isFileURL { + return url + } + // Unescaped paths (e.g. containing spaces) are not valid URL strings. + let rawPath = String(path.dropFirst("file://".count)) + return URL(fileURLWithPath: rawPath.removingPercentEncoding ?? rawPath) + } + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } + return nil + } + + private final class DownloadResult: @unchecked Sendable { + var data: Data? + } + + // Called from a background queue only. + private static func downloadData(from url: URL) throws -> Data { + let semaphore = DispatchSemaphore(value: 0) + let result = DownloadResult() + let task = URLSession.shared.dataTask(with: url) { data, response, error in + defer { semaphore.signal() } + guard error == nil else { + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + return + } + result.data = data + } + task.resume() + semaphore.wait() + guard let data = result.data else { + throw ImageCropPickerError.cropperImageNotFound + } + return data + } + + // Scales a cropped image to the requested size; smaller crops are scaled up. + // With a locked aspect ratio the crop box can still end up a few pixels off + // that ratio, so the result is scaled to exactly the requested size. + // Free-style crops keep their own aspect ratio and fit inside it. + static func resizeCroppedImage(_ image: UIImage, config: ImageCropPickerConfig) -> UIImage { + guard let targetSize = config.targetSize else { + return image + } + let exactSize = CGSize(width: targetSize.width.rounded(), height: targetSize.height.rounded()) + guard config.freeStyleCropEnabled else { + return draw(image, size: exactSize) + } + let sourceSize = pixelSize(of: image) + guard sourceSize.width > 0, sourceSize.height > 0 else { + return image + } + let widthRatio = exactSize.width / sourceSize.width + let heightRatio = exactSize.height / sourceSize.height + let destinationSize = widthRatio < heightRatio + ? CGSize(width: exactSize.width, height: (sourceSize.height * widthRatio).rounded()) + : CGSize(width: (sourceSize.width * heightRatio).rounded(), height: exactSize.height) + return draw(image, size: destinationSize) + } + + static func makeResult( + image: UIImage, + config: ImageCropPickerConfig, + cropRect: CGRect?, + filename: String? + ) throws -> PickedImage { + var output = image + let size = pixelSize(of: output) + let maxWidth = config.compressImageMaxWidth.map { CGFloat($0) } + let maxHeight = config.compressImageMaxHeight.map { CGFloat($0) } + let shouldResizeWidth = maxWidth.map { $0 < size.width } ?? false + let shouldResizeHeight = maxHeight.map { $0 < size.height } ?? false + if shouldResizeWidth || shouldResizeHeight { + let scale = min( + (maxWidth ?? size.width) / size.width, + (maxHeight ?? size.height) / size.height + ) + output = draw( + output, + size: CGSize(width: floor(size.width * scale), height: floor(size.height * scale)) + ) + } + + let quality = min(max(config.compressImageQuality ?? defaultCompressQuality, 0), 1) + guard let data = output.jpegData(compressionQuality: CGFloat(quality)) else { + throw ImageCropPickerError.cannotSaveImage + } + + let fileURL = try temporaryDirectory() + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + do { + try data.write(to: fileURL, options: .atomic) + } catch { + throw ImageCropPickerError.cannotSaveImage + } + + let outputSize = pixelSize(of: output) + return PickedImage( + path: fileURL.absoluteString, + size: Double(data.count), + width: Double(outputSize.width), + height: Double(outputSize.height), + mime: "image/jpeg", + data: config.includeBase64 ? data.base64EncodedString() : nil, + cropRect: cropRect.map { + CropRect( + x: Double($0.origin.x), + y: Double($0.origin.y), + width: Double($0.width), + height: Double($0.height) + ) + }, + filename: filename + ) + } + + static func temporaryDirectory() throws -> URL { + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(temporaryDirectoryName, isDirectory: true) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } catch { + throw ImageCropPickerError.cannotSaveImage + } + return directory + } + + static func cleanTemporaryDirectory() throws { + let directory = try temporaryDirectory() + let fileManager = FileManager.default + do { + for item in try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) { + try fileManager.removeItem(at: item) + } + } catch { + throw ImageCropPickerError.cleanupFailed + } + } + + static func removeFile(atPath path: String) throws { + guard let fileURL = fileURL(fromPath: path) else { + throw ImageCropPickerError.cleanupFailed + } + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + throw ImageCropPickerError.cleanupFailed + } + } + + static func color(fromHex hex: String?) -> UIColor? { + guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6 || value.count == 8, let rgba = UInt64(value, radix: 16) else { + return nil + } + let hasAlpha = value.count == 8 + let red = CGFloat((rgba >> (hasAlpha ? 24 : 16)) & 0xff) / 255 + let green = CGFloat((rgba >> (hasAlpha ? 16 : 8)) & 0xff) / 255 + let blue = CGFloat((rgba >> (hasAlpha ? 8 : 0)) & 0xff) / 255 + let alpha = hasAlpha ? CGFloat(rgba & 0xff) / 255 : 1 + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } + + private static func pixelSize(of image: UIImage) -> CGSize { + return CGSize(width: image.size.width * image.scale, height: image.size.height * image.scale) + } + + private static func draw(_ image: UIImage, size: CGSize) -> UIImage { + guard size.width >= 1, size.height >= 1, size != pixelSize(of: image) else { + return image + } + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + format.preferredRange = .standard + let renderer = UIGraphicsImageRenderer(size: size, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + } +} diff --git a/native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift new file mode 100644 index 000000000..d51e15506 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift @@ -0,0 +1,407 @@ +import NitroModules +import PhotosUI +import ReactNativeNativeLogger +import UIKit +import UniformTypeIdentifiers + +// One picker or cropper flow. It owns the JS promise until the presented UI +// reports back, and settles it exactly once. +final class ImageCropPickerSession: NSObject { + enum Mode { + case picker + case cropper(path: String) + } + + private let mode: Mode + private let config: ImageCropPickerConfig + private let promise: Promise + private let onFinish: (ImageCropPickerSession) -> Void + + // PHPickerViewController connects to its out-of-process service before it + // appears, so a requested presentation can stay pending for a moment. + private static let pendingPresentationTimeout: TimeInterval = 10 + + // Main thread state. + private var pickerController: PHPickerViewController? + private var cropController: TOCropViewController? + private var loadingView: UIView? + private var pickedFilename: String? + private var sourceScale: CGFloat = 1 + private var presentationRequestedAt: Date? + private var hasPresented = false + private var isBusy = false + private var isFinished = false + + init( + mode: Mode, + config: ImageCropPickerConfig, + promise: Promise, + onFinish: @escaping (ImageCropPickerSession) -> Void + ) { + self.mode = mode + self.config = config + self.promise = promise + self.onFinish = onFinish + super.init() + } + + // Whether the session still has UI on screen or work in flight. + var isActive: Bool { + guard !isFinished else { + return false + } + if isBusy { + return true + } + guard let rootController = rootController else { + return false + } + if rootController.presentingViewController != nil || rootController.isBeingPresented { + return true + } + if !hasPresented, let presentationRequestedAt { + return Date().timeIntervalSince(presentationRequestedAt) < Self.pendingPresentationTimeout + } + return false + } + + func start() { + switch mode { + case .picker: + presentPicker() + case .cropper(let path): + loadCropperImage(path: path) + } + } + + // Settles a session whose UI disappeared without calling back. + func abandon() { + finish(.failure(ImageCropPickerError.cancelled)) + } + + private var rootController: UIViewController? { + return pickerController ?? cropController + } + + private var isPickerMode: Bool { + if case .picker = mode { + return true + } + return false + } + + // MARK: - Picker + + private func presentPicker() { + guard let presenter = Self.topViewController() else { + finish(.failure(ImageCropPickerError.noViewController)) + return + } + + // PHPickerViewController runs out of process and needs no photo library + // permission, so a previously denied permission can't block the picker. + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 1 + configuration.preferredAssetRepresentationMode = .current + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + picker.modalPresentationStyle = .fullScreen + pickerController = picker + present(picker, from: presenter) + } + + private func loadPickedImage(_ result: PHPickerResult, in picker: PHPickerViewController) { + let provider = result.itemProvider + guard let typeIdentifier = Self.imageTypeIdentifier(of: provider) else { + dismissAll { self.finish(.failure(ImageCropPickerError.noImageData)) } + return + } + + isBusy = true + let filename = provider.suggestedName + pickedFilename = filename + showLoading(in: picker.view) + + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [self] url, _ in + // The file is removed once this handler returns, so decode it right here. + if let url, let decoded = ImageCropPickerImageProcessor.decodeImage(at: url) { + handlePickedImage(decoded, filename: filename) + return + } + provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { [self] data, _ in + handlePickedImage( + data.flatMap { ImageCropPickerImageProcessor.decodeImage(data: $0) }, + filename: filename + ) + } + } + } + + // Runs on the item provider's background queue. + private func handlePickedImage(_ decoded: DecodedImage?, filename: String?) { + guard let decoded else { + DispatchQueue.main.async { [self] in + isBusy = false + hideLoading() + dismissAll { self.finish(.failure(ImageCropPickerError.noImageData)) } + } + return + } + + guard !config.cropping else { + DispatchQueue.main.async { [self] in + isBusy = false + hideLoading() + guard !isFinished, let picker = pickerController else { + return + } + sourceScale = decoded.sourceScale + presentCropper(image: decoded.image, from: picker) + } + return + } + + let result = Result { + try ImageCropPickerImageProcessor.makeResult( + image: decoded.image, + config: config, + cropRect: nil, + filename: filename + ) + } + DispatchQueue.main.async { [self] in + isBusy = false + hideLoading() + dismissAll { self.finish(result) } + } + } + + // MARK: - Cropper + + private func loadCropperImage(path: String) { + isBusy = true + DispatchQueue.global(qos: .userInitiated).async { [self] in + let loaded = Result { try ImageCropPickerImageProcessor.loadImage(fromPath: path) } + DispatchQueue.main.async { [self] in + isBusy = false + switch loaded { + case .success(let decoded): + guard let presenter = Self.topViewController() else { + finish(.failure(ImageCropPickerError.noViewController)) + return + } + sourceScale = decoded.sourceScale + presentCropper(image: decoded.image, from: presenter) + case .failure(let error): + finish(.failure(error)) + } + } + } + } + + private func presentCropper(image: UIImage, from presenter: UIViewController) { + let controller: TOCropViewController + if config.cropperCircleOverlay { + controller = TOCropViewController(croppingStyle: .circular, image: image) + } else { + controller = TOCropViewController(image: image) + if let targetSize = config.targetSize { + controller.aspectRatioPreset = targetSize + } + controller.aspectRatioLockEnabled = !config.freeStyleCropEnabled + controller.resetAspectRatioEnabled = !controller.aspectRatioLockEnabled + } + + controller.title = config.cropperToolbarTitle + controller.delegate = self + if let color = ImageCropPickerImageProcessor.color(fromHex: config.cropperChooseColor) { + controller.doneButtonColor = color + } + if let color = ImageCropPickerImageProcessor.color(fromHex: config.cropperCancelColor) { + controller.cancelButtonColor = color + } + controller.doneButtonTitle = config.cropperChooseText + controller.cancelButtonTitle = config.cropperCancelText + controller.rotateButtonsHidden = config.cropperRotateButtonsHidden + controller.modalPresentationStyle = .fullScreen + controller.modalTransitionStyle = .coverVertical + + cropController = controller + present(controller, from: presenter) + } + + // MARK: - Completion + + private func present(_ controller: UIViewController, from presenter: UIViewController) { + // UIKit silently ignores a presentation from a controller that is off + // screen or already presenting, and the promise would never settle. + guard presenter.viewIfLoaded?.window != nil, presenter.presentedViewController == nil else { + if controller === cropController { + cropController = nil + } + dismissAll { self.finish(.failure(ImageCropPickerError.noViewController)) } + return + } + presentationRequestedAt = Date() + presenter.present(controller, animated: true) { [weak self] in + self?.hasPresented = true + } + } + + private func dismissAll(completion: @escaping () -> Void) { + guard let presenting = rootController?.presentingViewController else { + completion() + return + } + presenting.dismiss(animated: true, completion: completion) + } + + private func finish(_ result: Result) { + guard !isFinished else { + return + } + isFinished = true + hideLoading() + pickerController = nil + cropController = nil + + switch result { + case .success(let image): + promise.resolve(withResult: image) + case .failure(let error): + if let pickerError = error as? ImageCropPickerError, + pickerError.code != .pickerCancelled { + OneKeyLog.warn("ImageCropPicker", "\(pickerError)") + } + promise.reject(withError: error) + } + onFinish(self) + } + + private func showLoading(in view: UIView) { + hideLoading() + let overlay = UIView(frame: view.bounds) + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + overlay.backgroundColor = UIColor.black.withAlphaComponent(0.4) + + let indicator = UIActivityIndicatorView(style: .large) + indicator.color = .white + indicator.center = CGPoint(x: overlay.bounds.midX, y: overlay.bounds.midY) + indicator.autoresizingMask = [ + .flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin, + ] + indicator.startAnimating() + overlay.addSubview(indicator) + + view.addSubview(overlay) + loadingView = overlay + } + + private func hideLoading() { + loadingView?.removeFromSuperview() + loadingView = nil + } + + // MARK: - Helpers + + private static func imageTypeIdentifier(of provider: NSItemProvider) -> String? { + let identifier = provider.registeredTypeIdentifiers.first { identifier in + UTType(identifier)?.conforms(to: .image) ?? false + } + if let identifier { + return identifier + } + return provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) + ? UTType.image.identifier + : nil + } + + static func topViewController() -> UIViewController? { + let sceneWindows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState != .unattached && $0.activationState != .background } + .flatMap { $0.windows } + let appDelegateWindow = UIApplication.shared.delegate?.window ?? nil + let window = sceneWindows.first { $0.isKeyWindow && $0.windowLevel == .normal && $0.rootViewController != nil } + ?? appDelegateWindow + ?? sceneWindows.first { $0.rootViewController != nil } + + var controller = window?.rootViewController + while let presented = controller?.presentedViewController, !presented.isBeingDismissed { + controller = presented + } + return controller + } +} + +extension ImageCropPickerSession: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard !isFinished, !isBusy, cropController == nil else { + return + } + guard let result = results.first else { + dismissAll { self.finish(.failure(ImageCropPickerError.cancelled)) } + return + } + loadPickedImage(result, in: picker) + } +} + +extension ImageCropPickerSession: TOCropViewControllerDelegate { + func cropViewController( + _ cropViewController: TOCropViewController, + didCropTo image: UIImage, + with cropRect: CGRect, + angle: Int + ) { + guard !isFinished, !isBusy else { + return + } + isBusy = true + showLoading(in: cropViewController.view) + + let config = self.config + let filename = pickedFilename + // Report the crop rect in the coordinates of the original, undownsampled image. + let sourceCropRect = CGRect( + x: (cropRect.origin.x * sourceScale).rounded(), + y: (cropRect.origin.y * sourceScale).rounded(), + width: (cropRect.width * sourceScale).rounded(), + height: (cropRect.height * sourceScale).rounded() + ) + + DispatchQueue.global(qos: .userInitiated).async { [self] in + let result = Result { + try ImageCropPickerImageProcessor.makeResult( + image: ImageCropPickerImageProcessor.resizeCroppedImage(image, config: config), + config: config, + cropRect: sourceCropRect, + filename: filename + ) + } + DispatchQueue.main.async { [self] in + isBusy = false + hideLoading() + dismissAll { self.finish(result) } + } + } + } + + func cropViewController( + _ cropViewController: TOCropViewController, + didFinishCancelled cancelled: Bool + ) { + guard !isFinished, !isBusy else { + return + } + if isPickerMode, pickerController?.presentingViewController != nil { + // Go back to the photo picker, like react-native-image-crop-picker. + cropController = nil + cropViewController.dismiss(animated: true) + return + } + dismissAll { self.finish(.failure(ImageCropPickerError.cancelled)) } + } +} diff --git a/native-modules/react-native-image-crop-picker/ios/ReactNativeImageCropPicker.swift b/native-modules/react-native-image-crop-picker/ios/ReactNativeImageCropPicker.swift new file mode 100644 index 000000000..0b7398ac9 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/ReactNativeImageCropPicker.swift @@ -0,0 +1,65 @@ +import NitroModules +import UIKit + +class ReactNativeImageCropPicker: HybridReactNativeImageCropPickerSpec { + // Main thread only. + private var activeSession: ImageCropPickerSession? + + func openPicker(options: ImageCropPickerOptions) throws -> Promise { + return startSession(mode: .picker, config: ImageCropPickerConfig(options)) + } + + func openCropper(path: String, options: ImageCropPickerOptions) throws -> Promise { + return startSession( + mode: .cropper(path: path), + config: ImageCropPickerConfig(options, forceCropping: true) + ) + } + + func clean() throws -> Promise { + return Promise.parallel { + try ImageCropPickerImageProcessor.cleanTemporaryDirectory() + } + } + + func cleanSingle(path: String) throws -> Promise { + return Promise.parallel { + try ImageCropPickerImageProcessor.removeFile(atPath: path) + } + } + + private func startSession( + mode: ImageCropPickerSession.Mode, + config: ImageCropPickerConfig + ) -> Promise { + let promise = Promise() + DispatchQueue.main.async { + self.beginSession(mode: mode, config: config, promise: promise) + } + return promise + } + + private func beginSession( + mode: ImageCropPickerSession.Mode, + config: ImageCropPickerConfig, + promise: Promise + ) { + if let activeSession { + if activeSession.isActive { + promise.reject(withError: ImageCropPickerError.inProgress) + return + } + // Its UI went away without calling back; don't block new requests on it. + activeSession.abandon() + } + + let session = ImageCropPickerSession(mode: mode, config: config, promise: promise) { + [weak self] finishedSession in + if self?.activeSession === finishedSession { + self?.activeSession = nil + } + } + activeSession = session + session.start() + } +} diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.h new file mode 100644 index 000000000..7b1ad5a2f --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.h @@ -0,0 +1,39 @@ +// +// UIImage+CropRotate.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIImage (TOCropRotate) + +/// Crops a portion of an existing image object and returns it as a new image +/// @param frame The region inside the image to crop (in the image's point space, ie image.size) +/// @param angle If any, the angle the image is rotated at as well +/// @param circular Whether the resulting image is returned as a square or a circle +- (nonnull UIImage *)croppedImageWithFrame:(CGRect)frame + angle:(NSInteger)angle + circularClip:(BOOL)circular; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.m new file mode 100644 index 000000000..b8f1004b8 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Categories/UIImage+CropRotate.m @@ -0,0 +1,94 @@ +// +// UIImage+CropRotate.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "UIImage+CropRotate.h" + +@implementation UIImage (TOCropRotate) + +- (BOOL)hasAlpha { + CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(self.CGImage); + return (alphaInfo == kCGImageAlphaFirst || alphaInfo == kCGImageAlphaLast || + alphaInfo == kCGImageAlphaPremultipliedFirst || alphaInfo == kCGImageAlphaPremultipliedLast); +} + +- (UIImage *)croppedImageWithFrame:(CGRect)frame angle:(NSInteger)angle circularClip:(BOOL)circular { + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat new]; + +#if defined(__IPHONE_17_0) + // When the source image is HDR, request an HDR-capable rendering format so its + // highlights aren't silently tone-mapped down to SDR. Falls back to the default + // format wherever HDR rendering isn't supported. + if (@available(iOS 17.0, *)) { + if (self.isHighDynamicRange) { + UITraitCollection *hdrTraits = [UITraitCollection traitCollectionWithImageDynamicRange:UIImageDynamicRangeHigh]; + UIGraphicsImageRendererFormat *hdrFormat = [UIGraphicsImageRendererFormat formatForTraitCollection:hdrTraits]; + if (hdrFormat.supportsHighDynamicRange) { + format = hdrFormat; + } + } + } +#endif + + format.opaque = !self.hasAlpha && !circular; + format.scale = self.scale; + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:frame.size format:format]; + UIImage *croppedImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + CGContextRef context = rendererContext.CGContext; + + // If we're capturing a circular image, set the clip mask first + if (circular) { + CGContextAddEllipseInRect(context, (CGRect){CGPointZero, frame.size}); + CGContextClip(context); + } + + // Offset the origin (Which is the top left corner) to start where our cropping origin is + CGContextTranslateCTM(context, -frame.origin.x, -frame.origin.y); + + // If an angle was supplied, rotate the entire canvas + coordinate space to match + if (angle != 0) { + // Rotation in radians + CGFloat rotation = angle * (M_PI / 180.0f); + + // Work out the new bounding size of the canvas after rotation + CGRect imageBounds = (CGRect){CGPointZero, self.size}; + CGRect rotatedBounds = CGRectApplyAffineTransform(imageBounds, + CGAffineTransformMakeRotation(rotation)); + // As we're rotating from the top left corner, and not the center of the canvas, the frame + // will have rotated out of our visible canvas. Compensate for this. + CGContextTranslateCTM(context, -rotatedBounds.origin.x, -rotatedBounds.origin.y); + + // Perform the rotation transformation + CGContextRotateCTM(context, rotation); + } + + // Draw the image with all of the transformation parameters applied. + // We do not need to worry about specifying the size here since we're already + // constrained by the context image size + [self drawAtPoint:CGPointZero]; + }]; + + // Re-apply the retina scale we originally had + return [UIImage imageWithCGImage:croppedImage.CGImage scale:self.scale orientation:UIImageOrientationUp]; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Constants/TOCropViewConstants.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Constants/TOCropViewConstants.h new file mode 100644 index 000000000..c37d2751e --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Constants/TOCropViewConstants.h @@ -0,0 +1,63 @@ +// +// TOCropViewConstants.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +/** + The shape of the cropping region of this crop view controller + */ +typedef NS_ENUM(NSInteger, TOCropViewCroppingStyle) { + TOCropViewCroppingStyleDefault, // The regular, rectangular crop box + TOCropViewCroppingStyleCircular // A fixed, circular crop box +}; + +/** + Whether the control toolbar is placed at the bottom or the top + */ +typedef NS_ENUM(NSInteger, TOCropViewControllerToolbarPosition) { + TOCropViewControllerToolbarPositionBottom, // Bar is placed along the bottom in portrait + TOCropViewControllerToolbarPositionTop // Bar is placed along the top in portrait (Respects the status bar) +}; + +static inline NSBundle *TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(NSObject *object) { +#if SWIFT_PACKAGE + // SPM is supposed to support the keyword SWIFTPM_MODULE_BUNDLE + // but I can't figure out how to make it work, so doing it manually + NSString *bundleName = @"TOCropViewController_TOCropViewController"; +#else + NSString *bundleName = @"TOCropViewControllerBundle"; +#endif + NSBundle *resourceBundle = nil; + NSBundle *classBundle = [NSBundle bundleForClass:object.class]; + NSURL *resourceBundleURL = [classBundle URLForResource:bundleName withExtension:@"bundle"]; + if (resourceBundleURL) { + resourceBundle = [[NSBundle alloc] initWithURL:resourceBundleURL]; +#ifndef NDEBUG + if (resourceBundle == nil) { + @throw [[NSException alloc] initWithName:@"BundleAccessor" reason:[NSString stringWithFormat:@"unable to find bundle named %@", bundleName] userInfo:nil]; + } +#endif + } else { + resourceBundle = classBundle; + } + return resourceBundle; +} diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/LICENSE b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/LICENSE new file mode 100644 index 000000000..e129aa46c --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2025 Tim Oliver + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.h new file mode 100644 index 000000000..381c80e47 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.h @@ -0,0 +1,38 @@ +// +// TOActivityCroppedImageProvider.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface TOActivityCroppedImageProvider : UIActivityItemProvider + +@property (nonnull, nonatomic, readonly) UIImage *image; +@property (nonatomic, readonly) CGRect cropFrame; +@property (nonatomic, readonly) NSInteger angle; +@property (nonatomic, readonly) BOOL circular; + +- (nonnull instancetype)initWithImage:(nonnull UIImage *)image cropFrame:(CGRect)cropFrame angle:(NSInteger)angle circular:(BOOL)circular; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.m new file mode 100644 index 000000000..874573fe7 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.m @@ -0,0 +1,73 @@ +// +// TOActivityCroppedImageProvider.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOActivityCroppedImageProvider.h" + +#import "UIImage+CropRotate.h" + +@interface TOActivityCroppedImageProvider () + +@property (nonatomic, strong, readwrite) UIImage *image; +@property (nonatomic, assign, readwrite) CGRect cropFrame; +@property (nonatomic, assign, readwrite) NSInteger angle; +@property (nonatomic, assign, readwrite) BOOL circular; + +@property (atomic, strong) UIImage *croppedImage; + +@end + +@implementation TOActivityCroppedImageProvider + +- (instancetype)initWithImage:(UIImage *)image cropFrame:(CGRect)cropFrame angle:(NSInteger)angle circular:(BOOL)circular { + if (self = [super initWithPlaceholderItem:[UIImage new]]) { + _image = image; + _cropFrame = cropFrame; + _angle = angle; + _circular = circular; + } + + return self; +} + +#pragma mark - UIActivity Protocols - +- (id)activityViewControllerPlaceholderItem:(UIActivityViewController *)activityViewController { + return [[UIImage alloc] init]; +} + +- (id)activityViewController:(UIActivityViewController *)activityViewController itemForActivityType:(NSString *)activityType { + return self.croppedImage; +} + +#pragma mark - Image Generation - +- (id)item { + // If the user didn't touch the image, just forward along the original + if (self.angle == 0 && CGRectEqualToRect(self.cropFrame, (CGRect){CGPointZero, self.image.size})) { + self.croppedImage = self.image; + return self.croppedImage; + } + + UIImage *image = [self.image croppedImageWithFrame:self.cropFrame angle:self.angle circularClip:self.circular]; + self.croppedImage = image; + return self.croppedImage; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.h new file mode 100644 index 000000000..2504645c0 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.h @@ -0,0 +1,64 @@ +// +// TOCropViewControllerAspectRatioPreset.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface TOCropViewControllerAspectRatioPreset : NSObject + +@property (nonatomic, readonly) CGSize size; +@property (nonatomic, readonly) NSString *title; + +/// The original aspect ratio of the image (CGSizeZero) +@property (class, nonatomic, readonly) CGSize original; + +/// A square aspect ratio (1:1) +@property (class, nonatomic, readonly) CGSize square; + +/// A 3:2 aspect ratio +@property (class, nonatomic, readonly) CGSize ratio3x2; + +/// A 5:3 aspect ratio +@property (class, nonatomic, readonly) CGSize ratio5x3; + +/// A 4:3 aspect ratio +@property (class, nonatomic, readonly) CGSize ratio4x3; + +/// A 5:4 aspect ratio +@property (class, nonatomic, readonly) CGSize ratio5x4; + +/// A 7:5 aspect ratio +@property (class, nonatomic, readonly) CGSize ratio7x5; + +/// A 16:9 aspect ratio +@property (class, nonatomic, readonly) CGSize ratio16x9; + ++ (NSArray *)portraitPresets; ++ (NSArray *)landscapePresets; + +- (nonnull instancetype)initWithSize:(CGSize)size title:(NSString *)title; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m new file mode 100644 index 000000000..ab414dcd2 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m @@ -0,0 +1,121 @@ +// +// TOCropViewControllerAspectRatioPreset.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCropViewControllerAspectRatioPreset.h" +#if !__has_include() +#import "TOCropViewConstants.h" +#else +#import +#endif + +@interface TOCropViewControllerAspectRatioPreset () + +@property (nonatomic, strong, readwrite) NSString *title; + +@end + +@implementation TOCropViewControllerAspectRatioPreset + ++ (CGSize)original { return CGSizeZero; } ++ (CGSize)square { return CGSizeMake(1.0f, 1.0f); } ++ (CGSize)ratio3x2 { return CGSizeMake(3.0f, 2.0f); } ++ (CGSize)ratio5x3 { return CGSizeMake(5.0f, 3.0f); } ++ (CGSize)ratio4x3 { return CGSizeMake(4.0f, 3.0f); } ++ (CGSize)ratio5x4 { return CGSizeMake(5.0f, 4.0f); } ++ (CGSize)ratio7x5 { return CGSizeMake(7.0f, 5.0f); } ++ (CGSize)ratio16x9 { return CGSizeMake(16.0f, 9.0f); } + +- (instancetype)initWithSize:(CGSize)size title:(NSString *)title { + self = [super init]; + if (self) { + _size = size; + _title = title; + } + return self; +} + +- (BOOL)isEqual:(id)object { + if (self == object) { + return YES; + } + if (![object isKindOfClass:[TOCropViewControllerAspectRatioPreset class]]) { + return NO; + } + TOCropViewControllerAspectRatioPreset *other = (TOCropViewControllerAspectRatioPreset *)object; + return CGSizeEqualToSize(self.size, other.size) && [self.title isEqualToString:other.title]; +} + +- (NSUInteger)hash { + // Mix rather than XOR so symmetric sizes (eg 1:1, or 16:9 vs 9:16) don't collide + NSUInteger hash = 17; + hash = (hash * 31) + @(self.size.width).hash; + hash = (hash * 31) + @(self.size.height).hash; + hash = (hash * 31) + self.title.hash; + return hash; +} + ++ (NSArray *)portraitPresets { + TOCropViewControllerAspectRatioPreset *object = [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeZero title:@"Original"]; + NSBundle *resourceBundle = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(object); + return @[ + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeZero + title:NSLocalizedStringFromTableInBundle(@"Original", @"TOCropViewControllerLocalizable", resourceBundle, nil)], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(1.0f, 1.0f) + title:NSLocalizedStringFromTableInBundle(@"Square", @"TOCropViewControllerLocalizable", resourceBundle, nil)], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(2.0f, 3.0f) + title:@"2:3"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(3.0f, 5.0f) + title:@"3:5"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(3.0f, 4.0f) + title:@"3:4"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(4.0f, 5.0f) + title:@"4:5"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(5.0f, 7.0f) + title:@"5:7"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(9.0f, 16.0f) + title:@"9:16"], + ]; +} + ++ (NSArray *)landscapePresets { + TOCropViewControllerAspectRatioPreset *object = [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeZero title:@"Original"]; + NSBundle *resourceBundle = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(object); + return @[ + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeZero + title:NSLocalizedStringFromTableInBundle(@"Original", @"TOCropViewControllerLocalizable", resourceBundle, nil)], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(1.0f, 1.0f) + title:NSLocalizedStringFromTableInBundle(@"Square", @"TOCropViewControllerLocalizable", resourceBundle, nil)], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(3.0f, 2.0f) + title:@"3:2"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(5.0f, 3.0f) + title:@"5:3"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(4.0f, 3.0f) + title:@"4:3"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(5.0f, 4.0f) + title:@"5:4"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(7.0f, 5.0f) + title:@"7:5"], + [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeMake(16.0f, 9.0f) + title:@"16:9"], + ]; +} +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.h new file mode 100644 index 000000000..ae135a323 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.h @@ -0,0 +1,49 @@ +// +// TOCropViewControllerTransitioning.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface TOCropViewControllerTransitioning : NSObject + +/* State Tracking */ +@property (nonatomic, assign) BOOL isDismissing; // Whether this animation is presenting or dismissing +@property (nullable, nonatomic, strong) UIImage *image; // The image that will be used in this animation + +/* Destination/Origin points */ +@property (nullable, nonatomic, strong) UIView *fromView; // The origin view who's frame the image will be animated from +@property (nullable, nonatomic, strong) UIView *toView; // The destination view who's frame the image will animate to + +@property (nonatomic, assign) CGRect fromFrame; // An origin frame that the image will be animated from +@property (nonatomic, assign) CGRect toFrame; // A destination frame the image will aniamte to + +/* A block called just before the transition to perform any last-second UI configuration */ +@property (nullable, nonatomic, copy) void (^prepareForTransitionHandler)(void); + +/* Empties all of the properties in this object */ +- (void)reset; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.m new file mode 100644 index 000000000..46fc277f5 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.m @@ -0,0 +1,125 @@ +// +// TOCropViewControllerTransitioning.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCropViewControllerTransitioning.h" + +#import + +@implementation TOCropViewControllerTransitioning + +- (NSTimeInterval)transitionDuration:(id)transitionContext { + return 0.45f; +} + +- (void)animateTransition:(id)transitionContext { + // Get the master view where the animation takes place + UIView *containerView = [transitionContext containerView]; + + // Get the origin/destination view controllers + UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; + + // Work out which one is the crop view controller + UIViewController *cropViewController = (self.isDismissing == NO) ? toViewController : fromViewController; + UIViewController *previousController = (self.isDismissing == NO) ? fromViewController : toViewController; + + // Just in case, match up the frame sizes + cropViewController.view.frame = containerView.bounds; + if (self.isDismissing) { + previousController.view.frame = containerView.bounds; + } + + // Add the view layers beforehand as this will trigger the initial sets of layouts + if (self.isDismissing == NO) { + [containerView addSubview:cropViewController.view]; + + // Force a relayout now that the view is in the view hierarchy (so things like the safe area insets are now valid)] + [cropViewController.view setNeedsLayout]; + [cropViewController.view layoutIfNeeded]; + [cropViewController viewDidLayoutSubviews]; + } else { + [containerView insertSubview:previousController.view belowSubview:cropViewController.view]; + } + + // Perform any last UI updates now so we can potentially factor them into our calculations, but after + // the container views have been set up + if (self.prepareForTransitionHandler) { + self.prepareForTransitionHandler(); + } + + // If origin/destination views were supplied, use them to supplant the + // frames + if (!self.isDismissing && self.fromView) { + self.fromFrame = [self.fromView.superview convertRect:self.fromView.frame toView:containerView]; + } else if (self.isDismissing && self.toView) { + self.toFrame = [self.toView.superview convertRect:self.toView.frame toView:containerView]; + } + + UIImageView *imageView = nil; + if ((self.isDismissing && !CGRectIsEmpty(self.toFrame)) || (!self.isDismissing && !CGRectIsEmpty(self.fromFrame))) { + imageView = [[UIImageView alloc] initWithImage:self.image]; + imageView.frame = self.fromFrame; + imageView.accessibilityIgnoresInvertColors = YES; + [containerView addSubview:imageView]; + } + + cropViewController.view.alpha = (self.isDismissing ? 1.0f : 0.0f); + if (imageView) { + [UIView animateWithDuration:[self transitionDuration:transitionContext] + delay:0.0f + usingSpringWithDamping:1.0f + initialSpringVelocity:0.7f + options:0 + animations:^{ + imageView.frame = self.toFrame; + } + completion:^(BOOL complete) { + [UIView animateWithDuration:0.25f + animations:^{ + imageView.alpha = 0.0f; + } + completion:^(BOOL complete) { + [imageView removeFromSuperview]; + }]; + }]; + } + + [UIView animateWithDuration:[self transitionDuration:transitionContext] + animations:^{ + cropViewController.view.alpha = (self.isDismissing ? 0.0f : 1.0f); + } + completion:^(BOOL complete) { + [self reset]; + [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; + }]; +} + +- (void)reset { + self.image = nil; + self.toView = nil; + self.fromView = nil; + self.fromFrame = CGRectZero; + self.toFrame = CGRectZero; + self.prepareForTransitionHandler = nil; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.h new file mode 100644 index 000000000..1a21dcdec --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.h @@ -0,0 +1,38 @@ +// +// TOCroppedImageAttributes.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface TOCroppedImageAttributes : NSObject + +@property (nonatomic, readonly) NSInteger angle; +@property (nonatomic, readonly) CGRect croppedFrame; +@property (nonatomic, readonly) CGSize originalImageSize; + +- (instancetype)initWithCroppedFrame:(CGRect)croppedFrame angle:(NSInteger)angle originalImageSize:(CGSize)originalSize; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.m new file mode 100644 index 000000000..8d2dcf273 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Models/TOCroppedImageAttributes.m @@ -0,0 +1,45 @@ +// +// TOCroppedImageAttributes.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCroppedImageAttributes.h" + +@interface TOCroppedImageAttributes () + +@property (nonatomic, assign, readwrite) NSInteger angle; +@property (nonatomic, assign, readwrite) CGRect croppedFrame; +@property (nonatomic, assign, readwrite) CGSize originalImageSize; + +@end + +@implementation TOCroppedImageAttributes + +- (instancetype)initWithCroppedFrame:(CGRect)croppedFrame angle:(NSInteger)angle originalImageSize:(CGSize)originalSize { + if (self = [super init]) { + _angle = angle; + _croppedFrame = croppedFrame; + _originalImageSize = originalSize; + } + + return self; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/Base.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/Base.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..aca943ece --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/Base.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Done"; +"Cancel" = "Cancel"; +"Reset" = "Reset"; +"Original" = "Original"; +"Square" = "Square"; +"Delete Changes" = "Delete Changes"; +"Yes" = "Yes"; +"No" = "No"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/PrivacyInfo.xcprivacy b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..e08a130bc --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ar.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ar.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..a6fe0778e --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ar.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,9 @@ +"Done" = "تم"; +"Cancel" = "إلغاء"; +"Reset" = "إعادة تعيين"; +"Original" = "أصلي"; +"Square" = "مربع"; +"Delete Changes" = "حذف التغييرات"; +"Yes" = "نعم"; +"No" = "لا"; + diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ca.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ca.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..37c05c4fa --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ca.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Fet"; +"Cancel" = "Cancel·lar"; +"Reset" = "Restablir"; +"Original" = "Original"; +"Square" = "Quadrat"; +"Delete Changes" = "Esborrar Canvis"; +"Yes" = "Si"; +"No" = "No"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/cs.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/cs.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..ca97d04e7 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/cs.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Hotovo"; +"Cancel" = "Zrušit"; +"Reset" = "Reset"; +"Original" = "Originál"; +"Square" = "Čtverec"; +"Delete Changes" = "Smazat změny"; +"Yes" = "Ano"; +"No" = "Ne"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/da-DK.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/da-DK.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..ef9c884f4 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/da-DK.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "OK"; +"Cancel" = "Annuller"; +"Reset" = "Nulstil"; +"Original" = "Original"; +"Square" = "Firkantet"; +"Delete Changes" = "Slet ændringer"; +"Yes" = "Ja"; +"No" = "Nej"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/de.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/de.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..edaca3f07 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/de.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Fertig"; +"Cancel" = "Abbrechen"; +"Reset" = "Zurücksetzen"; +"Original" = "Original"; +"Square" = "Quadrat"; +"Delete Changes" = "Änderungen löschen"; +"Yes" = "Ja"; +"No" = "Nein"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/en.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/en.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..aca943ece --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/en.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Done"; +"Cancel" = "Cancel"; +"Reset" = "Reset"; +"Original" = "Original"; +"Square" = "Square"; +"Delete Changes" = "Delete Changes"; +"Yes" = "Yes"; +"No" = "No"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/es.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/es.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..fbf329ef4 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/es.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Aceptar"; +"Cancel" = "Cancelar"; +"Reset" = "Cambiar"; +"Original" = "Original"; +"Square" = "Cuadrada"; +"Delete Changes" = "Eliminar cambios"; +"Yes" = "Sí"; +"No" = "No"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa-IR.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa-IR.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..0316966bc --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa-IR.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "انجام شد"; +"Cancel" = "انصراف"; +"Reset" = "بازنشانی"; +"Original" = "اصلی"; +"Square" = "مربع"; +"Delete Changes" = "حذف تغییرات"; +"Yes" = "آری"; +"No" = "نه"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..0316966bc --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fa.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "انجام شد"; +"Cancel" = "انصراف"; +"Reset" = "بازنشانی"; +"Original" = "اصلی"; +"Square" = "مربع"; +"Delete Changes" = "حذف تغییرات"; +"Yes" = "آری"; +"No" = "نه"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fi.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fi.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..fdb3422ff --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fi.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Valmis"; +"Cancel" = "Kumoa"; +"Reset" = "Palauta"; +"Original" = "Alkuperäinen"; +"Square" = "Neliö"; +"Delete Changes" = "Peru muutokset"; +"Yes" = "Kyllä"; +"No" = "Ei"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fr.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fr.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..fb1bc694d --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/fr.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "OK"; +"Cancel" = "Annuler"; +"Reset" = "Réinitialiser"; +"Original" = "D’origine"; +"Square" = "Carré"; +"Delete Changes" = "Supprimer les modifications"; +"Yes" = "Oui"; +"No" = "Non"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/hu.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/hu.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..0fff7eec6 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/hu.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,9 @@ +"Done" = "Kész"; +"Cancel" = "Mégse"; +"Reset" = "Visszaállítás"; +"Original" = "Eredeti"; +"Square" = "Négyzet"; +"Delete Changes" = "Módosítások törlése"; +"Yes" = "Igen"; +"No" = "Nem"; + diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/id.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/id.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..b490df3d3 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/id.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Selesai"; +"Cancel" = "Batalkan"; +"Reset" = "Atur Ulang"; +"Original" = "Asli"; +"Square" = "Persegi"; +"Delete Changes" = "Hapus Perubahan"; +"Yes" = "Ya"; +"No" = "Tidak"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/it.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/it.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..578aed7c5 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/it.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Fatto"; +"Cancel" = "Annulla"; +"Reset" = "Ripristina"; +"Original" = "Originale"; +"Square" = "Quadrato"; +"Delete Changes" = "Elimina modifiche"; +"Yes" = "Sì"; +"No" = "No"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ja.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ja.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..82d9b7fa1 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ja.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "完了"; +"Cancel" = "キャンセル"; +"Reset" = "リセット"; +"Original" = "オリジナル"; +"Square" = "スクエア"; +"Delete Changes" = "変更を削除"; +"Yes" = "はい"; +"No" = "いいえ"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ko.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ko.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..6d6bd4b3d --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ko.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "완료"; +"Cancel" = "취소"; +"Reset" = "재설정"; +"Original" = "원본"; +"Square" = "정방형"; +"Delete Changes" = "변경사항 삭제"; +"Yes" = "예"; +"No" = "아니요"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ms.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ms.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..2a7198591 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ms.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Selesai"; +"Cancel" = "Batal"; +"Reset" = "Reset"; +"Original" = "Asal"; +"Square" = "Segi empat"; +"Delete Changes" = "Padam Perubahan"; +"Yes" = "Ya"; +"No" = "Tidak"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/nl.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/nl.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..0dacf0962 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/nl.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Gereed"; +"Cancel" = "Annuleer"; +"Reset" = "Herstel"; +"Original" = "Origineel"; +"Square" = "Vierkant"; +"Delete Changes" = "Wis wijzigingen"; +"Yes" = "Ja"; +"No" = "Nee"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pl.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pl.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..a0f07c4aa --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pl.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Gotowe"; +"Cancel" = "Anuluj"; +"Reset" = "Wyzeruj"; +"Original" = "Orygin."; +"Square" = "Kwadrat"; +"Delete Changes" = "Usuń zmiany"; +"Yes" = "Tak"; +"No" = "Nie"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt-BR.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt-BR.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..a3774e18a --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt-BR.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "OK"; +"Cancel" = "Cancelar"; +"Reset" = "Redefinir"; +"Original" = "Original"; +"Square" = "Quadrada"; +"Delete Changes" = "Apagar Alterações"; +"Yes" = "Sim"; +"No" = "Não"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..a3774e18a --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/pt.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "OK"; +"Cancel" = "Cancelar"; +"Reset" = "Redefinir"; +"Original" = "Original"; +"Square" = "Quadrada"; +"Delete Changes" = "Apagar Alterações"; +"Yes" = "Sim"; +"No" = "Não"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ro.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ro.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..8b9cb4aa4 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ro.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Gata"; +"Cancel" = "Anulare"; +"Reset" = "Resetare"; +"Original" = "Original"; +"Square" = "Patrat"; +"Delete Changes" = "Ștergeți modificările"; +"Yes" = "Da"; +"No" = "Nu"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ru.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ru.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..c9188b259 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/ru.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Готово"; +"Cancel" = "Отменить"; +"Reset" = "Сбросить"; +"Original" = "Оригинал"; +"Square" = "Квадрат"; +"Delete Changes" = "Удалить изменения"; +"Yes" = "Да"; +"No" = "Нет"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/sk.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/sk.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..ef7fc0e61 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/sk.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Hotovo"; +"Cancel" = "Zrušiť"; +"Reset" = "Reset"; +"Original" = "Originál"; +"Square" = "Štvorec"; +"Delete Changes" = "Zmazať zmeny"; +"Yes" = "Áno"; +"No" = "Nie"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/tr.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/tr.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..0913b47e2 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/tr.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,9 @@ +"Done" = "Tamam"; +"Cancel" = "Vazgeç"; +"Reset" = "Sıfırla"; +"Original" = "Orjinal"; +"Square" = "Kare"; +"Delete Changes" = "Değişiklikleri Sil"; +"Yes" = "Evet"; +"No" = "Hayır"; + diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/uk.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/uk.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..3f30b036b --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/uk.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,8 @@ +"Done" = "Готово"; +"Cancel" = "Скасувати"; +"Reset" = "Скинути"; +"Original" = "Оригінал"; +"Square" = "Квадрат"; +"Delete Changes" = "Видалити Зміни"; +"Yes" = "Так"; +"No" = "Ні"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/vi.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/vi.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..7826cc645 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/vi.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,9 @@ +"Done" = "Xong"; +"Cancel" = "Huỷ"; +"Reset" = "Đặt lại"; +"Original" = "Gốc"; +"Square" = "Vuông"; +"Delete Changes" = "Xóa Thay đổi"; +"Yes" = "Có"; +"No" = "Không"; + diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hans.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hans.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..7b02b3bad --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hans.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,9 @@ +"Done" = "完成"; +"Cancel" = "取消"; +"Reset" = "重设"; +"Original" = "原有"; +"Square" = "正方形"; +"Delete Changes" = "删除更改"; +"Yes" = "是"; +"No" = "否"; + diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hant.lproj/TOCropViewControllerLocalizable.strings b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hant.lproj/TOCropViewControllerLocalizable.strings new file mode 100644 index 000000000..3dfe2b7fb --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Resources/zh-Hant.lproj/TOCropViewControllerLocalizable.strings @@ -0,0 +1,9 @@ +"Done" = "完成"; +"Cancel" = "取消"; +"Reset" = "重置"; +"Original" = "原始檔"; +"Square" = "正方形"; +"Delete Changes" = "刪除更動"; + +"Yes" = "是"; +"No" = "否"; diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.h new file mode 100644 index 000000000..a3dceba48 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.h @@ -0,0 +1,479 @@ +// +// TOCropViewController.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +#if !__has_include() +#import "TOCropToolbar.h" +#import "TOCropView.h" +#import "TOCropViewConstants.h" +#import "TOCropViewControllerAspectRatioPreset.h" +#else +#import +#import +#import +#import +#endif + +@class TOCropViewController; + +///------------------------------------------------ +/// @name Delegate +///------------------------------------------------ + +@protocol TOCropViewControllerDelegate +@optional + +/** + Called when the user has committed the crop action, and provides + just the cropping rectangle. + + @param cropRect A rectangle indicating the crop region of the image the user chose (In the original image's local co-ordinate space) + @param angle The angle of the image when it was cropped + */ +- (void)cropViewController:(nonnull TOCropViewController *)cropViewController + didCropImageToRect:(CGRect)cropRect + angle:(NSInteger)angle; + +/** + Called when the user has committed the crop action, and provides + both the original image with crop co-ordinates. + + @param image The newly cropped image. + @param cropRect A rectangle indicating the crop region of the image the user chose (In the original image's local co-ordinate space) + @param angle The angle of the image when it was cropped + */ +- (void)cropViewController:(nonnull TOCropViewController *)cropViewController + didCropToImage:(nonnull UIImage *)image + withRect:(CGRect)cropRect + angle:(NSInteger)angle; + +/** + If the cropping style is set to circular, implementing this delegate will return a circle-cropped version of the selected + image, as well as it's cropping co-ordinates + + @param image The newly cropped image, clipped to a circle shape + @param cropRect A rectangle indicating the crop region of the image the user chose (In the original image's local co-ordinate space) + @param angle The angle of the image when it was cropped + */ +- (void)cropViewController:(nonnull TOCropViewController *)cropViewController + didCropToCircularImage:(nonnull UIImage *)image + withRect:(CGRect)cropRect + angle:(NSInteger)angle; + +/** + If implemented, when the user hits cancel, or completes a + UIActivityViewController operation, this delegate will be called, + giving you a chance to manually dismiss the view controller + + @param cancelled Whether a cropping action was actually performed, or if the user explicitly hit 'Cancel' + + */ +- (void)cropViewController:(nonnull TOCropViewController *)cropViewController + didFinishCancelled:(BOOL)cancelled; + +@end + +@interface TOCropViewController : UIViewController + +/** + The original, uncropped image that was passed to this controller. + */ +@property (nonnull, nonatomic, readonly) UIImage *image; + +/** + The minimum croping aspect ratio. If set, user is prevented from + setting cropping rectangle to lower aspect ratio than defined by the parameter. + */ +@property (nonatomic, assign) CGFloat minimumAspectRatio; + +/** + The view controller's delegate that will receive the resulting + cropped image, as well as crop information. + */ +@property (nullable, nonatomic, weak) id delegate; + +/** + If true, when the user hits 'Done', a UIActivityController will appear + before the view controller ends. + */ +@property (nonatomic, assign) BOOL showActivitySheetOnDone; + +/** + The crop view managed by this view controller. + */ +@property (nonnull, nonatomic, strong, readonly) TOCropView *cropView; + +/** + In the coordinate space of the image itself, the region that is currently + being highlighted by the crop box. + + This property can be set before the controller is presented to have + the image 'restored' to a previous cropping layout. + */ +@property (nonatomic, assign) CGRect imageCropFrame; + +/** + The angle in which the image is rotated in the crop view. + This can only be in 90 degree increments (eg, 0, 90, 180, 270). + + This property can be set before the controller is presented to have + the image 'restored' to a previous cropping layout. + */ +@property (nonatomic, assign) NSInteger angle; + +/** + The toolbar view managed by this view controller. + */ +@property (nonnull, nonatomic, strong, readonly) TOCropToolbar *toolbar; + +/** + The cropping style of this particular crop view controller + */ +@property (nonatomic, readonly) TOCropViewCroppingStyle croppingStyle; + +/** + A choice from one of the pre-defined aspect ratio presets + */ +@property (nonatomic, assign) CGSize aspectRatioPreset; + +/** + Title label which can be used to show instruction on the top of the crop view controller + */ +@property (nullable, nonatomic, readonly) UILabel *titleLabel; + +/** + Title for the 'Done' button. + Setting this will override the Default which is a localized string for "Done". + */ +@property (nullable, nonatomic, copy) NSString *doneButtonTitle; + +/** + Title for the 'Cancel' button. + Setting this will override the Default which is a localized string for "Cancel". + */ +@property (nullable, nonatomic, copy) NSString *cancelButtonTitle; + +/** + If true, button icons are visible in portairt instead button text. + + Default is NO. Has no effect on iOS 26 and up, where the toolbar is always icon-only. + */ +@property (nonatomic, assign) BOOL showOnlyIcons; + +/** + Color for the 'Done' button. + Setting this will override the default color. + */ +@property (null_resettable, nonatomic, copy) UIColor *doneButtonColor; + +/** + Color for the 'Cancel' button. + Setting this will override the default color. + */ +@property (nullable, nonatomic, copy) UIColor *cancelButtonColor; + +/** + Shows a confirmation dialog when the user hits 'Cancel' and there are pending changes. + (Default is NO) + */ +@property (nonatomic, assign) BOOL showCancelConfirmationDialog; + +/** + If true, a custom aspect ratio is set, and the aspectRatioLockEnabled is set to YES, the crop box + will swap it's dimensions depending on portrait or landscape sized images. + This value also controls whether the dimensions can swap when the image is rotated. + + Default is NO. + */ +@property (nonatomic, assign) BOOL aspectRatioLockDimensionSwapEnabled; + +/** + If true, while it can still be resized, the crop box will be locked to its current aspect ratio. + + If this is set to YES, and `resetAspectRatioEnabled` is set to NO, then the aspect ratio + button will automatically be hidden from the toolbar. + + Default is NO. + */ +@property (nonatomic, assign) BOOL aspectRatioLockEnabled; + +/** + If true, tapping the reset button will also reset the aspect ratio back to the image + default ratio. Otherwise, the reset will just zoom out to the current aspect ratio. + + If this is set to NO, and `aspectRatioLockEnabled` is set to YES, then the aspect ratio + button will automatically be hidden from the toolbar. + + Default is YES + */ +@property (nonatomic, assign) BOOL resetAspectRatioEnabled; + +/** + The position of the Toolbar the default value is `TOCropViewControllerToolbarPositionBottom`. + */ +@property (nonatomic, assign) TOCropViewControllerToolbarPosition toolbarPosition; + +/** + When disabled, an additional rotation button that rotates the canvas in + 90-degree segments in a clockwise direction is shown in the toolbar. + + Default is NO. + */ +@property (nonatomic, assign) BOOL rotateClockwiseButtonHidden; + +/* + If this controller is embedded in UINavigationController its navigation bar + is hidden by default. Set this property to false to show the navigation bar. + This must be set before this controller is presented. + */ +@property (nonatomic, assign) BOOL hidesNavigationBar; + +/** + When enabled, hides the rotation button, as well as the alternative rotation + button visible when `showClockwiseRotationButton` is set to YES. + + Default is NO. + */ +@property (nonatomic, assign) BOOL rotateButtonsHidden; + +/** + When enabled, hides the 'Reset' button on the toolbar. + + Default is NO. + */ +@property (nonatomic, assign) BOOL resetButtonHidden; +/** + When enabled, hides the 'Aspect Ratio Picker' button on the toolbar. + + Default is NO. + */ +@property (nonatomic, assign) BOOL aspectRatioPickerButtonHidden; + +/** + When enabled, hides the 'Done' button on the toolbar. + + Default is NO. + */ +@property (nonatomic, assign) BOOL doneButtonHidden; + +/** + When enabled, hides the 'Cancel' button on the toolbar. + + Default is NO. + */ +@property (nonatomic, assign) BOOL cancelButtonHidden; + +/** + When enabled, the toolbar is displayed in RTL layout. + + Default is NO. + */ +@property (nonatomic, assign) BOOL reverseContentLayout; + +/** + If `showActivitySheetOnDone` is true, then these activity items will + be supplied to that UIActivityViewController in addition to the + `TOActivityCroppedImageProvider` object. + */ +@property (nullable, nonatomic, strong) NSArray *activityItems; + +/** + If `showActivitySheetOnDone` is true, then you may specify any + custom activities your app implements in this array. If your activity requires + access to the cropping information, it can be accessed in the supplied + `TOActivityCroppedImageProvider` object + */ +@property (nullable, nonatomic, strong) NSArray *applicationActivities; + +/** + If `showActivitySheetOnDone` is true, then you may expliclty + set activities that won't appear in the share sheet here. + */ +@property (nullable, nonatomic, strong) NSArray *excludedActivityTypes; + +/** + An array of `TOCropViewControllerAspectRatioPreset` enum values denoting which + aspect ratios the crop view controller may display (Default is nil. All are shown) + */ +@property (nullable, nonatomic, strong) NSArray *allowedAspectRatios; + +/** + Called when the user hits the Done button. +*/ +@property (nullable, nonatomic, copy) void (^onDidTapDone)(void); + +/** + When the user hits cancel, or completes a + UIActivityViewController operation, this block will be called, + giving you a chance to manually dismiss the view controller + */ +@property (nullable, nonatomic, strong) void (^onDidFinishCancelled)(BOOL isFinished); + +/** + Called when the user has committed the crop action, and provides + just the cropping rectangle. + + @param cropRect A rectangle indicating the crop region of the image the user chose + (In the original image's local co-ordinate space) + @param angle The angle of the image when it was cropped + */ +@property (nullable, nonatomic, strong) void (^onDidCropImageToRect)(CGRect cropRect, NSInteger angle); + +/** + Called when the user has committed the crop action, and provides + both the cropped image with crop co-ordinates. + + @param image The newly cropped image. + @param cropRect A rectangle indicating the crop region of the image the user chose + (In the original image's local co-ordinate space) + @param angle The angle of the image when it was cropped + */ +@property (nullable, nonatomic, strong) void (^onDidCropToRect)(UIImage *_Nonnull image, CGRect cropRect, NSInteger angle); + +/** + If the cropping style is set to circular, this block will return a circle-cropped version of the selected + image, as well as it's cropping co-ordinates + + @param image The newly cropped image, clipped to a circle shape + @param cropRect A rectangle indicating the crop region of the image the user chose + (In the original image's local co-ordinate space) + @param angle The angle of the image when it was cropped + */ +@property (nullable, nonatomic, strong) void (^onDidCropToCircleImage)(UIImage *_Nonnull image, CGRect cropRect, NSInteger angle); + +///------------------------------------------------ +/// @name Object Creation +///------------------------------------------------ + +/** + Creates a new instance of a crop view controller with the supplied image + + @param image The image that will be used to crop. + */ +- (nonnull instancetype)initWithImage:(nonnull UIImage *)image NS_SWIFT_NAME(init(image:)); + +/** + Creates a new instance of a crop view controller with the supplied image and cropping style + + @param style The cropping style that will be used with this view controller (eg, rectangular, or circular) + @param image The image that will be cropped + */ +- (nonnull instancetype)initWithCroppingStyle:(TOCropViewCroppingStyle)style image:(nonnull UIImage *)image NS_SWIFT_NAME(init(croppingStyle:image:)); + +/** + Commits the crop action as if user pressed done button in the bottom bar themself + */ +- (void)commitCurrentCrop; + +/** + Resets object of TOCropViewController class as if user pressed reset button in the bottom bar themself + */ +- (void)resetCropViewLayout; + +/** + Set the aspect ratio to be one of the available preset options. These presets have specific behaviour + such as swapping their dimensions depending on portrait or landscape sized images. + + @param aspectRatioPreset The aspect ratio preset + @param animated Whether the transition to the aspect ratio is animated + */ +- (void)setAspectRatioPreset:(CGSize)aspectRatioPreset animated:(BOOL)animated NS_SWIFT_NAME(setAspectRatioPreset(_:animated:)); + +/** + Play a custom animation of the target image zooming to its position in + the crop controller while the background fades in. + + @param viewController The parent controller that this view controller would be presenting from. + @param fromView A view that's frame will be used as the origin for this animation. Optional if `fromFrame` has a value. + @param fromFrame In the screen's coordinate space, the frame from which the image should animate from. Optional if `fromView` has a value. + @param setup A block that is called just before the transition starts. Recommended for hiding any necessary image views. + @param completion A block that is called once the transition animation is completed. + */ +- (void)presentAnimatedFromParentViewController:(nonnull UIViewController *)viewController + fromView:(nullable UIView *)fromView + fromFrame:(CGRect)fromFrame + setup:(nullable void (^)(void))setup + completion:(nullable void (^)(void))completion NS_SWIFT_NAME(presentAnimatedFrom(_:view:frame:setup:completion:)); + +/** + Play a custom animation of the target image zooming to its position in + the crop controller while the background fades in. Additionally, if you're + 'restoring' to a previous crop setup, this method lets you provide a previously + cropped copy of the image, and the previous crop settings to transition back to + where the user would have left off. + + @param viewController The parent controller that this view controller would be presenting from. + @param image The previously cropped image that can be used in the transition animation. + @param fromView A view that's frame will be used as the origin for this animation. Optional if `fromFrame` has a value. + @param fromFrame In the screen's coordinate space, the frame from which the image should animate from. + @param angle The rotation angle in which the image was rotated when it was originally cropped. + @param toFrame In the image's coordinate space, the previous crop frame that created the previous crop + @param setup A block that is called just before the transition starts. Recommended for hiding any necessary image views. + @param completion A block that is called once the transition animation is completed. + */ +- (void)presentAnimatedFromParentViewController:(nonnull UIViewController *)viewController + fromImage:(nullable UIImage *)image + fromView:(nullable UIView *)fromView + fromFrame:(CGRect)fromFrame + angle:(NSInteger)angle + toImageFrame:(CGRect)toFrame + setup:(nullable void (^)(void))setup + completion:(nullable void (^)(void))completion NS_SWIFT_NAME(presentAnimatedFrom(_:fromImage:fromView:fromFrame:angle:toFrame:setup:completion:)); + +/** + Play a custom animation of the supplied cropped image zooming out from + the cropped frame to the specified frame as the rest of the content fades out. + If any view configurations need to be done before the animation starts, + + @param viewController The parent controller that this view controller would be presenting from. + @param toView A view who's frame will be used to establish the destination frame + @param frame The target frame that the image will animate to + @param setup A block that is called just before the transition starts. Recommended for hiding any necessary image views. + @param completion A block that is called once the transition animation is completed. + */ +- (void)dismissAnimatedFromParentViewController:(nonnull UIViewController *)viewController + toView:(nullable UIView *)toView + toFrame:(CGRect)frame + setup:(nullable void (^)(void))setup + completion:(nullable void (^)(void))completion NS_SWIFT_NAME(dismissAnimatedFrom(_:toView:toFrame:setup:completion:)); + +/** + Play a custom animation of the supplied cropped image zooming out from + the cropped frame to the specified frame as the rest of the content fades out. + If any view configurations need to be done before the animation starts, + + @param viewController The parent controller that this view controller would be presenting from. + @param image The resulting 'cropped' image. If supplied, will animate out of the crop box zone. If nil, the default image will entirely zoom out + @param toView A view who's frame will be used to establish the destination frame + @param frame The target frame that the image will animate to + @param setup A block that is called just before the transition starts. Recommended for hiding any necessary image views. + @param completion A block that is called once the transition animation is completed. + */ +- (void)dismissAnimatedFromParentViewController:(nonnull UIViewController *)viewController + withCroppedImage:(nullable UIImage *)image + toView:(nullable UIView *)toView + toFrame:(CGRect)frame + setup:(nullable void (^)(void))setup + completion:(nullable void (^)(void))completion NS_SWIFT_NAME(dismissAnimatedFrom(_:croppedImage:toView:toFrame:setup:completion:)); + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.m new file mode 100644 index 000000000..002efa32d --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/TOCropViewController.m @@ -0,0 +1,1355 @@ +// +// TOCropViewController.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCropViewController.h" + +#import "TOActivityCroppedImageProvider.h" +#import "TOCroppedImageAttributes.h" +#import "TOCropViewControllerTransitioning.h" +#import "UIImage+CropRotate.h" + +static const CGFloat kTOCropViewControllerTitleTopPadding = 14.0f; +static const CGFloat kTOCropViewControllerToolbarHeight = 44.0f; + +@interface TOCropViewController () + +/* The target image */ +@property (nonatomic, readwrite) UIImage *image; + +/* The cropping style of the crop view */ +@property (nonatomic, assign, readwrite) TOCropViewCroppingStyle croppingStyle; + +/* Views */ +@property (nonatomic, strong) TOCropToolbar *toolbar; +@property (nonatomic, strong, readwrite) TOCropView *cropView; +@property (nonatomic, strong) UIView *toolbarSnapshotView; +@property (nonatomic, strong, readwrite) UILabel *titleLabel; + +/* Transition animation controller */ +@property (nonatomic, copy) void (^prepareForTransitionHandler)(void); +@property (nonatomic, strong) TOCropViewControllerTransitioning *transitionController; +@property (nonatomic, assign) BOOL inTransition; + +/* If pushed from a navigation controller, the visibility of that controller's bars. */ +@property (nonatomic, assign) BOOL navigationBarHidden; +@property (nonatomic, assign) BOOL toolbarHidden; + +/* State for whether content is being laid out vertically or horizontally */ +@property (nonatomic, readonly) BOOL verticalLayout; + +/* Convenience method for managing status bar state */ +@property (nonatomic, readonly) BOOL overrideStatusBar; // Whether the view controller needs to touch the status bar +@property (nonatomic, readonly) BOOL statusBarHidden; // Whether it should be hidden or visible at this point +@property (nonatomic, readonly) CGFloat statusBarHeight; // The height of the status bar when visible + +/* Convenience method for getting the vertical inset for both iPhone X and status bar */ +@property (nonatomic, readonly) UIEdgeInsets statusBarSafeInsets; + +/* Flag to perform initial setup on the first run */ +@property (nonatomic, assign) BOOL firstTime; + +@end + +@implementation TOCropViewController + +- (instancetype)initWithCroppingStyle:(TOCropViewCroppingStyle)style image:(UIImage *)image { + NSParameterAssert(image); + + self = [super initWithNibName:nil bundle:nil]; + if (self) { + // Init parameters + _image = image; + _croppingStyle = style; + + if (@available(iOS 13.0, *)) { + self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark; + } + + // Set up base view controller behaviour + self.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; + self.modalPresentationStyle = UIModalPresentationFullScreen; + self.hidesNavigationBar = true; + + // Controller object that handles the transition animation when presenting / dismissing this app + _transitionController = [[TOCropViewControllerTransitioning alloc] init]; + + // Default initial behaviour + _aspectRatioPreset = CGSizeZero; + +#if TARGET_OS_MACCATALYST + _toolbarPosition = TOCropViewControllerToolbarPositionTop; +#else + _toolbarPosition = TOCropViewControllerToolbarPositionBottom; +#endif + } + + return self; +} + +- (instancetype)initWithImage:(UIImage *)image { + return [self initWithCroppingStyle:TOCropViewCroppingStyleDefault image:image]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + // Set up view controller properties + self.transitioningDelegate = self; + self.view.backgroundColor = self.cropView.backgroundColor; + + BOOL circularMode = (self.croppingStyle == TOCropViewCroppingStyleCircular); + + // Layout the views initially + self.cropView.frame = [self frameForCropViewWithVerticalLayout:self.verticalLayout]; + self.toolbar.frame = [self frameForToolbarWithVerticalLayout:self.verticalLayout]; + + // Set up toolbar default behaviour + self.toolbar.clampButtonHidden = self.aspectRatioPickerButtonHidden || circularMode; + self.toolbar.rotateClockwiseButtonHidden = self.rotateClockwiseButtonHidden; + + // Set up the toolbar button actions + __weak typeof(self) weakSelf = self; + self.toolbar.doneButtonTapped = ^{ [weakSelf doneButtonTapped]; }; + self.toolbar.cancelButtonTapped = ^{ [weakSelf cancelButtonTapped]; }; + self.toolbar.resetButtonTapped = ^{ [weakSelf resetCropViewLayout]; }; + self.toolbar.clampButtonTapped = ^{ [weakSelf showAspectRatioDialog]; }; + self.toolbar.rotateCounterclockwiseButtonTapped = ^{ [weakSelf rotateCropViewCounterclockwise]; }; + self.toolbar.rotateClockwiseButtonTapped = ^{ [weakSelf rotateCropViewClockwise]; }; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + + // If we're animating onto the screen, set a flag + // so we can manually control the status bar fade out timing + if (animated) { + self.inTransition = YES; +#if TARGET_OS_IOS + [self setNeedsStatusBarAppearanceUpdate]; +#endif + } + + // If this controller is pushed onto a navigation stack, set flags noting the + // state of the navigation controller bars before we present, and then hide them + if (self.navigationController) { + if (self.hidesNavigationBar) { + self.navigationBarHidden = self.navigationController.navigationBarHidden; + self.toolbarHidden = self.navigationController.toolbarHidden; + [self.navigationController setNavigationBarHidden:YES animated:animated]; + [self.navigationController setToolbarHidden:YES animated:animated]; + } + + self.modalTransitionStyle = UIModalTransitionStyleCoverVertical; + } else { + // Hide the background content when transitioning for performance + [self.cropView setBackgroundImageViewHidden:YES animated:NO]; + + // The title label will fade + self.titleLabel.alpha = animated ? 0.0f : 1.0f; + } + + // If an initial aspect ratio was set before presentation, set it now once the rest of + // the setup will have been done + if (!CGSizeEqualToSize(self.aspectRatioPreset, CGSizeZero)) { + [self setAspectRatioPreset:self.aspectRatioPreset animated:NO]; + } +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + + // Disable the transition flag for the status bar + self.inTransition = NO; + + // Re-enable translucency now that the animation has completed + self.cropView.simpleRenderMode = NO; + + // Now that the presentation animation will have finished, animate + // the status bar fading out, and if present, the title label fading in + void (^updateContentBlock)(void) = ^{ +#if TARGET_OS_IOS + [self setNeedsStatusBarAppearanceUpdate]; +#endif + self.titleLabel.alpha = 1.0f; + }; + + if (animated) { + [UIView animateWithDuration:0.3f animations:updateContentBlock]; + } else { + updateContentBlock(); + } + + // Make the grid overlay view fade in + if (self.cropView.gridOverlayHidden) { + [self.cropView setGridOverlayHidden:NO animated:animated]; + } + + // Fade in the background view content + if (self.navigationController == nil) { + [self.cropView setBackgroundImageViewHidden:NO animated:animated]; + } +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + + // Set the transition flag again so we can defer the status bar + self.inTransition = YES; +#if TARGET_OS_IOS + [UIView animateWithDuration:0.5f + animations:^{ [self setNeedsStatusBarAppearanceUpdate]; }]; +#endif + + // Restore the navigation controller to its state before we were presented + if (self.navigationController && self.hidesNavigationBar) { + [self.navigationController setNavigationBarHidden:self.navigationBarHidden animated:animated]; + [self.navigationController setToolbarHidden:self.toolbarHidden animated:animated]; + } +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + + // Reset the state once the view has gone offscreen + self.inTransition = NO; +#if TARGET_OS_IOS + [self setNeedsStatusBarAppearanceUpdate]; +#endif +} + +#pragma mark - Status Bar - +- (UIStatusBarStyle)preferredStatusBarStyle { + if (self.navigationController) { + return UIStatusBarStyleLightContent; + } + + // Even though we are a dark theme, leave the status bar + // as black so it's not obvious that it's still visible during the transition + if (@available(iOS 13.0, *)) { + return UIStatusBarStyleDarkContent; + } + return UIStatusBarStyleDefault; +} + +- (BOOL)prefersStatusBarHidden { + // Disregard the transition animation if we're not actively overriding it + if (!self.overrideStatusBar) { + return self.statusBarHidden; + } + + // Work out whether the status bar needs to be visible + // during a transition animation or not + BOOL hidden = YES; // Default is yes + hidden = hidden && !(self.inTransition); // Not currently in a presentation animation (Where removing the status bar would break the layout) + hidden = hidden && !(self.view.superview == nil); // Not currently waiting to be added to a super view + return hidden; +} + +- (UIRectEdge)preferredScreenEdgesDeferringSystemGestures { + if (self.navigationController) { + return super.preferredScreenEdgesDeferringSystemGestures; + } + return UIRectEdgeAll; +} + +- (CGRect)frameForToolbarWithVerticalLayout:(BOOL)verticalLayout { + UIEdgeInsets insets = self.statusBarSafeInsets; + + // fix: On iOS 26, overlay with iPadOS windowingControl area. +#if defined(__IPHONE_26_0) + if (@available(iOS 26.0, *)) { + if (!verticalLayout) { +#if __IPHONE_OS_VERSION_MAX_ALLOWED < 180000 + UIViewLayoutRegion *layoutRegion = [UIViewLayoutRegion safeAreaLayoutRegionWithCornerAdaptation: UIViewLayoutRegionAdaptivityAxisVertical]; + UIEdgeInsets edgeInsets = [self.view edgeInsetsForLayoutRegion:layoutRegion]; + insets.top = edgeInsets.top; + insets.left = edgeInsets.left; + insets.bottom = edgeInsets.bottom; +#endif + } + } +#endif + + CGRect frame = CGRectZero; + if (!verticalLayout) { // In landscape laying out toolbar to the left + if (@available(iOS 26.0, *)) { +#if !TARGET_OS_VISION + CGFloat minPadding = 8.0f; +#else + CGFloat minPadding = 16.0f; +#endif + frame.origin.x = minPadding + insets.left; + frame.origin.y = minPadding + insets.top; + frame.size.width = kTOCropViewControllerToolbarHeight; + frame.size.height = CGRectGetHeight(self.view.frame) - (minPadding * 2.0f) - insets.top - insets.bottom; + } else { + frame.origin.x = insets.left; + frame.origin.y = 0.0f; + frame.size.width = kTOCropViewControllerToolbarHeight; + frame.size.height = CGRectGetHeight(self.view.frame); + } + } else { + if (@available(iOS 26.0, *)) { + CGRect frameInWindow = [self.view convertRect:self.view.bounds toView:nil]; + BOOL isFullscreen = true; + if (self.view.window) { + isFullscreen = CGRectEqualToRect(frameInWindow, self.view.window.bounds); + } + if (isFullscreen) { + // On iOS 26, the safe area insets values are the same, however the default bottom value is so + // high that the buttons look incorrectly set. While you can try and hardcode a value lower to + // the bottom of the screen, trying to align the width with all the varied corner radii of modern iOS + // devices is also difficult. + + // For now, I've decided to use a private API to fetch the corner radius of the device so we can properly align + // the toolbar with the device's rounded corners. + // I've filed FB20413789 with Apple hoping that this can become a real solution in future. + if (insets.bottom > 0.0f) { + insets.bottom = 20.0f; + } else { + insets.bottom = 8.0f; + } + +#if !TARGET_OS_VISION + // Look the value up only once since it can't change for the life of the + // process, and fall back to a radius matching current-generation devices + // in case the private key is ever renamed or removed + static CGFloat cornerRadius = 44.0f; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + const char *components[] = {"Radius", "Corner", "display", "_"}; + NSString *selectorName = @""; + for (NSInteger i = 3; i >= 0; i--) { + selectorName = [selectorName stringByAppendingString:[NSString stringWithCString:components[i] + encoding:NSUTF8StringEncoding]]; + } + @try { + cornerRadius = [[UIScreen.mainScreen valueForKey:selectorName] floatValue]; + } @catch (NSException *exception) { + NSLog(@"TOCropViewController: Unable to read the display corner radius. Falling back to a default value."); + } + }); +#else + const CGFloat cornerRadius = 64.0f; +#endif + frame.size.width = CGRectGetWidth(self.view.bounds) - MAX(cornerRadius, insets.bottom * 2.0f); + } else { + // Match system toolbar insets + insets.left += self.view.layoutMargins.left + 12; + insets.right += self.view.layoutMargins.right + 12; + insets.bottom = MAX(0, insets.bottom - 6); + frame.size.width = CGRectGetWidth(self.view.bounds) - insets.left - insets.right; + } + } else { + frame.size.width = CGRectGetWidth(self.view.bounds); + } + + frame.origin.x = (CGRectGetWidth(self.view.bounds) - frame.size.width) / 2.0f; + frame.size.height = kTOCropViewControllerToolbarHeight; + + if (self.toolbarPosition == TOCropViewControllerToolbarPositionBottom) { + frame.origin.y = CGRectGetHeight(self.view.bounds) - (frame.size.height + insets.bottom); + } else { + if (self.titleLabel.text.length) { + // Work out the size of the title label based on the crop view size + CGRect frame = self.titleLabel.frame; + frame.size = [self.titleLabel sizeThatFits:self.cropView.frame.size]; + self.titleLabel.frame = frame; + + // Set out the appropriate inset for that + insets.top = CGRectGetMaxY(self.titleLabel.frame); + insets.top += kTOCropViewControllerTitleTopPadding; + } + frame.origin.y = insets.top; + } + } + + return frame; +} + +- (CGRect)frameForCropViewWithVerticalLayout:(BOOL)verticalLayout { + // On an iPad, if being presented in a modal view controller by a UINavigationController, + // at the time we need it, the size of our view will be incorrect. + // If this is the case, derive our view size from our parent view controller instead + UIView *view = nil; + if (self.parentViewController == nil) { + view = self.view; + } else { + view = self.parentViewController.view; + } + + // Always make the crop view edge-to-edge on iOS 26 and up + if (@available(iOS 26.0, *)) { + return view.bounds; + } + + CGRect bounds = view.bounds; + CGRect frame = CGRectZero; + + // Horizontal layout (eg landscape) + if (!verticalLayout) { + frame.origin.x = CGRectGetMaxX(self.toolbar.frame); + frame.size.width = CGRectGetWidth(bounds) - frame.origin.x; + frame.size.height = CGRectGetHeight(bounds); + } else { // Vertical layout + frame.size.width = CGRectGetWidth(bounds); + + // Set Y and adjust for height + if (self.toolbarPosition == TOCropViewControllerToolbarPositionTop) { + frame.origin.y = CGRectGetMaxY(self.toolbar.frame); + frame.size.height = CGRectGetHeight(bounds) - frame.origin.y; + } else { + frame.size.height = CGRectGetMinY(self.toolbar.frame); + } + } + + return frame; +} + +- (CGRect)frameForTitleLabelWithSize:(CGSize)size verticalLayout:(BOOL)verticalLayout { + CGRect frame = (CGRect){CGPointZero, size}; + CGFloat viewWidth = self.view.bounds.size.width; + CGFloat x = 0.0f; // Additional X offset in landscape mode + + // Adjust for landscape layout + if (!verticalLayout) { + x = kTOCropViewControllerTitleTopPadding; + x += self.view.safeAreaInsets.left; + viewWidth -= x; + } + + // Work out horizontal position + frame.origin.x = ceilf((viewWidth - frame.size.width) * 0.5f); + if (!verticalLayout) { + frame.origin.x += x; + } + + // Work out vertical position + frame.origin.y = self.view.safeAreaInsets.top + kTOCropViewControllerTitleTopPadding; + + return frame; +} + +- (void)adjustCropViewInsets { + UIEdgeInsets insets = self.statusBarSafeInsets; + + if (@available(iOS 26.0, *)) { + if (!self.verticalLayout) { + insets.left = CGRectGetMaxX(self.toolbar.frame); + } else { + if (self.toolbarPosition == TOCropViewControllerToolbarPositionTop) { + insets.top = CGRectGetMaxY(self.toolbar.frame); + } else { + insets.bottom = CGRectGetHeight(self.view.frame) - CGRectGetMinY(self.toolbar.frame); + } + } + } else { + if (!self.verticalLayout) { + insets.left = 0.0f; + } else { + if (self.toolbarPosition == TOCropViewControllerToolbarPositionTop) { + insets.top = 0.0f; + } else { + insets.bottom = 0.0f; + } + } + } + + if (!self.verticalLayout || self.toolbarPosition == TOCropViewControllerToolbarPositionBottom) { + if (self.titleLabel.text.length) { + // Work out the size of the title label based on the crop view size + CGRect frame = self.titleLabel.frame; + frame.size = [self.titleLabel sizeThatFits:self.cropView.frame.size]; + self.titleLabel.frame = frame; + + // Set out the appropriate inset for that + insets.top += self.titleLabel.frame.size.height; + insets.top += kTOCropViewControllerTitleTopPadding; + } + } + + self.cropView.cropRegionInsets = insets; +} + +- (void)adjustToolbarInsets { + UIEdgeInsets insets = UIEdgeInsetsZero; + + // Add padding to the left in landscape mode + if (!self.verticalLayout) { + insets.left = self.view.safeAreaInsets.left; + } else { + // Add padding on top if in vertical and tool bar is at the top + if (self.toolbarPosition == TOCropViewControllerToolbarPositionTop) { + insets.top = self.view.safeAreaInsets.top; + } else { // Add padding to the bottom otherwise + insets.bottom = self.view.safeAreaInsets.bottom; + } + } + + // Update the toolbar with these properties + self.toolbar.backgroundViewOutsets = insets; + self.toolbar.statusBarHeightInset = self.statusBarHeight; + [self.toolbar setNeedsLayout]; +} + +- (void)viewSafeAreaInsetsDidChange { + [super viewSafeAreaInsetsDidChange]; + [self adjustCropViewInsets]; + [self adjustToolbarInsets]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + + [UIView performWithoutAnimation:^{ + self.toolbar.frame = [self frameForToolbarWithVerticalLayout:self.verticalLayout]; + [self adjustToolbarInsets]; + [self.toolbar setNeedsLayout]; + }]; + + self.cropView.frame = [self frameForCropViewWithVerticalLayout:self.verticalLayout]; + [self adjustCropViewInsets]; + [self.cropView moveCroppedContentToCenterAnimated:NO]; + + if (self.firstTime == NO) { + [self.cropView performInitialSetup]; + self.firstTime = YES; + } + + if (self.title.length) { + self.titleLabel.frame = [self frameForTitleLabelWithSize:self.titleLabel.frame.size verticalLayout:self.verticalLayout]; + [self.cropView moveCroppedContentToCenterAnimated:NO]; + } +} + +#pragma mark - Rotation Handling - + +- (void)_willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration { + self.toolbarSnapshotView = [self.toolbar snapshotViewAfterScreenUpdates:NO]; + self.toolbarSnapshotView.frame = self.toolbar.frame; + + if (UIInterfaceOrientationIsLandscape(toInterfaceOrientation)) { + self.toolbarSnapshotView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin; + } else { + self.toolbarSnapshotView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleRightMargin; + } + [self.view addSubview:self.toolbarSnapshotView]; + + // Set up the toolbar frame to be just off t + CGRect frame = [self frameForToolbarWithVerticalLayout:UIInterfaceOrientationIsPortrait(toInterfaceOrientation)]; + if (UIInterfaceOrientationIsLandscape(toInterfaceOrientation)) { + frame.origin.x = -frame.size.width; + } else { + frame.origin.y = self.view.bounds.size.height; + } + self.toolbar.frame = frame; + + [self.toolbar layoutIfNeeded]; + self.toolbar.alpha = 0.0f; + + [self.cropView prepareforRotation]; + self.cropView.frame = [self frameForCropViewWithVerticalLayout:UIInterfaceOrientationIsPortrait(toInterfaceOrientation)]; + self.cropView.simpleRenderMode = YES; + self.cropView.internalLayoutDisabled = YES; +} + +- (void)_willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration { + // Remove all animations in the toolbar + self.toolbar.frame = [self frameForToolbarWithVerticalLayout:!UIInterfaceOrientationIsLandscape(toInterfaceOrientation)]; + [self.toolbar.layer removeAllAnimations]; + for (CALayer *sublayer in self.toolbar.layer.sublayers) { + [sublayer removeAllAnimations]; + } + + // On iOS 11 and up, since these layout calls are done multiple times, if we don't aggregate from the + // current state, the animation breaks. + [UIView animateWithDuration:duration + delay:0.0f + options:UIViewAnimationOptionBeginFromCurrentState + animations: + ^{ + self.cropView.frame = [self frameForCropViewWithVerticalLayout:!UIInterfaceOrientationIsLandscape(toInterfaceOrientation)]; + self.toolbar.frame = [self frameForToolbarWithVerticalLayout:UIInterfaceOrientationIsPortrait(toInterfaceOrientation)]; + [self.cropView performRelayoutForRotation]; + } + completion:nil]; + + self.toolbarSnapshotView.alpha = 0.0f; + self.toolbar.alpha = 1.0f; +} + +- (void)_didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation { + [self.toolbarSnapshotView removeFromSuperview]; + self.toolbarSnapshotView = nil; + + [self.cropView setSimpleRenderMode:NO animated:YES]; + self.cropView.internalLayoutDisabled = NO; +} + +- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + + // If the size doesn't change (e.g, we did a 180 degree device rotation), don't bother doing a relayout + if (CGSizeEqualToSize(size, self.view.bounds.size)) { + return; + } + +#if !TARGET_OS_VISION + // Derive the layout from the aspect of the size we're transitioning to, + // matching the logic in -verticalLayout + UIInterfaceOrientation orientation = UIInterfaceOrientationPortrait; +#if !TARGET_OS_MACCATALYST + // >= so a square size agrees with -verticalLayout (width < height) + if (size.width >= size.height) { + orientation = UIInterfaceOrientationLandscapeLeft; + } +#endif +#else + // On visionOS, this method is called on presentation with size=(0,0), + // which would set orientation incorrectly causing views to be misplaced. + UIInterfaceOrientation orientation = UIInterfaceOrientationLandscapeLeft; +#endif + + [self _willRotateToInterfaceOrientation:orientation + duration:coordinator.transitionDuration]; + [coordinator + animateAlongsideTransition:^(id context) { + [self _willAnimateRotationToInterfaceOrientation:orientation duration:coordinator.transitionDuration]; + } + completion:^(id context) { + [self _didRotateFromInterfaceOrientation:orientation]; + }]; +} + +#pragma mark - Reset - +- (void)resetCropViewLayout { + BOOL animated = (self.cropView.angle == 0); + + if (self.resetAspectRatioEnabled) { + self.aspectRatioLockEnabled = NO; + } + + [self.cropView resetLayoutToDefaultAnimated:animated]; +} + +#pragma mark - Aspect Ratio Handling - +- (void)showAspectRatioDialog { + if (self.cropView.aspectRatioLockEnabled) { + self.cropView.aspectRatioLockEnabled = NO; + self.toolbar.clampButtonGlowing = NO; + return; + } + + // Depending on the shape of the image, work out if horizontal, or vertical options are required + BOOL verticalCropBox = self.cropView.cropBoxAspectRatioIsPortrait; + + // Get the resource bundle depending on the framework/dependency manager we're using + NSBundle *resourceBundle = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(self); + + // Prepare the localized options + NSString *cancelButtonTitle = NSLocalizedStringFromTableInBundle(@"Cancel", @"TOCropViewControllerLocalizable", resourceBundle, nil); + + // Prepare the list that will be fed to the alert view/controller + + NSArray *presets; + + if (self.allowedAspectRatios == nil) { + presets = verticalCropBox ? [TOCropViewControllerAspectRatioPreset portraitPresets] : [TOCropViewControllerAspectRatioPreset landscapePresets]; + } else { + presets = self.allowedAspectRatios; + } + + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + [alertController addAction:[UIAlertAction actionWithTitle:cancelButtonTitle style:UIAlertActionStyleCancel handler:nil]]; + + // Add each item to the alert controller + for (NSInteger i = 0; i < presets.count; i++) { + id handlerBlock = ^(UIAlertAction *action) { + [self setAspectRatioPreset:presets[i].size animated:YES]; + self.aspectRatioLockEnabled = YES; + }; + UIAlertAction *action = [UIAlertAction actionWithTitle:presets[i].title style:UIAlertActionStyleDefault handler:handlerBlock]; + [alertController addAction:action]; + } + + alertController.modalPresentationStyle = UIModalPresentationPopover; + UIPopoverPresentationController *presentationController = [alertController popoverPresentationController]; + presentationController.sourceView = self.toolbar; + presentationController.sourceRect = self.toolbar.clampButtonFrame; + [self presentViewController:alertController animated:YES completion:nil]; +} + +- (void)setAspectRatioPreset:(CGSize)aspectRatioPreset animated:(BOOL)animated { + _aspectRatioPreset = aspectRatioPreset; + [self.cropView setAspectRatio:aspectRatioPreset animated:animated]; +} + +- (void)rotateCropViewClockwise { + self.toolbar.disableRotationButtons = YES; + [self.cropView rotateImageNinetyDegreesAnimated:YES + clockwise:YES + completion:^(BOOL success) { + self.toolbar.disableRotationButtons = NO; + }]; +} + +- (void)rotateCropViewCounterclockwise { + self.toolbar.disableRotationButtons = YES; + [self.cropView rotateImageNinetyDegreesAnimated:YES + clockwise:NO + completion:^(BOOL success) { + self.toolbar.disableRotationButtons = NO; + }]; +} + +#pragma mark - Crop View Delegates - +- (void)cropViewDidBecomeResettable:(TOCropView *)cropView { + self.toolbar.resetButtonEnabled = YES; +} + +- (void)cropViewDidBecomeNonResettable:(TOCropView *)cropView { + self.toolbar.resetButtonEnabled = NO; +} + +#pragma mark - Presentation Handling - +- (void)presentAnimatedFromParentViewController:(UIViewController *)viewController + fromView:(UIView *)fromView + fromFrame:(CGRect)fromFrame + setup:(void (^)(void))setup + completion:(void (^)(void))completion { + [self presentAnimatedFromParentViewController:viewController + fromImage:nil + fromView:fromView + fromFrame:fromFrame + angle:0 + toImageFrame:CGRectZero + setup:setup + completion:completion]; +} + +- (void)presentAnimatedFromParentViewController:(UIViewController *)viewController + fromImage:(UIImage *)image + fromView:(UIView *)fromView + fromFrame:(CGRect)fromFrame + angle:(NSInteger)angle + toImageFrame:(CGRect)toFrame + setup:(void (^)(void))setup + completion:(void (^)(void))completion { + self.transitionController.image = image ? image : self.image; + self.transitionController.fromFrame = fromFrame; + self.transitionController.fromView = fromView; + self.prepareForTransitionHandler = setup; + + if (angle != 0 || !CGRectIsEmpty(toFrame)) { + self.angle = angle; + self.imageCropFrame = toFrame; + } + + __weak typeof(self) weakSelf = self; + [viewController presentViewController:self.parentViewController ? self.parentViewController : self + animated:YES + completion:^{ + typeof(self) strongSelf = weakSelf; + if (completion) { + completion(); + } + + [strongSelf.cropView setCroppingViewsHidden:NO animated:YES]; + if (!CGRectIsEmpty(fromFrame)) { + [strongSelf.cropView setGridOverlayHidden:NO animated:YES]; + } + }]; +} + +- (void)dismissAnimatedFromParentViewController:(UIViewController *)viewController + toView:(UIView *)toView + toFrame:(CGRect)frame + setup:(void (^)(void))setup + completion:(void (^)(void))completion { + [self dismissAnimatedFromParentViewController:viewController withCroppedImage:nil toView:toView toFrame:frame setup:setup completion:completion]; +} + +- (void)dismissAnimatedFromParentViewController:(UIViewController *)viewController + withCroppedImage:(UIImage *)image + toView:(UIView *)toView + toFrame:(CGRect)frame + setup:(void (^)(void))setup + completion:(void (^)(void))completion { + // If a cropped image was supplied, use that, and only zoom out from the crop box + if (image) { + self.transitionController.image = image ? image : self.image; + self.transitionController.fromFrame = [self.cropView convertRect:self.cropView.cropBoxFrame toView:self.view]; + } else { // else use the main image, and zoom out from its entirety + self.transitionController.image = self.image; + self.transitionController.fromFrame = [self.cropView convertRect:self.cropView.imageViewFrame toView:self.view]; + } + + self.transitionController.toView = toView; + self.transitionController.toFrame = frame; + self.prepareForTransitionHandler = setup; + + [viewController dismissViewControllerAnimated:YES + completion:^{ + if (completion) { + completion(); + } + }]; +} + +- (id)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source { + if (self.navigationController || self.modalTransitionStyle == UIModalTransitionStyleCoverVertical) { + return nil; + } + + self.cropView.simpleRenderMode = YES; + + __weak typeof(self) weakSelf = self; + self.transitionController.prepareForTransitionHandler = ^{ + typeof(self) strongSelf = weakSelf; + TOCropViewControllerTransitioning *transitioning = strongSelf.transitionController; + + transitioning.toFrame = [strongSelf.cropView convertRect:strongSelf.cropView.cropBoxFrame toView:strongSelf.view]; + if (!CGRectIsEmpty(transitioning.fromFrame) || transitioning.fromView) { + strongSelf.cropView.croppingViewsHidden = YES; + } + + if (strongSelf.prepareForTransitionHandler) { + strongSelf.prepareForTransitionHandler(); + } + + strongSelf.prepareForTransitionHandler = nil; + }; + + self.transitionController.isDismissing = NO; + return self.transitionController; +} + +- (id)animationControllerForDismissedController:(UIViewController *)dismissed { + if (self.navigationController || self.modalTransitionStyle == UIModalTransitionStyleCoverVertical) { + return nil; + } + + __weak typeof(self) weakSelf = self; + self.transitionController.prepareForTransitionHandler = ^{ + typeof(self) strongSelf = weakSelf; + TOCropViewControllerTransitioning *transitioning = strongSelf.transitionController; + + if (!CGRectIsEmpty(transitioning.toFrame) || transitioning.toView) { + strongSelf.cropView.croppingViewsHidden = YES; + } else { + strongSelf.cropView.simpleRenderMode = YES; + } + + if (strongSelf.prepareForTransitionHandler) { + strongSelf.prepareForTransitionHandler(); + } + }; + + self.transitionController.isDismissing = YES; + return self.transitionController; +} + +#pragma mark - Button Feedback - +- (void)cancelButtonTapped { + if (!self.showCancelConfirmationDialog) { + [self dismissCropViewController]; + return; + } + + // Get the resource bundle depending on the framework/dependency manager we're using + NSBundle *resourceBundle = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(self); + + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:nil + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + alertController.popoverPresentationController.sourceView = self.toolbar.visibleCancelButton; + + NSString *yesButtonTitle = NSLocalizedStringFromTableInBundle(@"Delete Changes", @"TOCropViewControllerLocalizable", resourceBundle, nil); + NSString *noButtonTitle = NSLocalizedStringFromTableInBundle(@"Cancel", @"TOCropViewControllerLocalizable", resourceBundle, nil); + + __weak typeof(self) weakSelf = self; + UIAlertAction *yesAction = [UIAlertAction actionWithTitle:yesButtonTitle + style:UIAlertActionStyleDestructive + handler:^(UIAlertAction *action) { + [weakSelf dismissCropViewController]; + }]; + [alertController addAction:yesAction]; + + UIAlertAction *noAction = [UIAlertAction actionWithTitle:noButtonTitle style:UIAlertActionStyleCancel handler:nil]; + [alertController addAction:noAction]; + + [weakSelf presentViewController:alertController animated:YES completion:nil]; +} + +- (void)dismissCropViewController { + bool isDelegateOrCallbackHandled = NO; + + // Check if the delegate method was implemented and call if so + if ([self.delegate respondsToSelector:@selector(cropViewController:didFinishCancelled:)]) { + [self.delegate cropViewController:self didFinishCancelled:YES]; + isDelegateOrCallbackHandled = YES; + } + + // Check if the block version was implemented and call if so + if (self.onDidFinishCancelled != nil) { + self.onDidFinishCancelled(YES); + isDelegateOrCallbackHandled = YES; + } + + // If neither callbacks were implemented, perform a default dismissing animation + if (!isDelegateOrCallbackHandled) { + if (self.navigationController && self.navigationController.viewControllers.count > 1) { + [self.navigationController popViewControllerAnimated:YES]; + } else { + [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; + } + } +} + +- (void)doneButtonTapped { + CGRect cropFrame = self.cropView.imageCropFrame; + NSInteger angle = self.cropView.angle; + + if (self.onDidTapDone) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.onDidTapDone(); + }); + } + + // If desired, when the user taps done, show an activity sheet + if (self.showActivitySheetOnDone) { + TOActivityCroppedImageProvider *imageItem = [[TOActivityCroppedImageProvider alloc] initWithImage:self.image cropFrame:cropFrame angle:angle circular:(self.croppingStyle == TOCropViewCroppingStyleCircular)]; + TOCroppedImageAttributes *attributes = [[TOCroppedImageAttributes alloc] initWithCroppedFrame:cropFrame angle:angle originalImageSize:self.image.size]; + + NSMutableArray *activityItems = [@[imageItem, attributes] mutableCopy]; + if (self.activityItems) { + [activityItems addObjectsFromArray:self.activityItems]; + } + + UIActivityViewController *activityController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:self.applicationActivities]; + activityController.excludedActivityTypes = self.excludedActivityTypes; + + activityController.modalPresentationStyle = UIModalPresentationPopover; + activityController.popoverPresentationController.sourceView = self.toolbar; + activityController.popoverPresentationController.sourceRect = self.toolbar.doneButtonFrame; + [self presentViewController:activityController animated:YES completion:nil]; + + __weak typeof(activityController) blockController = activityController; + + activityController.completionWithItemsHandler = ^(NSString *activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) { + if (!completed) { + return; + } + + bool isCallbackOrDelegateHandled = NO; + + if (self.onDidFinishCancelled != nil) { + self.onDidFinishCancelled(NO); + isCallbackOrDelegateHandled = YES; + } + if ([self.delegate respondsToSelector:@selector(cropViewController:didFinishCancelled:)]) { + [self.delegate cropViewController:self didFinishCancelled:NO]; + isCallbackOrDelegateHandled = YES; + } + + if (!isCallbackOrDelegateHandled) { + if (self.navigationController != nil && self.navigationController.viewControllers.count > 1) { + [self.navigationController popViewControllerAnimated:YES]; + } else { + [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; + blockController.completionWithItemsHandler = nil; + } + } + }; + + return; + } else { + // Disable both variants; only the icon button exists on iOS 26, + // and it's also the visible one in landscape/icon-only mode + [self setDoneButtonsEnabled:NO]; + } + + BOOL isCallbackOrDelegateHandled = NO; + BOOL willRestoreDoneButtonsAfterCallback = NO; + + // If the delegate/block that only supplies crop data is provided, call it + if ([self.delegate respondsToSelector:@selector(cropViewController:didCropImageToRect:angle:)]) { + [self.delegate cropViewController:self didCropImageToRect:cropFrame angle:angle]; + isCallbackOrDelegateHandled = YES; + } + + if (self.onDidCropImageToRect != nil) { + self.onDidCropImageToRect(cropFrame, angle); + isCallbackOrDelegateHandled = YES; + } + + // Check if the circular APIs were implemented + BOOL isCircularImageDelegateAvailable = [self.delegate respondsToSelector:@selector(cropViewController:didCropToCircularImage:withRect:angle:)]; + BOOL isCircularImageCallbackAvailable = self.onDidCropToCircleImage != nil; + + // Check if non-circular was implemented + BOOL isDidCropToImageDelegateAvailable = [self.delegate respondsToSelector:@selector(cropViewController:didCropToImage:withRect:angle:)]; + BOOL isDidCropToImageCallbackAvailable = self.onDidCropToRect != nil; + + // If cropping circular and the circular generation delegate/block is implemented, call it + if (self.croppingStyle == TOCropViewCroppingStyleCircular && (isCircularImageDelegateAvailable || isCircularImageCallbackAvailable)) { + UIImage *image = [self.image croppedImageWithFrame:cropFrame angle:angle circularClip:YES]; + + // Dispatch on the next run-loop so the animation isn't interuppted by the crop operation + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.03f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (isCircularImageDelegateAvailable) { + [self.delegate cropViewController:self didCropToCircularImage:image withRect:cropFrame angle:angle]; + } + if (isCircularImageCallbackAvailable) { + self.onDidCropToCircleImage(image, cropFrame, angle); + } + + // Let hosts that keep the controller on screen commit again + [self setDoneButtonsEnabled:YES]; + }); + + isCallbackOrDelegateHandled = YES; + willRestoreDoneButtonsAfterCallback = YES; + } + // If the delegate/block that requires the specific cropped image is provided, call it + else if (isDidCropToImageDelegateAvailable || isDidCropToImageCallbackAvailable) { + UIImage *image = nil; + if (angle == 0 && CGRectEqualToRect(cropFrame, (CGRect){CGPointZero, self.image.size})) { + image = self.image; + } else { + image = [self.image croppedImageWithFrame:cropFrame angle:angle circularClip:NO]; + } + + // Dispatch on the next run-loop so the animation isn't interuppted by the crop operation + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.03f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (isDidCropToImageDelegateAvailable) { + [self.delegate cropViewController:self didCropToImage:image withRect:cropFrame angle:angle]; + } + + if (isDidCropToImageCallbackAvailable) { + self.onDidCropToRect(image, cropFrame, angle); + } + + // Let hosts that keep the controller on screen commit again + [self setDoneButtonsEnabled:YES]; + }); + + isCallbackOrDelegateHandled = YES; + willRestoreDoneButtonsAfterCallback = YES; + } + + if (!isCallbackOrDelegateHandled) { + [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; + } else if (!willRestoreDoneButtonsAfterCallback) { + // Only the synchronous crop-data callbacks ran; re-enable immediately + [self setDoneButtonsEnabled:YES]; + } +} + +- (void)setDoneButtonsEnabled:(BOOL)enabled { + self.toolbar.doneTextButton.enabled = enabled; + self.toolbar.doneIconButton.enabled = enabled; +} + +- (void)commitCurrentCrop { + [self doneButtonTapped]; +} + +#pragma mark - Property Methods - + +- (void)setTitle:(NSString *)title { + [super setTitle:title]; + + if (self.title.length == 0) { + [_titleLabel removeFromSuperview]; + _cropView.cropRegionInsets = UIEdgeInsetsMake(0, 0, 0, 0); + _titleLabel = nil; + return; + } + + self.titleLabel.text = self.title; + [self.titleLabel sizeToFit]; + self.titleLabel.frame = [self frameForTitleLabelWithSize:self.titleLabel.frame.size verticalLayout:self.verticalLayout]; +} + +- (void)setDoneButtonTitle:(NSString *)title { + self.toolbar.doneTextButtonTitle = title; +} + +- (NSString *)doneButtonTitle { + return self.toolbar.doneTextButtonTitle; +} + +- (void)setCancelButtonTitle:(NSString *)title { + self.toolbar.cancelTextButtonTitle = title; +} + +- (NSString *)cancelButtonTitle { + return self.toolbar.cancelTextButtonTitle; +} + +- (void)setShowOnlyIcons:(BOOL)showOnlyIcons { + self.toolbar.showOnlyIcons = showOnlyIcons; +} + +- (BOOL)showOnlyIcons { + return self.toolbar.showOnlyIcons; +} + +- (void)setDoneButtonColor:(UIColor *)color { + self.toolbar.doneButtonColor = color; +} + +- (UIColor *)doneButtonColor { + return self.toolbar.doneButtonColor; +} + +- (void)setCancelButtonColor:(UIColor *)color { + self.toolbar.cancelButtonColor = color; +} + +- (UIColor *)cancelButtonColor { + return self.toolbar.cancelButtonColor; +} + +- (TOCropView *)cropView { + // Lazily create the crop view in case we try and access it before presentation, but + // don't add it until our parent view controller view has loaded at the right time + if (!_cropView) { + _cropView = [[TOCropView alloc] initWithCroppingStyle:self.croppingStyle image:self.image]; + _cropView.delegate = self; + _cropView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [self.view insertSubview:_cropView atIndex:0]; + } + return _cropView; +} + +- (TOCropToolbar *)toolbar { + if (!_toolbar) { + _toolbar = [[TOCropToolbar alloc] initWithFrame:CGRectZero]; + [self.view addSubview:_toolbar]; + } + return _toolbar; +} + +- (UILabel *)titleLabel { + if (!self.title.length) { + return nil; + } + if (_titleLabel) { + return _titleLabel; + } + + _titleLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + _titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + _titleLabel.backgroundColor = [UIColor clearColor]; + _titleLabel.textColor = [UIColor whiteColor]; + _titleLabel.numberOfLines = 0; + _titleLabel.baselineAdjustment = UIBaselineAdjustmentAlignBaselines; + _titleLabel.clipsToBounds = YES; + _titleLabel.textAlignment = NSTextAlignmentCenter; + _titleLabel.text = self.title; + + [self.view insertSubview:self.titleLabel aboveSubview:self.toolbar]; + + return _titleLabel; +} + +- (void)setAspectRatioLockEnabled:(BOOL)aspectRatioLockEnabled { + self.toolbar.clampButtonGlowing = aspectRatioLockEnabled; + self.cropView.aspectRatioLockEnabled = aspectRatioLockEnabled; + if (!self.aspectRatioPickerButtonHidden) { + self.aspectRatioPickerButtonHidden = (aspectRatioLockEnabled && self.resetAspectRatioEnabled == NO); + } +} + +- (void)setAspectRatioLockDimensionSwapEnabled:(BOOL)aspectRatioLockDimensionSwapEnabled { + _aspectRatioLockDimensionSwapEnabled = aspectRatioLockDimensionSwapEnabled; + self.cropView.aspectRatioLockDimensionSwapEnabled = aspectRatioLockDimensionSwapEnabled; +} + +- (BOOL)aspectRatioLockEnabled { + return self.cropView.aspectRatioLockEnabled; +} + +- (void)setRotateButtonsHidden:(BOOL)rotateButtonsHidden { + self.toolbar.rotateCounterclockwiseButtonHidden = rotateButtonsHidden; + self.toolbar.rotateClockwiseButtonHidden = rotateButtonsHidden; +} + +- (void)setResetButtonHidden:(BOOL)resetButtonHidden { + self.toolbar.resetButtonHidden = resetButtonHidden; +} + +- (BOOL)resetButtonHidden { + return self.toolbar.resetButtonHidden; +} + +- (BOOL)rotateButtonsHidden { + return self.toolbar.rotateCounterclockwiseButtonHidden && self.toolbar.rotateClockwiseButtonHidden; +} + +- (void)setRotateClockwiseButtonHidden:(BOOL)rotateClockwiseButtonHidden { + self.toolbar.rotateClockwiseButtonHidden = rotateClockwiseButtonHidden; +} + +- (BOOL)rotateClockwiseButtonHidden { + return self.toolbar.rotateClockwiseButtonHidden; +} + +- (void)setAspectRatioPickerButtonHidden:(BOOL)aspectRatioPickerButtonHidden { + self.toolbar.clampButtonHidden = aspectRatioPickerButtonHidden; +} + +- (BOOL)aspectRatioPickerButtonHidden { + return self.toolbar.clampButtonHidden; +} + +- (void)setDoneButtonHidden:(BOOL)doneButtonHidden { + self.toolbar.doneButtonHidden = doneButtonHidden; +} + +- (BOOL)doneButtonHidden { + return self.toolbar.doneButtonHidden; +} + +- (void)setCancelButtonHidden:(BOOL)cancelButtonHidden { + self.toolbar.cancelButtonHidden = cancelButtonHidden; +} + +- (BOOL)cancelButtonHidden { + return self.toolbar.cancelButtonHidden; +} + +- (BOOL)reverseContentLayout { + return self.toolbar.reverseContentLayout; +} +- (void)setReverseContentLayout:(BOOL)reverseContentLayout { + self.toolbar.reverseContentLayout = reverseContentLayout; +} + +- (void)setResetAspectRatioEnabled:(BOOL)resetAspectRatioEnabled { + self.cropView.resetAspectRatioEnabled = resetAspectRatioEnabled; + if (!self.aspectRatioPickerButtonHidden) { + self.aspectRatioPickerButtonHidden = (resetAspectRatioEnabled == NO && self.aspectRatioLockEnabled); + } +} + +- (BOOL)resetAspectRatioEnabled { + return self.cropView.resetAspectRatioEnabled; +} + +- (void)setAngle:(NSInteger)angle { + self.cropView.angle = angle; +} + +- (NSInteger)angle { + return self.cropView.angle; +} + +- (void)setImageCropFrame:(CGRect)imageCropFrame { + self.cropView.imageCropFrame = imageCropFrame; +} + +- (CGRect)imageCropFrame { + return self.cropView.imageCropFrame; +} + +- (BOOL)verticalLayout { +#if TARGET_OS_MACCATALYST + return YES; +#endif + + return CGRectGetWidth(self.view.bounds) < CGRectGetHeight(self.view.bounds); +} + +- (BOOL)overrideStatusBar { + // If we're pushed from a navigation controller, we'll defer + // to its handling of the status bar + if (self.navigationController) { + return NO; + } + + // If the view controller presenting us already hid it, we don't need to + // do anything ourselves + if (self.presentingViewController.prefersStatusBarHidden) { + return NO; + } + + // We'll handle the status bar + return YES; +} + +- (BOOL)statusBarHidden { + // Defer behaviour to the hosting navigation controller + if (self.navigationController) { + return self.navigationController.prefersStatusBarHidden; + } + + // If our presenting controller has already hidden the status bar, + // hide the status bar by default + if (self.presentingViewController.prefersStatusBarHidden) { + return YES; + } + + // Our default behaviour is to always hide the status bar + return YES; +} + +- (CGFloat)statusBarHeight { + CGFloat statusBarHeight = 0.0f; + statusBarHeight = self.view.safeAreaInsets.top; + + // We do need to include the status bar height on devices + // that have a physical hardware inset, like an iPhone X notch + BOOL hardwareRelatedInset = self.view.safeAreaInsets.bottom > FLT_EPSILON && UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone; + +// Always have insetting on Mac Catalyst +#if TARGET_OS_MACCATALYST + hardwareRelatedInset = YES; +#endif + + // Unless the status bar is visible, or we need to account + // for a hardware notch, always treat the status bar height as zero + if (self.statusBarHidden && !hardwareRelatedInset) { + statusBarHeight = 0.0f; + } + + return statusBarHeight; +} + +- (UIEdgeInsets)statusBarSafeInsets { + UIEdgeInsets insets = UIEdgeInsetsZero; + insets = self.view.safeAreaInsets; + insets.top = self.statusBarHeight; + return insets; +} + +- (void)setMinimumAspectRatio:(CGFloat)minimumAspectRatio { + self.cropView.minimumAspectRatio = minimumAspectRatio; +} + +- (CGFloat)minimumAspectRatio { + return self.cropView.minimumAspectRatio; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h new file mode 100644 index 000000000..bbd2c4daf --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h @@ -0,0 +1,43 @@ +// +// TOCropOverlayView.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface TOCropOverlayView : UIView + +/** Hides the interior grid lines, sans animation. */ +@property (nonatomic, assign) BOOL gridHidden; + +/** Add/Remove the interior horizontal grid lines. */ +@property (nonatomic, assign) BOOL displayHorizontalGridLines; + +/** Add/Remove the interior vertical grid lines. */ +@property (nonatomic, assign) BOOL displayVerticalGridLines; + +/** Shows and hides the interior grid lines with an optional crossfade animation. */ +- (void)setGridHidden:(BOOL)hidden animated:(BOOL)animated; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m new file mode 100644 index 000000000..3388e9b42 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m @@ -0,0 +1,240 @@ +// +// TOCropOverlayView.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCropOverlayView.h" + +static const CGFloat kTOCropOverLayerCornerWidth = 20.0f; + +@interface TOCropOverlayView () + +@property (nonatomic, strong) NSArray *horizontalGridLines; +@property (nonatomic, strong) NSArray *verticalGridLines; + +@property (nonatomic, strong) NSArray *outerLineViews; // top, right, bottom, left + +@property (nonatomic, strong) NSArray *topLeftLineViews; // vertical, horizontal +@property (nonatomic, strong) NSArray *bottomLeftLineViews; +@property (nonatomic, strong) NSArray *bottomRightLineViews; +@property (nonatomic, strong) NSArray *topRightLineViews; + +@end + +@implementation TOCropOverlayView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.clipsToBounds = NO; + [self setup]; + } + + return self; +} + +- (void)setup { + UIView * (^newLineView)(void) = ^UIView *(void) { + return [self createNewLineView]; + }; + + _outerLineViews = @[newLineView(), newLineView(), newLineView(), newLineView()]; + + _topLeftLineViews = @[newLineView(), newLineView()]; + _bottomLeftLineViews = @[newLineView(), newLineView()]; + _topRightLineViews = @[newLineView(), newLineView()]; + _bottomRightLineViews = @[newLineView(), newLineView()]; + + self.displayHorizontalGridLines = YES; + self.displayVerticalGridLines = YES; +} + +- (void)setFrame:(CGRect)frame { + [super setFrame:frame]; + if (_outerLineViews) { + [self layoutLines]; + } +} + +- (void)didMoveToSuperview { + [super didMoveToSuperview]; + if (_outerLineViews) { + [self layoutLines]; + } +} + +- (void)layoutLines { + CGSize boundsSize = self.bounds.size; + + // border lines + for (NSInteger i = 0; i < 4; i++) { + UIView *lineView = self.outerLineViews[i]; + + CGRect frame = CGRectZero; + switch (i) { + case 0: + frame = (CGRect){-1.0f, -1.0f, boundsSize.width + 2.0f, 1.0f}; + break; // top + case 1: + frame = (CGRect){boundsSize.width, 0.0f, 1.0f, boundsSize.height}; + break; // right + case 2: + frame = (CGRect){-1.0f, boundsSize.height, boundsSize.width + 2.0f, 1.0f}; + break; // bottom + case 3: + frame = (CGRect){-1.0f, 0, 1.0f, boundsSize.height + 1.0f}; + break; // left + } + + lineView.frame = frame; + } + + // corner liness + NSArray *cornerLines = @[self.topLeftLineViews, self.topRightLineViews, self.bottomRightLineViews, self.bottomLeftLineViews]; + for (NSInteger i = 0; i < 4; i++) { + NSArray *cornerLine = cornerLines[i]; + + CGRect verticalFrame = CGRectZero, horizontalFrame = CGRectZero; + switch (i) { + case 0: // top left + verticalFrame = (CGRect){-3.0f, -3.0f, 3.0f, kTOCropOverLayerCornerWidth + 3.0f}; + horizontalFrame = (CGRect){0, -3.0f, kTOCropOverLayerCornerWidth, 3.0f}; + break; + case 1: // top right + verticalFrame = (CGRect){boundsSize.width, -3.0f, 3.0f, kTOCropOverLayerCornerWidth + 3.0f}; + horizontalFrame = (CGRect){boundsSize.width - kTOCropOverLayerCornerWidth, -3.0f, kTOCropOverLayerCornerWidth, 3.0f}; + break; + case 2: // bottom right + verticalFrame = (CGRect){boundsSize.width, boundsSize.height - kTOCropOverLayerCornerWidth, 3.0f, kTOCropOverLayerCornerWidth + 3.0f}; + horizontalFrame = (CGRect){boundsSize.width - kTOCropOverLayerCornerWidth, boundsSize.height, kTOCropOverLayerCornerWidth, 3.0f}; + break; + case 3: // bottom left + verticalFrame = (CGRect){-3.0f, boundsSize.height - kTOCropOverLayerCornerWidth, 3.0f, kTOCropOverLayerCornerWidth}; + horizontalFrame = (CGRect){-3.0f, boundsSize.height, kTOCropOverLayerCornerWidth + 3.0f, 3.0f}; + break; + } + + [cornerLine[0] setFrame:verticalFrame]; + [cornerLine[1] setFrame:horizontalFrame]; + } + + // grid lines - horizontal + // (displayScale can be 0 before the view joins a window) + CGFloat thickness = 1.0f / MAX(1.0f, self.traitCollection.displayScale); + NSInteger numberOfLines = self.horizontalGridLines.count; + CGFloat padding = (CGRectGetHeight(self.bounds) - (thickness * numberOfLines)) / (numberOfLines + 1); + for (NSInteger i = 0; i < numberOfLines; i++) { + UIView *lineView = self.horizontalGridLines[i]; + CGRect frame = CGRectZero; + frame.size.height = thickness; + frame.size.width = CGRectGetWidth(self.bounds); + frame.origin.y = (padding * (i + 1)) + (thickness * i); + lineView.frame = frame; + } + + // grid lines - vertical + numberOfLines = self.verticalGridLines.count; + padding = (CGRectGetWidth(self.bounds) - (thickness * numberOfLines)) / (numberOfLines + 1); + for (NSInteger i = 0; i < numberOfLines; i++) { + UIView *lineView = self.verticalGridLines[i]; + CGRect frame = CGRectZero; + frame.size.width = thickness; + frame.size.height = CGRectGetHeight(self.bounds); + frame.origin.x = (padding * (i + 1)) + (thickness * i); + lineView.frame = frame; + } +} + +- (void)setGridHidden:(BOOL)hidden animated:(BOOL)animated { + _gridHidden = hidden; + + if (animated == NO) { + for (UIView *lineView in self.horizontalGridLines) { + lineView.alpha = hidden ? 0.0f : 1.0f; + } + + for (UIView *lineView in self.verticalGridLines) { + lineView.alpha = hidden ? 0.0f : 1.0f; + } + + return; + } + + [UIView animateWithDuration:hidden ? 0.35f : 0.2f + animations:^{ + for (UIView *lineView in self.horizontalGridLines) + lineView.alpha = hidden ? 0.0f : 1.0f; + + for (UIView *lineView in self.verticalGridLines) + lineView.alpha = hidden ? 0.0f : 1.0f; + }]; +} + +#pragma mark - Property methods + +- (void)setDisplayHorizontalGridLines:(BOOL)displayHorizontalGridLines { + _displayHorizontalGridLines = displayHorizontalGridLines; + + [self.horizontalGridLines enumerateObjectsUsingBlock:^(UIView *__nonnull lineView, NSUInteger idx, BOOL *__nonnull stop) { + [lineView removeFromSuperview]; + }]; + + if (_displayHorizontalGridLines) { + self.horizontalGridLines = @[[self createNewLineView], [self createNewLineView]]; + } else { + self.horizontalGridLines = @[]; + } + + // Re-apply the current visibility to the rebuilt lines and lay them out + [self setGridHidden:_gridHidden animated:NO]; + [self layoutLines]; +} + +- (void)setDisplayVerticalGridLines:(BOOL)displayVerticalGridLines { + _displayVerticalGridLines = displayVerticalGridLines; + + [self.verticalGridLines enumerateObjectsUsingBlock:^(UIView *__nonnull lineView, NSUInteger idx, BOOL *__nonnull stop) { + [lineView removeFromSuperview]; + }]; + + if (_displayVerticalGridLines) { + self.verticalGridLines = @[[self createNewLineView], [self createNewLineView]]; + } else { + self.verticalGridLines = @[]; + } + + // Re-apply the current visibility to the rebuilt lines and lay them out + [self setGridHidden:_gridHidden animated:NO]; + [self layoutLines]; +} + +- (void)setGridHidden:(BOOL)gridHidden { + [self setGridHidden:gridHidden animated:NO]; +} + +#pragma mark - Private methods + +- (nonnull UIView *)createNewLineView { + UIView *newLine = [[UIView alloc] initWithFrame:CGRectZero]; + newLine.backgroundColor = [UIColor whiteColor]; + [self addSubview:newLine]; + return newLine; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.h new file mode 100644 index 000000000..b8a3a6ad5 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.h @@ -0,0 +1,39 @@ +// +// TOCropScrollView +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +NS_ASSUME_NONNULL_BEGIN + +/* + Subclassing UIScrollView was necessary in order to directly capture + touch events that weren't otherwise accessible via UIGestureRecognizer objects. + */ +@interface TOCropScrollView : UIScrollView + +@property (nullable, nonatomic, copy) void (^touchesBegan)(void); +@property (nullable, nonatomic, copy) void (^touchesCancelled)(void); +@property (nullable, nonatomic, copy) void (^touchesEnded)(void); + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.m new file mode 100644 index 000000000..d90a06ed6 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropScrollView.m @@ -0,0 +1,48 @@ +// +// TOCropScrollView +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCropScrollView.h" + +@implementation TOCropScrollView + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + if (self.touchesBegan) + self.touchesBegan(); + + [super touchesBegan:touches withEvent:event]; +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + if (self.touchesEnded) + self.touchesEnded(); + + [super touchesEnded:touches withEvent:event]; +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { + if (self.touchesCancelled) + self.touchesCancelled(); + + [super touchesCancelled:touches withEvent:event]; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.h new file mode 100644 index 000000000..408f22d61 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.h @@ -0,0 +1,104 @@ +// +// TOCropToolbar.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +#if !__has_include() +#import "TOCropViewConstants.h" +#else +#import +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface TOCropToolbar : UIView + +/* In horizontal mode, offsets all of the buttons vertically by height of status bar. */ +@property (nonatomic, assign) CGFloat statusBarHeightInset; + +/* Set an inset that will expand the background view beyond the bounds. */ +@property (nonatomic, assign) UIEdgeInsets backgroundViewOutsets; + +/* The 'Done' buttons to commit the crop. The text button is displayed + in portrait mode and the icon one, in landscape. + The text button is nil on iOS 26 and up, where the toolbar is always icon-only. */ +@property (nullable, nonatomic, strong, readonly) UIButton *doneTextButton; +@property (nonatomic, strong, readonly) UIButton *doneIconButton; +@property (nonatomic, copy) NSString *doneTextButtonTitle; +@property (null_resettable, nonatomic, copy) UIColor *doneButtonColor; + +/* The 'Cancel' buttons to cancel the crop. The text button is displayed + in portrait mode and the icon one, in landscape. + The text button is nil on iOS 26 and up, where the toolbar is always icon-only. */ +@property (nullable, nonatomic, strong, readonly) UIButton *cancelTextButton; +@property (nonatomic, strong, readonly) UIButton *cancelIconButton; +@property (nonatomic, readonly) UIView *visibleCancelButton; +@property (nonatomic, copy) NSString *cancelTextButtonTitle; +@property (nullable, nonatomic, copy) UIColor *cancelButtonColor; + +/* Show the tick and cross buttons instead of 'Done' and 'Cancel'. + Always YES, and not settable, on iOS 26 and up. */ +@property (nonatomic, assign) BOOL showOnlyIcons API_DEPRECATED("iOS 26 uses icons only", ios(7.0, 18.0)); + +/* The cropper control buttons */ +@property (nonatomic, strong, readonly) UIButton *rotateCounterclockwiseButton; +@property (nonatomic, strong, readonly) UIButton *resetButton; +@property (nonatomic, strong, readonly) UIButton *clampButton; +@property (nullable, nonatomic, strong, readonly) UIButton *rotateClockwiseButton; + +/* Set the rotation buttons to be disabled while rotating is in progress */ +@property (nonatomic, assign) BOOL disableRotationButtons; + +@property (nonatomic, readonly) UIButton *rotateButton; // Points to `rotateCounterClockwiseButton` + +/* Button feedback handler blocks */ +@property (nullable, nonatomic, copy) void (^cancelButtonTapped)(void); +@property (nullable, nonatomic, copy) void (^doneButtonTapped)(void); +@property (nullable, nonatomic, copy) void (^rotateCounterclockwiseButtonTapped)(void); +@property (nullable, nonatomic, copy) void (^rotateClockwiseButtonTapped)(void); +@property (nullable, nonatomic, copy) void (^clampButtonTapped)(void); +@property (nullable, nonatomic, copy) void (^resetButtonTapped)(void); + +/* State management for the 'clamp' button */ +@property (nonatomic, assign) BOOL clampButtonGlowing; +@property (nonatomic, readonly) CGRect clampButtonFrame; + +/* Aspect ratio button visibility settings */ +@property (nonatomic, assign) BOOL clampButtonHidden; +@property (nonatomic, assign) BOOL rotateCounterclockwiseButtonHidden; +@property (nonatomic, assign) BOOL rotateClockwiseButtonHidden; +@property (nonatomic, assign) BOOL resetButtonHidden; +@property (nonatomic, assign) BOOL doneButtonHidden; +@property (nonatomic, assign) BOOL cancelButtonHidden; + +/* For languages like Arabic where they natively present content flipped from English */ +@property (nonatomic, assign) BOOL reverseContentLayout; + +/* Enable the reset button */ +@property (nonatomic, assign) BOOL resetButtonEnabled; + +/* Done button frame for popover controllers */ +@property (nonatomic, readonly) CGRect doneButtonFrame; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.m new file mode 100644 index 000000000..d500619d3 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropToolbar.m @@ -0,0 +1,783 @@ +// +// TOCropToolbar.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCropToolbar.h" + +#define TOCROPTOOLBAR_DEBUG_SHOWING_BUTTONS_CONTAINER_RECT 0 // convenience debug toggle + +@interface TOCropToolbar () + +@property (nonatomic, strong) UIView *backgroundView; + +@property (nonatomic, strong, readwrite) UIButton *doneTextButton; +@property (nonatomic, strong, readwrite) UIButton *doneIconButton; + +@property (nonatomic, strong, readwrite) UIButton *cancelTextButton; +@property (nonatomic, strong, readwrite) UIButton *cancelIconButton; + +@property (nonatomic, strong) UIButton *resetButton; +@property (nonatomic, strong) UIButton *clampButton; + +@property (nonatomic, strong) UIButton *rotateButton; // defaults to counterclockwise button for legacy compatibility + +@property (nonatomic, strong) UIVisualEffectView *glassView; + +@end + +@implementation TOCropToolbar + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + [self setup]; + } + + return self; +} + +- (void)setup { + self.backgroundView = [[UIView alloc] initWithFrame:self.bounds]; + + UIView *containerView = self; +#ifdef __IPHONE_26_0 + if (@available(iOS 26.0, *)) { + UIVisualEffect *effect = nil; +#if !TARGET_OS_VISION + // We've been getting reports that some devices have been crashing with the error message + // that `effectWithStyle` was unrecognized. The ONLY way this seems to be possible + // is if there are users out there using the beta versions of iOS 26.0 before this method + // was introduced. Either way, we're going to need to manually confirm the selector exists + // in order to fix these crashes. + + UIGlassEffect *glassEffect = nil; + SEL effectSelector = NSSelectorFromString(@"effectWithStyle:"); + if ([[UIGlassEffect class] respondsToSelector:effectSelector]) { + glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleClear]; + } else { + glassEffect = [UIGlassEffect new]; + } + glassEffect.interactive = YES; + effect = glassEffect; +#else + UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleProminent]; + effect = blurEffect; +#endif + _glassView = [[UIVisualEffectView alloc] initWithEffect:effect]; + _glassView.cornerConfiguration = [UICornerConfiguration capsuleConfiguration]; + _glassView.userInteractionEnabled = YES; + [self addSubview:_glassView]; + + containerView = _glassView.contentView; + _showOnlyIcons = YES; + } else { + self.backgroundView.backgroundColor = [UIColor colorWithWhite:0.12f alpha:1.0f]; + [self addSubview:self.backgroundView]; + } +#else + self.backgroundView.backgroundColor = [UIColor colorWithWhite:0.12f alpha:1.0f]; + [self addSubview:self.backgroundView]; +#endif + + // On iOS 9 and up, we can use the new layout features to determine whether we're in an 'Arabic' style language mode + _reverseContentLayout = ([UIView userInterfaceLayoutDirectionForSemanticContentAttribute:self.semanticContentAttribute] == UIUserInterfaceLayoutDirectionRightToLeft); + + // Get the resource bundle depending on the framework/dependency manager we're using + NSBundle *resourceBundle = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(self); + + if (@available(iOS 26.0, *)) { + } else { + _doneTextButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_doneTextButton setTitle:_doneTextButtonTitle ? _doneTextButtonTitle : NSLocalizedStringFromTableInBundle(@"Done", @"TOCropViewControllerLocalizable", resourceBundle, nil) + forState:UIControlStateNormal]; + [_doneTextButton setTitleColor:[UIColor colorWithRed:1.0f green:0.8f blue:0.0f alpha:1.0f] forState:UIControlStateNormal]; + [_doneTextButton.titleLabel setFont:[UIFont systemFontOfSize:17.0f weight:UIFontWeightMedium]]; + [_doneTextButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; + [_doneTextButton sizeToFit]; + [self addSubview:_doneTextButton]; + } + + _doneIconButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_doneIconButton setImage:[TOCropToolbar doneImage] forState:UIControlStateNormal]; + [_doneIconButton setTintColor:[UIColor colorWithRed:1.0f green:0.8f blue:0.0f alpha:1.0f]]; + [_doneIconButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; +#if defined(__IPHONE_26_0) + if (@available(iOS 26.0, *)) { +#if !TARGET_OS_VISION + UIButtonConfiguration *configuration = [UIButtonConfiguration prominentGlassButtonConfiguration]; + configuration.baseForegroundColor = [UIColor labelColor]; +#else + UIButtonConfiguration *configuration = [UIButtonConfiguration filledButtonConfiguration]; +#endif + _doneIconButton.configuration = configuration; + } +#endif + [self addSubview:_doneIconButton]; + + // Set the default color for the done buttons + self.doneButtonColor = nil; + + if (@available(iOS 26.0, *)) { + } else { + _cancelTextButton = [UIButton buttonWithType:UIButtonTypeSystem]; + + [_cancelTextButton setTitle:_cancelTextButtonTitle ? _cancelTextButtonTitle : NSLocalizedStringFromTableInBundle(@"Cancel", @"TOCropViewControllerLocalizable", resourceBundle, nil) + forState:UIControlStateNormal]; + [_cancelTextButton.titleLabel setFont:[UIFont systemFontOfSize:17.0f]]; + [_cancelTextButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; + [_cancelTextButton sizeToFit]; + [self addSubview:_cancelTextButton]; + } + + _cancelIconButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_cancelIconButton setImage:[TOCropToolbar cancelImage] forState:UIControlStateNormal]; + [_cancelIconButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; +#if defined(__IPHONE_26_0) + if (@available(iOS 26.0, *)) { +#if !TARGET_OS_VISION + UIButtonConfiguration *cancelConfiguration = [UIButtonConfiguration clearGlassButtonConfiguration]; + cancelConfiguration.baseForegroundColor = [UIColor labelColor]; + _cancelIconButton.configuration = cancelConfiguration; +#else + _cancelIconButton.configuration = [UIButtonConfiguration filledButtonConfiguration]; +#endif + } +#endif + [self addSubview:_cancelIconButton]; + + _clampButton = [UIButton buttonWithType:UIButtonTypeSystem]; + _clampButton.contentMode = UIViewContentModeCenter; + _clampButton.tintColor = [UIColor whiteColor]; + [_clampButton setImage:[TOCropToolbar clampImage] forState:UIControlStateNormal]; + [_clampButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; + [containerView addSubview:_clampButton]; + + _rotateCounterclockwiseButton = [UIButton buttonWithType:UIButtonTypeSystem]; + _rotateCounterclockwiseButton.contentMode = UIViewContentModeCenter; + _rotateCounterclockwiseButton.tintColor = [UIColor whiteColor]; + [_rotateCounterclockwiseButton setImage:[TOCropToolbar rotateCCWImage] forState:UIControlStateNormal]; + [_rotateCounterclockwiseButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; + [containerView addSubview:_rotateCounterclockwiseButton]; + + _rotateClockwiseButton = [UIButton buttonWithType:UIButtonTypeSystem]; + _rotateClockwiseButton.contentMode = UIViewContentModeCenter; + _rotateClockwiseButton.tintColor = [UIColor whiteColor]; + [_rotateClockwiseButton setImage:[TOCropToolbar rotateCWImage] forState:UIControlStateNormal]; + [_rotateClockwiseButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; + [containerView addSubview:_rotateClockwiseButton]; + + _resetButton = [UIButton buttonWithType:UIButtonTypeSystem]; + _resetButton.contentMode = UIViewContentModeCenter; + _resetButton.tintColor = [UIColor whiteColor]; + _resetButton.enabled = NO; + [_resetButton setImage:[TOCropToolbar resetImage] forState:UIControlStateNormal]; + [_resetButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; + _resetButton.accessibilityLabel = NSLocalizedStringFromTableInBundle(@"Reset", + @"TOCropViewControllerLocalizable", + resourceBundle, + nil); + [containerView addSubview:_resetButton]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + BOOL verticalLayout = (CGRectGetWidth(self.bounds) < CGRectGetHeight(self.bounds)); + CGSize boundsSize = self.bounds.size; + + self.cancelIconButton.hidden = self.cancelButtonHidden || (_showOnlyIcons ? false : !verticalLayout); + self.cancelTextButton.hidden = self.cancelButtonHidden || (_showOnlyIcons ? true : verticalLayout); + self.doneIconButton.hidden = self.doneButtonHidden || (_showOnlyIcons ? false : !verticalLayout); + self.doneTextButton.hidden = self.doneButtonHidden || (_showOnlyIcons ? true : verticalLayout); + + CGRect frame = self.bounds; + frame.origin.x -= self.backgroundViewOutsets.left; + frame.size.width += self.backgroundViewOutsets.left; + frame.size.width += self.backgroundViewOutsets.right; + frame.origin.y -= self.backgroundViewOutsets.top; + frame.size.height += self.backgroundViewOutsets.top; + frame.size.height += self.backgroundViewOutsets.bottom; + self.backgroundView.frame = frame; + +#if TOCROPTOOLBAR_DEBUG_SHOWING_BUTTONS_CONTAINER_RECT + static UIView *containerView = nil; + if (!containerView) { + containerView = [[UIView alloc] initWithFrame:CGRectZero]; + containerView.backgroundColor = [UIColor redColor]; + containerView.alpha = 0.1; + [self addSubview:containerView]; + } +#endif + + if (verticalLayout == NO) { + CGFloat insetPadding = 10.0f; + if (@available(iOS 26.0, *)) { + insetPadding = 0.0f; + } + + // Work out the cancel button frame + CGRect frame = CGRectZero; + frame.size.height = 44.0f; + frame.size.width = _showOnlyIcons ? 44.0f : MIN(self.frame.size.width / 3.0, self.cancelTextButton.frame.size.width); + + // If normal layout, place on the left side, else place on the right + if (self.reverseContentLayout == NO) { + frame.origin.x = insetPadding; + } else { + frame.origin.x = boundsSize.width - (frame.size.width + insetPadding); + } + (_showOnlyIcons ? self.cancelIconButton : self.cancelTextButton).frame = frame; + + // Work out the Done button frame + frame.size.width = _showOnlyIcons ? 44.0f : MIN(self.frame.size.width / 3.0, self.doneTextButton.frame.size.width); + + if (self.reverseContentLayout == NO) { + frame.origin.x = boundsSize.width - (frame.size.width + insetPadding); + } else { + frame.origin.x = insetPadding; + } + (_showOnlyIcons ? self.doneIconButton : self.doneTextButton).frame = frame; + + // Work out the frame between the two buttons where we can layout our action buttons + CGFloat x = self.reverseContentLayout ? CGRectGetMaxX((_showOnlyIcons ? self.doneIconButton : self.doneTextButton).frame) : CGRectGetMaxX((_showOnlyIcons ? self.cancelIconButton : self.cancelTextButton).frame); + CGFloat width = 0.0f; + + if (self.reverseContentLayout == NO) { + width = CGRectGetMinX((_showOnlyIcons ? self.doneIconButton : self.doneTextButton).frame) - CGRectGetMaxX((_showOnlyIcons ? self.cancelIconButton : self.cancelTextButton).frame); + } else { + width = CGRectGetMinX((_showOnlyIcons ? self.cancelIconButton : self.cancelTextButton).frame) - CGRectGetMaxX((_showOnlyIcons ? self.doneIconButton : self.doneTextButton).frame); + } + + CGRect containerRect = CGRectIntegral((CGRect){x, frame.origin.y, width, 44.0f}); + +#if TOCROPTOOLBAR_DEBUG_SHOWING_BUTTONS_CONTAINER_RECT + containerView.frame = containerRect; +#endif + + CGSize buttonSize = (CGSize){44.0f, 44.0f}; + + NSMutableArray *buttonsInOrderHorizontally = [NSMutableArray new]; + if (!self.rotateCounterclockwiseButtonHidden) { + [buttonsInOrderHorizontally addObject:self.rotateCounterclockwiseButton]; + } + + if (!self.resetButtonHidden) { + [buttonsInOrderHorizontally addObject:self.resetButton]; + } + + if (!self.clampButtonHidden) { + [buttonsInOrderHorizontally addObject:self.clampButton]; + } + + if (!self.rotateClockwiseButtonHidden) { + [buttonsInOrderHorizontally addObject:self.rotateClockwiseButton]; + } + [self layoutToolbarButtons:buttonsInOrderHorizontally withSameButtonSize:buttonSize inContainerRect:containerRect horizontally:YES]; + } else { + CGRect frame = CGRectZero; + frame.size.height = 44.0f; + frame.size.width = 44.0f; + frame.origin.y = CGRectGetHeight(self.bounds) - 44.0f; + self.cancelIconButton.frame = frame; + + frame.origin.y = self.statusBarHeightInset; + frame.size.width = 44.0f; + frame.size.height = 44.0f; + self.doneIconButton.frame = frame; + + CGRect containerRect = (CGRect){0, CGRectGetMaxY(self.doneIconButton.frame), 44.0f, CGRectGetMinY(self.cancelIconButton.frame) - CGRectGetMaxY(self.doneIconButton.frame)}; + +#if TOCROPTOOLBAR_DEBUG_SHOWING_BUTTONS_CONTAINER_RECT + containerView.frame = containerRect; +#endif + + CGSize buttonSize = (CGSize){44.0f, 44.0f}; + + NSMutableArray *buttonsInOrderVertically = [NSMutableArray new]; + if (!self.rotateCounterclockwiseButtonHidden) { + [buttonsInOrderVertically addObject:self.rotateCounterclockwiseButton]; + } + + if (!self.resetButtonHidden) { + [buttonsInOrderVertically addObject:self.resetButton]; + } + + if (!self.clampButtonHidden) { + [buttonsInOrderVertically addObject:self.clampButton]; + } + + if (!self.rotateClockwiseButtonHidden) { + [buttonsInOrderVertically addObject:self.rotateClockwiseButton]; + } + + [self layoutToolbarButtons:buttonsInOrderVertically withSameButtonSize:buttonSize inContainerRect:containerRect horizontally:NO]; + } +} + +// The convenience method for calculating button's frame inside of the container rect +- (void)layoutToolbarButtons:(NSArray *)buttons withSameButtonSize:(CGSize)size inContainerRect:(CGRect)containerRect horizontally:(BOOL)horizontally { + // With no buttons to hold, collapse the glass container instead of leaving an + // empty capsule stranded at the frame it had when the last button was visible + if (@available(iOS 26.0, *)) { + _glassView.hidden = (buttons.count == 0); + } + + if (!buttons.count) { + return; + } + + const CGFloat buttonSize = 44.0f; + + if (@available(iOS 26.0, *)) { + CGFloat glassPadding = 6.0f; + CGFloat buttonPadding = 12.0f; + CGFloat maxExtent = buttons.count * buttonSize + (buttonPadding * (buttons.count - 1)) + (glassPadding * 2.0f); + + CGRect glassFrame = CGRectZero; + glassFrame.size.width = horizontally ? maxExtent : buttonSize; + glassFrame.size.height = horizontally ? buttonSize : maxExtent; + glassFrame.origin.x = horizontally ? CGRectGetMidX(containerRect) - (glassFrame.size.width / 2.0f) : 0.0f; + glassFrame.origin.y = horizontally ? 0.0f : CGRectGetMidY(containerRect) - (glassFrame.size.height / 2.0f); + _glassView.frame = glassFrame; + + CGFloat position = glassPadding; + for (UIButton *button in buttons) { + CGRect buttonFrame = CGRectMake(0.0, 0.0, buttonSize, buttonSize); + if (horizontally) { + buttonFrame.origin.x = position; + } else { + buttonFrame.origin.y = position; + } + button.frame = buttonFrame; + position += buttonSize + buttonPadding; + } + } else { + NSInteger count = buttons.count; + CGFloat fixedSize = horizontally ? size.width : size.height; + CGFloat maxLength = horizontally ? CGRectGetWidth(containerRect) : CGRectGetHeight(containerRect); + CGFloat padding = (maxLength - fixedSize * count) / (count + 1); + + for (NSInteger i = 0; i < count; i++) { + UIButton *button = buttons[i]; + CGFloat sameOffset = horizontally ? fabs(CGRectGetHeight(containerRect) - 44.0f) : fabs(CGRectGetWidth(containerRect) - size.width); + CGFloat diffOffset = padding + i * (fixedSize + padding); + CGPoint origin = horizontally ? CGPointMake(diffOffset, sameOffset) : CGPointMake(sameOffset, diffOffset); + if (horizontally) { + origin.x += CGRectGetMinX(containerRect); + if (@available(iOS 15.0, *)) { + // iOS 15+: Use UIButtonConfiguration + UIButtonConfiguration *config = button.configuration ?: [UIButtonConfiguration plainButtonConfiguration]; + + // Position image and text + config.imagePlacement = NSDirectionalRectEdgeLeading; + config.imagePadding = 8; + + UIImage *image = button.imageView.image; + config.contentInsets = NSDirectionalEdgeInsetsMake(0, 0, image.baselineOffsetFromBottom, 0); + + button.configuration = config; + + } else if (@available(iOS 13.0, *)) { + UIImage *image = button.imageView.image; + button.imageEdgeInsets = UIEdgeInsetsMake(0, 0, image.baselineOffsetFromBottom, 0); + } + } else { + origin.y += CGRectGetMinY(containerRect); + } + button.frame = (CGRect){origin, size}; + } + } +} + +- (void)buttonTapped:(id)button { + if (button == self.cancelTextButton || button == self.cancelIconButton) { + if (self.cancelButtonTapped) + self.cancelButtonTapped(); + } else if (button == self.doneTextButton || button == self.doneIconButton) { + if (self.doneButtonTapped) + self.doneButtonTapped(); + } else if (button == self.resetButton && self.resetButtonTapped) { + self.resetButtonTapped(); + } else if (button == self.rotateCounterclockwiseButton && self.rotateCounterclockwiseButtonTapped) { + self.rotateCounterclockwiseButtonTapped(); + } else if (button == self.rotateClockwiseButton && self.rotateClockwiseButtonTapped) { + self.rotateClockwiseButtonTapped(); + } else if (button == self.clampButton && self.clampButtonTapped) { + self.clampButtonTapped(); + return; + } +} + +- (CGRect)clampButtonFrame { + // Convert into the toolbar's space; on iOS 26 the button lives inside the glass container + return [self convertRect:self.clampButton.bounds fromView:self.clampButton]; +} + +- (void)setReverseContentLayout:(BOOL)reverseContentLayout { + if (_reverseContentLayout == reverseContentLayout) + return; + + _reverseContentLayout = reverseContentLayout; + [self setNeedsLayout]; +} + +- (void)setClampButtonHidden:(BOOL)clampButtonHidden { + if (_clampButtonHidden == clampButtonHidden) + return; + + _clampButtonHidden = clampButtonHidden; + [self setNeedsLayout]; +} + +- (void)setClampButtonGlowing:(BOOL)clampButtonGlowing { + if (_clampButtonGlowing == clampButtonGlowing) + return; + + _clampButtonGlowing = clampButtonGlowing; + + if (_clampButtonGlowing) + self.clampButton.tintColor = nil; + else + self.clampButton.tintColor = [UIColor whiteColor]; +} + +- (void)setRotateCounterclockwiseButtonHidden:(BOOL)rotateButtonHidden { + if (_rotateCounterclockwiseButtonHidden == rotateButtonHidden) + return; + + _rotateCounterclockwiseButtonHidden = rotateButtonHidden; + [self setNeedsLayout]; +} + +- (BOOL)resetButtonEnabled { + return self.resetButton.enabled; +} + +- (void)setResetButtonEnabled:(BOOL)resetButtonEnabled { + self.resetButton.enabled = resetButtonEnabled; +} + +- (void)setDoneButtonHidden:(BOOL)doneButtonHidden { + if (_doneButtonHidden == doneButtonHidden) + return; + + _doneButtonHidden = doneButtonHidden; + [self setNeedsLayout]; +} + +- (void)setCancelButtonHidden:(BOOL)cancelButtonHidden { + if (_cancelButtonHidden == cancelButtonHidden) + return; + + _cancelButtonHidden = cancelButtonHidden; + [self setNeedsLayout]; +} + +- (CGRect)doneButtonFrame { + if (self.doneIconButton.hidden == NO) + return self.doneIconButton.frame; + + return self.doneTextButton.frame; +} + +- (void)setShowOnlyIcons:(BOOL)showOnlyIcons { + // The text variants of the Done and Cancel buttons aren't created at all on iOS 26 + // and up, where the toolbar is permanently icon-only. Honouring a NO in that case + // would hide the icon buttons with nothing to replace them, leaving no way to + // commit or cancel the crop at all. + if (_doneTextButton == nil || _cancelTextButton == nil) { + return; + } + + if (_showOnlyIcons == showOnlyIcons) + return; + + _showOnlyIcons = showOnlyIcons; + [_doneIconButton sizeToFit]; + [_cancelIconButton sizeToFit]; + [self setNeedsLayout]; +} + +- (void)setCancelTextButtonTitle:(NSString *)cancelTextButtonTitle { + _cancelTextButtonTitle = cancelTextButtonTitle; + [_cancelTextButton setTitle:_cancelTextButtonTitle forState:UIControlStateNormal]; + [_cancelTextButton sizeToFit]; +} + +- (void)setDoneTextButtonTitle:(NSString *)doneTextButtonTitle { + _doneTextButtonTitle = doneTextButtonTitle; + [_doneTextButton setTitle:_doneTextButtonTitle forState:UIControlStateNormal]; + [_doneTextButton sizeToFit]; +} + +- (void)setCancelButtonColor:(UIColor *)cancelButtonColor { + // Default color is app tint color + if (cancelButtonColor == _cancelButtonColor) { + return; + } + _cancelButtonColor = cancelButtonColor; + [_cancelTextButton setTitleColor:_cancelButtonColor forState:UIControlStateNormal]; + [_cancelIconButton setTintColor:_cancelButtonColor]; + [_cancelTextButton sizeToFit]; +} + +- (void)setDoneButtonColor:(UIColor *)doneButtonColor { + // Set the default color when nil is specified + if (doneButtonColor == nil) { + doneButtonColor = [UIColor colorWithRed:1.0f green:0.8f blue:0.0f alpha:1.0f]; + } + + if (doneButtonColor == _doneButtonColor) { + return; + } + + _doneButtonColor = doneButtonColor; + [_doneTextButton setTitleColor:_doneButtonColor forState:UIControlStateNormal]; + [_doneIconButton setTintColor:_doneButtonColor]; + [_doneTextButton sizeToFit]; +} + +#pragma mark - Image Generation - ++ (UIImage *)doneImage { + if (@available(iOS 13.0, *)) { + return [UIImage systemImageNamed:@"checkmark" + withConfiguration:[UIImageSymbolConfiguration configurationWithWeight:UIImageSymbolWeightSemibold]]; + } + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:(CGSize){17, 14}]; + UIImage *doneImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + UIBezierPath *rectanglePath = UIBezierPath.bezierPath; + [rectanglePath moveToPoint:CGPointMake(1, 7)]; + [rectanglePath addLineToPoint:CGPointMake(6, 12)]; + [rectanglePath addLineToPoint:CGPointMake(16, 1)]; + [UIColor.whiteColor setStroke]; + rectanglePath.lineWidth = 2; + [rectanglePath stroke]; + }]; + + return doneImage; +} + ++ (UIImage *)cancelImage { + if (@available(iOS 13.0, *)) { + return [UIImage systemImageNamed:@"xmark" + withConfiguration:[UIImageSymbolConfiguration configurationWithWeight:UIImageSymbolWeightSemibold]]; + } + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:(CGSize){16, 16}]; + UIImage *cancelImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + UIBezierPath *bezierPath = UIBezierPath.bezierPath; + [bezierPath moveToPoint:CGPointMake(15, 15)]; + [bezierPath addLineToPoint:CGPointMake(1, 1)]; + [UIColor.whiteColor setStroke]; + bezierPath.lineWidth = 2; + [bezierPath stroke]; + + UIBezierPath *bezier2Path = UIBezierPath.bezierPath; + [bezier2Path moveToPoint:CGPointMake(1, 15)]; + [bezier2Path addLineToPoint:CGPointMake(15, 1)]; + [UIColor.whiteColor setStroke]; + bezier2Path.lineWidth = 2; + [bezier2Path stroke]; + }]; + + return cancelImage; +} + ++ (UIImage *)rotateCCWImage { + if (@available(iOS 13.0, *)) { + return [[UIImage systemImageNamed:@"rotate.left.fill" + withConfiguration:[UIImageSymbolConfiguration configurationWithWeight:UIImageSymbolWeightSemibold]] + imageWithBaselineOffsetFromBottom:4]; + } + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:(CGSize){18, 21}]; + UIImage *rotateImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + UIBezierPath *rectangle2Path = [UIBezierPath bezierPathWithRect:CGRectMake(0, 9, 12, 12)]; + [UIColor.whiteColor setFill]; + [rectangle2Path fill]; + + UIBezierPath *rectangle3Path = UIBezierPath.bezierPath; + [rectangle3Path moveToPoint:CGPointMake(5, 3)]; + [rectangle3Path addLineToPoint:CGPointMake(10, 6)]; + [rectangle3Path addLineToPoint:CGPointMake(10, 0)]; + [rectangle3Path addLineToPoint:CGPointMake(5, 3)]; + [rectangle3Path closePath]; + [UIColor.whiteColor setFill]; + [rectangle3Path fill]; + + UIBezierPath *bezierPath = UIBezierPath.bezierPath; + [bezierPath moveToPoint:CGPointMake(10, 3)]; + [bezierPath addCurveToPoint:CGPointMake(17.5, 11) controlPoint1:CGPointMake(15, 3) controlPoint2:CGPointMake(17.5, 5.91)]; + [UIColor.whiteColor setStroke]; + bezierPath.lineWidth = 1; + [bezierPath stroke]; + }]; + + return rotateImage; +} + ++ (UIImage *)rotateCWImage { + if (@available(iOS 13.0, *)) { + return [[UIImage systemImageNamed:@"rotate.right.fill" + withConfiguration:[UIImageSymbolConfiguration configurationWithWeight:UIImageSymbolWeightSemibold]] + imageWithBaselineOffsetFromBottom:4]; + } + + UIImage *rotateCCWImage = [self.class rotateCCWImage]; + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:rotateCCWImage.size]; + UIImage *rotateCWImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + CGContextRef context = rendererContext.CGContext; + CGContextTranslateCTM(context, rotateCCWImage.size.width, rotateCCWImage.size.height); + CGContextRotateCTM(context, M_PI); + CGContextDrawImage(context, CGRectMake(0, 0, rotateCCWImage.size.width, rotateCCWImage.size.height), rotateCCWImage.CGImage); + }]; + + return rotateCWImage; +} + ++ (UIImage *)resetImage { + if (@available(iOS 13.0, *)) { + return [[UIImage systemImageNamed:@"arrow.counterclockwise" + withConfiguration:[UIImageSymbolConfiguration configurationWithWeight:UIImageSymbolWeightSemibold]] + imageWithBaselineOffsetFromBottom:0]; + ; + } + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:(CGSize){22, 18}]; + UIImage *resetImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + UIBezierPath *bezier2Path = UIBezierPath.bezierPath; + [bezier2Path moveToPoint:CGPointMake(22, 9)]; + [bezier2Path addCurveToPoint:CGPointMake(13, 18) controlPoint1:CGPointMake(22, 13.97) controlPoint2:CGPointMake(17.97, 18)]; + [bezier2Path addCurveToPoint:CGPointMake(13, 16) controlPoint1:CGPointMake(13, 17.35) controlPoint2:CGPointMake(13, 16.68)]; + [bezier2Path addCurveToPoint:CGPointMake(20, 9) controlPoint1:CGPointMake(16.87, 16) controlPoint2:CGPointMake(20, 12.87)]; + [bezier2Path addCurveToPoint:CGPointMake(13, 2) controlPoint1:CGPointMake(20, 5.13) controlPoint2:CGPointMake(16.87, 2)]; + [bezier2Path addCurveToPoint:CGPointMake(6.55, 6.27) controlPoint1:CGPointMake(10.1, 2) controlPoint2:CGPointMake(7.62, 3.76)]; + [bezier2Path addCurveToPoint:CGPointMake(6, 9) controlPoint1:CGPointMake(6.2, 7.11) controlPoint2:CGPointMake(6, 8.03)]; + [bezier2Path addLineToPoint:CGPointMake(4, 9)]; + [bezier2Path addCurveToPoint:CGPointMake(4.65, 5.63) controlPoint1:CGPointMake(4, 7.81) controlPoint2:CGPointMake(4.23, 6.67)]; + [bezier2Path addCurveToPoint:CGPointMake(7.65, 1.76) controlPoint1:CGPointMake(5.28, 4.08) controlPoint2:CGPointMake(6.32, 2.74)]; + [bezier2Path addCurveToPoint:CGPointMake(13, 0) controlPoint1:CGPointMake(9.15, 0.65) controlPoint2:CGPointMake(11, 0)]; + [bezier2Path addCurveToPoint:CGPointMake(22, 9) controlPoint1:CGPointMake(17.97, 0) controlPoint2:CGPointMake(22, 4.03)]; + [bezier2Path closePath]; + [UIColor.whiteColor setFill]; + [bezier2Path fill]; + + UIBezierPath *polygonPath = UIBezierPath.bezierPath; + [polygonPath moveToPoint:CGPointMake(5, 15)]; + [polygonPath addLineToPoint:CGPointMake(10, 9)]; + [polygonPath addLineToPoint:CGPointMake(0, 9)]; + [polygonPath addLineToPoint:CGPointMake(5, 15)]; + [polygonPath closePath]; + [UIColor.whiteColor setFill]; + [polygonPath fill]; + }]; + + return resetImage; +} + ++ (UIImage *)clampImage { + if (@available(iOS 13.0, *)) { + return [[UIImage systemImageNamed:@"aspectratio.fill" + withConfiguration:[UIImageSymbolConfiguration configurationWithWeight:UIImageSymbolWeightSemibold]] + imageWithBaselineOffsetFromBottom:0]; + } + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:(CGSize){22, 16}]; + UIImage *clampImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + //// Color Declarations + UIColor *outerBox = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.553]; + UIColor *innerBox = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.773]; + + //// Rectangle Drawing + UIBezierPath *rectanglePath = [UIBezierPath bezierPathWithRect:CGRectMake(0, 3, 13, 13)]; + [UIColor.whiteColor setFill]; + [rectanglePath fill]; + + //// Outer + { + //// Top Drawing + UIBezierPath *topPath = [UIBezierPath bezierPathWithRect:CGRectMake(0, 0, 22, 2)]; + [outerBox setFill]; + [topPath fill]; + + //// Side Drawing + UIBezierPath *sidePath = [UIBezierPath bezierPathWithRect:CGRectMake(19, 2, 3, 14)]; + [outerBox setFill]; + [sidePath fill]; + } + + //// Rectangle 2 Drawing + UIBezierPath *rectangle2Path = [UIBezierPath bezierPathWithRect:CGRectMake(14, 3, 4, 13)]; + [innerBox setFill]; + [rectangle2Path fill]; + }]; + + return clampImage; +} + +#pragma mark - Accessors - + +- (void)setRotateClockwiseButtonHidden:(BOOL)rotateClockwiseButtonHidden { + if (_rotateClockwiseButtonHidden == rotateClockwiseButtonHidden) { + return; + } + + _rotateClockwiseButtonHidden = rotateClockwiseButtonHidden; + + [self setNeedsLayout]; +} + +- (void)setResetButtonHidden:(BOOL)resetButtonHidden { + if (_resetButtonHidden == resetButtonHidden) { + return; + } + + _resetButtonHidden = resetButtonHidden; + + [self setNeedsLayout]; +} +- (UIButton *)rotateButton { + return self.rotateCounterclockwiseButton; +} + +- (void)setStatusBarHeightInset:(CGFloat)statusBarHeightInset { + _statusBarHeightInset = statusBarHeightInset; + [self setNeedsLayout]; +} + +- (UIView *)visibleCancelButton { + if (self.cancelIconButton.hidden == NO) { + return self.cancelIconButton; + } + + return self.cancelTextButton; +} + +- (void)setDisableRotationButtons:(BOOL)disableRotationButtons { + if (_disableRotationButtons == disableRotationButtons) { + return; + } + _disableRotationButtons = disableRotationButtons; + _rotateClockwiseButton.enabled = !disableRotationButtons; + _rotateCounterclockwiseButton.enabled = !disableRotationButtons; +} + +@end diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.h new file mode 100644 index 000000000..00a1b1caa --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.h @@ -0,0 +1,311 @@ +// +// TOCropView.h +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import + +#if !__has_include() +#import "TOCropViewConstants.h" +#else +#import +#endif + +// Imported rather than forward-declared so that `gridOverlayView` has a complete type. +// Swift omits properties whose class it only knows as a forward declaration, which made +// the grid overlay unreachable from Swift in every configuration. +#if !__has_include() +#import "TOCropOverlayView.h" +#else +#import +#endif + +@class TOCropView; + +NS_ASSUME_NONNULL_BEGIN + +@protocol TOCropViewDelegate + +- (void)cropViewDidBecomeResettable:(nonnull TOCropView *)cropView; +- (void)cropViewDidBecomeNonResettable:(nonnull TOCropView *)cropView; + +@end + +@interface TOCropView : UIView + +/** + The image that the crop view is displaying. This cannot be changed once the crop view is instantiated. + */ +@property (nonnull, nonatomic, strong, readonly) UIImage *image; + +/** + The cropping style of the crop view (eg, rectangular or circular) + */ +@property (nonatomic, assign, readonly) TOCropViewCroppingStyle croppingStyle; + +/** + A semi-transparent grey view, overlaid on top of the background image + */ +@property (nonatomic, strong, readonly) UIView *overlayView; + +/** + A grid view overlaid on top of the foreground image view's container. + This is nil when the cropping style is circular, which has no rectangular grid. + */ +@property (nullable, nonatomic, strong, readonly) TOCropOverlayView *gridOverlayView; + +/** + A container view that clips the a copy of the image so it appears over the dimming view + */ +@property (nonnull, nonatomic, readonly) UIView *foregroundContainerView; + +/** + A delegate object that receives notifications from the crop view + */ +@property (nullable, nonatomic, weak) id delegate; + +/** + If false, the user cannot resize the crop box frame using a pan gesture from a corner. + Default vaue is YES. + */ +@property (nonatomic, assign) BOOL cropBoxResizeEnabled; + +/** + Whether the user has manipulated the crop view to the point where it can be reset + */ +@property (nonatomic, readonly) BOOL canBeReset; + +/** + The frame of the cropping box in the coordinate space of the crop view + */ +@property (nonatomic, readonly) CGRect cropBoxFrame; + +/** + The frame of the entire image in the backing scroll view + */ +@property (nonatomic, readonly) CGRect imageViewFrame; + +/** + Inset the workable region of the crop view in case in order to make space for accessory views + */ +@property (nonatomic, assign) UIEdgeInsets cropRegionInsets; + +/** + Disable the dynamic translucency in order to smoothly relayout the view + */ +@property (nonatomic, assign) BOOL simpleRenderMode; + +/** + When performing manual content layout (such as during screen rotation), disable any internal layout + */ +@property (nonatomic, assign) BOOL internalLayoutDisabled; + +/** + A width x height ratio that the crop box will be rescaled to (eg 4:3 is {4.0f, 3.0f}) + Setting it to CGSizeZero will reset the aspect ratio to the image's own ratio. + */ +@property (nonatomic, assign) CGSize aspectRatio; + +/** + When the cropping box is locked to its current aspect ratio (But can still be resized) + */ +@property (nonatomic, assign) BOOL aspectRatioLockEnabled; + +/** + If true, a custom aspect ratio is set, and the aspectRatioLockEnabled is set to YES, + the crop box will swap it's dimensions depending on portrait or landscape sized images. + This value also controls whether the dimensions can swap when the image is rotated. + + Default is NO. + */ +@property (nonatomic, assign) BOOL aspectRatioLockDimensionSwapEnabled; + +/** + When the user taps 'reset', whether the aspect ratio will also be reset as well + Default is YES + */ +@property (nonatomic, assign) BOOL resetAspectRatioEnabled; + +/** + True when the height of the crop box is bigger than the width + */ +@property (nonatomic, readonly) BOOL cropBoxAspectRatioIsPortrait; + +/** + The rotation angle of the crop view, in multiples of 90 degrees. Counter-clockwise + rotations are negative and clockwise ones positive, so the value is in the + range (-360, 360). Values that aren't a multiple of 90 are treated as 0. + */ +@property (nonatomic, assign) NSInteger angle; + +/** + Hide all of the crop elements for transition animations + */ +@property (nonatomic, assign) BOOL croppingViewsHidden; + +/** + In relation to the coordinate space of the image, the frame that the crop view is focusing on + */ +@property (nonatomic, assign) CGRect imageCropFrame; + +/** + Set the grid overlay graphic to be hidden + */ +@property (nonatomic, assign) BOOL gridOverlayHidden; + +///** +// Paddings of the crop rectangle. Default to 14.0 +// */ +@property (nonatomic) CGFloat cropViewPadding; + +/** + Delay before crop frame is adjusted according new crop area. Default to 0.8 + */ +@property (nonatomic) NSTimeInterval cropAdjustingDelay; + +/** +The minimum croping aspect ratio. If set, user is prevented from setting cropping + rectangle to lower aspect ratio than defined by the parameter. +*/ +@property (nonatomic, assign) CGFloat minimumAspectRatio; + +/** + The maximum scale that user can apply to image by pinching to zoom. Small values + are only recomended with aspectRatioLockEnabled set to true. Default to 15.0 + */ +@property (nonatomic, assign) CGFloat maximumZoomScale; + +/** + Always show the cropping grid lines, even when the user isn't interacting. + This also disables the fading animation. + (Default is NO) + */ +@property (nonatomic, assign) BOOL alwaysShowCroppingGrid; + +/** + Permanently hides the translucency effect covering the outside bounds of the + crop box. (Default is NO) + */ +@property (nonatomic, assign) BOOL translucencyAlwaysHidden; + +///* +// if YES it will always show grid +// if NO it will never show grid +// NOTE : Do not use this method if you want to keep grid hide/show animation +// */ +//- (void)setAlwaysShowGrid:(BOOL)showGrid; +// +///* +// if YES it will disable translucency effect +// */ +//- (void)setTranslucencyOff:(BOOL)disableTranslucency; + +/** + Create a default instance of the crop view with the supplied image + */ +- (nonnull instancetype)initWithImage:(nonnull UIImage *)image; + +/** + Create a new instance of the crop view with the specified image and cropping + */ +- (nonnull instancetype)initWithCroppingStyle:(TOCropViewCroppingStyle)style image:(nonnull UIImage *)image; + +/** + Performs the initial set up, including laying out the image and applying any restore properties. + This should be called once the crop view has been added to a parent that is in its final layout frame. + */ +- (void)performInitialSetup; + +/** + When performing large size transitions (eg, orientation rotation), + set simple mode to YES to temporarily graphically heavy effects like translucency. + + @param simpleMode Whether simple mode is enabled or not + + */ +- (void)setSimpleRenderMode:(BOOL)simpleMode animated:(BOOL)animated; + +/** + When performing a screen rotation that will change the size of the scroll view, this takes + a snapshot of all of the scroll view data before it gets manipulated by iOS. + Please call this in your view controller, before the rotation animation block is committed. + */ +- (void)prepareforRotation; + +/** + Performs the realignment of the crop view while the screen is rotating. + Please call this inside your view controller's screen rotation animation block. + */ +- (void)performRelayoutForRotation; + +/** + Reset the crop box and zoom scale back to the initial layout + + @param animated The reset is animated + */ +- (void)resetLayoutToDefaultAnimated:(BOOL)animated; + +/** + Changes the aspect ratio of the crop box to match the one specified + + @param aspectRatio The aspect ratio (For example 16:9 is 16.0f/9.0f). 'CGSizeZero' will reset it to the image's own ratio + @param animated Whether the locking effect is animated + */ +- (void)setAspectRatio:(CGSize)aspectRatio animated:(BOOL)animated; + +/** + Rotates the entire canvas to a 90-degree angle. The default rotation is counterclockwise. + + @param animated Whether the transition is animated + */ +- (void)rotateImageNinetyDegreesAnimated:(BOOL)animated completion:(nullable void (^)(BOOL completed))completionHandler; + +/** + Rotates the entire canvas to a 90-degree angle + + @param animated Whether the transition is animated + @param clockwise Whether the rotation is clockwise. Passing 'NO' means counterclockwise + */ +- (void)rotateImageNinetyDegreesAnimated:(BOOL)animated clockwise:(BOOL)clockwise completion:(nullable void (^)(BOOL completed))completionHandler; + +/** + Animate the grid overlay graphic to be visible + */ +- (void)setGridOverlayHidden:(BOOL)gridOverlayHidden animated:(BOOL)animated; + +/** + Animate the cropping component views to become visible + */ +- (void)setCroppingViewsHidden:(BOOL)hidden animated:(BOOL)animated; + +/** + Animate the background image view to become visible + */ +- (void)setBackgroundImageViewHidden:(BOOL)hidden animated:(BOOL)animated; + +/** + When triggered, the crop view will perform a relayout to ensure the crop box + fills the entire crop view region + */ +- (void)moveCroppedContentToCenterAnimated:(BOOL)animated; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.m new file mode 100644 index 000000000..48aebac6a --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropView.m @@ -0,0 +1,1787 @@ +// +// TOCropView.m +// +// Copyright 2015-2026 Timothy Oliver. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#import "TOCropView.h" + +#import "TOCropOverlayView.h" +#import "TOCropScrollView.h" + +#define TOCROPVIEW_BACKGROUND_COLOR [UIColor colorWithWhite:0.12f alpha:1.0f] + +static const CGFloat kTOCropViewPadding = 14.0f; +static const NSTimeInterval kTOCropTimerDuration = 0.8f; +static const CGFloat kTOCropViewMinimumBoxSize = 42.0f; +static const CGFloat kTOMaximumZoomScale = 15.0f; + +/* When the user taps down to resize the box, this state is used + to determine where they tapped and how to manipulate the box */ +typedef NS_ENUM(NSInteger, TOCropViewOverlayEdge) { + TOCropViewOverlayEdgeNone, + TOCropViewOverlayEdgeTopLeft, + TOCropViewOverlayEdgeTop, + TOCropViewOverlayEdgeTopRight, + TOCropViewOverlayEdgeRight, + TOCropViewOverlayEdgeBottomRight, + TOCropViewOverlayEdgeBottom, + TOCropViewOverlayEdgeBottomLeft, + TOCropViewOverlayEdgeLeft +}; + +@interface TOCropView () + +@property (nonatomic, strong, readwrite) UIImage *image; +@property (nonatomic, assign, readwrite) TOCropViewCroppingStyle croppingStyle; + +/* Views */ +@property (nonatomic, strong) UIImageView *backgroundImageView; /* The main image view, placed within the scroll view */ +@property (nonatomic, strong) UIView *backgroundContainerView; /* A view which contains the background image view, to separate its transforms from the scroll view. */ +@property (nonatomic, strong, readwrite) UIView *foregroundContainerView; +@property (nonatomic, strong) UIImageView *foregroundImageView; /* A copy of the background image view, placed over the dimming views */ +@property (nonatomic, strong) TOCropScrollView *scrollView; /* The scroll view in charge of panning/zooming the image. */ +@property (nonatomic, strong) UIView *overlayView; /* A semi-transparent grey view, overlaid on top of the background image */ +@property (nonatomic, strong) UIView *translucencyView; /* A blur view that is made visible when the user isn't interacting with the crop view */ +@property (nonatomic, strong) id translucencyEffect; /* The dark blur visual effect applied to the visual effect view. */ +@property (nonatomic, strong, readwrite) TOCropOverlayView *gridOverlayView; /* A grid view overlaid on top of the foreground image view's container. */ + +/* Gesture Recognizers */ +@property (nonatomic, strong) UIPanGestureRecognizer *gridPanGestureRecognizer; /* The gesture recognizer in charge of controlling the resizing of the crop view */ + +/* Crop box handling */ +@property (nonatomic, assign) BOOL applyInitialCroppedImageFrame; /* No by default, when setting initialCroppedImageFrame this will be set to YES, and set back to NO after first application - so it's only done once */ +@property (nonatomic, assign) TOCropViewOverlayEdge tappedEdge; /* The edge region that the user tapped on, to resize the cropping region */ +@property (nonatomic, assign) CGRect cropOriginFrame; /* When resizing, this is the original frame of the crop box. */ +@property (nonatomic, assign) CGPoint panOriginPoint; /* The initial touch point of the pan gesture recognizer */ +@property (nonatomic, assign, readwrite) CGRect cropBoxFrame; /* The frame, in relation to to this view where the grid, and crop container view are aligned */ +@property (nonatomic, strong) NSTimer *resetTimer; /* The timer used to reset the view after the user stops interacting with it */ +@property (nonatomic, assign) BOOL editing; /* Used to denote the active state of the user manipulating the content */ +@property (nonatomic, assign) BOOL disableForgroundMatching; /* At times during animation, disable matching the forground image view to the background */ + +/* Pre-screen-rotation state information */ +@property (nonatomic, assign) CGPoint rotationContentOffset; +@property (nonatomic, assign) CGSize rotationContentSize; +@property (nonatomic, assign) CGRect rotationBoundFrame; + +/* View State information */ +@property (nonatomic, readonly) CGRect contentBounds; /* Give the current screen real-estate, the frame that the scroll view is allowed to use */ +@property (nonatomic, readonly) CGSize imageSize; /* Given the current rotation of the image, the size of the image */ +@property (nonatomic, readonly) BOOL hasAspectRatio; /* True if an aspect ratio was explicitly applied to this crop view */ + +/* 90-degree rotation state data */ +@property (nonatomic, assign) CGSize cropBoxLastEditedSize; /* When performing 90-degree rotations, remember what our last manual size was to use that as a base */ +@property (nonatomic, assign) NSInteger cropBoxLastEditedAngle; /* Remember which angle we were at when we saved the editing size */ +@property (nonatomic, assign) CGFloat cropBoxLastEditedZoomScale; /* Remember the zoom size when we last edited */ +@property (nonatomic, assign) CGFloat cropBoxLastEditedMinZoomScale; /* Remember the minimum size when we last edited. */ +@property (nonatomic, assign) CGFloat cropBoxLastEditedMaxZoomScale; /* Remember the zoom ceiling when we last edited. */ +@property (nonatomic, assign) CGFloat baseMaximumZoomScale; /* The absolute zoom ceiling for the current layout; the scroll view's own maximum is always derived from this */ +@property (nonatomic, assign) BOOL rotateAnimationInProgress; /* Disallow any input while the rotation animation is playing */ + +/* Reset state data */ +@property (nonatomic, assign) CGSize originalCropBoxSize; /* Save the original crop box size so we can tell when the content has been edited */ +@property (nonatomic, assign) CGPoint originalContentOffset; /* Save the original content offset so we can tell if it's been scrolled. */ +@property (nonatomic, assign, readwrite) BOOL canBeReset; + +/* If restoring to a previous crop setting, these properties hang onto the + values until the view is configured for the first time. */ +@property (nonatomic, assign) NSInteger restoreAngle; +@property (nonatomic, assign) CGRect restoreImageCropFrame; + +/* Set to YES once `performInitialLayout` is called. This lets pending properties get queued until the view + has been properly set up in its parent. */ +@property (nonatomic, assign) BOOL initialSetupPerformed; + +@end + +@implementation TOCropView + +- (instancetype)initWithImage:(UIImage *)image { + return [self initWithCroppingStyle:TOCropViewCroppingStyleDefault image:image]; +} + +- (instancetype)initWithCroppingStyle:(TOCropViewCroppingStyle)style image:(UIImage *)image { + if (self = [super init]) { + _image = image; + _croppingStyle = style; + [self setup]; + } + + return self; +} + +- (void)setup { + __weak typeof(self) weakSelf = self; + + BOOL circularMode = (self.croppingStyle == TOCropViewCroppingStyleCircular); + + // View properties + self.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth; + self.backgroundColor = TOCROPVIEW_BACKGROUND_COLOR; + self.cropBoxFrame = CGRectZero; + self.applyInitialCroppedImageFrame = NO; + self.editing = NO; + self.cropBoxResizeEnabled = !circularMode; + self.aspectRatio = circularMode ? (CGSize){1.0f, 1.0f} : CGSizeZero; + self.resetAspectRatioEnabled = !circularMode; + self.restoreImageCropFrame = CGRectZero; + self.restoreAngle = 0; + self.cropAdjustingDelay = kTOCropTimerDuration; + self.cropViewPadding = kTOCropViewPadding; + self.maximumZoomScale = kTOMaximumZoomScale; + + // Scroll View properties + self.scrollView = [[TOCropScrollView alloc] initWithFrame:self.bounds]; + self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.scrollView.alwaysBounceHorizontal = YES; + self.scrollView.alwaysBounceVertical = YES; + self.scrollView.showsHorizontalScrollIndicator = NO; + self.scrollView.showsVerticalScrollIndicator = NO; + self.scrollView.delegate = self; + [self addSubview:self.scrollView]; + + self.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + self.scrollView.touchesBegan = ^{ [weakSelf startEditing]; }; + self.scrollView.touchesEnded = ^{ [weakSelf startResetTimer]; }; + self.scrollView.touchesCancelled = ^{ [weakSelf handleScrollViewTouchCancellation]; }; + + // Background Image View + self.backgroundImageView = [[UIImageView alloc] initWithImage:self.image]; + self.backgroundImageView.layer.minificationFilter = kCAFilterTrilinear; + + // Background container view + self.backgroundContainerView = [[UIView alloc] initWithFrame:self.backgroundImageView.frame]; + [self.backgroundContainerView addSubview:self.backgroundImageView]; + [self.scrollView addSubview:self.backgroundContainerView]; + + // Grey transparent overlay view + self.overlayView = [[UIView alloc] initWithFrame:self.bounds]; + self.overlayView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.overlayView.backgroundColor = [self.backgroundColor colorWithAlphaComponent:0.35f]; + self.overlayView.hidden = NO; + self.overlayView.userInteractionEnabled = NO; + [self addSubview:self.overlayView]; + + // Translucency View + self.translucencyEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]; + self.translucencyView = [[UIVisualEffectView alloc] initWithEffect:self.translucencyEffect]; + self.translucencyView.frame = self.bounds; + self.translucencyView.hidden = self.translucencyAlwaysHidden; + self.translucencyView.userInteractionEnabled = NO; + self.translucencyView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [self addSubview:self.translucencyView]; + + // The forground container that holds the foreground image view + self.foregroundContainerView = [[UIView alloc] initWithFrame:(CGRect){0, 0, 200, 200}]; + self.foregroundContainerView.clipsToBounds = YES; + self.foregroundContainerView.userInteractionEnabled = NO; + [self addSubview:self.foregroundContainerView]; + + self.foregroundImageView = [[UIImageView alloc] initWithImage:self.image]; + self.foregroundImageView.layer.minificationFilter = kCAFilterTrilinear; + [self.foregroundContainerView addSubview:self.foregroundImageView]; + + // Disable colour inversion for the image views + self.foregroundImageView.accessibilityIgnoresInvertColors = YES; + self.backgroundImageView.accessibilityIgnoresInvertColors = YES; + + // The following setup isn't needed during circular cropping + if (circularMode) { + return; + } + + // The white grid overlay view + self.gridOverlayView = [[TOCropOverlayView alloc] initWithFrame:self.foregroundContainerView.frame]; + self.gridOverlayView.userInteractionEnabled = NO; + self.gridOverlayView.gridHidden = YES; + [self addSubview:self.gridOverlayView]; + + // The pan controller to recognize gestures meant to resize the grid view + self.gridPanGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(gridPanGestureRecognized:)]; + self.gridPanGestureRecognizer.delegate = self; + [self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.gridPanGestureRecognizer]; + [self addGestureRecognizer:self.gridPanGestureRecognizer]; +} + +- (void)dealloc { + [_resetTimer invalidate]; +} + +#pragma mark - View Layout - +- (void)performInitialSetup { + // Calling this more than once is potentially destructive + if (self.initialSetupPerformed) { + return; + } + + // Disable from calling again + self.initialSetupPerformed = YES; + + // Perform the initial layout of the image + [self layoutInitialImage]; + + // -- State Restoration -- + + // If the angle value was previously set before this point, apply it now + if (self.restoreAngle != 0) { + self.angle = self.restoreAngle; + self.restoreAngle = 0; + self.cropBoxLastEditedAngle = self.angle; + } + + // If an image crop frame was also specified before creation, apply it now + if (!CGRectIsEmpty(self.restoreImageCropFrame)) { + self.imageCropFrame = self.restoreImageCropFrame; + self.restoreImageCropFrame = CGRectZero; + } + + // Save the current layout state for later + [self captureStateForImageRotation]; + + // Check if we performed any resetabble modifications + [self checkForCanReset]; +} + +- (void)layoutInitialImage { + CGSize imageSize = self.imageSize; + // A zero-sized image would produce NaN geometry below, which crashes CALayer + if (imageSize.width < FLT_EPSILON || imageSize.height < FLT_EPSILON) { + return; + } + self.scrollView.contentSize = imageSize; + + CGRect bounds = self.contentBounds; + CGSize boundsSize = bounds.size; + + // work out the minimum scale of the object + CGFloat scale = 0.0f; + + // Work out the size of the image to fit into the content bounds + scale = MIN(CGRectGetWidth(bounds) / imageSize.width, CGRectGetHeight(bounds) / imageSize.height); + CGSize scaledImageSize = (CGSize){floorf(imageSize.width * scale), floorf(imageSize.height * scale)}; + + // If an aspect ratio was pre-applied to the crop view, use that to work out the minimum scale the image needs to be to fit + CGSize cropBoxSize = CGSizeZero; + if (self.hasAspectRatio) { + CGFloat ratioScale = (self.aspectRatio.width / self.aspectRatio.height); // Work out the size of the width in relation to height + CGSize fullSizeRatio = (CGSize){boundsSize.height * ratioScale, boundsSize.height}; + CGFloat fitScale = MIN(boundsSize.width / fullSizeRatio.width, boundsSize.height / fullSizeRatio.height); + cropBoxSize = (CGSize){fullSizeRatio.width * fitScale, fullSizeRatio.height * fitScale}; + + scale = MAX(cropBoxSize.width / imageSize.width, cropBoxSize.height / imageSize.height); + } + + // Whether aspect ratio, or original, the final image size we'll base the rest of the calculations off + CGSize scaledSize = (CGSize){floorf(imageSize.width * scale), floorf(imageSize.height * scale)}; + + // Configure the scroll view + self.scrollView.minimumZoomScale = scale; + self.baseMaximumZoomScale = scale * self.maximumZoomScale; + [self updateScrollViewMaximumZoomScale]; + + // Set the crop box to the size we calculated and align in the middle of the screen + CGRect frame = CGRectZero; + frame.size = self.hasAspectRatio ? cropBoxSize : scaledSize; + frame.origin.x = floorf(bounds.origin.x + floorf((CGRectGetWidth(bounds) - frame.size.width) * 0.5f)); + frame.origin.y = floorf(bounds.origin.y + floorf((CGRectGetHeight(bounds) - frame.size.height) * 0.5f)); + self.cropBoxFrame = frame; + + // set the fully zoomed out state initially + self.scrollView.zoomScale = self.scrollView.minimumZoomScale; + self.scrollView.contentSize = scaledSize; + + // If we ended up with a smaller crop box than the content, line up the content so its center + // is in the center of the cropbox + if (frame.size.width < scaledSize.width - FLT_EPSILON || frame.size.height < scaledSize.height - FLT_EPSILON) { + CGPoint offset = CGPointZero; + offset.x = -floorf(CGRectGetMidX(bounds) - (scaledSize.width * 0.5f)); + offset.y = -floorf(CGRectGetMidY(bounds) - (scaledSize.height * 0.5f)); + self.scrollView.contentOffset = offset; + } + + // save the current state for use with 90-degree rotations + self.cropBoxLastEditedAngle = 0; + [self captureStateForImageRotation]; + + // save the size for checking if we're in a resettable state + self.originalCropBoxSize = self.resetAspectRatioEnabled ? scaledImageSize : self.cropBoxFrame.size; + self.originalContentOffset = self.scrollView.contentOffset; + + [self checkForCanReset]; + [self matchForegroundToBackground]; +} + +- (void)prepareforRotation { + self.rotationContentOffset = self.scrollView.contentOffset; + self.rotationContentSize = self.scrollView.contentSize; + self.rotationBoundFrame = self.contentBounds; +} + +- (void)performRelayoutForRotation { + CGRect cropFrame = self.cropBoxFrame; + CGRect contentFrame = self.contentBounds; + + CGFloat scale = MIN(contentFrame.size.width / cropFrame.size.width, contentFrame.size.height / cropFrame.size.height); + self.scrollView.minimumZoomScale *= scale; + self.baseMaximumZoomScale *= scale; + [self updateScrollViewMaximumZoomScale]; + self.scrollView.zoomScale *= scale; + + // Work out the centered, upscaled version of the crop rectangle + cropFrame.size.width = floorf(cropFrame.size.width * scale); + cropFrame.size.height = floorf(cropFrame.size.height * scale); + cropFrame.origin.x = floorf(contentFrame.origin.x + ((contentFrame.size.width - cropFrame.size.width) * 0.5f)); + cropFrame.origin.y = floorf(contentFrame.origin.y + ((contentFrame.size.height - cropFrame.size.height) * 0.5f)); + self.cropBoxFrame = cropFrame; + + [self captureStateForImageRotation]; + + // Work out the center point of the content before we rotated + CGPoint oldMidPoint = (CGPoint){CGRectGetMidX(self.rotationBoundFrame), CGRectGetMidY(self.rotationBoundFrame)}; + CGPoint contentCenter = (CGPoint){self.rotationContentOffset.x + oldMidPoint.x, self.rotationContentOffset.y + oldMidPoint.y}; + + // Normalize it to a percentage we can apply to different sizes + CGPoint normalizedCenter = CGPointZero; + normalizedCenter.x = contentCenter.x / self.rotationContentSize.width; + normalizedCenter.y = contentCenter.y / self.rotationContentSize.height; + + // Work out the new content offset by applying the normalized values to the new layout + CGPoint newMidPoint = (CGPoint){CGRectGetMidX(self.contentBounds), CGRectGetMidY(self.contentBounds)}; + + CGPoint translatedContentOffset = CGPointZero; + translatedContentOffset.x = self.scrollView.contentSize.width * normalizedCenter.x; + translatedContentOffset.y = self.scrollView.contentSize.height * normalizedCenter.y; + + CGPoint offset = CGPointZero; + offset.x = floorf(translatedContentOffset.x - newMidPoint.x); + offset.y = floorf(translatedContentOffset.y - newMidPoint.y); + + // Make sure it doesn't overshoot the top left corner of the crop box + offset.x = MAX(-self.scrollView.contentInset.left, offset.x); + offset.y = MAX(-self.scrollView.contentInset.top, offset.y); + + // Nor undershoot the bottom right corner + CGPoint maximumOffset = CGPointZero; + maximumOffset.x = self.scrollView.contentSize.width - (self.bounds.size.width - self.scrollView.contentInset.right); + maximumOffset.y = self.scrollView.contentSize.height - (self.bounds.size.height - self.scrollView.contentInset.bottom); + offset.x = MIN(offset.x, maximumOffset.x); + offset.y = MIN(offset.y, maximumOffset.y); + self.scrollView.contentOffset = offset; + + // Line up the background instance of the image + [self matchForegroundToBackground]; +} + +- (void)matchForegroundToBackground { + if (self.disableForgroundMatching) + return; + + // We can't simply match the frames since if the images are rotated, the frame property becomes unusable + self.foregroundImageView.frame = [self.backgroundContainerView.superview + convertRect:self.backgroundContainerView.frame + toView:self.foregroundContainerView]; +} + +- (void)updateCropBoxFrameWithGesturePoint:(CGPoint)point { + CGRect frame = self.cropBoxFrame; + CGRect originFrame = self.cropOriginFrame; + CGRect contentFrame = self.contentBounds; + + point.x = MAX(contentFrame.origin.x - self.cropViewPadding, point.x); + point.y = MAX(contentFrame.origin.y - self.cropViewPadding, point.y); + + // The delta between where we first tapped, and where our finger is now + CGFloat xDelta = ceilf(point.x - self.panOriginPoint.x); + CGFloat yDelta = ceilf(point.y - self.panOriginPoint.y); + + // Current aspect ratio of the crop box in case we need to clamp it + CGFloat aspectRatio = (originFrame.size.width / originFrame.size.height); + + // Note whether we're being aspect transformed horizontally or vertically + BOOL aspectHorizontal = NO, aspectVertical = NO; + + // Depending on which corner we drag from, set the appropriate min flag to + // ensure we can properly clamp the XY value of the box if it overruns the minimum size + //(Otherwise the image itself will slide with the drag gesture) + BOOL clampMinFromTop = NO, clampMinFromLeft = NO; + + switch (self.tappedEdge) { + case TOCropViewOverlayEdgeLeft: + if (self.aspectRatioLockEnabled) { + aspectHorizontal = YES; + xDelta = MAX(xDelta, 0); + CGPoint scaleOrigin = (CGPoint){CGRectGetMaxX(originFrame), CGRectGetMidY(originFrame)}; + frame.size.height = frame.size.width / aspectRatio; + frame.origin.y = scaleOrigin.y - (frame.size.height * 0.5f); + } + CGFloat newWidth = originFrame.size.width - xDelta; + CGFloat newHeight = originFrame.size.height; + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.origin.x = originFrame.origin.x + xDelta; + frame.size.width = originFrame.size.width - xDelta; + } + + clampMinFromLeft = YES; + + break; + case TOCropViewOverlayEdgeRight: + if (self.aspectRatioLockEnabled) { + aspectHorizontal = YES; + CGPoint scaleOrigin = (CGPoint){CGRectGetMinX(originFrame), CGRectGetMidY(originFrame)}; + frame.size.height = frame.size.width / aspectRatio; + frame.origin.y = scaleOrigin.y - (frame.size.height * 0.5f); + frame.size.width = originFrame.size.width + xDelta; + frame.size.width = MIN(frame.size.width, contentFrame.size.height * aspectRatio); + } else { + CGFloat newWidth = originFrame.size.width + xDelta; + CGFloat newHeight = originFrame.size.height; + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.size.width = originFrame.size.width + xDelta; + } + } + + break; + case TOCropViewOverlayEdgeBottom: + if (self.aspectRatioLockEnabled) { + aspectVertical = YES; + CGPoint scaleOrigin = (CGPoint){CGRectGetMidX(originFrame), CGRectGetMinY(originFrame)}; + frame.size.width = frame.size.height * aspectRatio; + frame.origin.x = scaleOrigin.x - (frame.size.width * 0.5f); + frame.size.height = originFrame.size.height + yDelta; + frame.size.height = MIN(frame.size.height, contentFrame.size.width / aspectRatio); + } else { + CGFloat newWidth = originFrame.size.width; + CGFloat newHeight = originFrame.size.height + yDelta; + + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.size.height = originFrame.size.height + yDelta; + } + } + break; + case TOCropViewOverlayEdgeTop: + if (self.aspectRatioLockEnabled) { + aspectVertical = YES; + yDelta = MAX(0, yDelta); + CGPoint scaleOrigin = (CGPoint){CGRectGetMidX(originFrame), CGRectGetMaxY(originFrame)}; + frame.size.width = frame.size.height * aspectRatio; + frame.origin.x = scaleOrigin.x - (frame.size.width * 0.5f); + frame.origin.y = originFrame.origin.y + yDelta; + frame.size.height = originFrame.size.height - yDelta; + } else { + CGFloat newWidth = originFrame.size.width; + CGFloat newHeight = originFrame.size.height - yDelta; + + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.origin.y = originFrame.origin.y + yDelta; + frame.size.height = originFrame.size.height - yDelta; + } + } + + clampMinFromTop = YES; + + break; + case TOCropViewOverlayEdgeTopLeft: + if (self.aspectRatioLockEnabled) { + xDelta = MAX(xDelta, 0); + yDelta = MAX(yDelta, 0); + + CGPoint distance; + distance.x = 1.0f - (xDelta / CGRectGetWidth(originFrame)); + distance.y = 1.0f - (yDelta / CGRectGetHeight(originFrame)); + + CGFloat scale = (distance.x + distance.y) * 0.5f; + + frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); + frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); + frame.origin.x = originFrame.origin.x + (CGRectGetWidth(originFrame) - frame.size.width); + frame.origin.y = originFrame.origin.y + (CGRectGetHeight(originFrame) - frame.size.height); + + aspectVertical = YES; + aspectHorizontal = YES; + } else { + CGFloat newWidth = originFrame.size.width - xDelta; + CGFloat newHeight = originFrame.size.height - yDelta; + + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.origin.x = originFrame.origin.x + xDelta; + frame.size.width = originFrame.size.width - xDelta; + frame.origin.y = originFrame.origin.y + yDelta; + frame.size.height = originFrame.size.height - yDelta; + } + } + + clampMinFromTop = YES; + clampMinFromLeft = YES; + + break; + case TOCropViewOverlayEdgeTopRight: + if (self.aspectRatioLockEnabled) { + xDelta = MIN(xDelta, 0); + yDelta = MAX(yDelta, 0); + + CGPoint distance; + distance.x = 1.0f - ((-xDelta) / CGRectGetWidth(originFrame)); + distance.y = 1.0f - ((yDelta) / CGRectGetHeight(originFrame)); + + CGFloat scale = (distance.x + distance.y) * 0.5f; + + frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); + frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); + frame.origin.y = originFrame.origin.y + (CGRectGetHeight(originFrame) - frame.size.height); + + aspectVertical = YES; + aspectHorizontal = YES; + } else { + CGFloat newWidth = originFrame.size.width + xDelta; + CGFloat newHeight = originFrame.size.height - yDelta; + + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.size.width = originFrame.size.width + xDelta; + frame.origin.y = originFrame.origin.y + yDelta; + frame.size.height = originFrame.size.height - yDelta; + } + } + + clampMinFromTop = YES; + + break; + case TOCropViewOverlayEdgeBottomLeft: + if (self.aspectRatioLockEnabled) { + CGPoint distance; + distance.x = 1.0f - (xDelta / CGRectGetWidth(originFrame)); + distance.y = 1.0f - (-yDelta / CGRectGetHeight(originFrame)); + + CGFloat scale = (distance.x + distance.y) * 0.5f; + + frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); + frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); + frame.origin.x = CGRectGetMaxX(originFrame) - frame.size.width; + + aspectVertical = YES; + aspectHorizontal = YES; + } else { + CGFloat newWidth = originFrame.size.width - xDelta; + CGFloat newHeight = originFrame.size.height + yDelta; + + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.size.height = originFrame.size.height + yDelta; + frame.origin.x = originFrame.origin.x + xDelta; + frame.size.width = originFrame.size.width - xDelta; + } + } + + clampMinFromLeft = YES; + + break; + case TOCropViewOverlayEdgeBottomRight: + if (self.aspectRatioLockEnabled) { + CGPoint distance; + distance.x = 1.0f - ((-1 * xDelta) / CGRectGetWidth(originFrame)); + distance.y = 1.0f - ((-1 * yDelta) / CGRectGetHeight(originFrame)); + + CGFloat scale = (distance.x + distance.y) * 0.5f; + + frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); + frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); + + aspectVertical = YES; + aspectHorizontal = YES; + } else { + CGFloat newWidth = originFrame.size.width + xDelta; + CGFloat newHeight = originFrame.size.height + yDelta; + + if (MIN(newHeight, newWidth) / MAX(newHeight, newWidth) >= (double)_minimumAspectRatio) { + frame.size.height = originFrame.size.height + yDelta; + frame.size.width = originFrame.size.width + xDelta; + } + } + break; + case TOCropViewOverlayEdgeNone: + break; + } + + // The absolute max/min size the box may be in the bounds of the crop view + CGSize minSize = (CGSize){kTOCropViewMinimumBoxSize, kTOCropViewMinimumBoxSize}; + CGSize maxSize = (CGSize){CGRectGetWidth(contentFrame), CGRectGetHeight(contentFrame)}; + + // clamp the box to ensure it doesn't go beyond the bounds we've set + if (self.aspectRatioLockEnabled && aspectHorizontal) { + maxSize.height = contentFrame.size.width / aspectRatio; + minSize.width = kTOCropViewMinimumBoxSize * aspectRatio; + } + + if (self.aspectRatioLockEnabled && aspectVertical) { + maxSize.width = contentFrame.size.height * aspectRatio; + minSize.height = kTOCropViewMinimumBoxSize / aspectRatio; + } + + // Clamp the width if it goes over + if (clampMinFromLeft) { + CGFloat maxWidth = CGRectGetMaxX(self.cropOriginFrame) - contentFrame.origin.x; + frame.size.width = MIN(frame.size.width, maxWidth); + } + + if (clampMinFromTop) { + CGFloat maxHeight = CGRectGetMaxY(self.cropOriginFrame) - contentFrame.origin.y; + frame.size.height = MIN(frame.size.height, maxHeight); + } + + // Clamp the minimum size + frame.size.width = MAX(frame.size.width, minSize.width); + frame.size.height = MAX(frame.size.height, minSize.height); + + // Clamp the maximum size + frame.size.width = MIN(frame.size.width, maxSize.width); + frame.size.height = MIN(frame.size.height, maxSize.height); + + // Clamp the X position of the box to the interior of the cropping bounds + frame.origin.x = MAX(frame.origin.x, CGRectGetMinX(contentFrame)); + frame.origin.x = MIN(frame.origin.x, CGRectGetMaxX(contentFrame) - minSize.width); + + // Clamp the Y postion of the box to the interior of the cropping bounds + frame.origin.y = MAX(frame.origin.y, CGRectGetMinY(contentFrame)); + frame.origin.y = MIN(frame.origin.y, CGRectGetMaxY(contentFrame) - minSize.height); + + // Once the box is completely shrunk, clamp its ability to move + if (clampMinFromLeft && frame.size.width <= minSize.width + FLT_EPSILON) { + frame.origin.x = CGRectGetMaxX(originFrame) - minSize.width; + } + + // Once the box is completely shrunk, clamp its ability to move + if (clampMinFromTop && frame.size.height <= minSize.height + FLT_EPSILON) { + frame.origin.y = CGRectGetMaxY(originFrame) - minSize.height; + } + + self.cropBoxFrame = frame; + + [self checkForCanReset]; +} + +- (void)resetLayoutToDefaultAnimated:(BOOL)animated { + // If resetting the crop view includes resetting the aspect ratio, + // reset it to zero here. But set the ivar directly since there's no point + // in performing the relayout calculations right before a reset. + if (self.hasAspectRatio && self.resetAspectRatioEnabled) { + _aspectRatio = CGSizeZero; + } + + if (animated == NO || self.angle != 0) { + // Reset all of the rotation transforms + _angle = 0; + + // Set the scroll to 1.0f to reset the transform scale + self.scrollView.zoomScale = 1.0f; + + CGRect imageRect = (CGRect){CGPointZero, self.image.size}; + + // Reset everything about the background container and image views + self.backgroundImageView.transform = CGAffineTransformIdentity; + self.backgroundContainerView.transform = CGAffineTransformIdentity; + self.backgroundImageView.frame = imageRect; + self.backgroundContainerView.frame = imageRect; + + // Reset the transform ans size of just the foreground image + self.foregroundImageView.transform = CGAffineTransformIdentity; + self.foregroundImageView.frame = imageRect; + + // Reset the layout + [self layoutInitialImage]; + + // Enable / Disable the reset button + [self checkForCanReset]; + + return; + } + + // If we were in the middle of a reset timer, cancel it as we'll + // manually perform a restoration animation here + if (self.resetTimer) { + [self cancelResetTimer]; + [self setEditing:NO resetCropBox:NO animated:NO]; + } + + [self setSimpleRenderMode:YES animated:NO]; + + // Perform an animation of the image zooming back out to its original size + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [UIView animateWithDuration:0.5f + delay:0.0f + usingSpringWithDamping:1.0f + initialSpringVelocity:1.0f + options:UIViewAnimationOptionBeginFromCurrentState + animations:^{ + [self layoutInitialImage]; + } + completion:^(BOOL complete) { + [self setSimpleRenderMode:NO animated:YES]; + }]; + }); +} + +- (void)toggleTranslucencyViewVisible:(BOOL)visible { + [(UIVisualEffectView *)self.translucencyView setEffect:visible ? self.translucencyEffect : nil]; +} + +- (void)updateToImageCropFrame:(CGRect)imageCropframe { + // Convert the image crop frame's size from image space to the screen space + CGFloat minimumSize = self.scrollView.minimumZoomScale; + CGPoint scaledOffset = (CGPoint){imageCropframe.origin.x * minimumSize, imageCropframe.origin.y * minimumSize}; + CGSize scaledCropSize = (CGSize){imageCropframe.size.width * minimumSize, imageCropframe.size.height * minimumSize}; + + // Work out the scale necessary to upscale the crop size to fit the content bounds of the crop bound + CGRect bounds = self.contentBounds; + CGFloat scale = MIN(bounds.size.width / scaledCropSize.width, bounds.size.height / scaledCropSize.height); + + // Zoom into the scroll view to the appropriate size, transiently raising the + // ceiling if the restored crop requires more zoom than is normally allowed + // (the next layout-driven update re-derives the ceiling from the base value) + self.scrollView.maximumZoomScale = MAX(self.scrollView.maximumZoomScale, self.scrollView.minimumZoomScale * scale); + self.scrollView.zoomScale = self.scrollView.minimumZoomScale * scale; + + CGSize contentSize = self.scrollView.contentSize; + self.scrollView.contentSize = CGSizeMake(floorf(contentSize.width), floorf(contentSize.height)); + + // Work out the size and offset of the upscaled crop box + CGRect frame = CGRectZero; + frame.size = (CGSize){floorf(scaledCropSize.width * scale), floorf(scaledCropSize.height * scale)}; + + // set the crop box + CGRect cropBoxFrame = CGRectZero; + cropBoxFrame.size = frame.size; + cropBoxFrame.origin.x = floorf(CGRectGetMidX(bounds) - (frame.size.width * 0.5f)); + cropBoxFrame.origin.y = floorf(CGRectGetMidY(bounds) - (frame.size.height * 0.5f)); + self.cropBoxFrame = cropBoxFrame; + + frame.origin.x = ceilf((scaledOffset.x * scale) - self.scrollView.contentInset.left); + frame.origin.y = ceilf((scaledOffset.y * scale) - self.scrollView.contentInset.top); + self.scrollView.contentOffset = frame.origin; +} + +#pragma mark - Gesture Recognizer - +- (void)gridPanGestureRecognized:(UIPanGestureRecognizer *)recognizer { + CGPoint point = [recognizer locationInView:self]; + + if (recognizer.state == UIGestureRecognizerStateBegan) { + [self startEditing]; + self.panOriginPoint = point; + self.cropOriginFrame = self.cropBoxFrame; + self.tappedEdge = [self cropEdgeForPoint:self.panOriginPoint]; + } + + if (recognizer.state == UIGestureRecognizerStateEnded || + recognizer.state == UIGestureRecognizerStateCancelled || + recognizer.state == UIGestureRecognizerStateFailed) { + [self startResetTimer]; + } + + [self updateCropBoxFrameWithGesturePoint:point]; +} + +- (void)longPressGestureRecognized:(UILongPressGestureRecognizer *)recognizer { + if (recognizer.state == UIGestureRecognizerStateBegan) + [self.gridOverlayView setGridHidden:NO animated:YES]; + + if (recognizer.state == UIGestureRecognizerStateEnded) + [self.gridOverlayView setGridHidden:YES animated:YES]; +} + +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { + if (gestureRecognizer != self.gridPanGestureRecognizer) + return YES; + + CGPoint tapPoint = [gestureRecognizer locationInView:self]; + + CGRect frame = self.gridOverlayView.frame; + CGRect innerFrame = CGRectInset(frame, 22.0f, 22.0f); + CGRect outerFrame = CGRectInset(frame, -22.0f, -22.0f); + + if (CGRectContainsPoint(innerFrame, tapPoint) || !CGRectContainsPoint(outerFrame, tapPoint)) + return NO; + + return YES; +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { + if (self.gridPanGestureRecognizer.state == UIGestureRecognizerStateChanged) { + return NO; + } + return YES; +} + +#pragma mark - Timer - +- (void)startResetTimer { + if (self.resetTimer) + return; + + // Use a block-based timer so a pending reset doesn't retain this view + // beyond its natural lifetime (eg, when dismissed mid-adjustment) + __weak typeof(self) weakSelf = self; + self.resetTimer = [NSTimer scheduledTimerWithTimeInterval:self.cropAdjustingDelay + repeats:NO + block:^(NSTimer *timer) { + [weakSelf timerTriggered]; + }]; +} + +// A cancelled touch doesn't imply the interaction is over: the scroll view cancels the +// touches it delivered as a normal part of its own pan or pinch recognizer taking over, +// which happens with the user's finger still down. While one of those is underway the +// scroll view's delegate arms the timer for us once it finishes, so arming it here as +// well would trigger a reset mid-gesture. What's left is a touch cancelled while nothing +// is scrolling - an incoming call or system alert - which would otherwise leave the crop +// view stuck in its editing appearance. +- (void)handleScrollViewTouchCancellation { + if (self.scrollView.isDragging || self.scrollView.isZooming || self.scrollView.isDecelerating) { + return; + } + + [self startResetTimer]; +} + +- (void)timerTriggered { + [self setEditing:NO resetCropBox:YES animated:YES]; + [self.resetTimer invalidate]; + self.resetTimer = nil; +} + +- (void)cancelResetTimer { + [self.resetTimer invalidate]; + self.resetTimer = nil; +} + +- (TOCropViewOverlayEdge)cropEdgeForPoint:(CGPoint)point { + CGRect frame = self.cropBoxFrame; + + // account for padding around the box + frame = CGRectInset(frame, -32.0f, -32.0f); + + // Make sure the corners take priority + CGRect topLeftRect = (CGRect){frame.origin, {64, 64}}; + if (CGRectContainsPoint(topLeftRect, point)) + return TOCropViewOverlayEdgeTopLeft; + + CGRect topRightRect = topLeftRect; + topRightRect.origin.x = CGRectGetMaxX(frame) - 64.0f; + if (CGRectContainsPoint(topRightRect, point)) + return TOCropViewOverlayEdgeTopRight; + + CGRect bottomLeftRect = topLeftRect; + bottomLeftRect.origin.y = CGRectGetMaxY(frame) - 64.0f; + if (CGRectContainsPoint(bottomLeftRect, point)) + return TOCropViewOverlayEdgeBottomLeft; + + CGRect bottomRightRect = topRightRect; + bottomRightRect.origin.y = bottomLeftRect.origin.y; + if (CGRectContainsPoint(bottomRightRect, point)) + return TOCropViewOverlayEdgeBottomRight; + + // Check for edges + CGRect topRect = (CGRect){frame.origin, {CGRectGetWidth(frame), 64.0f}}; + if (CGRectContainsPoint(topRect, point)) + return TOCropViewOverlayEdgeTop; + + CGRect bottomRect = topRect; + bottomRect.origin.y = CGRectGetMaxY(frame) - 64.0f; + if (CGRectContainsPoint(bottomRect, point)) + return TOCropViewOverlayEdgeBottom; + + CGRect leftRect = (CGRect){frame.origin, {64.0f, CGRectGetHeight(frame)}}; + if (CGRectContainsPoint(leftRect, point)) + return TOCropViewOverlayEdgeLeft; + + CGRect rightRect = leftRect; + rightRect.origin.x = CGRectGetMaxX(frame) - 64.0f; + if (CGRectContainsPoint(rightRect, point)) + return TOCropViewOverlayEdgeRight; + + return TOCropViewOverlayEdgeNone; +} + +#pragma mark - Scroll View Delegate - + +- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView { + return self.backgroundContainerView; +} +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + [self matchForegroundToBackground]; +} + +- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { + [self startEditing]; + self.canBeReset = YES; +} + +- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view { + [self startEditing]; + self.canBeReset = YES; +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { + [self startResetTimer]; + [self checkForCanReset]; +} + +- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale { + [self startResetTimer]; + [self checkForCanReset]; +} + +- (void)scrollViewDidZoom:(UIScrollView *)scrollView { + if (scrollView.isTracking) { + self.cropBoxLastEditedZoomScale = scrollView.zoomScale; + self.cropBoxLastEditedMinZoomScale = scrollView.minimumZoomScale; + self.cropBoxLastEditedMaxZoomScale = self.baseMaximumZoomScale; + } + + [self matchForegroundToBackground]; +} + +- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { + if (!decelerate) + [self startResetTimer]; +} + +#pragma mark - Accessors - + +- (void)setCropBoxResizeEnabled:(BOOL)panResizeEnabled { + _cropBoxResizeEnabled = panResizeEnabled; + self.gridPanGestureRecognizer.enabled = _cropBoxResizeEnabled; +} + +- (void)setCropBoxFrame:(CGRect)cropBoxFrame { + if (CGRectEqualToRect(cropBoxFrame, _cropBoxFrame)) { + return; + } + + // Upon init, sometimes the box size is still 0 (or NaN), which can result in CALayer issues + CGSize frameSize = cropBoxFrame.size; + if (frameSize.width < FLT_EPSILON || frameSize.height < FLT_EPSILON) { + return; + } + if (isnan(frameSize.width) || isnan(frameSize.height)) { + return; + } + + // clamp the cropping region to the inset boundaries of the screen + CGRect contentFrame = self.contentBounds; + CGFloat xOrigin = ceilf(contentFrame.origin.x); + CGFloat xDelta = cropBoxFrame.origin.x - xOrigin; + cropBoxFrame.origin.x = floorf(MAX(cropBoxFrame.origin.x, xOrigin)); + if (xDelta < -FLT_EPSILON) // If we clamp the x value, ensure we compensate for the subsequent delta generated in the width (Or else, the box will keep growing) + cropBoxFrame.size.width += xDelta; + + CGFloat yOrigin = ceilf(contentFrame.origin.y); + CGFloat yDelta = cropBoxFrame.origin.y - yOrigin; + cropBoxFrame.origin.y = floorf(MAX(cropBoxFrame.origin.y, yOrigin)); + if (yDelta < -FLT_EPSILON) + cropBoxFrame.size.height += yDelta; + + // given the clamped X/Y values, make sure we can't extend the crop box beyond the edge of the screen in the current state + CGFloat maxWidth = (contentFrame.size.width + contentFrame.origin.x) - cropBoxFrame.origin.x; + cropBoxFrame.size.width = floorf(MIN(cropBoxFrame.size.width, maxWidth)); + + CGFloat maxHeight = (contentFrame.size.height + contentFrame.origin.y) - cropBoxFrame.origin.y; + cropBoxFrame.size.height = floorf(MIN(cropBoxFrame.size.height, maxHeight)); + + // Make sure we can't make the crop box too small + cropBoxFrame.size.width = MAX(cropBoxFrame.size.width, kTOCropViewMinimumBoxSize); + cropBoxFrame.size.height = MAX(cropBoxFrame.size.height, kTOCropViewMinimumBoxSize); + + _cropBoxFrame = cropBoxFrame; + + self.foregroundContainerView.frame = _cropBoxFrame; // set the clipping view to match the new rect + self.gridOverlayView.frame = _cropBoxFrame; // set the new overlay view to match the same region + + // If the mask layer is present, adjust its transform to fit the new container view size + if (self.croppingStyle == TOCropViewCroppingStyleCircular) { + CGFloat halfWidth = self.foregroundContainerView.frame.size.width * 0.5f; + self.foregroundContainerView.layer.cornerRadius = halfWidth; + } + + // reset the scroll view insets to match the region of the new crop rect + self.scrollView.contentInset = (UIEdgeInsets){CGRectGetMinY(_cropBoxFrame), + CGRectGetMinX(_cropBoxFrame), + CGRectGetMaxY(self.bounds) - CGRectGetMaxY(_cropBoxFrame), + CGRectGetMaxX(self.bounds) - CGRectGetMaxX(_cropBoxFrame)}; + + // if necessary, work out the new minimum size of the scroll view so it fills the crop box + CGSize imageSize = self.backgroundContainerView.bounds.size; + CGFloat scale = MAX(cropBoxFrame.size.height / imageSize.height, cropBoxFrame.size.width / imageSize.width); + self.scrollView.minimumZoomScale = scale; + [self updateScrollViewMaximumZoomScale]; + + // make sure content isn't smaller than the crop box + CGSize size = self.scrollView.contentSize; + size.width = floorf(size.width); + size.height = floorf(size.height); + self.scrollView.contentSize = size; + + // IMPORTANT: Force the scroll view to update its content after changing the zoom scale + self.scrollView.zoomScale = self.scrollView.zoomScale; + + [self matchForegroundToBackground]; // re-align the background content to match +} + +- (void)setEditing:(BOOL)editing { + [self setEditing:editing resetCropBox:NO animated:NO]; +} + +- (void)setSimpleRenderMode:(BOOL)simpleMode { + [self setSimpleRenderMode:simpleMode animated:NO]; +} + +- (BOOL)cropBoxAspectRatioIsPortrait { + CGRect cropFrame = self.cropBoxFrame; + return CGRectGetWidth(cropFrame) < CGRectGetHeight(cropFrame); +} + +- (CGRect)imageCropFrame { + CGSize imageSize = self.imageSize; + CGSize contentSize = self.scrollView.contentSize; + CGRect cropBoxFrame = self.cropBoxFrame; + CGPoint contentOffset = self.scrollView.contentOffset; + UIEdgeInsets edgeInsets = self.scrollView.contentInset; + CGFloat scale = MIN(imageSize.width / contentSize.width, imageSize.height / contentSize.height); + + CGRect frame = CGRectZero; + + // Calculate the normalized origin, clamped inside the image so the size + // subtraction below can never go negative (eg, during rubber-band overscroll) + frame.origin.x = floorf((floorf(contentOffset.x) + edgeInsets.left) * (imageSize.width / contentSize.width)); + frame.origin.x = MAX(0, MIN(frame.origin.x, imageSize.width)); + + frame.origin.y = floorf((floorf(contentOffset.y) + edgeInsets.top) * (imageSize.height / contentSize.height)); + frame.origin.y = MAX(0, MIN(frame.origin.y, imageSize.height)); + + // Calculate the normalized width, clamped so the rect never extends past the image + frame.size.width = ceilf(cropBoxFrame.size.width * scale); + frame.size.width = MIN(imageSize.width - frame.origin.x, frame.size.width); + + // Calculate normalized height + if (floor(cropBoxFrame.size.width) == floor(cropBoxFrame.size.height)) { + frame.size.height = frame.size.width; + } else { + frame.size.height = ceilf(cropBoxFrame.size.height * scale); + } + frame.size.height = MIN(imageSize.height - frame.origin.y, frame.size.height); + + return frame; +} + +- (void)setImageCropFrame:(CGRect)imageCropFrame { + if (!self.initialSetupPerformed) { + self.restoreImageCropFrame = imageCropFrame; + return; + } + + [self updateToImageCropFrame:imageCropFrame]; +} + +- (void)setCroppingViewsHidden:(BOOL)hidden { + [self setCroppingViewsHidden:hidden animated:NO]; +} + +- (void)setCroppingViewsHidden:(BOOL)hidden animated:(BOOL)animated { + if (_croppingViewsHidden == hidden) + return; + + _croppingViewsHidden = hidden; + + CGFloat alpha = hidden ? 0.0f : 1.0f; + + if (animated == NO) { + self.backgroundImageView.alpha = alpha; + self.foregroundContainerView.alpha = alpha; + self.gridOverlayView.alpha = alpha; + + [self toggleTranslucencyViewVisible:!hidden]; + + return; + } + + self.foregroundContainerView.alpha = alpha; + self.backgroundImageView.alpha = alpha; + + [UIView animateWithDuration:0.4f + animations:^{ + [self toggleTranslucencyViewVisible:!hidden]; + self.gridOverlayView.alpha = alpha; + }]; +} + +- (void)setBackgroundImageViewHidden:(BOOL)hidden animated:(BOOL)animated { + if (animated == NO) { + self.backgroundImageView.hidden = hidden; + return; + } + + CGFloat beforeAlpha = hidden ? 1.0f : 0.0f; + CGFloat toAlpha = hidden ? 0.0f : 1.0f; + + self.backgroundImageView.hidden = NO; + self.backgroundImageView.alpha = beforeAlpha; + [UIView animateWithDuration:0.5f + animations:^{ + self.backgroundImageView.alpha = toAlpha; + } + completion:^(BOOL complete) { + if (hidden) { + self.backgroundImageView.hidden = YES; + } + }]; +} + +- (void)setAlwaysShowCroppingGrid:(BOOL)alwaysShowCroppingGrid { + if (alwaysShowCroppingGrid == _alwaysShowCroppingGrid) { + return; + } + _alwaysShowCroppingGrid = alwaysShowCroppingGrid; + [self.gridOverlayView setGridHidden:!_alwaysShowCroppingGrid animated:YES]; +} + +- (void)setTranslucencyAlwaysHidden:(BOOL)translucencyAlwaysHidden { + if (_translucencyAlwaysHidden == translucencyAlwaysHidden) { + return; + } + _translucencyAlwaysHidden = translucencyAlwaysHidden; + self.translucencyView.hidden = _translucencyAlwaysHidden; +} + +- (void)setGridOverlayHidden:(BOOL)gridOverlayHidden { + [self setGridOverlayHidden:gridOverlayHidden animated:NO]; +} + +- (void)setGridOverlayHidden:(BOOL)gridOverlayHidden animated:(BOOL)animated { + _gridOverlayHidden = gridOverlayHidden; + + if (!animated) { + self.gridOverlayView.alpha = gridOverlayHidden ? 0.0f : 1.0f; + return; + } + + self.gridOverlayView.alpha = gridOverlayHidden ? 1.0f : 0.0f; + [UIView animateWithDuration:0.4f + animations:^{ + self.gridOverlayView.alpha = gridOverlayHidden ? 0.0f : 1.0f; + }]; +} + +- (CGRect)imageViewFrame { + CGRect frame = CGRectZero; + frame.origin.x = -self.scrollView.contentOffset.x; + frame.origin.y = -self.scrollView.contentOffset.y; + frame.size = self.scrollView.contentSize; + return frame; +} + +- (void)setCanBeReset:(BOOL)canReset { + if (canReset == _canBeReset) { + return; + } + + _canBeReset = canReset; + + if (canReset) { + if ([self.delegate respondsToSelector:@selector(cropViewDidBecomeResettable:)]) + [self.delegate cropViewDidBecomeResettable:self]; + } else { + if ([self.delegate respondsToSelector:@selector(cropViewDidBecomeNonResettable:)]) + [self.delegate cropViewDidBecomeNonResettable:self]; + } +} + +- (void)setAngle:(NSInteger)angle { + // Only multiples of 90 degrees are supported + NSInteger newAngle = angle; + if (angle % 90 != 0) { + newAngle = 0; + } + + // Normalize to the (-360, 360) range the rotation logic produces + // (eg, 360 wraps back around to 0), preserving direction + newAngle %= 360; + + // The initial layout would not have been performed yet. + // Save the value and it will be applied when it has + if (!self.initialSetupPerformed) { + self.restoreAngle = newAngle; + return; + } + + // Negative values are allowed, so rotate clockwise or counter clockwise depending + // on direction. Compare the signed values since +90 and -90 are distinct states. + const BOOL clockwise = (newAngle >= 0); + while (self.angle != newAngle) { + const NSInteger previousAngle = self.angle; + [self rotateImageNinetyDegreesAnimated:NO clockwise:clockwise completion:nil]; + + // Bail out if no rotation was performed (eg, an animated rotation is + // still in flight) rather than spinning forever on the main thread + if (self.angle == previousAngle) { + break; + } + } +} + +#pragma mark - Editing Mode - +- (void)startEditing { + [self cancelResetTimer]; + [self setEditing:YES resetCropBox:NO animated:YES]; +} + +- (void)setEditing:(BOOL)editing resetCropBox:(BOOL)resetCropbox animated:(BOOL)animated { + if (editing == _editing) + return; + + _editing = editing; + + // Toggle the visiblity of the gridlines when not editing + BOOL hidden = !_editing; + if (self.alwaysShowCroppingGrid) { + hidden = NO; + } // Override this if the user requires + [self.gridOverlayView setGridHidden:hidden animated:animated]; + + if (resetCropbox) { + [self moveCroppedContentToCenterAnimated:animated]; + [self captureStateForImageRotation]; + self.cropBoxLastEditedAngle = self.angle; + } + + if (animated == NO) { + [self toggleTranslucencyViewVisible:!editing]; + return; + } + + CGFloat duration = editing ? 0.05f : 0.35f; + CGFloat delay = editing ? 0.0f : 0.35f; + + if (self.croppingStyle == TOCropViewCroppingStyleCircular) { + delay = 0.0f; + } + + [UIView animateKeyframesWithDuration:duration + delay:delay + options:0 + animations:^{ + [self toggleTranslucencyViewVisible:!editing]; + } + completion:nil]; +} + +- (void)moveCroppedContentToCenterAnimated:(BOOL)animated { + if (self.internalLayoutDisabled) + return; + + CGRect contentRect = self.contentBounds; + CGRect cropFrame = self.cropBoxFrame; + + // Ensure we only proceed after the crop frame has been setup for the first time + if (cropFrame.size.width < FLT_EPSILON || cropFrame.size.height < FLT_EPSILON) { + return; + } + + // The scale we need to scale up the crop box to fit full screen + CGFloat scale = MIN(CGRectGetWidth(contentRect) / CGRectGetWidth(cropFrame), CGRectGetHeight(contentRect) / CGRectGetHeight(cropFrame)); + + CGPoint focusPoint = (CGPoint){CGRectGetMidX(cropFrame), CGRectGetMidY(cropFrame)}; + CGPoint midPoint = (CGPoint){CGRectGetMidX(contentRect), CGRectGetMidY(contentRect)}; + + cropFrame.size.width = ceilf(cropFrame.size.width * scale); + cropFrame.size.height = ceilf(cropFrame.size.height * scale); + cropFrame.origin.x = contentRect.origin.x + ceilf((contentRect.size.width - cropFrame.size.width) * 0.5f); + cropFrame.origin.y = contentRect.origin.y + ceilf((contentRect.size.height - cropFrame.size.height) * 0.5f); + + // Work out the point on the scroll content that the focusPoint is aiming at + CGPoint contentTargetPoint = CGPointZero; + contentTargetPoint.x = ((focusPoint.x + self.scrollView.contentOffset.x) * scale); + contentTargetPoint.y = ((focusPoint.y + self.scrollView.contentOffset.y) * scale); + + // Work out where the crop box is focusing, so we can re-align to center that point + __block CGPoint offset = CGPointZero; + offset.x = -midPoint.x + contentTargetPoint.x; + offset.y = -midPoint.y + contentTargetPoint.y; + + // clamp the content so it doesn't create any seams around the grid + offset.x = MAX(-cropFrame.origin.x, offset.x); + offset.y = MAX(-cropFrame.origin.y, offset.y); + + __weak typeof(self) weakSelf = self; + void (^translateBlock)(void) = ^{ + typeof(self) strongSelf = weakSelf; + + // Setting these scroll view properties will trigger + // the foreground matching method via their delegates, + // multiple times inside the same animation block, resulting + // in glitchy animations. + // + // Disable matching for now, and explicitly update at the end. + strongSelf.disableForgroundMatching = YES; + { + // Slight hack. This method needs to be called during `[UIViewController viewDidLayoutSubviews]` + // in order for the crop view to resize itself during iPad split screen events. + // On the first run, even though scale is exactly 1.0f, performing this multiplication introduces + // a floating point noise that zooms the image in by about 5 pixels. This fixes that issue. + if (scale < 1.0f - FLT_EPSILON || scale > 1.0f + FLT_EPSILON) { + strongSelf.scrollView.zoomScale *= scale; + strongSelf.scrollView.zoomScale = MIN(strongSelf.scrollView.maximumZoomScale, strongSelf.scrollView.zoomScale); + } + + // If it turns out the zoom operation would have exceeded the minizum zoom scale, don't apply + // the content offset + if (strongSelf.scrollView.zoomScale < strongSelf.scrollView.maximumZoomScale - FLT_EPSILON) { + offset.x = MIN(-CGRectGetMaxX(cropFrame) + strongSelf.scrollView.contentSize.width, offset.x); + offset.y = MIN(-CGRectGetMaxY(cropFrame) + strongSelf.scrollView.contentSize.height, offset.y); + strongSelf.scrollView.contentOffset = offset; + } + + strongSelf.cropBoxFrame = cropFrame; + } + strongSelf.disableForgroundMatching = NO; + + // Explicitly update the matching at the end of the calculations + [strongSelf matchForegroundToBackground]; + }; + + if (!animated) { + translateBlock(); + return; + } + + [self matchForegroundToBackground]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [UIView animateWithDuration:0.5f + delay:0.0f + usingSpringWithDamping:1.0f + initialSpringVelocity:1.0f + options:UIViewAnimationOptionBeginFromCurrentState + animations:translateBlock + completion:nil]; + }); +} + +- (void)setSimpleRenderMode:(BOOL)simpleMode animated:(BOOL)animated { + if (simpleMode == _simpleRenderMode) + return; + + _simpleRenderMode = simpleMode; + + self.editing = NO; + + if (animated == NO) { + [self toggleTranslucencyViewVisible:!simpleMode]; + + return; + } + + [UIView animateWithDuration:0.25f + animations:^{ + [self toggleTranslucencyViewVisible:!simpleMode]; + }]; +} + +- (void)setAspectRatio:(CGSize)aspectRatio { + [self setAspectRatio:aspectRatio animated:NO]; +} + +- (void)setAspectRatio:(CGSize)aspectRatio animated:(BOOL)animated { + _aspectRatio = aspectRatio; + + // Will be executed automatically when added to a super view + if (!self.initialSetupPerformed) { + return; + } + + BOOL zoomOut = NO; + + // Passing in an empty size (or one with a zero component) will revert back to the image aspect ratio + if (aspectRatio.width < FLT_EPSILON || aspectRatio.height < FLT_EPSILON) { + aspectRatio = (CGSize){self.imageSize.width, self.imageSize.height}; + zoomOut = YES; // Prevent from steadily zooming in when cycling between alternate aspectRatios and original + } + + CGRect boundsFrame = self.contentBounds; + CGRect cropBoxFrame = self.cropBoxFrame; + CGPoint offset = self.scrollView.contentOffset; + + BOOL cropBoxIsPortrait = NO; + if ((NSInteger)aspectRatio.width == 1 && (NSInteger)aspectRatio.height == 1) + cropBoxIsPortrait = self.image.size.width > self.image.size.height; + else + cropBoxIsPortrait = aspectRatio.width < aspectRatio.height; + + // OneKey OK-51551: size the crop box from the content bounds. Upstream + // derives it from the current crop box, which shrinks a little more on + // every rotation because the rotated box is scaled down to fit. + CGFloat boundsWidth = CGRectGetWidth(boundsFrame); + CGFloat boundsHeight = CGRectGetHeight(boundsFrame); + + if (cropBoxIsPortrait) { + CGFloat targetHeight = boundsHeight; + CGFloat newWidth = floorf(targetHeight * (aspectRatio.width / aspectRatio.height)); + + CGFloat deltaH = cropBoxFrame.size.height - targetHeight; + cropBoxFrame.size.height = targetHeight; + offset.y += (deltaH * 0.5f); + + if (newWidth > boundsWidth) { + CGFloat scale = boundsWidth / newWidth; + CGFloat scaledHeight = targetHeight * scale; + CGFloat deltaH2 = cropBoxFrame.size.height - scaledHeight; + cropBoxFrame.size.height = scaledHeight; + offset.y += (deltaH2 * 0.5f); + newWidth = boundsWidth; + } + + CGFloat deltaW = cropBoxFrame.size.width - newWidth; + cropBoxFrame.size.width = newWidth; + offset.x += (deltaW * 0.5f); + zoomOut = YES; + } else { + CGFloat targetWidth = boundsWidth; + CGFloat newHeight = floorf(targetWidth * (aspectRatio.height / aspectRatio.width)); + + CGFloat deltaW = cropBoxFrame.size.width - targetWidth; + cropBoxFrame.size.width = targetWidth; + offset.x += (deltaW * 0.5f); + + if (newHeight > boundsHeight) { + CGFloat scale = boundsHeight / newHeight; + CGFloat scaledWidth = targetWidth * scale; + CGFloat deltaW2 = cropBoxFrame.size.width - scaledWidth; + cropBoxFrame.size.width = scaledWidth; + offset.x += (deltaW2 * 0.5f); + newHeight = boundsHeight; + } + + CGFloat deltaH = cropBoxFrame.size.height - newHeight; + cropBoxFrame.size.height = newHeight; + offset.y += (deltaH * 0.5f); + zoomOut = YES; + } + + self.cropBoxLastEditedSize = cropBoxFrame.size; + self.cropBoxLastEditedAngle = self.angle; + + void (^translateBlock)(void) = ^{ + self.scrollView.contentOffset = offset; + self.cropBoxFrame = cropBoxFrame; + + if (zoomOut) { + self.scrollView.zoomScale = self.scrollView.minimumZoomScale; + } + + [self moveCroppedContentToCenterAnimated:NO]; + [self checkForCanReset]; + }; + + if (animated == NO) { + translateBlock(); + return; + } + + [UIView animateWithDuration:0.5f + delay:0.0 + usingSpringWithDamping:1.0f + initialSpringVelocity:0.7f + options:UIViewAnimationOptionBeginFromCurrentState + animations:translateBlock + completion:nil]; +} + +- (void)rotateImageNinetyDegreesAnimated:(BOOL)animated completion:(void (^)(BOOL completed))completionHandler { + [self rotateImageNinetyDegreesAnimated:animated clockwise:NO completion:completionHandler]; +} + +- (void)rotateImageNinetyDegreesAnimated:(BOOL)animated clockwise:(BOOL)clockwise completion:(void (^)(BOOL completed))completionHandler { + // Only allow one rotation animation at a time. Report the dropped call rather + // than swallowing the handler, or callers that gate UI on it (such as the + // toolbar's own rotation buttons) would stay disabled forever. + if (self.rotateAnimationInProgress) { + if (completionHandler) { + completionHandler(NO); + } + return; + } + + // Cancel any pending resizing timers + if (self.resetTimer) { + [self cancelResetTimer]; + [self setEditing:NO resetCropBox:YES animated:NO]; + + self.cropBoxLastEditedAngle = self.angle; + [self captureStateForImageRotation]; + } + + // Work out the new angle, and wrap around once we exceed 360s + NSInteger newAngle = self.angle; + newAngle = clockwise ? newAngle + 90 : newAngle - 90; + if (newAngle <= -360 || newAngle >= 360) { + newAngle = 0; + } + + _angle = newAngle; + + // Convert the new angle to radians + CGFloat angleInRadians = 0.0f; + switch (newAngle) { + case 90: + angleInRadians = M_PI_2; + break; + case -90: + angleInRadians = -M_PI_2; + break; + case 180: + angleInRadians = M_PI; + break; + case -180: + angleInRadians = -M_PI; + break; + case 270: + angleInRadians = (M_PI + M_PI_2); + break; + case -270: + angleInRadians = -(M_PI + M_PI_2); + break; + default: + break; + } + + // Set up the transformation matrix for the rotation + CGAffineTransform rotation = CGAffineTransformRotate(CGAffineTransformIdentity, angleInRadians); + + // Work out how much we'll need to scale everything to fit to the new rotation + CGRect contentBounds = self.contentBounds; + CGRect cropBoxFrame = self.cropBoxFrame; + CGFloat scale = MIN(contentBounds.size.width / cropBoxFrame.size.height, contentBounds.size.height / cropBoxFrame.size.width); + + // Work out which section of the image we're currently focusing at + CGPoint cropMidPoint = (CGPoint){CGRectGetMidX(cropBoxFrame), CGRectGetMidY(cropBoxFrame)}; + CGPoint cropTargetPoint = (CGPoint){cropMidPoint.x + self.scrollView.contentOffset.x, cropMidPoint.y + self.scrollView.contentOffset.y}; + + // Work out the dimensions of the crop box when rotated + const CGFloat oldZoomScale = self.scrollView.zoomScale; + CGRect newCropFrame = CGRectZero; + if (labs(self.angle) == labs(self.cropBoxLastEditedAngle) || (labs(self.angle) * -1) == ((labs(self.cropBoxLastEditedAngle) - 180) % 360)) { + newCropFrame.size = self.cropBoxLastEditedSize; + + // Restore the full zoom state, ceiling first so the saved zoom isn't clamped + self.baseMaximumZoomScale = self.cropBoxLastEditedMaxZoomScale; + self.scrollView.minimumZoomScale = self.cropBoxLastEditedMinZoomScale; + [self updateScrollViewMaximumZoomScale]; + self.scrollView.zoomScale = self.cropBoxLastEditedZoomScale; + } else { + newCropFrame.size = (CGSize){floorf(self.cropBoxFrame.size.height * scale), floorf(self.cropBoxFrame.size.width * scale)}; + + // Re-adjust the scrolling dimensions of the scroll view to match the new size + self.scrollView.minimumZoomScale *= scale; + self.baseMaximumZoomScale *= scale; + [self updateScrollViewMaximumZoomScale]; + self.scrollView.zoomScale *= scale; + } + + newCropFrame.origin.x = floorf(CGRectGetMidX(contentBounds) - (newCropFrame.size.width * 0.5f)); + newCropFrame.origin.y = floorf(CGRectGetMidY(contentBounds) - (newCropFrame.size.height * 0.5f)); + + // If we're animated, generate a snapshot view that we'll animate in place of the real view + UIView *snapshotView = nil; + if (animated) { + snapshotView = [self.foregroundContainerView snapshotViewAfterScreenUpdates:NO]; + self.rotateAnimationInProgress = YES; + } + + // Rotate the background image view, inside its container view + self.backgroundImageView.transform = rotation; + + // Flip the width/height of the container view so it matches the rotated image view's size + CGSize containerSize = self.backgroundContainerView.frame.size; + self.backgroundContainerView.frame = (CGRect){CGPointZero, {containerSize.height, containerSize.width}}; + self.backgroundImageView.frame = (CGRect){CGPointZero, self.backgroundImageView.frame.size}; + + // Rotate the foreground image view to match + self.foregroundContainerView.transform = CGAffineTransformIdentity; + self.foregroundImageView.transform = rotation; + + // Flip the content size of the scroll view to match the rotated bounds + self.scrollView.contentSize = self.backgroundContainerView.frame.size; + + // assign the new crop box frame and re-adjust the content to fill it + self.cropBoxFrame = newCropFrame; + [self moveCroppedContentToCenterAnimated:NO]; + newCropFrame = self.cropBoxFrame; + + // work out how to line up out point of interest into the middle of the crop box. + // Use the zoom delta that was actually applied (including any adjustment made while + // re-centering above), which differs from the geometric scale when a previously + // edited crop state was restored + const CGFloat appliedScale = (oldZoomScale > FLT_EPSILON) ? (self.scrollView.zoomScale / oldZoomScale) : scale; + cropTargetPoint.x *= appliedScale; + cropTargetPoint.y *= appliedScale; + + // swap the target dimensions to match a 90 degree rotation (clockwise or counterclockwise) + CGFloat swap = cropTargetPoint.x; + if (clockwise) { + cropTargetPoint.x = self.scrollView.contentSize.width - cropTargetPoint.y; + cropTargetPoint.y = swap; + } else { + cropTargetPoint.x = cropTargetPoint.y; + cropTargetPoint.y = self.scrollView.contentSize.height - swap; + } + + // reapply the translated scroll offset to the scroll view + CGPoint midPoint = {CGRectGetMidX(newCropFrame), CGRectGetMidY(newCropFrame)}; + CGPoint offset = CGPointZero; + offset.x = floorf(-midPoint.x + cropTargetPoint.x); + offset.y = floorf(-midPoint.y + cropTargetPoint.y); + offset.x = MAX(-self.scrollView.contentInset.left, offset.x); + offset.y = MAX(-self.scrollView.contentInset.top, offset.y); + offset.x = MIN(self.scrollView.contentSize.width - CGRectGetMaxX(newCropFrame), offset.x); + offset.y = MIN(self.scrollView.contentSize.height - CGRectGetMaxY(newCropFrame), offset.y); + + // if the scroll view's new scale is 1 and the new offset is equal to the old, will not trigger the delegate 'scrollViewDidScroll:' + // so we should call the method manually to update the foregroundImageView's frame + if (offset.x == self.scrollView.contentOffset.x && offset.y == self.scrollView.contentOffset.y && fabs(appliedScale - 1.0) < FLT_EPSILON) { + [self matchForegroundToBackground]; + } + self.scrollView.contentOffset = offset; + + // If we're animated, play an animation of the snapshot view rotating, + // then fade it out over the live content + if (animated) { + snapshotView.center = (CGPoint){CGRectGetMidX(contentBounds), CGRectGetMidY(contentBounds)}; + [self addSubview:snapshotView]; + + self.backgroundContainerView.hidden = YES; + self.foregroundContainerView.hidden = YES; + self.translucencyView.hidden = YES; + self.gridOverlayView.hidden = YES; + + [UIView animateWithDuration:0.45f + delay:0.0f + usingSpringWithDamping:1.0f + initialSpringVelocity:0.8f + options:UIViewAnimationOptionBeginFromCurrentState + animations:^{ + CGAffineTransform transform = CGAffineTransformRotate(CGAffineTransformIdentity, clockwise ? M_PI_2 : -M_PI_2); + transform = CGAffineTransformScale(transform, scale, scale); + snapshotView.transform = transform; + } + completion:^(BOOL complete) { + self.backgroundContainerView.hidden = NO; + self.foregroundContainerView.hidden = NO; + self.translucencyView.hidden = self.translucencyAlwaysHidden; + self.gridOverlayView.hidden = NO; + + self.backgroundContainerView.alpha = 0.0f; + self.gridOverlayView.alpha = 0.0f; + + self.translucencyView.alpha = 1.0f; + + [UIView animateWithDuration:0.45f + animations:^{ + snapshotView.alpha = 0.0f; + self.backgroundContainerView.alpha = 1.0f; + self.gridOverlayView.alpha = 1.0f; + } + completion:^(BOOL complete) { + self.rotateAnimationInProgress = NO; + [snapshotView removeFromSuperview]; + + // If the aspect ratio lock is not enabled, allow a swap + // If the aspect ratio lock is on, allow a aspect ratio swap + // only if the allowDimensionSwap option is specified. + BOOL aspectRatioCanSwapDimensions = !self.aspectRatioLockEnabled || + (self.aspectRatioLockEnabled && self.aspectRatioLockDimensionSwapEnabled); + + if (!aspectRatioCanSwapDimensions) { + // This will animate the aspect ratio back to the desired locked ratio after the image is rotated. + [self setAspectRatio:self.aspectRatio animated:animated]; + } + + if (completionHandler) { + completionHandler(complete); + } + }]; + }]; + } + + [self checkForCanReset]; + + // The un-animated path has already finished by this point, so there's no + // animation completion block to defer the handler to + if (!animated && completionHandler) { + completionHandler(YES); + } +} + +- (void)captureStateForImageRotation { + self.cropBoxLastEditedSize = self.cropBoxFrame.size; + self.cropBoxLastEditedZoomScale = self.scrollView.zoomScale; + self.cropBoxLastEditedMinZoomScale = self.scrollView.minimumZoomScale; + self.cropBoxLastEditedMaxZoomScale = self.baseMaximumZoomScale; +} + +// The scroll view's zoom ceiling normally sits at the layout's absolute base value, +// but must never fall below the minimum or UIScrollView clamps the zoom beneath it +- (void)updateScrollViewMaximumZoomScale { + self.scrollView.maximumZoomScale = MAX(self.scrollView.minimumZoomScale, self.baseMaximumZoomScale); +} + +#pragma mark - Resettable State - +- (void)checkForCanReset { + BOOL canReset = NO; + + if (self.angle != 0) { // Image has been rotated + canReset = YES; + } else if (self.scrollView.zoomScale > self.scrollView.minimumZoomScale + FLT_EPSILON) { // image has been zoomed in + canReset = YES; + } else if ((NSInteger)floorf(self.cropBoxFrame.size.width) != (NSInteger)floorf(self.originalCropBoxSize.width) || + (NSInteger)floorf(self.cropBoxFrame.size.height) != (NSInteger)floorf(self.originalCropBoxSize.height)) { // crop box has been changed + canReset = YES; + } else if ((NSInteger)floorf(self.scrollView.contentOffset.x) != (NSInteger)floorf(self.originalContentOffset.x) || + (NSInteger)floorf(self.scrollView.contentOffset.y) != (NSInteger)floorf(self.originalContentOffset.y)) { + canReset = YES; + } + + self.canBeReset = canReset; +} + +#pragma mark - Convienience Methods - +- (CGRect)contentBounds { + CGRect contentRect = CGRectZero; + contentRect.origin.x = self.cropViewPadding + self.cropRegionInsets.left; + contentRect.origin.y = self.cropViewPadding + self.cropRegionInsets.top; + contentRect.size.width = CGRectGetWidth(self.frame) - ((self.cropViewPadding * 2) + self.cropRegionInsets.left + self.cropRegionInsets.right); + contentRect.size.height = CGRectGetHeight(self.frame) - ((self.cropViewPadding * 2) + self.cropRegionInsets.top + self.cropRegionInsets.bottom); + return contentRect; +} + +- (CGSize)imageSize { + if (self.angle == -90 || self.angle == -270 || self.angle == 90 || self.angle == 270) + return (CGSize){self.image.size.height, self.image.size.width}; + + return (CGSize){self.image.size.width, self.image.size.height}; +} + +- (BOOL)hasAspectRatio { + return (self.aspectRatio.width > FLT_EPSILON && self.aspectRatio.height > FLT_EPSILON); +} + +@end diff --git a/native-modules/react-native-image-crop-picker/lefthook.yml b/native-modules/react-native-image-crop-picker/lefthook.yml new file mode 100644 index 000000000..89766b1f3 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/lefthook.yml @@ -0,0 +1,10 @@ +pre-commit: + parallel: true + commands: + lint: + glob: "*.{js,ts,tsx}" + run: yarn lint {staged_files} + typecheck: + files: git diff --cached --name-only --diff-filter=ACMRTUXB + glob: "*.{js,ts,tsx}" + run: yarn typecheck diff --git a/native-modules/react-native-image-crop-picker/nitro.json b/native-modules/react-native-image-crop-picker/nitro.json new file mode 100644 index 000000000..93c9d1b6d --- /dev/null +++ b/native-modules/react-native-image-crop-picker/nitro.json @@ -0,0 +1,23 @@ +{ + "cxxNamespace": ["reactnativeimagecroppicker"], + "ios": { + "iosModuleName": "ReactNativeImageCropPicker" + }, + "android": { + "androidNamespace": ["reactnativeimagecroppicker"], + "androidCxxLibName": "reactnativeimagecroppicker" + }, + "autolinking": { + "ReactNativeImageCropPicker": { + "ios": { + "language": "swift", + "implementationClassName": "ReactNativeImageCropPicker" + }, + "android": { + "language": "kotlin", + "implementationClassName": "ReactNativeImageCropPicker" + } + } + }, + "ignorePaths": ["node_modules"] +} diff --git a/native-modules/react-native-image-crop-picker/package.json b/native-modules/react-native-image-crop-picker/package.json new file mode 100644 index 000000000..318ad5906 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/package.json @@ -0,0 +1,173 @@ +{ + "name": "@onekeyfe/react-native-image-crop-picker", + "version": "3.0.146", + "description": "Single photo picker and cropper Nitro module for OneKey, replacing react-native-image-crop-picker", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "android", + "ios", + "cpp", + "nitrogen", + "nitro.json", + "*.podspec", + "react-native.config.js", + "!ios/build", + "!android/build", + "!android/gradle", + "!android/gradlew", + "!android/gradlew.bat", + "!android/local.properties", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], + "scripts": { + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "prepare": "bob build", + "nitrogen": "nitrogen", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android", + "image", + "picker", + "crop", + "cropping" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-image-crop-picker.git" + }, + "author": "onekeyfe (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-image-crop-picker/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-image-crop-picker#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.86.2", + "@react-native/eslint-config": "0.86.2", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "nitrogen": "0.37.0", + "prettier": "^2.8.8", + "react": "19.2.3", + "react-native": "patch:react-native@npm%3A0.86.2#~/.yarn/patches/react-native-npm-0.86.2-c9d546616f.patch", + "react-native-builder-bob": "^0.40.13", + "react-native-nitro-modules": "0.37.0", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "0.37.0" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "custom", + { + "script": "nitrogen", + "clean": "nitrogen/" + } + ], + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "nitro-module", + "languages": "kotlin-swift", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts b/native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts new file mode 100644 index 000000000..5bffc86a3 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts @@ -0,0 +1,72 @@ +import type { HybridObject } from 'react-native-nitro-modules'; + +export interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PickedImage { + // file:// URI of the processed image inside the module's temporary directory. + path: string; + size: number; + width: number; + height: number; + mime: string; + // Base64 encoded image data (without a data: prefix) when `includeBase64` is set. + data?: string; + cropRect?: CropRect; + filename?: string; +} + +export interface ImageCropPickerOptions { + // Target size of the cropped image. Also defines the crop aspect ratio. + width?: number; + height?: number; + cropping?: boolean; + includeBase64?: boolean; + compressImageQuality?: number; + compressImageMaxWidth?: number; + compressImageMaxHeight?: number; + freeStyleCropEnabled?: boolean; + cropperCircleOverlay?: boolean; + cropperToolbarTitle?: string; + cropperChooseText?: string; + cropperCancelText?: string; + // iOS only + cropperChooseColor?: string; + cropperCancelColor?: string; + cropperRotateButtonsHidden?: boolean; + // Android only + cropperActiveWidgetColor?: string; + cropperToolbarColor?: string; + cropperToolbarWidgetColor?: string; + cropperStatusBarLight?: boolean; + cropperNavigationBarLight?: boolean; + showCropGuidelines?: boolean; + showCropFrame?: boolean; + enableRotationGesture?: boolean; + hideBottomControls?: boolean; + disableCropperColorSetters?: boolean; +} + +export interface ReactNativeImageCropPicker + extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { + // Pick a single photo from the system photo picker, optionally cropping it. + // Neither platform asks for photo library permission. + openPicker(options: ImageCropPickerOptions): Promise; + + // Crop an existing image. `path` accepts file:// URIs, absolute paths, + // http(s):// URLs, data: URIs and, on Android, content:// URIs. + openCropper( + path: string, + options: ImageCropPickerOptions + ): Promise; + + // Delete every file this module wrote to its temporary directory. + clean(): Promise; + + // Delete a single file returned by this module. + cleanSingle(path: string): Promise; +} diff --git a/native-modules/react-native-image-crop-picker/src/index.tsx b/native-modules/react-native-image-crop-picker/src/index.tsx new file mode 100644 index 000000000..c13b40cdd --- /dev/null +++ b/native-modules/react-native-image-crop-picker/src/index.tsx @@ -0,0 +1,134 @@ +import { NitroModules } from 'react-native-nitro-modules'; + +import type { + ImageCropPickerOptions, + PickedImage, + ReactNativeImageCropPicker as ReactNativeImageCropPickerSpec, +} from './ReactNativeImageCropPicker.nitro'; + +export type * from './ReactNativeImageCropPicker.nitro'; + +// Created on first use so importing this module costs nothing at startup. +let hybridObject: ReactNativeImageCropPickerSpec | undefined; + +export function getReactNativeImageCropPicker(): ReactNativeImageCropPickerSpec { + if (!hybridObject) { + hybridObject = + NitroModules.createHybridObject( + 'ReactNativeImageCropPicker' + ); + } + return hybridObject; +} + +const ERROR_CODES = [ + 'E_PICKER_CANCELLED', + 'E_PICKER_IN_PROGRESS', + 'E_ACTIVITY_DOES_NOT_EXIST', + 'E_FAILED_TO_SHOW_PICKER', + 'E_NO_IMAGE_DATA_FOUND', + 'E_CROPPER_IMAGE_NOT_FOUND', + 'E_CANNOT_SAVE_IMAGE', + 'E_LOW_MEMORY_ERROR', + 'E_ERROR_WHILE_CLEANING_FILES', +] as const; + +export type ImageCropPickerErrorCode = + | (typeof ERROR_CODES)[number] + | 'E_UNKNOWN'; + +// Rejections carry the same `code` values as react-native-image-crop-picker, +// e.g. `E_PICKER_CANCELLED` when the user dismisses the picker or cropper. +export class ImageCropPickerError extends Error { + readonly code: ImageCropPickerErrorCode; + + constructor(code: ImageCropPickerErrorCode, message: string) { + super(message); + this.name = 'ImageCropPickerError'; + this.code = code; + } +} + +function isErrorCode(value: string): value is (typeof ERROR_CODES)[number] { + return (ERROR_CODES as readonly string[]).includes(value); +} + +// Native errors reach JS as ": ". Android prefixes the message +// with the exception class name and appends the Java stack trace, so only the +// first line is kept. +const NATIVE_ERROR_PATTERN = /\b(E_[A-Z_]+): (.*)/; + +function toImageCropPickerError(error: unknown): ImageCropPickerError { + if (error instanceof ImageCropPickerError) { + return error; + } + const message = error instanceof Error ? error.message : String(error); + const match = NATIVE_ERROR_PATTERN.exec(message); + if (match?.[1] && isErrorCode(match[1])) { + return new ImageCropPickerError(match[1], match[2]?.trim() ?? ''); + } + return new ImageCropPickerError( + 'E_UNKNOWN', + message.split('\n')[0] ?? message + ); +} + +// Options accepted for source compatibility with react-native-image-crop-picker. +// Only single photos are supported, and results are always JPEG. +export interface Options extends ImageCropPickerOptions { + mediaType?: 'photo'; + multiple?: false; + forceJpg?: boolean; + // The system photo picker controls ordering. + sortOrder?: 'none' | 'asc' | 'desc'; +} + +export interface CropperOptions extends Options { + path: string; +} + +export type Image = PickedImage; + +export async function openPicker(options: Options = {}): Promise { + try { + return await getReactNativeImageCropPicker().openPicker(options); + } catch (error) { + throw toImageCropPickerError(error); + } +} + +export async function openCropper({ + path, + ...options +}: CropperOptions): Promise { + try { + return await getReactNativeImageCropPicker().openCropper(path, options); + } catch (error) { + throw toImageCropPickerError(error); + } +} + +export async function clean(): Promise { + try { + await getReactNativeImageCropPicker().clean(); + } catch (error) { + throw toImageCropPickerError(error); + } +} + +export async function cleanSingle(path: string): Promise { + try { + await getReactNativeImageCropPicker().cleanSingle(path); + } catch (error) { + throw toImageCropPickerError(error); + } +} + +const ImageCropPicker = { + openPicker, + openCropper, + clean, + cleanSingle, +}; + +export default ImageCropPicker; diff --git a/native-modules/react-native-image-crop-picker/tsconfig.build.json b/native-modules/react-native-image-crop-picker/tsconfig.build.json new file mode 100644 index 000000000..45777014e --- /dev/null +++ b/native-modules/react-native-image-crop-picker/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true + }, + "exclude": ["**/__tests__/**/*", "**/__fixtures__/**/*", "**/__mocks__/**/*"] +} diff --git a/native-modules/react-native-image-crop-picker/tsconfig.json b/native-modules/react-native-image-crop-picker/tsconfig.json new file mode 100644 index 000000000..1b8f20a08 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-image-crop-picker": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-image-crop-picker/turbo.json b/native-modules/react-native-image-crop-picker/turbo.json new file mode 100644 index 000000000..08b9676e7 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/turbo.json @@ -0,0 +1,17 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": [ + "lib/**", + "nitrogen/**" + ], + "inputs": [ + "src/**", + "android/src/**", + "ios/**", + "cpp/**" + ] + } + } +} diff --git a/yarn.lock b/yarn.lock index 0f70ae699..1e291b31d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2957,6 +2957,7 @@ __metadata: "@onekeyfe/react-native-dns-lookup": "workspace:*" "@onekeyfe/react-native-get-random-values": "workspace:*" "@onekeyfe/react-native-image": "workspace:*" + "@onekeyfe/react-native-image-crop-picker": "workspace:*" "@onekeyfe/react-native-keychain-module": "workspace:*" "@onekeyfe/react-native-lite-card": "workspace:*" "@onekeyfe/react-native-native-list": "workspace:*" @@ -3524,6 +3525,42 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-image-crop-picker@workspace:*, @onekeyfe/react-native-image-crop-picker@workspace:native-modules/react-native-image-crop-picker": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-image-crop-picker@workspace:native-modules/react-native-image-crop-picker" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.86.2" + "@react-native/eslint-config": "npm:0.86.2" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + nitrogen: "npm:0.37.0" + prettier: "npm:^2.8.8" + react: "npm:19.2.3" + react-native: "patch:react-native@npm%3A0.86.2#~/.yarn/patches/react-native-npm-0.86.2-c9d546616f.patch" + react-native-builder-bob: "npm:^0.40.13" + react-native-nitro-modules: "npm:0.37.0" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + react-native-nitro-modules: 0.37.0 + languageName: unknown + linkType: soft + "@onekeyfe/react-native-image@workspace:*, @onekeyfe/react-native-image@workspace:native-views/react-native-image": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-image@workspace:native-views/react-native-image" From b21fdcd7aafc3ddfa06a872cb56f384730791658 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 18 Sep 2026 02:33:29 +0800 Subject: [PATCH 2/6] chore: bump packages to 3.0.147 Bump all 41 publishable packages, including the new image crop picker, to 3.0.147. Co-Authored-By: Claude Opus 5 --- native-modules/native-logger/package.json | 2 +- native-modules/react-native-aes-crypto/package.json | 2 +- native-modules/react-native-app-update/package.json | 2 +- native-modules/react-native-async-storage/package.json | 2 +- .../react-native-background-thread/package.json | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- native-modules/react-native-bundle-update/package.json | 2 +- .../package.json | 2 +- native-modules/react-native-cloud-fs/package.json | 2 +- native-modules/react-native-cloud-kit-module/package.json | 2 +- native-modules/react-native-device-utils/package.json | 2 +- native-modules/react-native-dns-lookup/package.json | 2 +- .../react-native-get-random-values/package.json | 2 +- .../react-native-image-crop-picker/package.json | 2 +- native-modules/react-native-keychain-module/package.json | 2 +- native-modules/react-native-lite-card/package.json | 2 +- native-modules/react-native-network-info/package.json | 2 +- native-modules/react-native-network-throttle/package.json | 2 +- native-modules/react-native-pbkdf2/package.json | 2 +- native-modules/react-native-perf-memory/package.json | 2 +- native-modules/react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- native-modules/react-native-range-downloader/package.json | 2 +- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- .../react-native-split-bundle-loader/package.json | 2 +- native-modules/react-native-tcp-socket/package.json | 2 +- native-modules/react-native-zip-archive/package.json | 2 +- native-views/react-native-auto-size-input/package.json | 2 +- native-views/react-native-chart-webview/package.json | 2 +- native-views/react-native-image/package.json | 4 ++-- native-views/react-native-native-list/package.json | 6 +++--- native-views/react-native-native-sheet/package.json | 2 +- native-views/react-native-pager-view/package.json | 4 ++-- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- native-views/react-native-text-input/package.json | 2 +- native-views/react-native-text/package.json | 2 +- yarn.lock | 8 ++++---- 42 files changed, 49 insertions(+), 49 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 238aeca33..a1b47b7d3 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index 9c16c1a49..9fd7db215 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 33ca9ebc4..241a66a74 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 6534f870a..32b0d7cd9 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 88d149961..1465b0be7 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 1d754a202..da218ac29 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 8b8b3443f..0b788409e 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 4be03687e..9129f0306 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 1e0f0642f..ce6a33a78 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 312f757c0..4b659af8d 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 71c0a1fc3..5463b2ed9 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index b84e00b0c..db5b96b8f 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index f061ea118..386c5e2d3 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-image-crop-picker/package.json b/native-modules/react-native-image-crop-picker/package.json index 318ad5906..0f2d32813 100644 --- a/native-modules/react-native-image-crop-picker/package.json +++ b/native-modules/react-native-image-crop-picker/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image-crop-picker", - "version": "3.0.146", + "version": "3.0.147", "description": "Single photo picker and cropper Nitro module for OneKey, replacing react-native-image-crop-picker", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 45f7ebaf7..628952aef 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index 7030c9538..ae6f655ae 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.146", + "version": "3.0.147", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index dccdd1e7d..e58b6698a 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 18588dc86..ad24323ce 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 89fed52dc..6a6684021 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index cb6a7d52b..cb492bdd3 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 9409fbf28..9ad1fd217 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 64d0c778e..2f7f3f58a 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index b5c8cc1a3..f45d26a2d 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 1619ab902..010d58826 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.146", + "version": "3.0.147", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 39aedbb8d..d7ae8de64 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 4161f41a4..a10303d21 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 7f32bfef1..17ff080df 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 0b2d78bf8..559493256 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 006b8aa32..79aa7fd13 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.146", + "version": "3.0.147", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 03cf994a7..e3573cbe2 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-image/package.json b/native-views/react-native-image/package.json index e2c8ab71d..7ea871c3b 100644 --- a/native-views/react-native-image/package.json +++ b/native-views/react-native-image/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image", - "version": "3.0.146", + "version": "3.0.147", "description": "High-performance native image view for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -81,7 +81,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-skeleton": "3.0.146", + "@onekeyfe/react-native-skeleton": "3.0.147", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-list/package.json b/native-views/react-native-native-list/package.json index 7cea1bad5..4ea412fed 100644 --- a/native-views/react-native-native-list/package.json +++ b/native-views/react-native-native-list/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-list", - "version": "3.0.146", + "version": "3.0.147", "description": "Template-driven native RecyclerView and UICollectionView for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", @@ -83,8 +83,8 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-image": "3.0.146", - "@onekeyfe/react-native-native-logger": "3.0.146", + "@onekeyfe/react-native-image": "3.0.147", + "@onekeyfe/react-native-native-logger": "3.0.147", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-sheet/package.json b/native-views/react-native-native-sheet/package.json index 1a38b77a5..f88c89ab8 100644 --- a/native-views/react-native-native-sheet/package.json +++ b/native-views/react-native-native-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-sheet", - "version": "3.0.146", + "version": "3.0.147", "description": "Native bottom sheet host for arbitrary React Native content", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index eab3e42c5..fb520eb2e 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.146", + "version": "3.0.147", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -66,7 +66,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-native-logger": "3.0.146", + "@onekeyfe/react-native-native-logger": "3.0.147", "react": "*", "react-native": "*" }, diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 43166f99d..d1dc6b219 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index fb6d9a501..db2e7ccf8 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.146", + "version": "3.0.147", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 60f0af8c9..8df0e683d 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 6a97826ba..9b2ba80a1 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.146", + "version": "3.0.147", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index e1b9a48a7..52b0a3c99 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.146", + "version": "3.0.147", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text-input/package.json b/native-views/react-native-text-input/package.json index d420fa347..3d423bf1c 100644 --- a/native-views/react-native-text-input/package.json +++ b/native-views/react-native-text-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text-input", - "version": "3.0.146", + "version": "3.0.147", "description": "React Native TextInput with native paste events", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text/package.json b/native-views/react-native-text/package.json index c3964bc73..f9ae4aba7 100644 --- a/native-views/react-native-text/package.json +++ b/native-views/react-native-text/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text", - "version": "3.0.146", + "version": "3.0.147", "description": "Opt-in native text rendering for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", diff --git a/yarn.lock b/yarn.lock index 1e291b31d..2ea6b110b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3587,7 +3587,7 @@ __metadata: react-test-renderer: "npm:19.2.3" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-skeleton": 3.0.146 + "@onekeyfe/react-native-skeleton": 3.0.147 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3688,8 +3688,8 @@ __metadata: react-native-nitro-modules: "npm:0.37.0" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-image": 3.0.146 - "@onekeyfe/react-native-native-logger": 3.0.146 + "@onekeyfe/react-native-image": 3.0.147 + "@onekeyfe/react-native-native-logger": 3.0.147 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3841,7 +3841,7 @@ __metadata: react-native-builder-bob: "npm:^0.40.13" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-native-logger": 3.0.146 + "@onekeyfe/react-native-native-logger": 3.0.147 react: "*" react-native: "*" languageName: unknown From 4fd15800e85b43f9a002a888c31e0244a6ad9274 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 18 Sep 2026 14:10:58 +0800 Subject: [PATCH 3/6] fix: keep iOS log files after the first roll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DDLogFileManagerDefault defaults logFilesDiskQuota to 20 MB, the same size as the configured maximumFileSize, so a single rolled file already filled the quota. Cleanup then deleted every log file — its "don't delete the active file" guard only covers unarchived files — and the logger wrote nothing for the rest of the session, so exported log bundles came back with no .log files at all. Size the quota after the retention above (7 files x 20 MB), matching Android's TOTAL_SIZE_CAP. Reproduced on a simulator with a function-trace build, which fills 20 MB in about 15 seconds. Co-Authored-By: Claude Opus 5 --- native-modules/native-logger/ios/OneKeyLog.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/native-modules/native-logger/ios/OneKeyLog.swift b/native-modules/native-logger/ios/OneKeyLog.swift index cdefcc5ee..80025a161 100644 --- a/native-modules/native-logger/ios/OneKeyLog.swift +++ b/native-modules/native-logger/ios/OneKeyLog.swift @@ -96,6 +96,10 @@ private class OneKeyLogFileManager: DDLogFileManagerDefault { private static let maxMessageLength = 4096 + // Log retention, mirroring Android's MAX_FILE_SIZE / MAX_HISTORY / TOTAL_SIZE_CAP. + private static let maxLogFileSize: UInt64 = 20 * 1024 * 1024 // 20 MB + private static let maxLogFiles: UInt = 7 // 1 active + 6 archived + private struct TokenBucket { let ratePerSecond: Double let burstCapacity: Double @@ -164,11 +168,19 @@ private class OneKeyLogFileManager: DDLogFileManagerDefault { // NOTE: DDLogFileManagerDefault.maximumNumberOfLogFiles counts ALL log files // including the current active file (app-latest.log). // Set to 7 = 1 active + 6 archived, matching Android MAX_HISTORY=6. - fileManager.maximumNumberOfLogFiles = 7 + fileManager.maximumNumberOfLogFiles = OneKeyLog.maxLogFiles + // logFilesDiskQuota defaults to 20 MB — the size of one full log file. The + // first roll then exceeds the quota, and because the rolled file is already + // archived, cleanup deletes every log file (the "don't delete the active + // file" guard only covers unarchived ones) and the logger writes nothing for + // the rest of the session. Size the quota after the retention above so a full + // set of files fits, matching Android's TOTAL_SIZE_CAP. + fileManager.logFilesDiskQuota = + UInt64(OneKeyLog.maxLogFiles) * OneKeyLog.maxLogFileSize let logger = DDFileLogger(logFileManager: fileManager) logger.rollingFrequency = 86400 // daily rolling - logger.maximumFileSize = 20_971_520 // 20 MB + logger.maximumFileSize = OneKeyLog.maxLogFileSize logger.logFormatter = OneKeyLogFormatter() DDLog.add(logger) From f2aaa9e74e78bddce64d6dd025731079aa9ee6f2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 18 Sep 2026 14:24:42 +0800 Subject: [PATCH 4/6] fix: stop iOS Lite card callbacks from firing twice on NFC cancel Tapping Cancel on the iOS NFC sheet right after a Lite card connected crashed the app with SIGABRT in RCTTurboModule.mm ("Callback arg cannot be called more than once"), seen in the backup and restore flow on 6.6.0. The user's cancel invalidates the session, and didInvalidateWithError: reported it as a cancel because sessionType is only reset after the whole card operation returns. The card operation was still running on another thread of the concurrent delegate queue. Its APDUs then failed and it reported a connection failure through the same React Native callback, which aborts on the second call. - Take each completion out of completionBlocks under a lock when it is delivered, so the card operation and the invalidation can't both deliver it. - Don't treat the app's own invalidateSession as a user cancel. CoreNFC reports it with the same code 200, which could race a finished read and report it as cancelled. - Report a connection failure when connectToTag fails or the card is not a Lite V1/V2. These paths used to end the session and rely on the racy cancel report, or leave the JS promise pending. - Wrap the React Native callbacks so any repeated result is logged and dropped instead of aborting the app. Co-Authored-By: Claude Opus 5 --- .../ios/Classes/OKNFTLite/OKNFCManager.m | 154 ++++++++++++------ .../ios/ReactNativeLiteCard.mm | 22 +++ 2 files changed, 128 insertions(+), 48 deletions(-) diff --git a/native-modules/react-native-lite-card/ios/Classes/OKNFTLite/OKNFCManager.m b/native-modules/react-native-lite-card/ios/Classes/OKNFTLite/OKNFCManager.m index 6f21903f3..c45c7e271 100644 --- a/native-modules/react-native-lite-card/ios/Classes/OKNFTLite/OKNFCManager.m +++ b/native-modules/react-native-lite-card/ios/Classes/OKNFTLite/OKNFCManager.m @@ -40,6 +40,9 @@ @interface OKNFCManager() @property (nonatomic, strong) OKLiteV1 *lite; @property (nonatomic, strong) NSMutableDictionary *completionBlocks; +// CoreNFC reports the app's own invalidateSession with the same code as a user +// cancel, so the invalidation callback has to know who ended the session. +@property (atomic, assign) BOOL sessionEndedByApp; @end @@ -58,6 +61,7 @@ - (OKNFCLiteSessionType)getSessionType { - (void)endNFCSessionWithError:(BOOL)isError { + self.sessionEndedByApp = YES; self.session.alertMessage = @""; if (isError) { [self.session invalidateSessionWithErrorMessage:OKTools.isChineseLan ? @"读取失败,请重试":@"Connect fail, please try again."]; @@ -75,7 +79,66 @@ -(NSMutableDictionary *)completionBlocks { return _completionBlocks; } +// The card operation and the session invalidation run on different threads of +// the delegate queue and can both try to finish the same request, e.g. when the +// user cancels while the card is being read. Only the first caller gets the +// completion: React Native aborts the app when a callback runs twice. +- (id)takeCompletionForKey:(NSString *)key { + id completion = nil; + @synchronized (self) { + completion = [_completionBlocks objectForKey:key]; + [_completionBlocks removeObjectForKey:key]; + } + if (!completion) { + [LCLogger debug:[NSString stringWithFormat:@"%@ already finished, dropping the late result", key]]; + } + return completion; +} + +// Finishes the pending request when the session ends before the card operation +// reports its own result: as a cancel when the user or system ended the session, +// otherwise as a connection failure. +- (void)finishPendingRequestAsCancel:(BOOL)asCancel sessionError:(NSError *)sessionError { + switch (self.sessionType) { + case OKNFCLiteSessionTypeGetInfo: + case OKNFCLiteSessionTypeUpdateInfo:{ + GetLiteInfoCallback callback = [self takeCompletionForKey:kGetLiteInfoBlock]; + if (callback) { + callback(nil, OKNFCLiteStatusError); + } + } break; + case OKNFCLiteSessionTypeReset: { + ResetCallback callback = [self takeCompletionForKey:kResetBlock]; + if (callback) { + callback(self.lite, NO, sessionError); + } + } break; + case OKNFCLiteSessionTypeSetMnemonic: + case OKNFCLiteSessionTypeSetMnemonicForce: { + SetMnemonicCallback callback = [self takeCompletionForKey:kSetMnemonicBlock]; + if (callback) { + callback(self.lite, asCancel ? OKNFCLiteSetMncStatusCancel : OKNFCLiteSetMncStatusError); + } + } break; + case OKNFCLiteSessionTypeGetMnemonic: { + GetMnemonicCallback callback = [self takeCompletionForKey:kGetMnemonicBlock]; + if (callback) { + callback(self.lite, nil, asCancel ? OKNFCLiteGetMncStatusCancel : OKNFCLiteGetMncStatusError); + } + } break; + case OKNFCLiteSessionTypeChangePin: { + ChangePinCallback callback = [self takeCompletionForKey:kChangePinBlock]; + if (callback) { + callback(self.lite, asCancel ? OKNFCLiteChangePinStatusCancel : OKNFCLiteChangePinStatusError); + } + } break; + default: + break; + } +} + - (void)beginNewNFCSession { + self.sessionEndedByApp = NO; self.session = [[NFCTagReaderSession alloc] initWithPollingOption:NFCPollingISO14443 delegate:self queue:dispatch_get_global_queue(2, 0)]; [self.session beginSession]; } @@ -94,6 +157,7 @@ - (void)tagReaderSession:(NFCTagReaderSession *)session didDetectTags:(NSArray<_ [LCLogger error:errMsg]; // [kTools debugTipMessage:errMsg]; [self endNFCSessionWithError:YES]; + [self finishPendingRequestAsCancel:NO sessionError:nil]; return; } [self nfcSessionComplete:session]; @@ -102,44 +166,10 @@ - (void)tagReaderSession:(NFCTagReaderSession *)session didDetectTags:(NSArray<_ - (void)tagReaderSession:(NFCTagReaderSession *)session didInvalidateWithError:(NSError *)error { [LCLogger debug:[NSString stringWithFormat:@"tagReaderSession didInvalidateWithError: %@", error.localizedDescription]]; - if (error.code == 200 || error.code == 6) { - switch (self.sessionType) { - case OKNFCLiteSessionTypeGetInfo: - case OKNFCLiteSessionTypeUpdateInfo:{ - GetLiteInfoCallback callback = [_completionBlocks objectForKey:kGetLiteInfoBlock]; - if(callback) { - callback(nil,OKNFCLiteStatusError); - } - } break; - case OKNFCLiteSessionTypeReset: { - ResetCallback callback = [_completionBlocks objectForKey:kResetBlock]; - if (callback) { - callback(self.lite, NO,error); - } - } break; - case OKNFCLiteSessionTypeSetMnemonic: - case OKNFCLiteSessionTypeSetMnemonicForce: { - SetMnemonicCallback callback = [_completionBlocks objectForKey:kSetMnemonicBlock]; - if (callback) { - callback(self.lite,OKNFCLiteSetMncStatusCancel); - } - } break; - case OKNFCLiteSessionTypeGetMnemonic: { - GetMnemonicCallback callback = [_completionBlocks objectForKey:kGetMnemonicBlock]; - if (callback) { - callback(self.lite,nil,OKNFCLiteGetMncStatusCancel); - } - } break; - case OKNFCLiteSessionTypeChangePin: { - ChangePinCallback callback = [_completionBlocks objectForKey:kChangePinBlock]; - if (callback) { - callback(self.lite,OKNFCLiteChangePinStatusCancel); - } - - } break; - default: - break; - } + // When the app ended the session, the card operation that ended it reports + // the real result; treating this as a cancel would race with it. + if ((error.code == 200 || error.code == 6) && !self.sessionEndedByApp) { + [self finishPendingRequestAsCancel:YES sessionError:error]; } [session invalidateSession]; } @@ -153,6 +183,9 @@ - (void)tagReaderSessionDidBecomeActive:(NFCTagReaderSession *)session { - (void)nfcSessionComplete:(NFCTagReaderSession *)session { if (![self checkLiteVersion]) { [self endNFCSessionWithError:YES]; + [self finishPendingRequestAsCancel:NO sessionError:nil]; + self.sessionType = OKNFCLiteSessionTypeNone; + return; } self.selectNFCApp = OKNFCLiteAppNONE; switch (self.sessionType) { @@ -205,8 +238,13 @@ - (void)getLiteInfo { } - (void)_getLiteInfo { - GetLiteInfoCallback callback = [_completionBlocks objectForKey:kGetLiteInfoBlock]; - [self.lite getLiteInfo:callback]; + __weak typeof(self) weakSelf = self; + [self.lite getLiteInfo:^(OKLiteV1 *lite, OKNFCLiteStatus status) { + GetLiteInfoCallback callback = [weakSelf takeCompletionForKey:kGetLiteInfoBlock]; + if (callback) { + callback(lite, status); + } + }]; } - (BOOL)syncLiteInfo { @@ -237,13 +275,18 @@ - (void)setMnemonic:(NSString *)mnemonic } - (void)_setMnemonic:(BOOL)force { - SetMnemonicCallback callback = [_completionBlocks objectForKey:kSetMnemonicBlock]; NSString *mnemonic = self.exportMnemonic; NSString *pin = self.pin; // Clear sensitive data from properties immediately self.exportMnemonic = nil; self.pin = nil; - [self.lite setMnemonic:mnemonic withPin:pin overwrite:force complete:callback]; + __weak typeof(self) weakSelf = self; + [self.lite setMnemonic:mnemonic withPin:pin overwrite:force complete:^(OKLiteV1 *lite, OKNFCLiteSetMncStatus status) { + SetMnemonicCallback callback = [weakSelf takeCompletionForKey:kSetMnemonicBlock]; + if (callback) { + callback(lite, status); + } + }]; } #pragma mark - getMnemonic @@ -259,11 +302,16 @@ - (void)getMnemonicWithPin:(NSString *)pin complete:(GetMnemonicCallback)complet } - (void)_getMnemonic { - GetMnemonicCallback callback = [_completionBlocks objectForKey:kGetMnemonicBlock]; NSString *pin = self.pin; // Clear sensitive data from property immediately self.pin = nil; - [self.lite getMnemonicWithPin:pin complete:callback]; + __weak typeof(self) weakSelf = self; + [self.lite getMnemonicWithPin:pin complete:^(OKLiteV1 *lite, NSString *mnemonic, OKNFCLiteGetMncStatus status) { + GetMnemonicCallback callback = [weakSelf takeCompletionForKey:kGetMnemonicBlock]; + if (callback) { + callback(lite, mnemonic, status); + } + }]; } #pragma mark - changePin @@ -277,13 +325,18 @@ - (void)changePin:(NSString *)oldPin to:(NSString *)newPin complete:(ChangePinCa } - (void)_changePin { - ChangePinCallback callback = [_completionBlocks objectForKey:kChangePinBlock]; NSString *oldPin = self.pin; NSString *newPin = self.neoPin; // Clear sensitive data from properties immediately self.pin = nil; self.neoPin = nil; - [self.lite changePin:oldPin to:newPin complete:callback]; + __weak typeof(self) weakSelf = self; + [self.lite changePin:oldPin to:newPin complete:^(OKLiteV1 *lite, OKNFCLiteChangePinStatus status) { + ChangePinCallback callback = [weakSelf takeCompletionForKey:kChangePinBlock]; + if (callback) { + callback(lite, status); + } + }]; } #pragma mark - reset @@ -295,8 +348,13 @@ - (void)reset:(ResetCallback)callBack { } - (void)_reset { - ResetCallback callback = [_completionBlocks objectForKey:kResetBlock]; - [self.lite reset:callback]; + __weak typeof(self) weakSelf = self; + [self.lite reset:^(OKLiteV1 *lite, BOOL isSuccess, NSError *error) { + ResetCallback callback = [weakSelf takeCompletionForKey:kResetBlock]; + if (callback) { + callback(lite, isSuccess, error); + } + }]; } - (BOOL)_resetSync { diff --git a/native-modules/react-native-lite-card/ios/ReactNativeLiteCard.mm b/native-modules/react-native-lite-card/ios/ReactNativeLiteCard.mm index c846e606f..0d898d24a 100644 --- a/native-modules/react-native-lite-card/ios/ReactNativeLiteCard.mm +++ b/native-modules/react-native-lite-card/ios/ReactNativeLiteCard.mm @@ -5,6 +5,9 @@ #import "OKLiteV1.h" #import "LCLogger.h" +#include +#include + typedef NS_ENUM(NSInteger, NFCLiteExceptions) { NFCLiteExceptionsInitChannel = 1000,// 初始化异常 NFCLiteExceptionsNotExistsNFC = 1001,// 没有 NFC 设备 @@ -25,6 +28,20 @@ typedef NS_ENUM(NSInteger, NFCLiteExceptions) { NFCLiteExceptionsNotInitialized = 4002,// 没有备份过内容 }; +// React Native aborts the app when a callback runs twice, and NFC results can +// arrive from more than one CoreNFC thread, so only the first result is sent. +static RCTResponseSenderBlock OKLiteCallbackOnce(RCTResponseSenderBlock callback) +{ + auto invoked = std::make_shared>(false); + return ^(NSArray *response) { + if (invoked->exchange(true)) { + [LCLogger warn:@"Dropped a repeated Lite card callback"]; + return; + } + callback(response); + }; +} + @implementation ReactNativeLiteCard - (NSNumber *)multiply:(double)a b:(double)b { NSNumber *result = @(a * b); @@ -57,6 +74,7 @@ - (void)checkNFCPermission:(RCTResponseSenderBlock)callback - (void)getLiteInfo:(RCTResponseSenderBlock)callBack { + callBack = OKLiteCallbackOnce(callBack); if ([ReactNativeLiteCard checkSDKVaild:callBack]) { __block OKNFCManager *liteManager = [[OKNFCManager alloc] init]; [liteManager getLiteInfo:^(OKLiteV1 *lite, OKNFCLiteStatus status) { @@ -74,6 +92,7 @@ - (void)getLiteInfo:(RCTResponseSenderBlock)callBack - (void)setMnemonic:(NSString *)mnemonic pwd:(NSString *)pwd overwrite:(BOOL)overwrite callback:(RCTResponseSenderBlock)callBack { + callBack = OKLiteCallbackOnce(callBack); if ([ReactNativeLiteCard checkSDKVaild:callBack]) { __block OKNFCManager *liteManager = [[OKNFCManager alloc] init]; [liteManager setMnemonic:mnemonic withPin:pwd overwrite:overwrite complete:^(OKLiteV1 *lite, OKNFCLiteSetMncStatus status) { @@ -111,6 +130,7 @@ - (void)setMnemonic:(NSString *)mnemonic pwd:(NSString *)pwd overwrite:(BOOL)ove - (void)getMnemonicWithPin:(NSString *)pwd callback:(RCTResponseSenderBlock)callBack { + callBack = OKLiteCallbackOnce(callBack); if ([ReactNativeLiteCard checkSDKVaild:callBack]) { __block OKNFCManager *liteManager = [[OKNFCManager alloc] init]; [liteManager getMnemonicWithPin:pwd complete:^(OKLiteV1 *lite, NSString *mnemonic, OKNFCLiteGetMncStatus status) { @@ -150,6 +170,7 @@ - (void)getMnemonicWithPin:(NSString *)pwd callback:(RCTResponseSenderBlock)call - (void)changePin:(NSString *)oldPin newPin:(NSString *)newPin callback:(RCTResponseSenderBlock)callBack { + callBack = OKLiteCallbackOnce(callBack); if ([ReactNativeLiteCard checkSDKVaild:callBack]) { __block OKNFCManager *liteManager = [[OKNFCManager alloc] init]; [liteManager changePin:oldPin to:newPin complete:^(OKLiteV1 *lite, OKNFCLiteChangePinStatus status) { @@ -183,6 +204,7 @@ - (void)changePin:(NSString *)oldPin newPin:(NSString *)newPin callback:(RCTResp - (void)reset:(RCTResponseSenderBlock)callBack { + callBack = OKLiteCallbackOnce(callBack); if ([ReactNativeLiteCard checkSDKVaild:callBack]) { __block OKNFCManager *liteManager = [[OKNFCManager alloc] init]; [liteManager reset:^(OKLiteV1 *lite, BOOL isSuccess, NSError *error) { From aea437922a8281d592fe49cef814fcfeda03f1d6 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 18 Sep 2026 16:19:12 +0800 Subject: [PATCH 5/6] feat: unify the image cropper screen with the app on iOS and Android The iOS cropper was TOCropViewController's own screen: a dark toolbar with plain-text Cancel and Done that ignored the app theme. Android showed uCrop's screen, with a toolbar check mark and aspect, rotate and scale tabs, so the two platforms looked unrelated to each other and to the app. Both platforms now show the same page, drawn by this package: - iOS hosts TOCropView in ImageCropperViewController, Android hosts uCrop's UCropView in ImageCropperActivity. Both have a header with the title and OneKey's rotate icon, the crop area with 20pt margins, and a footer with capsule Cancel and Confirm buttons that match the app's large Button, including pressed colors and a loading spinner. - cropperAppearance sets the color scheme, colors, fonts and a size scale. Colors are CSS hex strings; anything left out falls back to OneKey's light or dark palette. - The area outside the crop box shows the page background at 70% instead of a dark blur or black dimming. Corner handles only show when the box can be resized, and the grid only shows while the image is moved. - Android draws edge to edge with bar icons that follow the scheme, keeps the caller's requested orientation, animates the rotate button and drops two-finger rotation, which iOS never had. - TOCropOverlayView gains frameColor, gridColor and cornerHandlesHidden. - Remove the options that styled the old screens. Also reopen the cropper on iOS when the same photo is tapped again after cancelling it. PHPicker keeps the photo selected when the cropper is dismissed back to it, so the first tap only deselected it. On iOS 17 and later the selection is now cleared; asking for asset identifiers needs no photo library permission. Co-Authored-By: Claude Opus 5 --- .../react-native-image-crop-picker/README.md | 53 +- .../ReactNativeImageCropPicker.podspec | 8 +- .../android/src/main/AndroidManifest.xml | 4 +- .../ImageCropPickerImageProcessor.kt | 26 +- .../ImageCropPickerSession.kt | 67 +- .../ImageCropperActivity.kt | 636 ++++++++++++++++++ .../ImageCropperTheme.kt | 188 ++++++ .../res/drawable/image_crop_picker_rotate.xml | 11 + .../ios/ImageCropPickerImageProcessor.swift | 12 +- .../ios/ImageCropPickerSession.swift | 89 ++- .../ios/ImageCropperTheme.swift | 155 +++++ .../ios/ImageCropperViewController.swift | 462 +++++++++++++ .../Views/TOCropOverlayView.h | 9 + .../Views/TOCropOverlayView.m | 50 +- .../src/ReactNativeImageCropPicker.nitro.ts | 42 +- 15 files changed, 1672 insertions(+), 140 deletions(-) create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperActivity.kt create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperTheme.kt create mode 100644 native-modules/react-native-image-crop-picker/android/src/main/res/drawable/image_crop_picker_rotate.xml create mode 100644 native-modules/react-native-image-crop-picker/ios/ImageCropperTheme.swift create mode 100644 native-modules/react-native-image-crop-picker/ios/ImageCropperViewController.swift diff --git a/native-modules/react-native-image-crop-picker/README.md b/native-modules/react-native-image-crop-picker/README.md index 12ca05be0..23e792b1f 100644 --- a/native-modules/react-native-image-crop-picker/README.md +++ b/native-modules/react-native-image-crop-picker/README.md @@ -4,16 +4,18 @@ Single photo picker and cropper for OneKey, built on [Nitro Modules](https://nit Neither platform asks for photo library permission: -- **iOS** picks with `PHPickerViewController`, which runs out of process. `react-native-image-crop-picker` requested full photo library access first. Once a user denied it, iOS never showed the prompt again and every later `openPicker` call failed silently (OK-48227). Cropping uses a vendored TOCropViewController 3.2.0 with the OK-51551 rotation fix, which upstream still lacks. -- **Android** picks with the system Photo Picker (`ActivityResultContracts.PickVisualMedia`, which falls back to `ACTION_OPEN_DOCUMENT` on devices without it) and crops with uCrop `2.2.11-native`. Activity results go through the activity's `ActivityResultRegistry`, so no `ActivityEventListener` is needed. +- **iOS** picks with `PHPickerViewController`, which runs out of process. `react-native-image-crop-picker` requested full photo library access first. Once a user denied it, iOS never showed the prompt again and every later `openPicker` call failed silently (OK-48227). The crop gestures come from a vendored TOCropViewController 3.2.0 `TOCropView`, with the OK-51551 rotation fix that upstream still lacks. +- **Android** picks with the system Photo Picker (`ActivityResultContracts.PickVisualMedia`, which falls back to `ACTION_OPEN_DOCUMENT` on devices without it). The crop gestures come from uCrop `2.2.11-native`'s `UCropView`. Activity results go through the activity's `ActivityResultRegistry`, so no `ActivityEventListener` is needed. + +Both platforms show the same cropper screen, drawn by this package rather than by TOCropViewController or uCrop. See [Cropper screen](#cropper-screen). ## Installation ```sh -yarn add react-native-image-crop-picker@npm:@onekeyfe/react-native-image-crop-picker react-native-nitro-modules +yarn add @onekeyfe/react-native-image-crop-picker react-native-nitro-modules ``` -The npm alias keeps existing `react-native-image-crop-picker` imports working. +To keep existing `react-native-image-crop-picker` imports working, install it under that name with an npm alias instead: `react-native-image-crop-picker@npm:@onekeyfe/react-native-image-crop-picker`. - **iOS**: do not also install the `TOCropViewController` pod. This package compiles its own copy, and the duplicate classes would clash at link time. - **Android**: uCrop is only published to JitPack. Add `maven { url "https://www.jitpack.io" }` to the app's `allprojects.repositories`. @@ -23,7 +25,7 @@ The npm alias keeps existing `react-native-image-crop-picker` imports working. ```ts import ImageCropPicker, { ImageCropPickerError, -} from 'react-native-image-crop-picker'; +} from '@onekeyfe/react-native-image-crop-picker'; try { const image = await ImageCropPicker.openPicker({ @@ -50,6 +52,46 @@ const cropped = await ImageCropPicker.openCropper({ `openCropper` accepts `file://` URIs, absolute paths, `http(s)://` URLs, `data:` URIs and, on Android, `content://` URIs. +## Cropper screen + +The cropper is a full-screen page with the same layout and metrics on iOS and Android: + +- A 56 pt header with `cropperToolbarTitle` centered and a rotate button on the right. The rotate button turns the image 90° counterclockwise; `cropperRotateButtonsHidden` hides it. +- The crop area. The crop box keeps 20 pt from every edge, and the image outside it shows the page background at 70% opacity. The rule-of-thirds grid shows while the image is moved, unless `showCropGuidelines` is `false`. +- A footer with two capsule buttons, `cropperCancelText` and `cropperChooseText`, 50 pt tall with 10 pt between them. The confirm button shows a spinner while the image is saved. + +`cropperAppearance` sets its colors and fonts: + +```ts +await ImageCropPicker.openPicker({ + width: 240, + height: 240, + cropping: true, + cropperToolbarTitle: 'Crop image', + cropperAppearance: { + colorScheme: 'dark', + backgroundColor: '#0f0f0f', + confirmButtonColor: '#ffffffed', + confirmButtonTextColor: '#000000df', + titleFontFamily: 'Roobert-SemiBold', + buttonFontFamily: 'Roobert-Medium', + }, +}); +``` + +| Field | Used for | +| --- | --- | +| `colorScheme` | `'light'` or `'dark'`. Picks the fallback palette and the status bar style. Defaults to the system appearance. | +| `backgroundColor` | The page. The area outside the crop box is this color at 70% opacity. | +| `titleColor` | The title and the crop box border. | +| `iconColor` | The rotate button. | +| `cancelButtonColor`, `cancelButtonPressedColor`, `cancelButtonTextColor` | The cancel button. | +| `confirmButtonColor`, `confirmButtonPressedColor`, `confirmButtonTextColor` | The confirm button. | +| `titleFontFamily`, `buttonFontFamily` | Fonts bundled with the app. iOS looks them up with `UIFont(name:)`, Android with React Native's `ReactFontManager`. | +| `scale` | Multiplies every size, for apps that scale their UI. | + +Colors are CSS hex strings: `#RGB`, `#RRGGBB` or `#RRGGBBAA`. Anything left out falls back to OneKey's light or dark palette. + ## Behavior - Results are always JPEG. `width` and `height` set the crop aspect ratio, and the cropped image is scaled to exactly that size, so a crop box a pixel off the ratio still yields the requested dimensions. With `freeStyleCropEnabled`, the crop keeps its own aspect ratio and is scaled to fit inside `width` × `height`. @@ -58,6 +100,7 @@ const cropped = await ImageCropPicker.openCropper({ - Results are written to `/react-native-image-crop-picker/` on iOS and `/react-native-image-crop-picker/` on Android. `clean()` empties that directory. - Only one picker or cropper can be open at a time. A second call rejects with `E_PICKER_IN_PROGRESS`. - On iOS, cancelling the cropper that `openPicker` opened returns to the photo picker. On Android it rejects with `E_PICKER_CANCELLED`. +- On Android the cropper keeps the calling activity's requested orientation, so a portrait-only app gets a portrait-only cropper. ## Not supported diff --git a/native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec b/native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec index d9682edf5..f468c05e6 100644 --- a/native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec +++ b/native-modules/react-native-image-crop-picker/ReactNativeImageCropPicker.podspec @@ -19,9 +19,11 @@ Pod::Spec.new do |s| "cpp/**/*.{hpp,cpp}", ] - # Vendored TOCropViewController 3.2.0 with the OK-51551 rotation fix. Its - # headers are public so the Swift sources can use it. It replaces the - # standalone TOCropViewController pod, which must not be installed alongside. + # Vendored TOCropViewController 3.2.0 with the OK-51551 rotation fix, plus + # color hooks on TOCropOverlayView. Only its TOCropView is used, hosted by + # ImageCropperViewController. Its headers are public so the Swift sources can + # use it. It replaces the standalone TOCropViewController pod, which must not + # be installed alongside. s.public_header_files = ["ios/TOCropViewController/**/*.h"] # TOCropViewController looks up its strings in a bundle with exactly this name. s.resource_bundles = { diff --git a/native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml b/native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml index 853ec5d4d..92de63a9d 100644 --- a/native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml +++ b/native-modules/react-native-image-crop-picker/android/src/main/AndroidManifest.xml @@ -3,9 +3,9 @@ + android:theme="@style/Theme.AppCompat.NoActionBar" /> handleCropResult(result) } - launch { launcher.launch(cropper.getIntent(activity)) } + launch { launcher.launch(intent) } } private fun handleCropResult(result: ActivityResult) { val data = result.data when (result.resultCode) { Activity.RESULT_OK -> { - val output = data?.let { UCrop.getOutput(it) } + val output = data?.let { ImageCropperActivity.getOutputUri(it) } if (data == null || output == null) { finish(Result.failure(ImageCropPickerException.noImageData())) return } val cropRect = CropRect( - x = data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_X, -1).toDouble(), - y = data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_Y, -1).toDouble(), - width = data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_WIDTH, -1).toDouble(), - height = data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_HEIGHT, -1).toDouble(), + x = data.getIntExtra(ImageCropperActivity.EXTRA_OUTPUT_OFFSET_X, -1).toDouble(), + y = data.getIntExtra(ImageCropperActivity.EXTRA_OUTPUT_OFFSET_Y, -1).toDouble(), + width = data.getIntExtra(ImageCropperActivity.EXTRA_OUTPUT_WIDTH, -1).toDouble(), + height = data.getIntExtra(ImageCropperActivity.EXTRA_OUTPUT_HEIGHT, -1).toDouble(), ) val filename = pickedFilename runInBackground( @@ -164,35 +168,15 @@ internal class ImageCropPickerSession( onComplete = ::finish, ) } - UCrop.RESULT_ERROR -> { - val message = data?.let { UCrop.getError(it)?.message } ?: "Cannot crop image" + ImageCropperActivity.RESULT_ERROR -> { + val message = data?.getStringExtra(ImageCropperActivity.EXTRA_ERROR_MESSAGE) + ?: "Cannot crop image" finish(Result.failure(ImageCropPickerException.noImageData(message))) } else -> finish(Result.failure(ImageCropPickerException.cancelled())) } } - private fun buildCropOptions(): UCrop.Options = UCrop.Options().apply { - setCompressionFormat(Bitmap.CompressFormat.JPEG) - setCompressionQuality(100) - setCircleDimmedLayer(config.cropperCircleOverlay) - setFreeStyleCropEnabled(config.freeStyleCropEnabled) - setShowCropGrid(config.showCropGuidelines) - setShowCropFrame(config.showCropFrame) - setHideBottomControls(config.hideBottomControls) - config.cropperToolbarTitle?.let { setToolbarTitle(it) } - if (config.enableRotationGesture) { - setAllowedGestures(UCropActivity.ALL, UCropActivity.ALL, UCropActivity.ALL) - } - if (!config.disableCropperColorSetters) { - parseColor(config.cropperActiveWidgetColor)?.let { setActiveControlsWidgetColor(it) } - parseColor(config.cropperToolbarColor)?.let { setToolbarColor(it) } - parseColor(config.cropperToolbarWidgetColor)?.let { setToolbarWidgetColor(it) } - setStatusBarLight(config.cropperStatusBarLight) - setNavigationBarLight(config.cropperNavigationBarLight) - } - } - private fun register( contract: ActivityResultContract, callback: (O) -> Unit, @@ -280,15 +264,6 @@ internal class ImageCropPickerSession( return displayName ?: uri.lastPathSegment } - private fun parseColor(value: String?): Int? = - value?.let { - try { - Color.parseColor(it) - } catch (error: IllegalArgumentException) { - null - } - } - companion object { private const val TAG = "ImageCropPicker" private val mainHandler = Handler(Looper.getMainLooper()) diff --git a/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperActivity.kt b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperActivity.kt new file mode 100644 index 000000000..842475c61 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperActivity.kt @@ -0,0 +1,636 @@ +package com.margelo.nitro.reactnativeimagecroppicker + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.TextUtils +import android.util.TypedValue +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityNodeInfo +import android.view.animation.DecelerateInterpolator +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.common.assets.ReactFontManager +import com.yalantis.ucrop.callback.BitmapCropCallback +import com.yalantis.ucrop.view.CropImageView +import com.yalantis.ucrop.view.GestureCropImageView +import com.yalantis.ucrop.view.OverlayView +import com.yalantis.ucrop.view.TransformImageView +import com.yalantis.ucrop.view.UCropView +import kotlin.math.roundToInt + +// Full-screen cropper laid out like a OneKey page: a header with the title and +// a rotate button, the crop area, and a Cancel / Confirm footer. It draws the +// same screen, with the same metrics, as the iOS ImageCropperViewController. +class ImageCropperActivity : AppCompatActivity() { + private lateinit var cropperTheme: ImageCropperTheme + private lateinit var cropView: UCropView + private lateinit var cropImageView: GestureCropImageView + private lateinit var overlayView: OverlayView + private lateinit var rotateButton: IconButton + private lateinit var confirmButton: CapsuleButton + + private var showsGrid = false + private var gridAlpha = 0f + private var gridAnimator: ValueAnimator? = null + private var rotationAnimator: ValueAnimator? = null + private var isImageLoaded = false + private var isProcessing = false + private val hideGrid = Runnable { animateGrid(visible = false) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + cropperTheme = ImageCropperTheme.readFrom(intent) + val barStyle = if (cropperTheme.isDark) { + SystemBarStyle.dark(Color.TRANSPARENT) + } else { + SystemBarStyle.light(Color.TRANSPARENT, DARK_SCRIM) + } + enableEdgeToEdge(barStyle, barStyle) + requestedOrientation = intent.getIntExtra( + EXTRA_REQUESTED_ORIENTATION, + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, + ) + + val inputUri = intent.uriExtra(EXTRA_INPUT_URI) + val outputUri = intent.uriExtra(EXTRA_OUTPUT_URI) + if (inputUri == null || outputUri == null) { + finishWithError("Missing image") + return + } + + window.setBackgroundDrawable(ColorDrawable(cropperTheme.backgroundColor)) + setContentView(buildContent()) + configureCropView() + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = cancel() + }, + ) + + cropView.alpha = 0f + cropImageView.setTransformImageListener( + object : TransformImageView.TransformImageListener { + override fun onLoadComplete() { + isImageLoaded = true + cropView.animate().alpha(1f).setDuration(FADE_IN_DURATION).start() + } + + override fun onLoadFailure(error: Exception) { + finishWithError(error.message ?: "Cannot load image") + } + + override fun onRotate(currentAngle: Float) = Unit + + override fun onScale(currentScale: Float) = Unit + }, + ) + try { + cropImageView.setImageUri(inputUri, outputUri) + } catch (error: Exception) { + finishWithError(error.message ?: "Cannot load image") + } + } + + override fun onStop() { + super.onStop() + if (::cropImageView.isInitialized) { + cropImageView.cancelAllAnimations() + } + } + + override fun onDestroy() { + super.onDestroy() + gridAnimator?.cancel() + rotationAnimator?.cancel() + if (::cropView.isInitialized) { + cropView.removeCallbacks(hideGrid) + } + } + + private fun buildContent(): View { + val theme = cropperTheme + val spacing = dp(20f) + val headerHeight = dp(56f) + val iconButtonSize = dp(40f) + val buttonHeight = dp(50f) + + val column = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(theme.backgroundColor) + } + + val header = FrameLayout(this) + val title = intent.getStringExtra(EXTRA_TITLE) + if (!title.isNullOrEmpty()) { + val titleView = TextView(this).apply { + text = title + setTextColor(theme.titleColor) + setTextSize(TypedValue.COMPLEX_UNIT_DIP, (18 * theme.scale).roundToInt().toFloat()) + typeface = loadTypeface(theme.titleFontFamily, fallbackWeight = 600) + gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + includeFontPadding = false + ViewCompat.setAccessibilityHeading(this, true) + } + // Centered, clear of the rotate button on both sides. + header.addView( + titleView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ).apply { + marginStart = spacing + iconButtonSize + marginEnd = spacing + iconButtonSize + }, + ) + } + rotateButton = IconButton( + this, + iconSize = dp(24f), + iconColor = theme.iconColor, + pressedColor = theme.cancelButtonColor, + ).apply { + contentDescription = "Rotate" + visibility = if (intent.getBooleanExtra(EXTRA_ROTATE_BUTTON_HIDDEN, false)) { + View.GONE + } else { + View.VISIBLE + } + setOnClickListener { rotate() } + } + header.addView( + rotateButton, + FrameLayout.LayoutParams(iconButtonSize, iconButtonSize, Gravity.END or Gravity.CENTER_VERTICAL) + .apply { marginEnd = spacing - dp(8f) }, + ) + column.addView( + header, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, headerHeight), + ) + + cropView = UCropView(this, null) + cropImageView = cropView.cropImageView + overlayView = cropView.overlayView + // The crop box keeps `spacing` from every edge of the crop area. + cropImageView.setPadding(spacing, spacing, spacing, spacing) + overlayView.setPadding(spacing, spacing, spacing, spacing) + val cropContainer = CropContainer(this).apply { addView(cropView) } + column.addView( + cropContainer, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f), + ) + + val footer = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL } + val buttonPadding = dp(20f) + dp(1f) + val buttonTextSize = (16 * theme.scale).roundToInt().toFloat() + val buttonTypeface = loadTypeface(theme.buttonFontFamily, fallbackWeight = 500) + val cancelButton = CapsuleButton( + this, + title = intent.getStringExtra(EXTRA_CANCEL_TEXT)?.takeIf { it.isNotEmpty() } + ?: getString(android.R.string.cancel), + textSize = buttonTextSize, + typeface = buttonTypeface, + textColor = theme.cancelButtonTextColor, + backgroundColor = theme.cancelButtonColor, + pressedColor = theme.cancelButtonPressedColor, + horizontalPadding = buttonPadding, + spinnerSize = dp(20f), + spinnerSpacing = dp(8f), + ).apply { setOnClickListener { cancel() } } + confirmButton = CapsuleButton( + this, + title = intent.getStringExtra(EXTRA_CONFIRM_TEXT)?.takeIf { it.isNotEmpty() } + ?: getString(android.R.string.ok), + textSize = buttonTextSize, + typeface = buttonTypeface, + textColor = theme.confirmButtonTextColor, + backgroundColor = theme.confirmButtonColor, + pressedColor = theme.confirmButtonPressedColor, + horizontalPadding = buttonPadding, + spinnerSize = dp(20f), + spinnerSpacing = dp(8f), + ).apply { setOnClickListener { confirm() } } + footer.addView(cancelButton, LinearLayout.LayoutParams(0, buttonHeight, 1f)) + footer.addView( + confirmButton, + LinearLayout.LayoutParams(0, buttonHeight, 1f).apply { marginStart = dp(10f) }, + ) + column.addView( + footer, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + + val navigationBarReduction = (10 * resources.displayMetrics.density).roundToInt() + ViewCompat.setOnApplyWindowInsetsListener(column) { _, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + column.setPadding(insets.left, 0, insets.right, 0) + header.setPadding(0, insets.top, 0, 0) + header.layoutParams = header.layoutParams.apply { height = headerHeight + insets.top } + // Same as OneKey's page footer: 20dp of padding, plus the navigation bar + // inset less 10dp. + val bottomInset = if (insets.bottom > navigationBarReduction) { + insets.bottom - navigationBarReduction + } else { + insets.bottom + } + footer.setPadding(spacing, 0, spacing, spacing + bottomInset) + WindowInsetsCompat.CONSUMED + } + return column + } + + private fun configureCropView() { + val isCircular = intent.getBooleanExtra(EXTRA_CIRCLE_OVERLAY, false) + val isFreeStyle = intent.getBooleanExtra(EXTRA_FREE_STYLE, false) && !isCircular + val aspectRatioX = intent.getFloatExtra(EXTRA_ASPECT_RATIO_X, 0f) + val aspectRatioY = intent.getFloatExtra(EXTRA_ASPECT_RATIO_Y, 0f) + + cropImageView.isRotateEnabled = false + cropImageView.isScaleEnabled = true + cropImageView.targetAspectRatio = when { + isCircular -> 1f + aspectRatioX > 0f && aspectRatioY > 0f -> aspectRatioX / aspectRatioY + else -> CropImageView.SOURCE_IMAGE_ASPECT_RATIO + } + + val density = resources.displayMetrics.density + overlayView.freestyleCropMode = if (isFreeStyle) { + OverlayView.FREESTYLE_CROP_MODE_ENABLE + } else { + OverlayView.FREESTYLE_CROP_MODE_DISABLE + } + overlayView.setCircleDimmedLayer(isCircular) + overlayView.setDimmedColor(withAlpha(cropperTheme.backgroundColor, DIMMED_ALPHA)) + overlayView.setShowCropFrame(!isCircular) + overlayView.setCropFrameColor(cropperTheme.titleColor) + overlayView.setCropGridCornerColor(cropperTheme.titleColor) + overlayView.setCropFrameStrokeWidth(density.roundToInt().coerceAtLeast(1)) + // A hairline grid that only shows while the image is moved, like iOS. + showsGrid = intent.getBooleanExtra(EXTRA_SHOW_GRID, true) && !isCircular + overlayView.setShowCropGrid(showsGrid) + overlayView.setCropGridStrokeWidth(1) + overlayView.setCropGridColor(withAlpha(GRID_COLOR, 0f)) + } + + private fun onCropTouchStart() { + cropView.removeCallbacks(hideGrid) + animateGrid(visible = true) + } + + private fun onCropTouchEnd() { + cropView.removeCallbacks(hideGrid) + cropView.postDelayed(hideGrid, GRID_HIDE_DELAY) + } + + private fun animateGrid(visible: Boolean) { + if (!showsGrid) { + return + } + val target = if (visible) 1f else 0f + gridAnimator?.cancel() + if (gridAlpha == target) { + return + } + gridAnimator = ValueAnimator.ofFloat(gridAlpha, target).apply { + duration = if (visible) GRID_FADE_IN_DURATION else GRID_FADE_OUT_DURATION + addUpdateListener { animator -> + gridAlpha = animator.animatedValue as Float + overlayView.setCropGridColor(withAlpha(GRID_COLOR, gridAlpha)) + overlayView.invalidate() + } + start() + } + } + + private fun rotate() { + if (!isImageLoaded || isProcessing || rotationAnimator != null) { + return + } + cropImageView.cancelAllAnimations() + var appliedAngle = 0f + rotationAnimator = ValueAnimator.ofFloat(0f, -90f).apply { + duration = ROTATION_DURATION + interpolator = DecelerateInterpolator() + addUpdateListener { animator -> + val angle = animator.animatedValue as Float + cropImageView.postRotate(angle - appliedAngle) + appliedAngle = angle + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + rotationAnimator = null + cropImageView.setImageToWrapCropBounds() + } + }, + ) + start() + } + } + + private fun cancel() { + if (isProcessing) { + return + } + setResult(RESULT_CANCELED) + finish() + } + + private fun confirm() { + if (!isImageLoaded || isProcessing || rotationAnimator != null) { + return + } + isProcessing = true + confirmButton.isLoading = true + rotateButton.alpha = DISABLED_ALPHA + cropImageView.cropAndSaveImage( + Bitmap.CompressFormat.JPEG, + 100, + object : BitmapCropCallback { + override fun onBitmapCropped( + resultUri: Uri, + offsetX: Int, + offsetY: Int, + imageWidth: Int, + imageHeight: Int, + ) { + setResult( + RESULT_OK, + Intent() + .putExtra(EXTRA_OUTPUT_URI, resultUri) + .putExtra(EXTRA_OUTPUT_OFFSET_X, offsetX) + .putExtra(EXTRA_OUTPUT_OFFSET_Y, offsetY) + .putExtra(EXTRA_OUTPUT_WIDTH, imageWidth) + .putExtra(EXTRA_OUTPUT_HEIGHT, imageHeight), + ) + finish() + } + + override fun onCropFailure(error: Throwable) { + finishWithError(error.message ?: "Cannot crop image") + } + }, + ) + } + + private fun finishWithError(message: String) { + setResult(RESULT_ERROR, Intent().putExtra(EXTRA_ERROR_MESSAGE, message)) + finish() + } + + private fun dp(value: Float): Int = + (value * cropperTheme.scale * resources.displayMetrics.density).roundToInt() + + private fun loadTypeface(family: String?, fallbackWeight: Int): Typeface { + if (family != null) { + return ReactFontManager.getInstance().getTypeface(family, Typeface.NORMAL, assets) + } + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> + Typeface.create(Typeface.DEFAULT, fallbackWeight, false) + fallbackWeight >= 600 -> Typeface.DEFAULT_BOLD + else -> Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + } + + // Shows the grid while the image is being moved, and blocks gestures while + // the image is cropped. + @SuppressLint("ViewConstructor") + private inner class CropContainer(context: Context) : FrameLayout(context) { + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> onCropTouchStart() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> onCropTouchEnd() + } + return super.dispatchTouchEvent(event) + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean = + isProcessing || rotationAnimator != null || super.onInterceptTouchEvent(event) + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean = + isProcessing || rotationAnimator != null || super.onTouchEvent(event) + } + + // OneKey's large Button: a capsule with a 16dp medium label, a pressed + // color, and a spinner next to the label while loading. + @SuppressLint("ViewConstructor") + private class CapsuleButton( + context: Context, + title: String, + textSize: Float, + typeface: Typeface, + textColor: Int, + backgroundColor: Int, + pressedColor: Int, + horizontalPadding: Int, + spinnerSize: Int, + spinnerSpacing: Int, + ) : LinearLayout(context) { + private val spinner = ProgressBar(context).apply { + isIndeterminate = true + indeterminateTintList = ColorStateList.valueOf(textColor) + visibility = View.GONE + } + + var isLoading = false + set(value) { + field = value + spinner.visibility = if (value) View.VISIBLE else View.GONE + // OneKey dims disabled and loading buttons alike. + alpha = if (value) DISABLED_ALPHA else 1f + } + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER + setPadding(horizontalPadding, 0, horizontalPadding, 0) + background = pressable(capsule(backgroundColor), capsule(pressedColor)) + contentDescription = title + addView( + spinner, + LayoutParams(spinnerSize, spinnerSize).apply { marginEnd = spinnerSpacing }, + ) + addView( + TextView(context).apply { + text = title + setTextColor(textColor) + setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) + this.typeface = typeface + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + includeFontPadding = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT), + ) + } + + override fun getAccessibilityClassName(): CharSequence = Button::class.java.name + + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(info) + info.isEnabled = !isLoading + } + } + + // OneKey's header icon button: a 24dp icon with a round pressed background. + @SuppressLint("ViewConstructor") + private class IconButton( + context: Context, + iconSize: Int, + iconColor: Int, + pressedColor: Int, + ) : FrameLayout(context) { + init { + background = pressable( + ColorDrawable(Color.TRANSPARENT), + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(pressedColor) + }, + ) + addView( + ImageView(context).apply { + setImageResource(R.drawable.image_crop_picker_rotate) + imageTintList = ColorStateList.valueOf(iconColor) + scaleType = ImageView.ScaleType.FIT_CENTER + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, + LayoutParams(iconSize, iconSize, Gravity.CENTER), + ) + } + + override fun getAccessibilityClassName(): CharSequence = Button::class.java.name + } + + companion object { + private const val PREFIX = "com.margelo.nitro.reactnativeimagecroppicker.cropper." + private const val EXTRA_INPUT_URI = "${PREFIX}inputUri" + private const val EXTRA_ASPECT_RATIO_X = "${PREFIX}aspectRatioX" + private const val EXTRA_ASPECT_RATIO_Y = "${PREFIX}aspectRatioY" + private const val EXTRA_FREE_STYLE = "${PREFIX}freeStyle" + private const val EXTRA_CIRCLE_OVERLAY = "${PREFIX}circleOverlay" + private const val EXTRA_SHOW_GRID = "${PREFIX}showGrid" + private const val EXTRA_ROTATE_BUTTON_HIDDEN = "${PREFIX}rotateButtonHidden" + private const val EXTRA_TITLE = "${PREFIX}title" + private const val EXTRA_CANCEL_TEXT = "${PREFIX}cancelText" + private const val EXTRA_CONFIRM_TEXT = "${PREFIX}confirmText" + private const val EXTRA_REQUESTED_ORIENTATION = "${PREFIX}requestedOrientation" + + const val EXTRA_OUTPUT_URI = "${PREFIX}outputUri" + const val EXTRA_OUTPUT_OFFSET_X = "${PREFIX}outputOffsetX" + const val EXTRA_OUTPUT_OFFSET_Y = "${PREFIX}outputOffsetY" + const val EXTRA_OUTPUT_WIDTH = "${PREFIX}outputWidth" + const val EXTRA_OUTPUT_HEIGHT = "${PREFIX}outputHeight" + const val EXTRA_ERROR_MESSAGE = "${PREFIX}errorMessage" + const val RESULT_ERROR = RESULT_FIRST_USER + 1 + + // The area outside the crop box shows the page background at this opacity. + private const val DIMMED_ALPHA = 0.7f + private const val DISABLED_ALPHA = 0.4f + private const val GRID_COLOR = Color.WHITE + private const val GRID_HIDE_DELAY = 800L + private const val GRID_FADE_IN_DURATION = 200L + private const val GRID_FADE_OUT_DURATION = 350L + private const val ROTATION_DURATION = 250L + private const val FADE_IN_DURATION = 300L + private val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b) + + internal fun createIntent( + context: Context, + source: Uri, + destination: Uri, + config: ImageCropPickerConfig, + theme: ImageCropperTheme, + requestedOrientation: Int, + ): Intent { + val intent = Intent(context, ImageCropperActivity::class.java) + .putExtra(EXTRA_INPUT_URI, source) + .putExtra(EXTRA_OUTPUT_URI, destination) + .putExtra(EXTRA_FREE_STYLE, config.freeStyleCropEnabled) + .putExtra(EXTRA_CIRCLE_OVERLAY, config.cropperCircleOverlay) + .putExtra(EXTRA_SHOW_GRID, config.showCropGuidelines) + .putExtra(EXTRA_ROTATE_BUTTON_HIDDEN, config.cropperRotateButtonsHidden) + .putExtra(EXTRA_TITLE, config.cropperToolbarTitle) + .putExtra(EXTRA_CANCEL_TEXT, config.cropperCancelText) + .putExtra(EXTRA_CONFIRM_TEXT, config.cropperChooseText) + .putExtra(EXTRA_REQUESTED_ORIENTATION, requestedOrientation) + if (config.width != null && config.height != null) { + intent.putExtra(EXTRA_ASPECT_RATIO_X, config.width.toFloat()) + .putExtra(EXTRA_ASPECT_RATIO_Y, config.height.toFloat()) + } + theme.writeTo(intent) + return intent + } + + internal fun getOutputUri(data: Intent): Uri? = data.uriExtra(EXTRA_OUTPUT_URI) + + private fun Intent.uriExtra(name: String): Uri? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(name, Uri::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(name) + } + + private fun withAlpha(color: Int, alpha: Float): Int = + Color.argb( + (Color.alpha(color) * alpha).roundToInt(), + Color.red(color), + Color.green(color), + Color.blue(color), + ) + + private fun capsule(color: Int): Drawable = + GradientDrawable().apply { + // Clamped to half the height, which makes a capsule. + cornerRadius = 10_000f + setColor(color) + } + + private fun pressable(normal: Drawable, pressed: Drawable): Drawable = + StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), pressed) + addState(intArrayOf(), normal) + } + } +} diff --git a/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperTheme.kt b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperTheme.kt new file mode 100644 index 000000000..744ba3101 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperTheme.kt @@ -0,0 +1,188 @@ +package com.margelo.nitro.reactnativeimagecroppicker + +import android.content.Intent +import android.graphics.Color + +// Resolved colors, fonts and scale of the cropper screen. The layout matches +// the iOS ImageCropperViewController. Colors are ARGB ints. +internal data class ImageCropperTheme( + val isDark: Boolean, + val backgroundColor: Int, + val titleColor: Int, + val iconColor: Int, + val cancelButtonColor: Int, + val cancelButtonPressedColor: Int, + val cancelButtonTextColor: Int, + val confirmButtonColor: Int, + val confirmButtonPressedColor: Int, + val confirmButtonTextColor: Int, + val titleFontFamily: String?, + val buttonFontFamily: String?, + val scale: Float, +) { + fun writeTo(intent: Intent) { + intent.putExtra(EXTRA_IS_DARK, isDark) + .putExtra(EXTRA_BACKGROUND_COLOR, backgroundColor) + .putExtra(EXTRA_TITLE_COLOR, titleColor) + .putExtra(EXTRA_ICON_COLOR, iconColor) + .putExtra(EXTRA_CANCEL_BUTTON_COLOR, cancelButtonColor) + .putExtra(EXTRA_CANCEL_BUTTON_PRESSED_COLOR, cancelButtonPressedColor) + .putExtra(EXTRA_CANCEL_BUTTON_TEXT_COLOR, cancelButtonTextColor) + .putExtra(EXTRA_CONFIRM_BUTTON_COLOR, confirmButtonColor) + .putExtra(EXTRA_CONFIRM_BUTTON_PRESSED_COLOR, confirmButtonPressedColor) + .putExtra(EXTRA_CONFIRM_BUTTON_TEXT_COLOR, confirmButtonTextColor) + .putExtra(EXTRA_TITLE_FONT_FAMILY, titleFontFamily) + .putExtra(EXTRA_BUTTON_FONT_FAMILY, buttonFontFamily) + .putExtra(EXTRA_SCALE, scale) + } + + // OneKey's tokens: $bgApp, $text, $icon, $bgStrong, $bgStrongActive, + // $bgPrimary, $bgPrimaryActive and $textInverse. + private class Palette( + val background: String, + val title: String, + val icon: String, + val cancelButton: String, + val cancelButtonPressed: String, + val cancelButtonText: String, + val confirmButton: String, + val confirmButtonPressed: String, + val confirmButtonText: String, + ) + + companion object { + private const val PREFIX = "com.margelo.nitro.reactnativeimagecroppicker.theme." + private const val EXTRA_IS_DARK = "${PREFIX}isDark" + private const val EXTRA_BACKGROUND_COLOR = "${PREFIX}backgroundColor" + private const val EXTRA_TITLE_COLOR = "${PREFIX}titleColor" + private const val EXTRA_ICON_COLOR = "${PREFIX}iconColor" + private const val EXTRA_CANCEL_BUTTON_COLOR = "${PREFIX}cancelButtonColor" + private const val EXTRA_CANCEL_BUTTON_PRESSED_COLOR = "${PREFIX}cancelButtonPressedColor" + private const val EXTRA_CANCEL_BUTTON_TEXT_COLOR = "${PREFIX}cancelButtonTextColor" + private const val EXTRA_CONFIRM_BUTTON_COLOR = "${PREFIX}confirmButtonColor" + private const val EXTRA_CONFIRM_BUTTON_PRESSED_COLOR = "${PREFIX}confirmButtonPressedColor" + private const val EXTRA_CONFIRM_BUTTON_TEXT_COLOR = "${PREFIX}confirmButtonTextColor" + private const val EXTRA_TITLE_FONT_FAMILY = "${PREFIX}titleFontFamily" + private const val EXTRA_BUTTON_FONT_FAMILY = "${PREFIX}buttonFontFamily" + private const val EXTRA_SCALE = "${PREFIX}scale" + + private val lightPalette = Palette( + background = "#FFFFFF", + title = "#000000DF", + icon = "#0000009B", + cancelButton = "#0000000F", + cancelButtonPressed = "#0000001F", + cancelButtonText = "#000000DF", + confirmButton = "#000000DF", + confirmButtonPressed = "#0000009B", + confirmButtonText = "#FFFFFFED", + ) + + private val darkPalette = Palette( + background = "#0F0F0F", + title = "#FFFFFFED", + icon = "#FFFFFFAF", + cancelButton = "#FFFFFF12", + cancelButtonPressed = "#FFFFFF22", + cancelButtonText = "#FFFFFFED", + confirmButton = "#FFFFFFED", + confirmButtonPressed = "#FFFFFF72", + confirmButtonText = "#000000DF", + ) + + fun resolve(appearance: ImageCropperAppearance?, systemIsDark: Boolean): ImageCropperTheme { + val isDark = when (appearance?.colorScheme) { + ImageCropperColorScheme.DARK -> true + ImageCropperColorScheme.LIGHT -> false + null -> systemIsDark + } + val palette = if (isDark) darkPalette else lightPalette + fun color(value: String?, fallback: String): Int = + parseCssColor(value) ?: parseCssColor(fallback) ?: Color.TRANSPARENT + + val scale = appearance?.scale?.toFloat()?.takeIf { it.isFinite() && it > 0f } ?: 1f + return ImageCropperTheme( + isDark = isDark, + backgroundColor = color(appearance?.backgroundColor, palette.background), + titleColor = color(appearance?.titleColor, palette.title), + iconColor = color(appearance?.iconColor, palette.icon), + cancelButtonColor = color(appearance?.cancelButtonColor, palette.cancelButton), + cancelButtonPressedColor = color( + appearance?.cancelButtonPressedColor, + palette.cancelButtonPressed, + ), + cancelButtonTextColor = color(appearance?.cancelButtonTextColor, palette.cancelButtonText), + confirmButtonColor = color(appearance?.confirmButtonColor, palette.confirmButton), + confirmButtonPressedColor = color( + appearance?.confirmButtonPressedColor, + palette.confirmButtonPressed, + ), + confirmButtonTextColor = color( + appearance?.confirmButtonTextColor, + palette.confirmButtonText, + ), + titleFontFamily = appearance?.titleFontFamily?.takeIf { it.isNotEmpty() }, + buttonFontFamily = appearance?.buttonFontFamily?.takeIf { it.isNotEmpty() }, + scale = scale, + ) + } + + fun readFrom(intent: Intent): ImageCropperTheme { + val fallback = resolve(null, systemIsDark = intent.getBooleanExtra(EXTRA_IS_DARK, false)) + return ImageCropperTheme( + isDark = fallback.isDark, + backgroundColor = intent.getIntExtra(EXTRA_BACKGROUND_COLOR, fallback.backgroundColor), + titleColor = intent.getIntExtra(EXTRA_TITLE_COLOR, fallback.titleColor), + iconColor = intent.getIntExtra(EXTRA_ICON_COLOR, fallback.iconColor), + cancelButtonColor = intent.getIntExtra(EXTRA_CANCEL_BUTTON_COLOR, fallback.cancelButtonColor), + cancelButtonPressedColor = intent.getIntExtra( + EXTRA_CANCEL_BUTTON_PRESSED_COLOR, + fallback.cancelButtonPressedColor, + ), + cancelButtonTextColor = intent.getIntExtra( + EXTRA_CANCEL_BUTTON_TEXT_COLOR, + fallback.cancelButtonTextColor, + ), + confirmButtonColor = intent.getIntExtra(EXTRA_CONFIRM_BUTTON_COLOR, fallback.confirmButtonColor), + confirmButtonPressedColor = intent.getIntExtra( + EXTRA_CONFIRM_BUTTON_PRESSED_COLOR, + fallback.confirmButtonPressedColor, + ), + confirmButtonTextColor = intent.getIntExtra( + EXTRA_CONFIRM_BUTTON_TEXT_COLOR, + fallback.confirmButtonTextColor, + ), + titleFontFamily = intent.getStringExtra(EXTRA_TITLE_FONT_FAMILY), + buttonFontFamily = intent.getStringExtra(EXTRA_BUTTON_FONT_FAMILY), + scale = intent.getFloatExtra(EXTRA_SCALE, fallback.scale), + ) + } + + // Parses CSS hex colors: #RGB, #RRGGBB and #RRGGBBAA. Color.parseColor + // reads 8 digits as #AARRGGBB, so it cannot be used here. + fun parseCssColor(value: String?): Int? { + var hex = value?.trim()?.removePrefix("#") ?: return null + if (hex.length == 3) { + hex = hex.map { "$it$it" }.joinToString("") + } + if (hex.length != 6 && hex.length != 8) { + return null + } + val rgba = hex.toLongOrNull(16) ?: return null + return if (hex.length == 8) { + Color.argb( + (rgba and 0xff).toInt(), + ((rgba shr 24) and 0xff).toInt(), + ((rgba shr 16) and 0xff).toInt(), + ((rgba shr 8) and 0xff).toInt(), + ) + } else { + Color.rgb( + ((rgba shr 16) and 0xff).toInt(), + ((rgba shr 8) and 0xff).toInt(), + (rgba and 0xff).toInt(), + ) + } + } + } +} diff --git a/native-modules/react-native-image-crop-picker/android/src/main/res/drawable/image_crop_picker_rotate.xml b/native-modules/react-native-image-crop-picker/android/src/main/res/drawable/image_crop_picker_rotate.xml new file mode 100644 index 000000000..e67edeff6 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/android/src/main/res/drawable/image_crop_picker_rotate.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift index 30e8a05fc..c218e535d 100644 --- a/native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift +++ b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerImageProcessor.swift @@ -16,9 +16,9 @@ struct ImageCropPickerConfig { let cropperToolbarTitle: String? let cropperChooseText: String? let cropperCancelText: String? - let cropperChooseColor: String? - let cropperCancelColor: String? let cropperRotateButtonsHidden: Bool + let showCropGuidelines: Bool + let appearance: ImageCropperAppearanceConfig init(_ options: ImageCropPickerOptions, forceCropping: Bool = false) { width = options.width @@ -33,9 +33,9 @@ struct ImageCropPickerConfig { cropperToolbarTitle = options.cropperToolbarTitle cropperChooseText = options.cropperChooseText cropperCancelText = options.cropperCancelText - cropperChooseColor = options.cropperChooseColor - cropperCancelColor = options.cropperCancelColor cropperRotateButtonsHidden = options.cropperRotateButtonsHidden ?? false + showCropGuidelines = options.showCropGuidelines ?? true + appearance = ImageCropperAppearanceConfig(options.cropperAppearance) } var targetSize: CGSize? { @@ -305,6 +305,7 @@ enum ImageCropPickerImageProcessor { } } + // Parses CSS hex colors: #RGB, #RRGGBB and #RRGGBBAA. static func color(fromHex hex: String?) -> UIColor? { guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil @@ -312,6 +313,9 @@ enum ImageCropPickerImageProcessor { if value.hasPrefix("#") { value.removeFirst() } + if value.count == 3 { + value = value.map { "\($0)\($0)" }.joined() + } guard value.count == 6 || value.count == 8, let rgba = UInt64(value, radix: 16) else { return nil } diff --git a/native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift index d51e15506..87efc3b84 100644 --- a/native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift +++ b/native-modules/react-native-image-crop-picker/ios/ImageCropPickerSession.swift @@ -23,9 +23,10 @@ final class ImageCropPickerSession: NSObject { // Main thread state. private var pickerController: PHPickerViewController? - private var cropController: TOCropViewController? + private var cropController: ImageCropperViewController? private var loadingView: UIView? private var pickedFilename: String? + private var pickedAssetIdentifier: String? private var sourceScale: CGFloat = 1 private var presentationRequestedAt: Date? private var hasPresented = false @@ -100,7 +101,9 @@ final class ImageCropPickerSession: NSObject { // PHPickerViewController runs out of process and needs no photo library // permission, so a previously denied permission can't block the picker. - var configuration = PHPickerConfiguration() + // Passing the library only adds asset identifiers to the results; it + // doesn't ask for access either. + var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .images configuration.selectionLimit = 1 configuration.preferredAssetRepresentationMode = .current @@ -108,6 +111,14 @@ final class ImageCropPickerSession: NSObject { let picker = PHPickerViewController(configuration: configuration) picker.delegate = self picker.modalPresentationStyle = .fullScreen + switch config.appearance.colorScheme { + case .dark: + picker.overrideUserInterfaceStyle = .dark + case .light: + picker.overrideUserInterfaceStyle = .light + case nil: + break + } pickerController = picker present(picker, from: presenter) } @@ -202,32 +213,12 @@ final class ImageCropPickerSession: NSObject { } private func presentCropper(image: UIImage, from presenter: UIViewController) { - let controller: TOCropViewController - if config.cropperCircleOverlay { - controller = TOCropViewController(croppingStyle: .circular, image: image) - } else { - controller = TOCropViewController(image: image) - if let targetSize = config.targetSize { - controller.aspectRatioPreset = targetSize - } - controller.aspectRatioLockEnabled = !config.freeStyleCropEnabled - controller.resetAspectRatioEnabled = !controller.aspectRatioLockEnabled - } - - controller.title = config.cropperToolbarTitle + let theme = ImageCropperTheme( + config.appearance, + systemIsDark: presenter.traitCollection.userInterfaceStyle == .dark + ) + let controller = ImageCropperViewController(image: image, config: config, theme: theme) controller.delegate = self - if let color = ImageCropPickerImageProcessor.color(fromHex: config.cropperChooseColor) { - controller.doneButtonColor = color - } - if let color = ImageCropPickerImageProcessor.color(fromHex: config.cropperCancelColor) { - controller.cancelButtonColor = color - } - controller.doneButtonTitle = config.cropperChooseText - controller.cancelButtonTitle = config.cropperCancelText - controller.rotateButtonsHidden = config.cropperRotateButtonsHidden - controller.modalPresentationStyle = .fullScreen - controller.modalTransitionStyle = .coverVertical - cropController = controller present(controller, from: presenter) } @@ -345,37 +336,42 @@ extension ImageCropPickerSession: PHPickerViewControllerDelegate { dismissAll { self.finish(.failure(ImageCropPickerError.cancelled)) } return } + pickedAssetIdentifier = result.assetIdentifier loadPickedImage(result, in: picker) } } -extension ImageCropPickerSession: TOCropViewControllerDelegate { - func cropViewController( - _ cropViewController: TOCropViewController, - didCropTo image: UIImage, - with cropRect: CGRect, +extension ImageCropPickerSession: ImageCropperViewControllerDelegate { + func imageCropperViewController( + _ controller: ImageCropperViewController, + didCropWithFrame cropFrame: CGRect, angle: Int ) { guard !isFinished, !isBusy else { return } isBusy = true - showLoading(in: cropViewController.view) + controller.isProcessing = true + let image = controller.image let config = self.config let filename = pickedFilename // Report the crop rect in the coordinates of the original, undownsampled image. let sourceCropRect = CGRect( - x: (cropRect.origin.x * sourceScale).rounded(), - y: (cropRect.origin.y * sourceScale).rounded(), - width: (cropRect.width * sourceScale).rounded(), - height: (cropRect.height * sourceScale).rounded() + x: (cropFrame.origin.x * sourceScale).rounded(), + y: (cropFrame.origin.y * sourceScale).rounded(), + width: (cropFrame.width * sourceScale).rounded(), + height: (cropFrame.height * sourceScale).rounded() ) DispatchQueue.global(qos: .userInitiated).async { [self] in let result = Result { - try ImageCropPickerImageProcessor.makeResult( - image: ImageCropPickerImageProcessor.resizeCroppedImage(image, config: config), + let isWholeImage = angle == 0 && cropFrame == CGRect(origin: .zero, size: image.size) + let cropped = isWholeImage + ? image + : image.croppedImage(withFrame: cropFrame, angle: angle, circularClip: false) + return try ImageCropPickerImageProcessor.makeResult( + image: ImageCropPickerImageProcessor.resizeCroppedImage(cropped, config: config), config: config, cropRect: sourceCropRect, filename: filename @@ -383,23 +379,24 @@ extension ImageCropPickerSession: TOCropViewControllerDelegate { } DispatchQueue.main.async { [self] in isBusy = false - hideLoading() dismissAll { self.finish(result) } } } } - func cropViewController( - _ cropViewController: TOCropViewController, - didFinishCancelled cancelled: Bool - ) { + func imageCropperViewControllerDidCancel(_ controller: ImageCropperViewController) { guard !isFinished, !isBusy else { return } - if isPickerMode, pickerController?.presentingViewController != nil { + if isPickerMode, let picker = pickerController, picker.presentingViewController != nil { // Go back to the photo picker, like react-native-image-crop-picker. + // The picker keeps the photo selected, so without this, tapping the + // same photo again would only deselect it. + if #available(iOS 17.0, *), let identifier = pickedAssetIdentifier { + picker.deselectAssets(withIdentifiers: [identifier]) + } cropController = nil - cropViewController.dismiss(animated: true) + controller.dismiss(animated: true) return } dismissAll { self.finish(.failure(ImageCropPickerError.cancelled)) } diff --git a/native-modules/react-native-image-crop-picker/ios/ImageCropperTheme.swift b/native-modules/react-native-image-crop-picker/ios/ImageCropperTheme.swift new file mode 100644 index 000000000..fb6c0c338 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/ImageCropperTheme.swift @@ -0,0 +1,155 @@ +import UIKit + +// Plain Swift copy of `ImageCropperAppearance`. +struct ImageCropperAppearanceConfig { + let colorScheme: ImageCropperColorScheme? + let backgroundColor: String? + let titleColor: String? + let iconColor: String? + let cancelButtonColor: String? + let cancelButtonPressedColor: String? + let cancelButtonTextColor: String? + let confirmButtonColor: String? + let confirmButtonPressedColor: String? + let confirmButtonTextColor: String? + let titleFontFamily: String? + let buttonFontFamily: String? + let scale: Double? + + init(_ appearance: ImageCropperAppearance?) { + colorScheme = appearance?.colorScheme + backgroundColor = appearance?.backgroundColor + titleColor = appearance?.titleColor + iconColor = appearance?.iconColor + cancelButtonColor = appearance?.cancelButtonColor + cancelButtonPressedColor = appearance?.cancelButtonPressedColor + cancelButtonTextColor = appearance?.cancelButtonTextColor + confirmButtonColor = appearance?.confirmButtonColor + confirmButtonPressedColor = appearance?.confirmButtonPressedColor + confirmButtonTextColor = appearance?.confirmButtonTextColor + titleFontFamily = appearance?.titleFontFamily + buttonFontFamily = appearance?.buttonFontFamily + scale = appearance?.scale + } +} + +// Resolved colors, fonts and metrics of the cropper screen. The layout +// matches Android's ImageCropperActivity. +struct ImageCropperTheme { + let isDark: Bool + let backgroundColor: UIColor + let titleColor: UIColor + let iconColor: UIColor + let cancelButtonColor: UIColor + let cancelButtonPressedColor: UIColor + let cancelButtonTextColor: UIColor + let confirmButtonColor: UIColor + let confirmButtonPressedColor: UIColor + let confirmButtonTextColor: UIColor + let titleFont: UIFont + let buttonFont: UIFont + let scale: CGFloat + + // OneKey's tokens: $bgApp, $text, $icon, $bgStrong, $bgStrongActive, + // $bgPrimary, $bgPrimaryActive and $textInverse. + private struct Palette { + let background: String + let title: String + let icon: String + let cancelButton: String + let cancelButtonPressed: String + let cancelButtonText: String + let confirmButton: String + let confirmButtonPressed: String + let confirmButtonText: String + } + + private static let lightPalette = Palette( + background: "#FFFFFF", + title: "#000000DF", + icon: "#0000009B", + cancelButton: "#0000000F", + cancelButtonPressed: "#0000001F", + cancelButtonText: "#000000DF", + confirmButton: "#000000DF", + confirmButtonPressed: "#0000009B", + confirmButtonText: "#FFFFFFED" + ) + + private static let darkPalette = Palette( + background: "#0F0F0F", + title: "#FFFFFFED", + icon: "#FFFFFFAF", + cancelButton: "#FFFFFF12", + cancelButtonPressed: "#FFFFFF22", + cancelButtonText: "#FFFFFFED", + confirmButton: "#FFFFFFED", + confirmButtonPressed: "#FFFFFF72", + confirmButtonText: "#000000DF" + ) + + init(_ appearance: ImageCropperAppearanceConfig, systemIsDark: Bool) { + switch appearance.colorScheme { + case .dark: + isDark = true + case .light: + isDark = false + case nil: + isDark = systemIsDark + } + let palette = isDark ? Self.darkPalette : Self.lightPalette + + func color(_ value: String?, fallback: String) -> UIColor { + return ImageCropPickerImageProcessor.color(fromHex: value) + ?? ImageCropPickerImageProcessor.color(fromHex: fallback) + ?? .clear + } + + backgroundColor = color(appearance.backgroundColor, fallback: palette.background) + titleColor = color(appearance.titleColor, fallback: palette.title) + iconColor = color(appearance.iconColor, fallback: palette.icon) + cancelButtonColor = color(appearance.cancelButtonColor, fallback: palette.cancelButton) + cancelButtonPressedColor = color( + appearance.cancelButtonPressedColor, + fallback: palette.cancelButtonPressed + ) + cancelButtonTextColor = color( + appearance.cancelButtonTextColor, + fallback: palette.cancelButtonText + ) + confirmButtonColor = color(appearance.confirmButtonColor, fallback: palette.confirmButton) + confirmButtonPressedColor = color( + appearance.confirmButtonPressedColor, + fallback: palette.confirmButtonPressed + ) + confirmButtonTextColor = color( + appearance.confirmButtonTextColor, + fallback: palette.confirmButtonText + ) + + let resolvedScale = CGFloat(appearance.scale ?? 1) + scale = resolvedScale > 0 ? resolvedScale : 1 + // OneKey's $headingLg and $bodyLgMedium, rounded like its scaled fonts. + titleFont = Self.font( + named: appearance.titleFontFamily, + size: (18 * scale).rounded(), + fallbackWeight: .semibold + ) + buttonFont = Self.font( + named: appearance.buttonFontFamily, + size: (16 * scale).rounded(), + fallbackWeight: .medium + ) + } + + func metric(_ value: CGFloat) -> CGFloat { + return (value * scale).rounded() + } + + private static func font(named name: String?, size: CGFloat, fallbackWeight: UIFont.Weight) -> UIFont { + if let name, let font = UIFont(name: name, size: size) { + return font + } + return .systemFont(ofSize: size, weight: fallbackWeight) + } +} diff --git a/native-modules/react-native-image-crop-picker/ios/ImageCropperViewController.swift b/native-modules/react-native-image-crop-picker/ios/ImageCropperViewController.swift new file mode 100644 index 000000000..a6761ca41 --- /dev/null +++ b/native-modules/react-native-image-crop-picker/ios/ImageCropperViewController.swift @@ -0,0 +1,462 @@ +import UIKit + +protocol ImageCropperViewControllerDelegate: AnyObject { + // `cropFrame` is in the coordinate space of `image`, rotated by `angle`. + func imageCropperViewController( + _ controller: ImageCropperViewController, + didCropWithFrame cropFrame: CGRect, + angle: Int + ) + func imageCropperViewControllerDidCancel(_ controller: ImageCropperViewController) +} + +// Full-screen cropper laid out like a OneKey page: a header with the title and +// a rotate button, the crop area, and a Cancel / Confirm footer. Android's +// ImageCropperActivity draws the same screen with the same metrics. +final class ImageCropperViewController: UIViewController { + // The area outside the crop box shows the page background at this opacity. + static let dimmedAlpha: CGFloat = 0.7 + + weak var delegate: ImageCropperViewControllerDelegate? + let image: UIImage + + private let config: ImageCropPickerConfig + private let theme: ImageCropperTheme + private let cropView: TOCropView + private let titleLabel = UILabel() + private let rotateButton: ImageCropperIconButton + private let cancelButton: ImageCropperButton + private let confirmButton: ImageCropperButton + private var didPerformInitialSetup = false + private var isRotating = false + + // Set while the session crops and saves the image. + var isProcessing = false { + didSet { updateControls() } + } + + init(image: UIImage, config: ImageCropPickerConfig, theme: ImageCropperTheme) { + self.image = image + self.config = config + self.theme = theme + let cropView = TOCropView( + croppingStyle: config.cropperCircleOverlay ? .circular : .default, + image: image + ) + self.cropView = cropView + + // TOCropViewController's own translations, for callers that pass no text. + let strings = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(cropView) ?? .main + func localized(_ key: String) -> String { + return strings.localizedString(forKey: key, value: key, table: "TOCropViewControllerLocalizable") + } + // Large Button: 12pt + 1pt border vertically, 20pt + 1pt horizontally. + let buttonPadding = theme.metric(20) + 1 + let spinnerSpacing = theme.metric(8) + cancelButton = ImageCropperButton( + title: Self.nonEmpty(config.cropperCancelText) ?? localized("Cancel"), + horizontalPadding: buttonPadding, + spinnerSpacing: spinnerSpacing, + font: theme.buttonFont, + textColor: theme.cancelButtonTextColor, + backgroundColor: theme.cancelButtonColor, + pressedBackgroundColor: theme.cancelButtonPressedColor + ) + confirmButton = ImageCropperButton( + title: Self.nonEmpty(config.cropperChooseText) ?? localized("Done"), + horizontalPadding: buttonPadding, + spinnerSpacing: spinnerSpacing, + font: theme.buttonFont, + textColor: theme.confirmButtonTextColor, + backgroundColor: theme.confirmButtonColor, + pressedBackgroundColor: theme.confirmButtonPressedColor + ) + rotateButton = ImageCropperIconButton( + image: ImageCropperIcons.rotateCounterclockwise(size: theme.metric(24)), + tintColor: theme.iconColor, + pressedBackgroundColor: theme.cancelButtonColor + ) + + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + modalTransitionStyle = .coverVertical + overrideUserInterfaceStyle = theme.isDark ? .dark : .light + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return theme.isDark ? .lightContent : .darkContent + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = theme.backgroundColor + + configureCropView() + view.addSubview(cropView) + + titleLabel.text = config.cropperToolbarTitle + titleLabel.font = theme.titleFont + titleLabel.textColor = theme.titleColor + titleLabel.textAlignment = .center + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.accessibilityTraits = .header + view.addSubview(titleLabel) + + rotateButton.isHidden = config.cropperRotateButtonsHidden + rotateButton.accessibilityLabel = "Rotate" + rotateButton.addTarget(self, action: #selector(rotateTapped), for: .touchUpInside) + view.addSubview(rotateButton) + + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + view.addSubview(cancelButton) + view.addSubview(confirmButton) + } + + private func configureCropView() { + cropView.backgroundColor = theme.backgroundColor + cropView.overlayView.backgroundColor = theme.backgroundColor.withAlphaComponent(Self.dimmedAlpha) + // The built-in dark blur ignores the theme, so rely on the dimmed overlay alone. + cropView.translucencyAlwaysHidden = true + cropView.cropViewPadding = theme.metric(20) + + let isCircular = config.cropperCircleOverlay + let isFreeStyle = config.freeStyleCropEnabled && !isCircular + if !isCircular, let targetSize = config.targetSize { + // Applied by performInitialSetup, which sizes the first crop box from it. + cropView.aspectRatio = targetSize + } + cropView.aspectRatioLockEnabled = !isFreeStyle + cropView.resetAspectRatioEnabled = isFreeStyle + cropView.cropBoxResizeEnabled = isFreeStyle + + if let overlay = cropView.gridOverlayView { + overlay.frameColor = theme.titleColor + overlay.gridColor = UIColor.white.withAlphaComponent(0.8) + overlay.cornerHandlesHidden = !isFreeStyle + if !config.showCropGuidelines { + overlay.displayHorizontalGridLines = false + overlay.displayVerticalGridLines = false + } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let cropFrame = layoutChrome(in: view.bounds.size) + guard cropFrame.width > 0, cropFrame.height > 0 else { + return + } + cropView.frame = cropFrame + cropView.moveCroppedContentToCenter(animated: false) + if !didPerformInitialSetup { + didPerformInitialSetup = true + cropView.performInitialSetup() + } + } + + override func viewWillTransition( + to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator + ) { + super.viewWillTransition(to: size, with: coordinator) + guard didPerformInitialSetup, size != view.bounds.size else { + return + } + // Same sequence as TOCropViewController, so the crop box keeps its content. + cropView.prepareforRotation() + cropView.simpleRenderMode = true + cropView.internalLayoutDisabled = true + coordinator.animate(alongsideTransition: { [self] _ in + cropView.frame = layoutChrome(in: size) + cropView.performRelayoutForRotation() + }, completion: { [self] _ in + cropView.setSimpleRenderMode(false, animated: true) + cropView.internalLayoutDisabled = false + }) + } + + // Lays out the header and footer, and returns the frame left for the crop view. + @discardableResult + private func layoutChrome(in size: CGSize) -> CGRect { + let insets = view.safeAreaInsets + let spacing = theme.metric(20) + let headerHeight = theme.metric(56) + let iconButtonSize = theme.metric(40) + let buttonHeight = theme.metric(50) + let buttonGap = theme.metric(10) + // Same as OneKey's page footer: 20pt of padding, plus the home indicator + // inset less 10pt. + let bottomInset = insets.bottom > 10 ? insets.bottom - 10 : insets.bottom + + let contentMinX = insets.left + let contentWidth = max(size.width - insets.left - insets.right, 0) + + let headerMidY = insets.top + headerHeight / 2 + rotateButton.frame = CGRect( + x: contentMinX + contentWidth - spacing + theme.metric(8) - iconButtonSize, + y: headerMidY - iconButtonSize / 2, + width: iconButtonSize, + height: iconButtonSize + ) + // Centered, clear of the rotate button on both sides. + let titleInset = spacing + iconButtonSize + titleLabel.frame = CGRect( + x: contentMinX + titleInset, + y: insets.top, + width: max(contentWidth - titleInset * 2, 0), + height: headerHeight + ) + + let buttonY = size.height - bottomInset - spacing - buttonHeight + let buttonWidth = max((contentWidth - spacing * 2 - buttonGap) / 2, 0) + cancelButton.frame = CGRect( + x: contentMinX + spacing, + y: buttonY, + width: buttonWidth, + height: buttonHeight + ) + confirmButton.frame = CGRect( + x: cancelButton.frame.maxX + buttonGap, + y: buttonY, + width: buttonWidth, + height: buttonHeight + ) + + // The crop view pads the crop box by `spacing` on every side. + let cropMinY = insets.top + headerHeight + return CGRect( + x: contentMinX, + y: cropMinY, + width: contentWidth, + height: max(buttonY - cropMinY, 0) + ) + } + + private func updateControls() { + cropView.isUserInteractionEnabled = !isProcessing + rotateButton.isEnabled = !isProcessing + confirmButton.isLoading = isProcessing + } + + @objc private func rotateTapped() { + guard !isRotating, !isProcessing else { + return + } + isRotating = true + cropView.rotateImageNinetyDegrees(animated: true, clockwise: false) { [weak self] _ in + self?.isRotating = false + } + } + + @objc private func cancelTapped() { + delegate?.imageCropperViewControllerDidCancel(self) + } + + @objc private func confirmTapped() { + guard !isProcessing, !isRotating else { + return + } + delegate?.imageCropperViewController( + self, + didCropWithFrame: cropView.imageCropFrame, + angle: cropView.angle + ) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { + return nil + } + return value + } +} + +// OneKey's large Button: a capsule with a 16pt medium label, a pressed color, +// and a spinner next to the label while loading. +final class ImageCropperButton: UIControl { + private let label = UILabel() + private let spinner = UIActivityIndicatorView(style: .medium) + private let horizontalPadding: CGFloat + private let spinnerSpacing: CGFloat + private let normalBackgroundColor: UIColor + private let pressedBackgroundColor: UIColor + + var isLoading = false { + didSet { + guard isLoading != oldValue else { + return + } + if isLoading { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + updateAppearance() + setNeedsLayout() + } + } + + override var isHighlighted: Bool { + didSet { updateAppearance() } + } + + override var isEnabled: Bool { + didSet { updateAppearance() } + } + + init( + title: String, + horizontalPadding: CGFloat, + spinnerSpacing: CGFloat, + font: UIFont, + textColor: UIColor, + backgroundColor: UIColor, + pressedBackgroundColor: UIColor + ) { + self.horizontalPadding = horizontalPadding + self.spinnerSpacing = spinnerSpacing + normalBackgroundColor = backgroundColor + self.pressedBackgroundColor = pressedBackgroundColor + super.init(frame: .zero) + + layer.cornerCurve = .continuous + label.text = title + label.font = font + label.textColor = textColor + label.textAlignment = .center + label.lineBreakMode = .byTruncatingTail + label.isUserInteractionEnabled = false + addSubview(label) + + spinner.color = textColor + spinner.hidesWhenStopped = true + spinner.isUserInteractionEnabled = false + addSubview(spinner) + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityLabel = title + updateAppearance() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + + let spinnerSize = spinner.intrinsicContentSize + let leadingWidth = isLoading ? spinnerSize.width + spinnerSpacing : 0 + let maxLabelWidth = max(bounds.width - horizontalPadding * 2 - leadingWidth, 0) + let labelWidth = min(ceil(label.intrinsicContentSize.width), maxLabelWidth) + let startX = (bounds.width - labelWidth - leadingWidth) / 2 + + spinner.frame = CGRect( + x: startX, + y: (bounds.height - spinnerSize.height) / 2, + width: spinnerSize.width, + height: spinnerSize.height + ) + label.frame = CGRect( + x: startX + leadingWidth, + y: 0, + width: labelWidth, + height: bounds.height + ) + } + + private func updateAppearance() { + backgroundColor = isHighlighted ? pressedBackgroundColor : normalBackgroundColor + // OneKey dims disabled and loading buttons alike. + alpha = isEnabled && !isLoading ? 1 : 0.4 + accessibilityTraits = isEnabled ? .button : [.button, .notEnabled] + } +} + +// OneKey's header icon button: a 24pt icon with a round pressed background. +final class ImageCropperIconButton: UIControl { + private let imageView = UIImageView() + private let pressedBackgroundColor: UIColor + + override var isHighlighted: Bool { + didSet { updateAppearance() } + } + + override var isEnabled: Bool { + didSet { updateAppearance() } + } + + init(image: UIImage, tintColor: UIColor, pressedBackgroundColor: UIColor) { + self.pressedBackgroundColor = pressedBackgroundColor + super.init(frame: .zero) + imageView.image = image + imageView.tintColor = tintColor + imageView.contentMode = .center + imageView.isUserInteractionEnabled = false + addSubview(imageView) + isAccessibilityElement = true + accessibilityTraits = .button + updateAppearance() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + imageView.frame = bounds + } + + private func updateAppearance() { + backgroundColor = isHighlighted ? pressedBackgroundColor : .clear + imageView.alpha = isEnabled ? 1 : 0.4 + } +} + +enum ImageCropperIcons { + // OneKey's RotateCounterclockwise icon (24x24 viewBox). Android uses the + // same path in res/drawable/image_crop_picker_rotate.xml. + static func rotateCounterclockwise(size: CGFloat) -> UIImage { + let path = UIBezierPath() + path.move(to: CGPoint(x: 6, y: 5.426)) + path.addCurve(to: CGPoint(x: 12.028, y: 3), controlPoint1: CGPoint(x: 7.628, y: 3.919), controlPoint2: CGPoint(x: 9.484, y: 3)) + path.addCurve(to: CGPoint(x: 20.969, y: 10.984), controlPoint1: CGPoint(x: 16.605, y: 3.001), controlPoint2: CGPoint(x: 20.452, y: 6.436)) + path.addCurve(to: CGPoint(x: 14.047, y: 20.77), controlPoint1: CGPoint(x: 21.485, y: 15.531), controlPoint2: CGPoint(x: 18.507, y: 19.742)) + path.addCurve(to: CGPoint(x: 3.541, y: 15), controlPoint1: CGPoint(x: 9.588, y: 21.798), controlPoint2: CGPoint(x: 5.067, y: 19.315)) + path.addLine(to: CGPoint(x: 3.207, y: 14.057)) + path.addLine(to: CGPoint(x: 5.093, y: 13.391)) + path.addLine(to: CGPoint(x: 5.426, y: 14.333)) + path.addCurve(to: CGPoint(x: 13.597, y: 18.821), controlPoint1: CGPoint(x: 6.612, y: 17.689), controlPoint2: CGPoint(x: 10.128, y: 19.62)) + path.addCurve(to: CGPoint(x: 18.981, y: 11.211), controlPoint1: CGPoint(x: 17.066, y: 18.023), controlPoint2: CGPoint(x: 19.382, y: 14.748)) + path.addCurve(to: CGPoint(x: 12.029, y: 5), controlPoint1: CGPoint(x: 18.58, y: 7.674), controlPoint2: CGPoint(x: 15.588, y: 5.002)) + path.addCurve(to: CGPoint(x: 7.244, y: 7), controlPoint1: CGPoint(x: 10.047, y: 5), controlPoint2: CGPoint(x: 8.622, y: 5.686)) + path.addLine(to: CGPoint(x: 10, y: 7)) + path.addLine(to: CGPoint(x: 10, y: 9)) + path.addLine(to: CGPoint(x: 4, y: 9)) + path.addLine(to: CGPoint(x: 4, y: 3)) + path.addLine(to: CGPoint(x: 6, y: 3)) + path.close() + path.apply(CGAffineTransform(scaleX: size / 24, y: size / 24)) + + let format = UIGraphicsImageRendererFormat.preferred() + format.opaque = false + let image = UIGraphicsImageRenderer(size: CGSize(width: size, height: size), format: format) + .image { _ in + UIColor.black.setFill() + path.fill() + } + return image.withRenderingMode(.alwaysTemplate) + } +} diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h index bbd2c4daf..125a59a3e 100644 --- a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.h @@ -38,6 +38,15 @@ NS_ASSUME_NONNULL_BEGIN /** Shows and hides the interior grid lines with an optional crossfade animation. */ - (void)setGridHidden:(BOOL)hidden animated:(BOOL)animated; +/** OneKey: color of the crop box border and corner handles. Default is white. */ +@property (nonatomic, strong) UIColor *frameColor; + +/** OneKey: color of the interior grid lines. Default is white. */ +@property (nonatomic, strong) UIColor *gridColor; + +/** OneKey: hides the corner handles, for crop boxes that cannot be resized. */ +@property (nonatomic, assign) BOOL cornerHandlesHidden; + @end NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m index 3388e9b42..e441fc863 100644 --- a/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m +++ b/native-modules/react-native-image-crop-picker/ios/TOCropViewController/Views/TOCropOverlayView.m @@ -50,6 +50,9 @@ - (instancetype)initWithFrame:(CGRect)frame { } - (void)setup { + _frameColor = [UIColor whiteColor]; + _gridColor = [UIColor whiteColor]; + UIView * (^newLineView)(void) = ^UIView *(void) { return [self createNewLineView]; }; @@ -188,6 +191,41 @@ - (void)setGridHidden:(BOOL)hidden animated:(BOOL)animated { #pragma mark - Property methods +- (NSArray *)cornerHandleViews { + NSMutableArray *views = [NSMutableArray array]; + for (NSArray *lines in @[self.topLeftLineViews, self.topRightLineViews, self.bottomRightLineViews, self.bottomLeftLineViews]) { + [views addObjectsFromArray:lines]; + } + return views; +} + +- (void)setFrameColor:(UIColor *)frameColor { + _frameColor = frameColor ?: [UIColor whiteColor]; + for (UIView *lineView in self.outerLineViews) { + lineView.backgroundColor = _frameColor; + } + for (UIView *lineView in [self cornerHandleViews]) { + lineView.backgroundColor = _frameColor; + } +} + +- (void)setGridColor:(UIColor *)gridColor { + _gridColor = gridColor ?: [UIColor whiteColor]; + for (UIView *lineView in self.horizontalGridLines) { + lineView.backgroundColor = _gridColor; + } + for (UIView *lineView in self.verticalGridLines) { + lineView.backgroundColor = _gridColor; + } +} + +- (void)setCornerHandlesHidden:(BOOL)cornerHandlesHidden { + _cornerHandlesHidden = cornerHandlesHidden; + for (UIView *lineView in [self cornerHandleViews]) { + lineView.hidden = cornerHandlesHidden; + } +} + - (void)setDisplayHorizontalGridLines:(BOOL)displayHorizontalGridLines { _displayHorizontalGridLines = displayHorizontalGridLines; @@ -196,7 +234,7 @@ - (void)setDisplayHorizontalGridLines:(BOOL)displayHorizontalGridLines { }]; if (_displayHorizontalGridLines) { - self.horizontalGridLines = @[[self createNewLineView], [self createNewLineView]]; + self.horizontalGridLines = @[[self createGridLineView], [self createGridLineView]]; } else { self.horizontalGridLines = @[]; } @@ -214,7 +252,7 @@ - (void)setDisplayVerticalGridLines:(BOOL)displayVerticalGridLines { }]; if (_displayVerticalGridLines) { - self.verticalGridLines = @[[self createNewLineView], [self createNewLineView]]; + self.verticalGridLines = @[[self createGridLineView], [self createGridLineView]]; } else { self.verticalGridLines = @[]; } @@ -232,9 +270,15 @@ - (void)setGridHidden:(BOOL)gridHidden { - (nonnull UIView *)createNewLineView { UIView *newLine = [[UIView alloc] initWithFrame:CGRectZero]; - newLine.backgroundColor = [UIColor whiteColor]; + newLine.backgroundColor = self.frameColor ?: [UIColor whiteColor]; [self addSubview:newLine]; return newLine; } +- (nonnull UIView *)createGridLineView { + UIView *newLine = [self createNewLineView]; + newLine.backgroundColor = self.gridColor ?: [UIColor whiteColor]; + return newLine; +} + @end diff --git a/native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts b/native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts index 5bffc86a3..e585f1a92 100644 --- a/native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts +++ b/native-modules/react-native-image-crop-picker/src/ReactNativeImageCropPicker.nitro.ts @@ -20,6 +20,32 @@ export interface PickedImage { filename?: string; } +export type ImageCropperColorScheme = 'light' | 'dark'; + +// Colors and fonts of the cropper screen, which is identical on iOS and +// Android. Colors are CSS hex strings (#RGB, #RRGGBB or #RRGGBBAA). Anything +// left out falls back to OneKey's own palette for `colorScheme`. +export interface ImageCropperAppearance { + // Defaults to the system appearance. + colorScheme?: ImageCropperColorScheme; + // Page background. The area outside the crop box is this color, 70% opaque. + backgroundColor?: string; + // Title, and the crop box border. + titleColor?: string; + // The rotate button. + iconColor?: string; + cancelButtonColor?: string; + cancelButtonPressedColor?: string; + cancelButtonTextColor?: string; + confirmButtonColor?: string; + confirmButtonPressedColor?: string; + confirmButtonTextColor?: string; + titleFontFamily?: string; + buttonFontFamily?: string; + // Multiplies every size and spacing, for apps that scale their UI. + scale?: number; +} + export interface ImageCropPickerOptions { // Target size of the cropped image. Also defines the crop aspect ratio. width?: number; @@ -29,26 +55,16 @@ export interface ImageCropPickerOptions { compressImageQuality?: number; compressImageMaxWidth?: number; compressImageMaxHeight?: number; + // Lets the user resize the crop box to any aspect ratio. freeStyleCropEnabled?: boolean; cropperCircleOverlay?: boolean; cropperToolbarTitle?: string; cropperChooseText?: string; cropperCancelText?: string; - // iOS only - cropperChooseColor?: string; - cropperCancelColor?: string; cropperRotateButtonsHidden?: boolean; - // Android only - cropperActiveWidgetColor?: string; - cropperToolbarColor?: string; - cropperToolbarWidgetColor?: string; - cropperStatusBarLight?: boolean; - cropperNavigationBarLight?: boolean; + // Shows the rule-of-thirds grid inside the crop box. showCropGuidelines?: boolean; - showCropFrame?: boolean; - enableRotationGesture?: boolean; - hideBottomControls?: boolean; - disableCropperColorSetters?: boolean; + cropperAppearance?: ImageCropperAppearance; } export interface ReactNativeImageCropPicker From a2a43c278c6b1c437b8eb4b6ab4d297ce16f1e27 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 18 Sep 2026 16:20:21 +0800 Subject: [PATCH 6/6] chore: bump packages to 3.0.148 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 21 +++++++++++++++++++ native-modules/native-logger/package.json | 2 +- .../react-native-aes-crypto/package.json | 2 +- .../react-native-app-update/package.json | 2 +- .../react-native-async-storage/package.json | 2 +- .../package.json | 2 +- .../react-native-bundle-crypto/package.json | 2 +- .../react-native-bundle-update/package.json | 2 +- .../package.json | 2 +- .../react-native-cloud-fs/package.json | 2 +- .../package.json | 2 +- .../react-native-device-utils/package.json | 2 +- .../react-native-dns-lookup/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../react-native-keychain-module/package.json | 2 +- .../react-native-lite-card/package.json | 2 +- .../react-native-network-info/package.json | 2 +- .../package.json | 2 +- .../react-native-pbkdf2/package.json | 2 +- .../react-native-perf-memory/package.json | 2 +- .../react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- .../package.json | 2 +- .../react-native-sni-connect/package.json | 2 +- .../react-native-splash-screen/package.json | 2 +- .../package.json | 2 +- .../react-native-tcp-socket/package.json | 2 +- .../react-native-zip-archive/package.json | 2 +- .../react-native-auto-size-input/package.json | 2 +- .../react-native-chart-webview/package.json | 2 +- native-views/react-native-image/package.json | 4 ++-- .../react-native-native-list/package.json | 6 +++--- .../react-native-native-sheet/package.json | 2 +- .../react-native-pager-view/package.json | 4 ++-- .../react-native-perp-depth-bar/package.json | 2 +- .../react-native-scroll-guard/package.json | 2 +- .../react-native-segment-slider/package.json | 2 +- .../react-native-skeleton/package.json | 2 +- .../react-native-tab-view/package.json | 2 +- .../react-native-text-input/package.json | 2 +- native-views/react-native-text/package.json | 2 +- yarn.lock | 8 +++---- 43 files changed, 70 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 048e655b6..f7a40fd18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [3.0.148] - 2026-09-18 + +### Features +- **image-crop-picker (iOS and Android)**: Replace the TOCropViewController and uCrop screens with one cropper screen that looks like an app page and is the same on both platforms. The iOS cropper had TOCropViewController's dark toolbar with plain-text Cancel and Done, whatever the app theme, and Android's uCrop screen had a toolbar with a check mark plus aspect, rotate and scale tabs, so the two looked unrelated. The new screen has a header with the title and a rotate button, the crop area, and a footer with capsule Cancel and Confirm buttons, with the same metrics on both platforms. iOS hosts TOCropView in `ImageCropperViewController`; Android hosts uCrop's `UCropView` in `ImageCropperActivity`. + - Add `cropperAppearance`: `colorScheme`, background, title, icon and button colors, title and button fonts, and a size `scale`. Colors are CSS hex strings, and anything left out falls back to OneKey's light or dark palette. + - The area outside the crop box shows the page background at 70% opacity instead of TOCropViewController's dark blur or uCrop's black dimming. The crop box border uses the title color, and its corner handles only show when the box can be resized (`freeStyleCropEnabled`). + - The rule-of-thirds grid shows while the image is moved, on Android as well. Android no longer rotates with two fingers, as iOS never did, and its rotate button animates the turn. + - Android keeps the calling activity's requested orientation and draws edge to edge, with status and navigation bar icons that follow `colorScheme`. + - iOS: `TOCropOverlayView` gains `frameColor`, `gridColor` and `cornerHandlesHidden`. + +### Bug Fixes +- **image-crop-picker (iOS)**: Reopen the cropper when the same photo is tapped again after cancelling it. `PHPickerViewController` keeps the photo selected when the cropper is dismissed back to it, so the first tap only deselected it and nothing seemed to happen. On iOS 17 and later the picker's selection is now cleared; the configuration passes the shared photo library to get asset identifiers, which asks for no permission. +- **native-logger (iOS)**: Keep log files after the first roll. `DDLogFileManagerDefault` defaults `logFilesDiskQuota` to 20 MB, the same as the configured `maximumFileSize`, so a single rolled file filled the quota and cleanup deleted every log file, including the one being written. The logger then wrote nothing for the rest of the session and exported log bundles had no `.log` files. The quota now follows the retention of 7 files × 20 MB, matching Android's `TOTAL_SIZE_CAP`. +- **lite-card (iOS)**: Stop Lite card callbacks from firing twice when the NFC sheet is cancelled right after a card connects, which crashed the app with SIGABRT in `RCTTurboModule.mm` ("Callback arg cannot be called more than once") in the backup and restore flow on 6.6.0. The user's cancel was reported while the card operation was still running on another thread, and the operation's failed APDUs then reported a connection failure through the same callback. Each completion is now taken out under a lock when delivered, the app's own `invalidateSession` is no longer treated as a user cancel, `connectToTag` failures and non-Lite cards report a connection failure instead of leaving the JS promise pending, and repeated results are logged and dropped instead of aborting. + +### Breaking Changes +- **image-crop-picker**: Remove `cropperChooseColor`, `cropperCancelColor`, `cropperActiveWidgetColor`, `cropperToolbarColor`, `cropperToolbarWidgetColor`, `cropperStatusBarLight`, `cropperNavigationBarLight`, `showCropFrame`, `enableRotationGesture`, `hideBottomControls` and `disableCropperColorSetters`. They styled the TOCropViewController and uCrop screens; use `cropperAppearance` instead. + +### Chores +- Bump all 41 publishable packages to 3.0.148. + ## [3.0.147] - 2026-09-18 ### Features diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index a1b47b7d3..a16239be8 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index 9fd7db215..a5df39111 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 241a66a74..521ac25e9 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 32b0d7cd9..ed75e4de9 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 1465b0be7..0b4a5028e 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index da218ac29..0c863674f 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 0b788409e..e54863f95 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 9129f0306..7667613c8 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index ce6a33a78..ad8c19e6d 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 4b659af8d..5905279a6 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 5463b2ed9..88199291d 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index db5b96b8f..9eb3522c0 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index 386c5e2d3..3845ecaa4 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-image-crop-picker/package.json b/native-modules/react-native-image-crop-picker/package.json index 0f2d32813..3e2955f96 100644 --- a/native-modules/react-native-image-crop-picker/package.json +++ b/native-modules/react-native-image-crop-picker/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image-crop-picker", - "version": "3.0.147", + "version": "3.0.148", "description": "Single photo picker and cropper Nitro module for OneKey, replacing react-native-image-crop-picker", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 628952aef..b961d4bbd 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index ae6f655ae..ffcf86d75 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.147", + "version": "3.0.148", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index e58b6698a..3a5aef0b1 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index ad24323ce..227de26fc 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 6a6684021..625781fd5 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index cb492bdd3..4bae5b338 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 9ad1fd217..da6ebd7a4 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 2f7f3f58a..24b7748fc 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index f45d26a2d..3de41591c 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 010d58826..8ff8f8a60 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.147", + "version": "3.0.148", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index d7ae8de64..fb0a9fee8 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index a10303d21..658ddb23c 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 17ff080df..7ce3cadce 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 559493256..03d41d5a2 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 79aa7fd13..a42607485 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.147", + "version": "3.0.148", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index e3573cbe2..1322f35e4 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-image/package.json b/native-views/react-native-image/package.json index 7ea871c3b..933820230 100644 --- a/native-views/react-native-image/package.json +++ b/native-views/react-native-image/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image", - "version": "3.0.147", + "version": "3.0.148", "description": "High-performance native image view for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -81,7 +81,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-skeleton": "3.0.147", + "@onekeyfe/react-native-skeleton": "3.0.148", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-list/package.json b/native-views/react-native-native-list/package.json index 4ea412fed..411ad34b8 100644 --- a/native-views/react-native-native-list/package.json +++ b/native-views/react-native-native-list/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-list", - "version": "3.0.147", + "version": "3.0.148", "description": "Template-driven native RecyclerView and UICollectionView for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", @@ -83,8 +83,8 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-image": "3.0.147", - "@onekeyfe/react-native-native-logger": "3.0.147", + "@onekeyfe/react-native-image": "3.0.148", + "@onekeyfe/react-native-native-logger": "3.0.148", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-sheet/package.json b/native-views/react-native-native-sheet/package.json index f88c89ab8..5ced4167c 100644 --- a/native-views/react-native-native-sheet/package.json +++ b/native-views/react-native-native-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-sheet", - "version": "3.0.147", + "version": "3.0.148", "description": "Native bottom sheet host for arbitrary React Native content", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index fb520eb2e..e8a124802 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.147", + "version": "3.0.148", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -66,7 +66,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-native-logger": "3.0.147", + "@onekeyfe/react-native-native-logger": "3.0.148", "react": "*", "react-native": "*" }, diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index d1dc6b219..11652b92d 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index db2e7ccf8..8bf235b57 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.147", + "version": "3.0.148", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 8df0e683d..19b3b71e1 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 9b2ba80a1..3a1717b8a 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.147", + "version": "3.0.148", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 52b0a3c99..0dcabe102 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.147", + "version": "3.0.148", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text-input/package.json b/native-views/react-native-text-input/package.json index 3d423bf1c..7ed17336c 100644 --- a/native-views/react-native-text-input/package.json +++ b/native-views/react-native-text-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text-input", - "version": "3.0.147", + "version": "3.0.148", "description": "React Native TextInput with native paste events", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text/package.json b/native-views/react-native-text/package.json index f9ae4aba7..943a67129 100644 --- a/native-views/react-native-text/package.json +++ b/native-views/react-native-text/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text", - "version": "3.0.147", + "version": "3.0.148", "description": "Opt-in native text rendering for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", diff --git a/yarn.lock b/yarn.lock index 2ea6b110b..d0eef62ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3587,7 +3587,7 @@ __metadata: react-test-renderer: "npm:19.2.3" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-skeleton": 3.0.147 + "@onekeyfe/react-native-skeleton": 3.0.148 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3688,8 +3688,8 @@ __metadata: react-native-nitro-modules: "npm:0.37.0" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-image": 3.0.147 - "@onekeyfe/react-native-native-logger": 3.0.147 + "@onekeyfe/react-native-image": 3.0.148 + "@onekeyfe/react-native-native-logger": 3.0.148 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3841,7 +3841,7 @@ __metadata: react-native-builder-bob: "npm:^0.40.13" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-native-logger": 3.0.147 + "@onekeyfe/react-native-native-logger": 3.0.148 react: "*" react-native: "*" languageName: unknown