react-native-customizable-image-crop-picker 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +44 -0
- package/README.md +479 -0
- package/RNCustomizableImageCropPicker.podspec +26 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build/.transforms/a34a6297c7e9b65c987429ecd018e9a9/results.bin +1 -0
- package/android/build/.transforms/a34a6297c7e9b65c987429ecd018e9a9/transformed/classes/classes_dex/classes.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/results.bin +1 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/BuildConfig.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperActivity$Companion.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperActivity.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperModule$Companion.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperModule.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperPackage.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.dex +0 -0
- package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin +0 -0
- package/android/build/generated/source/buildConfig/debug/com/rncustomizableimagecroppicker/BuildConfig.java +10 -0
- package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml +26 -0
- package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json +18 -0
- package/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties +6 -0
- package/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json +1 -0
- package/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar +0 -0
- package/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar +0 -0
- package/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt +1 -0
- package/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/xml_nativeimagepicker_file_paths.xml.flat +0 -0
- package/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +2 -0
- package/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml +2 -0
- package/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +2 -0
- package/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +2 -0
- package/android/build/intermediates/incremental/mergeDebugShaders/merger.xml +2 -0
- package/android/build/intermediates/java_res/debug/processDebugJavaRes/out/META-INF/react-native-customizable-image-crop-picker_debug.kotlin_module +0 -0
- package/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/rncustomizableimagecroppicker/BuildConfig.class +0 -0
- package/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt +3 -0
- package/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt +41 -0
- package/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml +26 -0
- package/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json +1 -0
- package/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt +1 -0
- package/android/build/intermediates/packaged_res/debug/packageDebugResources/xml/nativeimagepicker_file_paths.xml +6 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/META-INF/react-native-customizable-image-crop-picker_debug.kotlin_module +0 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/BuildConfig.class +0 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperActivity$Companion.class +0 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperActivity.class +0 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperModule$Companion.class +0 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperModule.class +0 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperPackage.class +0 -0
- package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.class +0 -0
- package/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar +0 -0
- package/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt +2 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.values +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.values.s +1 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s +1 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab +2 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s +1 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len +0 -0
- package/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin +0 -0
- package/android/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin +0 -0
- package/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin +0 -0
- package/android/build/outputs/logs/manifest-merger-debug-report.txt +46 -0
- package/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin +0 -0
- package/android/build/tmp/kotlin-classes/debug/META-INF/react-native-customizable-image-crop-picker_debug.kotlin_module +0 -0
- package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperActivity$Companion.class +0 -0
- package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperActivity.class +0 -0
- package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperModule$Companion.class +0 -0
- package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperModule.class +0 -0
- package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperPackage.class +0 -0
- package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.class +0 -0
- package/android/build.gradle +49 -0
- package/android/consumer-rules.pro +3 -0
- package/android/src/main/AndroidManifest.xml +23 -0
- package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImageCropperActivity.kt +1009 -0
- package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImageCropperModule.kt +684 -0
- package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImageCropperPackage.kt +17 -0
- package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.kt +6 -0
- package/android/src/main/res/xml/nativeimagepicker_file_paths.xml +6 -0
- package/ios/NativeImageCropperModule.m +13 -0
- package/ios/NativeImageCropperModule.swift +1400 -0
- package/package.json +116 -0
- package/react-native.config.js +13 -0
- package/src/api.ts +41 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +4 -0
- package/src/native/NativeImageCropperModule.ts +34 -0
- package/src/native/mapOptions.ts +164 -0
- package/src/types.ts +258 -0
- package/src/ui/ImageCropPickerModal.tsx +322 -0
|
@@ -0,0 +1,1400 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NativeImageCropperModule.swift
|
|
3
|
+
//
|
|
4
|
+
import AVFoundation
|
|
5
|
+
import CoreText
|
|
6
|
+
import Foundation
|
|
7
|
+
import ImageIO
|
|
8
|
+
import PhotosUI
|
|
9
|
+
import React
|
|
10
|
+
import TOCropViewController
|
|
11
|
+
import UniformTypeIdentifiers
|
|
12
|
+
import UIKit
|
|
13
|
+
|
|
14
|
+
@objc(NativeImageCropperModule)
|
|
15
|
+
class NativeImageCropperModule: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate, PHPickerViewControllerDelegate, TOCropViewControllerDelegate {
|
|
16
|
+
private let ePickerCancelled = "E_PICKER_CANCELLED"
|
|
17
|
+
private let ePickerCancelledMsg = "User cancelled image selection"
|
|
18
|
+
private let ePickerError = "E_PICKER_ERROR"
|
|
19
|
+
private let ePermissionMissing = "E_PERMISSION_MISSING"
|
|
20
|
+
private let eNoImageDataFound = "E_NO_IMAGE_DATA_FOUND"
|
|
21
|
+
private let eNoAppAvailable = "E_NO_APP_AVAILABLE"
|
|
22
|
+
private let sourceCamera = "camera"
|
|
23
|
+
|
|
24
|
+
private var pendingResolve: RCTPromiseResolveBlock?
|
|
25
|
+
private var pendingReject: RCTPromiseRejectBlock?
|
|
26
|
+
private var cropperConfig = CropperConfig()
|
|
27
|
+
private var isFlowInProgress = false
|
|
28
|
+
|
|
29
|
+
deinit {
|
|
30
|
+
cleanupFlow()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@objc
|
|
34
|
+
static func requiresMainQueueSetup() -> Bool {
|
|
35
|
+
true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@objc
|
|
39
|
+
func openImagePreview(
|
|
40
|
+
_ options: NSDictionary,
|
|
41
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
42
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
43
|
+
) {
|
|
44
|
+
DispatchQueue.main.async { [weak self] in
|
|
45
|
+
guard let self else { return }
|
|
46
|
+
guard !self.isFlowInProgress else {
|
|
47
|
+
reject(self.ePickerError, "Image picker is already in progress", nil)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
guard let presenter = self.topViewController() else {
|
|
51
|
+
reject(self.ePickerError, "Unable to find visible view controller", nil)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
self.isFlowInProgress = true
|
|
56
|
+
self.pendingResolve = resolve
|
|
57
|
+
self.pendingReject = reject
|
|
58
|
+
self.cropperConfig = CropperConfig.parse(from: options)
|
|
59
|
+
|
|
60
|
+
if self.cropperConfig.pickerSource == self.sourceCamera {
|
|
61
|
+
self.launchCamera(from: presenter)
|
|
62
|
+
} else {
|
|
63
|
+
self.launchGallery(from: presenter)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func launchCamera(from presenter: UIViewController) {
|
|
69
|
+
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
|
|
70
|
+
rejectPending(code: eNoAppAvailable, message: "Camera is not available on this device")
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let cameraPermission = AVCaptureDevice.authorizationStatus(for: .video)
|
|
75
|
+
if cameraPermission == .denied || cameraPermission == .restricted {
|
|
76
|
+
rejectPending(code: ePermissionMissing, message: "Camera permission is not granted")
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if cameraPermission == .notDetermined {
|
|
80
|
+
AVCaptureDevice.requestAccess(for: .video) { [weak self, weak presenter] granted in
|
|
81
|
+
DispatchQueue.main.async {
|
|
82
|
+
guard let self, let presenter else { return }
|
|
83
|
+
if !granted {
|
|
84
|
+
self.rejectPending(code: self.ePermissionMissing, message: "Camera permission is not granted")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
self.launchCamera(from: presenter)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let picker = UIImagePickerController()
|
|
94
|
+
picker.delegate = self
|
|
95
|
+
picker.sourceType = .camera
|
|
96
|
+
picker.mediaTypes = ["public.image"]
|
|
97
|
+
picker.modalPresentationStyle = .fullScreen
|
|
98
|
+
presenter.present(picker, animated: true)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func launchGallery(from presenter: UIViewController) {
|
|
102
|
+
if #available(iOS 14.0, *) {
|
|
103
|
+
var configuration = PHPickerConfiguration()
|
|
104
|
+
configuration.filter = .images
|
|
105
|
+
configuration.selectionLimit = 1
|
|
106
|
+
|
|
107
|
+
let picker = PHPickerViewController(configuration: configuration)
|
|
108
|
+
picker.delegate = self
|
|
109
|
+
picker.modalPresentationStyle = .fullScreen
|
|
110
|
+
presenter.present(picker, animated: true)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let picker = UIImagePickerController()
|
|
115
|
+
picker.delegate = self
|
|
116
|
+
picker.sourceType = .photoLibrary
|
|
117
|
+
picker.mediaTypes = ["public.image"]
|
|
118
|
+
picker.modalPresentationStyle = .fullScreen
|
|
119
|
+
// iOS 13 and below uses UIImagePickerController which requires photo library permission.
|
|
120
|
+
if #available(iOS 14.0, *) {
|
|
121
|
+
presenter.present(picker, animated: true)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
let status = PHPhotoLibrary.authorizationStatus()
|
|
125
|
+
if status == .denied || status == .restricted {
|
|
126
|
+
rejectPending(code: ePermissionMissing, message: "Photo library permission is not granted")
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if status == .notDetermined {
|
|
130
|
+
PHPhotoLibrary.requestAuthorization { [weak self, weak presenter] newStatus in
|
|
131
|
+
DispatchQueue.main.async {
|
|
132
|
+
guard let self, let presenter else { return }
|
|
133
|
+
if newStatus == .authorized {
|
|
134
|
+
presenter.present(picker, animated: true)
|
|
135
|
+
} else {
|
|
136
|
+
self.rejectPending(code: self.ePermissionMissing, message: "Photo library permission is not granted")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
presenter.present(picker, animated: true)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private func presentCropper(for image: UIImage) {
|
|
146
|
+
guard let presenter = topViewController() else {
|
|
147
|
+
rejectPending(code: ePickerError, message: "Unable to show cropper screen")
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
guard let preparedImage = prepareImageForCropper(image) else {
|
|
151
|
+
rejectPending(code: eNoImageDataFound, message: "Selected image is invalid for cropping")
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let cropper = NativeImageCropperViewController(
|
|
156
|
+
image: preparedImage,
|
|
157
|
+
config: cropperConfig,
|
|
158
|
+
colorParser: { [weak self] hex, fallback in
|
|
159
|
+
self?.parseColor(hex, fallback: fallback) ?? fallback
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
cropper.delegate = self
|
|
163
|
+
cropper.modalPresentationStyle = .fullScreen
|
|
164
|
+
if presenter.isBeingDismissed || presenter.isBeingPresented {
|
|
165
|
+
rejectPending(code: ePickerError, message: "Unable to present cropper right now")
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
presenter.present(cropper, animated: false)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
172
|
+
picker.dismiss(animated: true) { [weak self] in
|
|
173
|
+
self?.rejectPending(code: self?.ePickerCancelled ?? "E_PICKER_CANCELLED", message: self?.ePickerCancelledMsg ?? "User cancelled image selection")
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func imagePickerController(
|
|
178
|
+
_ picker: UIImagePickerController,
|
|
179
|
+
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
|
180
|
+
) {
|
|
181
|
+
let selectedImage = (info[.editedImage] ?? info[.originalImage]) as? UIImage
|
|
182
|
+
picker.dismiss(animated: true) { [weak self] in
|
|
183
|
+
guard let self else { return }
|
|
184
|
+
guard let image = selectedImage else {
|
|
185
|
+
self.rejectPending(code: self.eNoImageDataFound, message: "Cannot resolve selected image")
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
self.presentCropper(for: image)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@available(iOS 14.0, *)
|
|
193
|
+
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
194
|
+
picker.dismiss(animated: true) { [weak self] in
|
|
195
|
+
guard let self else { return }
|
|
196
|
+
guard let provider = results.first?.itemProvider else {
|
|
197
|
+
self.rejectPending(code: self.ePickerCancelled, message: self.ePickerCancelledMsg)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
|
201
|
+
self.rejectPending(code: self.eNoImageDataFound, message: "Selected item is not an image")
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
|
206
|
+
provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { [weak self] data, error in
|
|
207
|
+
guard let self else { return }
|
|
208
|
+
if let error {
|
|
209
|
+
self.rejectPending(code: self.ePickerError, message: error.localizedDescription)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
guard let data, let selectedImage = UIImage(data: data) else {
|
|
213
|
+
self.rejectPending(code: self.eNoImageDataFound, message: "Cannot decode selected image")
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
DispatchQueue.main.async {
|
|
217
|
+
self.presentCropper(for: selectedImage)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
|
|
224
|
+
guard let self else { return }
|
|
225
|
+
if let error {
|
|
226
|
+
self.rejectPending(code: self.ePickerError, message: error.localizedDescription)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
guard let selectedImage = image as? UIImage else {
|
|
230
|
+
self.rejectPending(code: self.eNoImageDataFound, message: "Cannot resolve selected image")
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
DispatchQueue.main.async {
|
|
234
|
+
self.presentCropper(for: selectedImage)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func cropViewController(_ cropViewController: TOCropViewController, didFinishCancelled cancelled: Bool) {
|
|
241
|
+
cropViewController.dismiss(animated: true) { [weak self] in
|
|
242
|
+
self?.rejectPending(code: self?.ePickerCancelled ?? "E_PICKER_CANCELLED", message: self?.ePickerCancelledMsg ?? "User cancelled image selection")
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
func cropViewController(
|
|
247
|
+
_ cropViewController: TOCropViewController,
|
|
248
|
+
didCropTo image: UIImage,
|
|
249
|
+
with cropRect: CGRect,
|
|
250
|
+
angle: Int
|
|
251
|
+
) {
|
|
252
|
+
cropViewController.dismiss(animated: true) { [weak self] in
|
|
253
|
+
guard let self else { return }
|
|
254
|
+
self.encodeAndResolve(image: image)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private func encodeAndResolve(image: UIImage) {
|
|
259
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
260
|
+
guard let self else { return }
|
|
261
|
+
guard self.isFlowInProgress else { return }
|
|
262
|
+
autoreleasepool {
|
|
263
|
+
guard let (imageData, fileExt) = self.encodeImage(image: image) else {
|
|
264
|
+
self.rejectPending(code: self.eNoImageDataFound, message: "Cannot encode selected image")
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
let tempPath = self.writeTempImage(data: imageData, fileExt: fileExt)
|
|
268
|
+
var payload: [String: Any] = [
|
|
269
|
+
"path": tempPath ?? "",
|
|
270
|
+
]
|
|
271
|
+
if self.cropperConfig.includeBase64 {
|
|
272
|
+
payload["base64"] = imageData.base64EncodedString()
|
|
273
|
+
}
|
|
274
|
+
self.resolvePending(payload: payload)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private func encodeImage(image: UIImage) -> (Data, String)? {
|
|
280
|
+
let format = cropperConfig.compressFormat
|
|
281
|
+
let quality = min(max(cropperConfig.compressQuality, 0.0), 1.0)
|
|
282
|
+
|
|
283
|
+
if format == "png" {
|
|
284
|
+
guard let data = image.pngData() else { return nil }
|
|
285
|
+
return (data, "png")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if format == "webp" {
|
|
289
|
+
// Best-effort WebP encoding (iOS 14+). Falls back to JPEG if not available.
|
|
290
|
+
if #available(iOS 14.0, *), let cg = image.cgImage {
|
|
291
|
+
let type = UTType("org.webmproject.webp")
|
|
292
|
+
if let type {
|
|
293
|
+
let out = NSMutableData()
|
|
294
|
+
if let dest = CGImageDestinationCreateWithData(out, type.identifier as CFString, 1, nil) {
|
|
295
|
+
let options: [CFString: Any] = [
|
|
296
|
+
kCGImageDestinationLossyCompressionQuality: quality,
|
|
297
|
+
]
|
|
298
|
+
CGImageDestinationAddImage(dest, cg, options as CFDictionary)
|
|
299
|
+
if CGImageDestinationFinalize(dest) {
|
|
300
|
+
return (out as Data, "webp")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// fallback
|
|
306
|
+
guard let data = image.jpegData(compressionQuality: CGFloat(quality)) else { return nil }
|
|
307
|
+
return (data, "jpg")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// jpeg (default)
|
|
311
|
+
guard let data = image.jpegData(compressionQuality: CGFloat(quality)) else { return nil }
|
|
312
|
+
return (data, "jpg")
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private func writeTempImage(data: Data, fileExt: String) -> String? {
|
|
316
|
+
do {
|
|
317
|
+
let directory = FileManager.default.temporaryDirectory
|
|
318
|
+
let ext = fileExt.isEmpty ? "jpg" : fileExt
|
|
319
|
+
let url = directory.appendingPathComponent("native-cropped-\(UUID().uuidString).\(ext)")
|
|
320
|
+
try data.write(to: url, options: .atomic)
|
|
321
|
+
return url.path
|
|
322
|
+
} catch {
|
|
323
|
+
return nil
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private func parseColor(_ value: String, fallback: UIColor) -> UIColor {
|
|
328
|
+
guard value.hasPrefix("#") else { return fallback }
|
|
329
|
+
var hex = String(value.dropFirst())
|
|
330
|
+
if hex.count == 6 {
|
|
331
|
+
hex = "FF" + hex
|
|
332
|
+
}
|
|
333
|
+
guard hex.count == 8, let intVal = UInt64(hex, radix: 16) else {
|
|
334
|
+
return fallback
|
|
335
|
+
}
|
|
336
|
+
let a = CGFloat((intVal >> 24) & 0xFF) / 255.0
|
|
337
|
+
let r = CGFloat((intVal >> 16) & 0xFF) / 255.0
|
|
338
|
+
let g = CGFloat((intVal >> 8) & 0xFF) / 255.0
|
|
339
|
+
let b = CGFloat(intVal & 0xFF) / 255.0
|
|
340
|
+
return UIColor(red: r, green: g, blue: b, alpha: a)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private func prepareImageForCropper(_ image: UIImage) -> UIImage? {
|
|
344
|
+
guard image.size.width > 0, image.size.height > 0 else {
|
|
345
|
+
return nil
|
|
346
|
+
}
|
|
347
|
+
if image.cgImage != nil {
|
|
348
|
+
return image
|
|
349
|
+
}
|
|
350
|
+
let format = UIGraphicsImageRendererFormat.default()
|
|
351
|
+
format.scale = image.scale
|
|
352
|
+
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
|
353
|
+
let normalized = renderer.image { _ in
|
|
354
|
+
image.draw(in: CGRect(origin: .zero, size: image.size))
|
|
355
|
+
}
|
|
356
|
+
guard normalized.size.width > 0, normalized.size.height > 0 else {
|
|
357
|
+
return nil
|
|
358
|
+
}
|
|
359
|
+
return normalized
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private func topViewController() -> UIViewController? {
|
|
363
|
+
let keyWindow: UIWindow? = {
|
|
364
|
+
if #available(iOS 13.0, *) {
|
|
365
|
+
let scenes = UIApplication.shared.connectedScenes
|
|
366
|
+
.compactMap { $0 as? UIWindowScene }
|
|
367
|
+
.filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive }
|
|
368
|
+
for scene in scenes {
|
|
369
|
+
for window in scene.windows where window.isKeyWindow {
|
|
370
|
+
return window
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return scenes.first?.windows.first
|
|
374
|
+
}
|
|
375
|
+
// iOS 12 and below
|
|
376
|
+
return UIApplication.shared.keyWindow
|
|
377
|
+
}()
|
|
378
|
+
var controller = keyWindow?.rootViewController
|
|
379
|
+
while let presented = controller?.presentedViewController {
|
|
380
|
+
controller = presented
|
|
381
|
+
}
|
|
382
|
+
return controller
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private func resolvePending(payload: [String: Any]) {
|
|
386
|
+
DispatchQueue.main.async { [weak self] in
|
|
387
|
+
guard let self else { return }
|
|
388
|
+
guard self.isFlowInProgress else { return }
|
|
389
|
+
self.pendingResolve?(payload)
|
|
390
|
+
self.cleanupFlow()
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private func rejectPending(code: String, message: String) {
|
|
395
|
+
DispatchQueue.main.async { [weak self] in
|
|
396
|
+
guard let self else { return }
|
|
397
|
+
guard self.isFlowInProgress else { return }
|
|
398
|
+
self.pendingReject?(code, message, nil)
|
|
399
|
+
self.cleanupFlow()
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private func cleanupFlow() {
|
|
404
|
+
pendingResolve = nil
|
|
405
|
+
pendingReject = nil
|
|
406
|
+
isFlowInProgress = false
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private struct HeaderStyleConfig {
|
|
411
|
+
var backgroundColor = "#FFFFFF"
|
|
412
|
+
var color = "#2D2D2D"
|
|
413
|
+
var fontSize = 22
|
|
414
|
+
var fontFamily = ""
|
|
415
|
+
// When controls are in the footer ("bottom"), keep the header compact by default.
|
|
416
|
+
// When controls are in the header ("top"), header is taller to fit the action buttons.
|
|
417
|
+
var height: Int = 56
|
|
418
|
+
var paddingHorizontal: Int = 20
|
|
419
|
+
var paddingTop: Int = 12
|
|
420
|
+
var paddingBottom: Int = 12
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private struct ButtonContainerStyleConfig {
|
|
424
|
+
var backgroundColor = "#FFFFFF"
|
|
425
|
+
var paddingHorizontal: Int = 20
|
|
426
|
+
var paddingTop: Int = 16
|
|
427
|
+
var paddingBottom: Int = 24
|
|
428
|
+
var gap: Int = 12
|
|
429
|
+
var buttonHeight: Int = 54
|
|
430
|
+
var layout: String = "vertical"
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private struct ButtonStyleConfig {
|
|
434
|
+
var textColor: String
|
|
435
|
+
var backgroundColor: String
|
|
436
|
+
var borderColor: String
|
|
437
|
+
var borderWidth: Int
|
|
438
|
+
var fontSize: Int
|
|
439
|
+
var fontFamily: String
|
|
440
|
+
var borderRadius: Int = 27
|
|
441
|
+
var content: String = "text" // text | icon | iconText | textIcon (also accepts icon+text, text+icon, TextIcon)
|
|
442
|
+
var iconUri: String = ""
|
|
443
|
+
var iconBase64: String = ""
|
|
444
|
+
var iconTintColor: String = ""
|
|
445
|
+
var iconSize: Int = 18
|
|
446
|
+
var iconGap: Int = 8
|
|
447
|
+
var paddingHorizontal: Int = 12
|
|
448
|
+
var paddingVertical: Int = 0
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private struct CropperConfig {
|
|
452
|
+
var cropWidth = 100
|
|
453
|
+
var cropHeight = 100
|
|
454
|
+
var freeStyleCropEnabled = false
|
|
455
|
+
var compressQuality: Double = 1.0 // 0..1
|
|
456
|
+
var compressFormat: String = "jpeg" // jpeg | png | webp
|
|
457
|
+
var circularCrop = false
|
|
458
|
+
var rotationEnabled = false
|
|
459
|
+
var cropGridEnabled = false
|
|
460
|
+
var cropGridColor: String = ""
|
|
461
|
+
var dimmedLayerColor: String = "#B3000000"
|
|
462
|
+
var showNativeCropControls = false
|
|
463
|
+
var isDarkTheme = false
|
|
464
|
+
var statusBarColor = "#FFFFFF"
|
|
465
|
+
var includeBase64 = false
|
|
466
|
+
var headerTitle = "Preview Image"
|
|
467
|
+
var headerAlignment = "left" // left | center | right
|
|
468
|
+
var cancelText = "Cancel"
|
|
469
|
+
var uploadText = "Upload"
|
|
470
|
+
// Defaults aligned with the "demo button layout":
|
|
471
|
+
// - Cancel is a light button with dark text
|
|
472
|
+
// - Upload is a black button with white text
|
|
473
|
+
var cancelColor = "#111111"
|
|
474
|
+
var uploadColor = "#111111"
|
|
475
|
+
var pickerSource = "gallery"
|
|
476
|
+
var controlsPlacement = "bottom" // bottom | top
|
|
477
|
+
var topLeftControl = "cancel" // cancel | upload | none
|
|
478
|
+
var topRightControl = "upload" // cancel | upload | none
|
|
479
|
+
var footerButtonOrder = "uploadFirst" // uploadFirst | cancelFirst
|
|
480
|
+
var headerStyle = HeaderStyleConfig()
|
|
481
|
+
var buttonContainerStyle = ButtonContainerStyleConfig()
|
|
482
|
+
var cancelButtonStyle = ButtonStyleConfig(
|
|
483
|
+
textColor: "#111111",
|
|
484
|
+
backgroundColor: "#EFEFF4",
|
|
485
|
+
borderColor: "#EFEFF4",
|
|
486
|
+
borderWidth: 0,
|
|
487
|
+
fontSize: 16,
|
|
488
|
+
fontFamily: ""
|
|
489
|
+
)
|
|
490
|
+
var uploadButtonStyle = ButtonStyleConfig(
|
|
491
|
+
textColor: "#FFFFFF",
|
|
492
|
+
backgroundColor: "#111111",
|
|
493
|
+
borderColor: "#111111",
|
|
494
|
+
borderWidth: 0,
|
|
495
|
+
fontSize: 16,
|
|
496
|
+
fontFamily: ""
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
static func parse(from options: NSDictionary) -> CropperConfig {
|
|
500
|
+
var config = CropperConfig()
|
|
501
|
+
config.cropWidth = max(options["width"] as? Int ?? 100, 1)
|
|
502
|
+
config.cropHeight = max(options["height"] as? Int ?? 100, 1)
|
|
503
|
+
config.freeStyleCropEnabled = options["freeStyleCropEnabled"] as? Bool ?? false
|
|
504
|
+
config.isDarkTheme = options["isDarkTheme"] as? Bool ?? false
|
|
505
|
+
if let q = options["compressQuality"] as? NSNumber {
|
|
506
|
+
config.compressQuality = min(max(q.doubleValue, 0.0), 1.0)
|
|
507
|
+
}
|
|
508
|
+
if let rawFmt = (options["compressFormat"] as? String)?.nonEmpty?.lowercased() {
|
|
509
|
+
config.compressFormat = rawFmt
|
|
510
|
+
}
|
|
511
|
+
if config.compressFormat != "jpeg", config.compressFormat != "png", config.compressFormat != "webp" {
|
|
512
|
+
config.compressFormat = "jpeg"
|
|
513
|
+
}
|
|
514
|
+
config.circularCrop = options["circularCrop"] as? Bool ?? false
|
|
515
|
+
config.rotationEnabled = options["rotationEnabled"] as? Bool ?? false
|
|
516
|
+
config.cropGridEnabled = options["cropGridEnabled"] as? Bool ?? false
|
|
517
|
+
config.cropGridColor = (options["cropGridColor"] as? String)?.nonEmpty ?? ""
|
|
518
|
+
config.showNativeCropControls = options["showNativeCropControls"] as? Bool ?? false
|
|
519
|
+
config.dimmedLayerColor = (options["cropperDimmedLayerColor"] as? String)?.nonEmpty
|
|
520
|
+
?? (options["cropOverlayColor"] as? String)?.nonEmpty
|
|
521
|
+
?? (config.isDarkTheme ? "#E0000000" : "#B3000000")
|
|
522
|
+
config.includeBase64 = options["includeBase64"] as? Bool ?? false
|
|
523
|
+
config.statusBarColor = (options["cropperStatusBarColor"] as? String)?.nonEmpty
|
|
524
|
+
?? (config.isDarkTheme ? "#000000" : "#FFFFFF")
|
|
525
|
+
config.headerTitle = (options["cropperToolbarTitle"] as? String)?.nonEmpty ?? config.headerTitle
|
|
526
|
+
config.headerAlignment = (options["headerAlignment"] as? String)?.nonEmpty ?? config.headerAlignment
|
|
527
|
+
config.cancelText = (options["cropperCancelText"] as? String)?.nonEmpty ?? config.cancelText
|
|
528
|
+
config.uploadText = (options["cropperChooseText"] as? String)?.nonEmpty ?? config.uploadText
|
|
529
|
+
config.cancelColor = (options["cropperCancelColor"] as? String)?.nonEmpty ?? config.cancelColor
|
|
530
|
+
config.uploadColor = (options["cropperChooseColor"] as? String)?.nonEmpty ?? config.uploadColor
|
|
531
|
+
config.pickerSource = (options["pickerSource"] as? String)?.nonEmpty ?? config.pickerSource
|
|
532
|
+
config.controlsPlacement = (options["controlsPlacement"] as? String)?.nonEmpty == "top" ? "top" : "bottom"
|
|
533
|
+
// If native crop controls (toolbar) are visible, we must place action buttons in the header.
|
|
534
|
+
// Otherwise the footer overlaps the toolbar and makes it look "missing" on iOS.
|
|
535
|
+
if config.showNativeCropControls { config.controlsPlacement = "top" }
|
|
536
|
+
config.topLeftControl = (options["topLeftControl"] as? String)?.nonEmpty ?? config.topLeftControl
|
|
537
|
+
config.topRightControl = (options["topRightControl"] as? String)?.nonEmpty ?? config.topRightControl
|
|
538
|
+
config.footerButtonOrder = (options["footerButtonOrder"] as? String)?.nonEmpty ?? config.footerButtonOrder
|
|
539
|
+
|
|
540
|
+
// sanitize
|
|
541
|
+
let allowedControl = Set(["cancel", "upload", "none"])
|
|
542
|
+
if !allowedControl.contains(config.topLeftControl) { config.topLeftControl = "cancel" }
|
|
543
|
+
if !allowedControl.contains(config.topRightControl) { config.topRightControl = "upload" }
|
|
544
|
+
if config.topLeftControl == config.topRightControl { config.topRightControl = "none" }
|
|
545
|
+
if config.footerButtonOrder != "cancelFirst" { config.footerButtonOrder = "uploadFirst" }
|
|
546
|
+
let allowedAlignment = Set(["left", "center", "right"])
|
|
547
|
+
if !allowedAlignment.contains(config.headerAlignment) { config.headerAlignment = "left" }
|
|
548
|
+
|
|
549
|
+
if let headerStyle = options["headerStyle"] as? NSDictionary {
|
|
550
|
+
config.headerStyle.backgroundColor = (headerStyle["backgroundColor"] as? String)?.nonEmpty ?? config.headerStyle.backgroundColor
|
|
551
|
+
config.headerStyle.color = (headerStyle["color"] as? String)?.nonEmpty
|
|
552
|
+
?? (headerStyle["titleColor"] as? String)?.nonEmpty
|
|
553
|
+
?? config.headerStyle.color
|
|
554
|
+
config.headerStyle.fontSize = max(
|
|
555
|
+
(headerStyle["fontSize"] as? Int)
|
|
556
|
+
?? (headerStyle["titleFontSize"] as? Int)
|
|
557
|
+
?? config.headerStyle.fontSize,
|
|
558
|
+
10
|
|
559
|
+
)
|
|
560
|
+
config.headerStyle.fontFamily = (headerStyle["fontFamily"] as? String)?.nonEmpty
|
|
561
|
+
?? (headerStyle["titleFontFamily"] as? String)?.nonEmpty
|
|
562
|
+
?? config.headerStyle.fontFamily
|
|
563
|
+
let defaultHeaderHeight = (config.controlsPlacement == "top") ? 84 : 56
|
|
564
|
+
config.headerStyle.height = max(headerStyle["height"] as? Int ?? defaultHeaderHeight, 48)
|
|
565
|
+
config.headerStyle.paddingHorizontal = max(headerStyle["paddingHorizontal"] as? Int ?? config.headerStyle.paddingHorizontal, 0)
|
|
566
|
+
let defaultPadTop = (config.controlsPlacement == "top") ? 20 : 12
|
|
567
|
+
let defaultPadBottom = (config.controlsPlacement == "top") ? 20 : 12
|
|
568
|
+
config.headerStyle.paddingTop = max(headerStyle["paddingTop"] as? Int ?? defaultPadTop, 0)
|
|
569
|
+
config.headerStyle.paddingBottom = max(headerStyle["paddingBottom"] as? Int ?? defaultPadBottom, 0)
|
|
570
|
+
} else {
|
|
571
|
+
// Apply compact defaults when controls are in the footer.
|
|
572
|
+
if config.controlsPlacement == "top" {
|
|
573
|
+
config.headerStyle.height = 84
|
|
574
|
+
config.headerStyle.paddingTop = 20
|
|
575
|
+
config.headerStyle.paddingBottom = 20
|
|
576
|
+
} else {
|
|
577
|
+
config.headerStyle.height = 56
|
|
578
|
+
config.headerStyle.paddingTop = 12
|
|
579
|
+
config.headerStyle.paddingBottom = 12
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if let containerStyle = options["buttonContainerStyle"] as? NSDictionary {
|
|
584
|
+
config.buttonContainerStyle.backgroundColor = (containerStyle["backgroundColor"] as? String)?.nonEmpty ?? config.buttonContainerStyle.backgroundColor
|
|
585
|
+
config.buttonContainerStyle.paddingHorizontal = max(containerStyle["paddingHorizontal"] as? Int ?? config.buttonContainerStyle.paddingHorizontal, 0)
|
|
586
|
+
config.buttonContainerStyle.paddingTop = max(containerStyle["paddingTop"] as? Int ?? config.buttonContainerStyle.paddingTop, 0)
|
|
587
|
+
config.buttonContainerStyle.paddingBottom = max(containerStyle["paddingBottom"] as? Int ?? config.buttonContainerStyle.paddingBottom, 0)
|
|
588
|
+
config.buttonContainerStyle.gap = max(containerStyle["gap"] as? Int ?? config.buttonContainerStyle.gap, 0)
|
|
589
|
+
config.buttonContainerStyle.buttonHeight = max(containerStyle["buttonHeight"] as? Int ?? config.buttonContainerStyle.buttonHeight, 1)
|
|
590
|
+
config.buttonContainerStyle.layout =
|
|
591
|
+
((containerStyle["layout"] as? String)?.nonEmpty == "horizontal") ? "horizontal" : "vertical"
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if let cancelStyle = options["cancelButtonStyle"] as? NSDictionary {
|
|
595
|
+
config.cancelButtonStyle.textColor = (cancelStyle["textColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.textColor
|
|
596
|
+
config.cancelButtonStyle.backgroundColor = (cancelStyle["backgroundColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.backgroundColor
|
|
597
|
+
config.cancelButtonStyle.borderColor = (cancelStyle["borderColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.borderColor
|
|
598
|
+
config.cancelButtonStyle.borderWidth = max(cancelStyle["borderWidth"] as? Int ?? config.cancelButtonStyle.borderWidth, 0)
|
|
599
|
+
config.cancelButtonStyle.fontSize = max(cancelStyle["fontSize"] as? Int ?? config.cancelButtonStyle.fontSize, 10)
|
|
600
|
+
config.cancelButtonStyle.fontFamily = (cancelStyle["fontFamily"] as? String)?.nonEmpty ?? config.cancelButtonStyle.fontFamily
|
|
601
|
+
config.cancelButtonStyle.borderRadius = max(cancelStyle["borderRadius"] as? Int ?? config.cancelButtonStyle.borderRadius, 0)
|
|
602
|
+
config.cancelColor = config.cancelButtonStyle.textColor
|
|
603
|
+
config.cancelButtonStyle.content = (cancelStyle["content"] as? String)?.nonEmpty ?? config.cancelButtonStyle.content
|
|
604
|
+
config.cancelButtonStyle.iconUri = (cancelStyle["iconUri"] as? String)?.nonEmpty ?? config.cancelButtonStyle.iconUri
|
|
605
|
+
config.cancelButtonStyle.iconBase64 = (cancelStyle["iconBase64"] as? String)?.nonEmpty ?? config.cancelButtonStyle.iconBase64
|
|
606
|
+
config.cancelButtonStyle.iconTintColor = (cancelStyle["iconTintColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.iconTintColor
|
|
607
|
+
config.cancelButtonStyle.iconSize = max(cancelStyle["iconSize"] as? Int ?? config.cancelButtonStyle.iconSize, 12)
|
|
608
|
+
config.cancelButtonStyle.iconGap = max(cancelStyle["iconGap"] as? Int ?? config.cancelButtonStyle.iconGap, 0)
|
|
609
|
+
config.cancelButtonStyle.paddingHorizontal = max(cancelStyle["paddingHorizontal"] as? Int ?? config.cancelButtonStyle.paddingHorizontal, 0)
|
|
610
|
+
config.cancelButtonStyle.paddingVertical = max(cancelStyle["paddingVertical"] as? Int ?? config.cancelButtonStyle.paddingVertical, 0)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if let uploadStyle = options["uploadButtonStyle"] as? NSDictionary {
|
|
614
|
+
config.uploadButtonStyle.textColor = (uploadStyle["textColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.textColor
|
|
615
|
+
config.uploadButtonStyle.backgroundColor = (uploadStyle["backgroundColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.backgroundColor
|
|
616
|
+
config.uploadButtonStyle.borderColor = (uploadStyle["borderColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.borderColor
|
|
617
|
+
config.uploadButtonStyle.borderWidth = max(uploadStyle["borderWidth"] as? Int ?? config.uploadButtonStyle.borderWidth, 0)
|
|
618
|
+
config.uploadButtonStyle.fontSize = max(uploadStyle["fontSize"] as? Int ?? config.uploadButtonStyle.fontSize, 10)
|
|
619
|
+
config.uploadButtonStyle.fontFamily = (uploadStyle["fontFamily"] as? String)?.nonEmpty ?? config.uploadButtonStyle.fontFamily
|
|
620
|
+
config.uploadButtonStyle.borderRadius = max(uploadStyle["borderRadius"] as? Int ?? config.uploadButtonStyle.borderRadius, 0)
|
|
621
|
+
config.uploadColor = config.uploadButtonStyle.backgroundColor
|
|
622
|
+
config.uploadButtonStyle.content = (uploadStyle["content"] as? String)?.nonEmpty ?? config.uploadButtonStyle.content
|
|
623
|
+
config.uploadButtonStyle.iconUri = (uploadStyle["iconUri"] as? String)?.nonEmpty ?? config.uploadButtonStyle.iconUri
|
|
624
|
+
config.uploadButtonStyle.iconBase64 = (uploadStyle["iconBase64"] as? String)?.nonEmpty ?? config.uploadButtonStyle.iconBase64
|
|
625
|
+
config.uploadButtonStyle.iconTintColor = (uploadStyle["iconTintColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.iconTintColor
|
|
626
|
+
config.uploadButtonStyle.iconSize = max(uploadStyle["iconSize"] as? Int ?? config.uploadButtonStyle.iconSize, 12)
|
|
627
|
+
config.uploadButtonStyle.iconGap = max(uploadStyle["iconGap"] as? Int ?? config.uploadButtonStyle.iconGap, 0)
|
|
628
|
+
config.uploadButtonStyle.paddingHorizontal = max(uploadStyle["paddingHorizontal"] as? Int ?? config.uploadButtonStyle.paddingHorizontal, 0)
|
|
629
|
+
config.uploadButtonStyle.paddingVertical = max(uploadStyle["paddingVertical"] as? Int ?? config.uploadButtonStyle.paddingVertical, 0)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return config
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private extension String {
|
|
637
|
+
var nonEmpty: String? {
|
|
638
|
+
isEmpty ? nil : self
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private func defaultLabelColor() -> UIColor {
|
|
643
|
+
if #available(iOS 13.0, *) {
|
|
644
|
+
return .label
|
|
645
|
+
}
|
|
646
|
+
return .black
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private final class NativeImageCropperViewController: TOCropViewController {
|
|
650
|
+
private static let iconCache = NSCache<NSString, UIImage>()
|
|
651
|
+
private let config: CropperConfig
|
|
652
|
+
private let parseColor: (String, UIColor) -> UIColor
|
|
653
|
+
private var statusBarFillHeightConstraint: NSLayoutConstraint?
|
|
654
|
+
private var footerHeightConstraint: NSLayoutConstraint?
|
|
655
|
+
private var isCommittingCrop = false
|
|
656
|
+
private var circularGridOverlay: CircularGridOverlayView?
|
|
657
|
+
|
|
658
|
+
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
659
|
+
if #available(iOS 13.0, *) {
|
|
660
|
+
return config.isDarkTheme ? .lightContent : .darkContent
|
|
661
|
+
}
|
|
662
|
+
return config.isDarkTheme ? .lightContent : .default
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private lazy var headerContainer: UIView = {
|
|
666
|
+
let view = UIView()
|
|
667
|
+
view.translatesAutoresizingMaskIntoConstraints = false
|
|
668
|
+
view.backgroundColor = parseColor(config.headerStyle.backgroundColor, .white)
|
|
669
|
+
return view
|
|
670
|
+
}()
|
|
671
|
+
|
|
672
|
+
private lazy var statusBarFillView: UIView = {
|
|
673
|
+
let view = UIView()
|
|
674
|
+
view.translatesAutoresizingMaskIntoConstraints = false
|
|
675
|
+
view.backgroundColor = parseColor(config.statusBarColor, .white)
|
|
676
|
+
return view
|
|
677
|
+
}()
|
|
678
|
+
|
|
679
|
+
private lazy var headerTitleLabel: UILabel = {
|
|
680
|
+
let label = UILabel()
|
|
681
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
682
|
+
label.text = config.headerTitle
|
|
683
|
+
if config.controlsPlacement == "top" {
|
|
684
|
+
label.textAlignment = .center
|
|
685
|
+
} else {
|
|
686
|
+
switch config.headerAlignment {
|
|
687
|
+
case "center":
|
|
688
|
+
label.textAlignment = .center
|
|
689
|
+
case "right":
|
|
690
|
+
label.textAlignment = .right
|
|
691
|
+
default:
|
|
692
|
+
label.textAlignment = .left
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
label.textColor = parseColor(config.headerStyle.color, defaultLabelColor())
|
|
696
|
+
label.font = resolvedFont(
|
|
697
|
+
family: config.headerStyle.fontFamily,
|
|
698
|
+
size: CGFloat(config.headerStyle.fontSize),
|
|
699
|
+
fallback: UIFont.systemFont(ofSize: CGFloat(config.headerStyle.fontSize), weight: .bold)
|
|
700
|
+
)
|
|
701
|
+
return label
|
|
702
|
+
}()
|
|
703
|
+
|
|
704
|
+
private lazy var footerContainer: UIView = {
|
|
705
|
+
let view = UIView()
|
|
706
|
+
view.translatesAutoresizingMaskIntoConstraints = false
|
|
707
|
+
view.backgroundColor = parseColor(config.buttonContainerStyle.backgroundColor, .white)
|
|
708
|
+
return view
|
|
709
|
+
}()
|
|
710
|
+
|
|
711
|
+
private lazy var cancelButton: UIButton = createActionButton(
|
|
712
|
+
title: config.cancelText,
|
|
713
|
+
style: config.cancelButtonStyle
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
private lazy var uploadButton: UIButton = createActionButton(
|
|
717
|
+
title: config.uploadText,
|
|
718
|
+
style: config.uploadButtonStyle
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
init(
|
|
722
|
+
image: UIImage,
|
|
723
|
+
config: CropperConfig,
|
|
724
|
+
colorParser: @escaping (String, UIColor) -> UIColor
|
|
725
|
+
) {
|
|
726
|
+
self.config = config
|
|
727
|
+
self.parseColor = colorParser
|
|
728
|
+
super.init(croppingStyle: config.circularCrop ? .circular : .default, image: image)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
required override init(croppingStyle style: TOCropViewCroppingStyle, image: UIImage) {
|
|
732
|
+
self.config = CropperConfig()
|
|
733
|
+
self.parseColor = { _, fallback in fallback }
|
|
734
|
+
super.init(croppingStyle: style, image: image)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
@available(*, unavailable)
|
|
738
|
+
required init?(coder: NSCoder) {
|
|
739
|
+
fatalError("init(coder:) has not been implemented")
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
override func viewDidLoad() {
|
|
743
|
+
super.viewDidLoad()
|
|
744
|
+
modalPresentationCapturesStatusBarAppearance = true
|
|
745
|
+
setupCropBehavior()
|
|
746
|
+
setupCustomChrome()
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
override func viewWillAppear(_ animated: Bool) {
|
|
750
|
+
super.viewWillAppear(animated)
|
|
751
|
+
setNeedsStatusBarAppearanceUpdate()
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
override func viewDidLayoutSubviews() {
|
|
755
|
+
super.viewDidLayoutSubviews()
|
|
756
|
+
statusBarFillHeightConstraint?.constant = view.safeAreaInsets.top
|
|
757
|
+
footerHeightConstraint?.constant =
|
|
758
|
+
footerBaseHeight() + view.safeAreaInsets.bottom
|
|
759
|
+
|
|
760
|
+
// Ensure pill rounding stays correct after AutoLayout updates button sizes.
|
|
761
|
+
applyButtonRounding(button: cancelButton, style: config.cancelButtonStyle)
|
|
762
|
+
applyButtonRounding(button: uploadButton, style: config.uploadButtonStyle)
|
|
763
|
+
|
|
764
|
+
// Keep circular grid overlay in sync with layout.
|
|
765
|
+
if let grid = circularGridOverlay {
|
|
766
|
+
grid.frame = cropView.foregroundContainerView.bounds
|
|
767
|
+
grid.setNeedsLayout()
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private func footerBaseHeight() -> CGFloat {
|
|
772
|
+
if config.controlsPlacement == "top" {
|
|
773
|
+
return 0
|
|
774
|
+
}
|
|
775
|
+
let base =
|
|
776
|
+
CGFloat(config.buttonContainerStyle.paddingTop +
|
|
777
|
+
config.buttonContainerStyle.paddingBottom +
|
|
778
|
+
config.buttonContainerStyle.buttonHeight)
|
|
779
|
+
if config.buttonContainerStyle.layout == "horizontal" {
|
|
780
|
+
return base
|
|
781
|
+
}
|
|
782
|
+
return base + CGFloat(config.buttonContainerStyle.gap + config.buttonContainerStyle.buttonHeight)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private func setupCropBehavior() {
|
|
786
|
+
doneButtonHidden = true
|
|
787
|
+
cancelButtonHidden = true
|
|
788
|
+
rotateButtonsHidden = !config.rotationEnabled
|
|
789
|
+
resetButtonHidden = !config.rotationEnabled
|
|
790
|
+
aspectRatioPickerButtonHidden = !config.showNativeCropControls
|
|
791
|
+
toolbar.isHidden = !config.showNativeCropControls
|
|
792
|
+
showCancelConfirmationDialog = false
|
|
793
|
+
|
|
794
|
+
setAspectRatioPreset(CGSize(width: config.cropWidth, height: config.cropHeight), animated: false)
|
|
795
|
+
aspectRatioLockEnabled = !config.freeStyleCropEnabled
|
|
796
|
+
// Keep the selected aspect ratio when user taps reset.
|
|
797
|
+
// Otherwise TOCropViewController resets back to the image's original ratio,
|
|
798
|
+
// which looks especially wrong for circular crops.
|
|
799
|
+
resetAspectRatioEnabled = false
|
|
800
|
+
aspectRatioLockDimensionSwapEnabled = true
|
|
801
|
+
cropView.cropBoxResizeEnabled = config.freeStyleCropEnabled
|
|
802
|
+
cropView.overlayView.backgroundColor = parseColor(config.dimmedLayerColor, .black.withAlphaComponent(0.7))
|
|
803
|
+
|
|
804
|
+
let footerBase = footerBaseHeight()
|
|
805
|
+
cropView.cropRegionInsets = UIEdgeInsets(
|
|
806
|
+
top: CGFloat(config.headerStyle.height),
|
|
807
|
+
left: 0,
|
|
808
|
+
bottom: footerBase,
|
|
809
|
+
right: 0
|
|
810
|
+
)
|
|
811
|
+
cropView.performInitialSetup()
|
|
812
|
+
|
|
813
|
+
// Grid overlay:
|
|
814
|
+
// - Rectangle mode: use built-in grid overlay.
|
|
815
|
+
// - Circular mode: TOCropViewController doesn't create the built-in grid view, so we render our own.
|
|
816
|
+
if config.circularCrop {
|
|
817
|
+
circularGridOverlay?.removeFromSuperview()
|
|
818
|
+
circularGridOverlay = nil
|
|
819
|
+
if config.cropGridEnabled {
|
|
820
|
+
let color = config.cropGridColor.nonEmpty.flatMap { UIColor(hex: $0) } ?? UIColor.white.withAlphaComponent(0.9)
|
|
821
|
+
let grid = CircularGridOverlayView(frame: cropView.foregroundContainerView.bounds, lineColor: color)
|
|
822
|
+
grid.isUserInteractionEnabled = false
|
|
823
|
+
cropView.foregroundContainerView.addSubview(grid)
|
|
824
|
+
circularGridOverlay = grid
|
|
825
|
+
}
|
|
826
|
+
} else {
|
|
827
|
+
circularGridOverlay?.removeFromSuperview()
|
|
828
|
+
circularGridOverlay = nil
|
|
829
|
+
cropView.gridOverlayHidden = !config.cropGridEnabled
|
|
830
|
+
cropView.alwaysShowCroppingGrid = config.cropGridEnabled
|
|
831
|
+
cropView.setGridOverlayHidden(!config.cropGridEnabled, animated: false)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private func setupCustomChrome() {
|
|
836
|
+
view.addSubview(statusBarFillView)
|
|
837
|
+
view.addSubview(headerContainer)
|
|
838
|
+
headerContainer.addSubview(headerTitleLabel)
|
|
839
|
+
view.addSubview(footerContainer)
|
|
840
|
+
if config.controlsPlacement == "top" {
|
|
841
|
+
headerContainer.addSubview(cancelButton)
|
|
842
|
+
headerContainer.addSubview(uploadButton)
|
|
843
|
+
footerContainer.isHidden = true
|
|
844
|
+
} else {
|
|
845
|
+
footerContainer.addSubview(cancelButton)
|
|
846
|
+
footerContainer.addSubview(uploadButton)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
cancelButton.addTarget(self, action: #selector(onCancelTapped), for: .touchUpInside)
|
|
850
|
+
uploadButton.addTarget(self, action: #selector(onUploadTapped), for: .touchUpInside)
|
|
851
|
+
|
|
852
|
+
statusBarFillHeightConstraint = statusBarFillView.heightAnchor.constraint(equalToConstant: view.safeAreaInsets.top)
|
|
853
|
+
footerHeightConstraint = footerContainer.heightAnchor.constraint(equalToConstant: footerBaseHeight() + view.safeAreaInsets.bottom)
|
|
854
|
+
|
|
855
|
+
guard let statusHeightConstraint = statusBarFillHeightConstraint,
|
|
856
|
+
let bottomHeightConstraint = footerHeightConstraint else {
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
var constraints: [NSLayoutConstraint] = [
|
|
861
|
+
statusBarFillView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
862
|
+
statusBarFillView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
863
|
+
statusBarFillView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
864
|
+
statusHeightConstraint,
|
|
865
|
+
|
|
866
|
+
headerContainer.topAnchor.constraint(equalTo: statusBarFillView.bottomAnchor),
|
|
867
|
+
headerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
868
|
+
headerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
869
|
+
headerContainer.heightAnchor.constraint(equalToConstant: CGFloat(config.headerStyle.height)),
|
|
870
|
+
headerTitleLabel.topAnchor.constraint(equalTo: headerContainer.topAnchor, constant: CGFloat(config.headerStyle.paddingTop)),
|
|
871
|
+
headerTitleLabel.bottomAnchor.constraint(equalTo: headerContainer.bottomAnchor, constant: -CGFloat(config.headerStyle.paddingBottom)),
|
|
872
|
+
|
|
873
|
+
footerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
874
|
+
footerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
875
|
+
footerContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
876
|
+
bottomHeightConstraint,
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
if config.controlsPlacement == "top" {
|
|
880
|
+
let left = config.topLeftControl
|
|
881
|
+
let right = config.topRightControl
|
|
882
|
+
cancelButton.isHidden = !(left == "cancel" || right == "cancel")
|
|
883
|
+
uploadButton.isHidden = !(left == "upload" || right == "upload")
|
|
884
|
+
|
|
885
|
+
let paddingH = CGFloat(config.headerStyle.paddingHorizontal)
|
|
886
|
+
let gap = CGFloat(max(config.buttonContainerStyle.gap, 8))
|
|
887
|
+
let h = CGFloat(min(max(config.buttonContainerStyle.buttonHeight, 36), 44))
|
|
888
|
+
|
|
889
|
+
var leftButton: UIButton?
|
|
890
|
+
var rightButton: UIButton?
|
|
891
|
+
|
|
892
|
+
if left == "cancel" {
|
|
893
|
+
leftButton = cancelButton
|
|
894
|
+
constraints += [
|
|
895
|
+
cancelButton.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor, constant: paddingH),
|
|
896
|
+
cancelButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
|
|
897
|
+
cancelButton.heightAnchor.constraint(equalToConstant: h),
|
|
898
|
+
]
|
|
899
|
+
} else if left == "upload" {
|
|
900
|
+
leftButton = uploadButton
|
|
901
|
+
constraints += [
|
|
902
|
+
uploadButton.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor, constant: paddingH),
|
|
903
|
+
uploadButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
|
|
904
|
+
uploadButton.heightAnchor.constraint(equalToConstant: h),
|
|
905
|
+
]
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if right == "cancel" {
|
|
909
|
+
rightButton = cancelButton
|
|
910
|
+
constraints += [
|
|
911
|
+
cancelButton.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor, constant: -paddingH),
|
|
912
|
+
cancelButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
|
|
913
|
+
cancelButton.heightAnchor.constraint(equalToConstant: h),
|
|
914
|
+
]
|
|
915
|
+
} else if right == "upload" {
|
|
916
|
+
rightButton = uploadButton
|
|
917
|
+
constraints += [
|
|
918
|
+
uploadButton.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor, constant: -paddingH),
|
|
919
|
+
uploadButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
|
|
920
|
+
uploadButton.heightAnchor.constraint(equalToConstant: h),
|
|
921
|
+
]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Keep title centered and avoid overlapping the side controls.
|
|
925
|
+
constraints += [
|
|
926
|
+
headerTitleLabel.centerXAnchor.constraint(equalTo: headerContainer.centerXAnchor),
|
|
927
|
+
]
|
|
928
|
+
if let lb = leftButton {
|
|
929
|
+
constraints.append(headerTitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lb.trailingAnchor, constant: gap))
|
|
930
|
+
} else {
|
|
931
|
+
constraints.append(headerTitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: headerContainer.leadingAnchor, constant: paddingH))
|
|
932
|
+
}
|
|
933
|
+
if let rb = rightButton {
|
|
934
|
+
constraints.append(headerTitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: rb.leadingAnchor, constant: -gap))
|
|
935
|
+
} else {
|
|
936
|
+
constraints.append(headerTitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: headerContainer.trailingAnchor, constant: -paddingH))
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
constraints += [
|
|
940
|
+
headerTitleLabel.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor, constant: CGFloat(config.headerStyle.paddingHorizontal)),
|
|
941
|
+
headerTitleLabel.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor, constant: -CGFloat(config.headerStyle.paddingHorizontal)),
|
|
942
|
+
]
|
|
943
|
+
|
|
944
|
+
let paddingH = CGFloat(config.buttonContainerStyle.paddingHorizontal)
|
|
945
|
+
let paddingTop = CGFloat(config.buttonContainerStyle.paddingTop)
|
|
946
|
+
let gap = CGFloat(config.buttonContainerStyle.gap)
|
|
947
|
+
let buttonHeight = CGFloat(config.buttonContainerStyle.buttonHeight)
|
|
948
|
+
|
|
949
|
+
let firstIsCancel = config.footerButtonOrder == "cancelFirst"
|
|
950
|
+
let firstButton = firstIsCancel ? cancelButton : uploadButton
|
|
951
|
+
let secondButton = firstIsCancel ? uploadButton : cancelButton
|
|
952
|
+
|
|
953
|
+
if config.buttonContainerStyle.layout == "horizontal" {
|
|
954
|
+
constraints += [
|
|
955
|
+
firstButton.leadingAnchor.constraint(equalTo: footerContainer.leadingAnchor, constant: paddingH),
|
|
956
|
+
firstButton.topAnchor.constraint(equalTo: footerContainer.topAnchor, constant: paddingTop),
|
|
957
|
+
firstButton.heightAnchor.constraint(equalToConstant: buttonHeight),
|
|
958
|
+
|
|
959
|
+
secondButton.leadingAnchor.constraint(equalTo: firstButton.trailingAnchor, constant: gap),
|
|
960
|
+
secondButton.trailingAnchor.constraint(equalTo: footerContainer.trailingAnchor, constant: -paddingH),
|
|
961
|
+
secondButton.topAnchor.constraint(equalTo: firstButton.topAnchor),
|
|
962
|
+
secondButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor),
|
|
963
|
+
|
|
964
|
+
secondButton.widthAnchor.constraint(equalTo: firstButton.widthAnchor),
|
|
965
|
+
]
|
|
966
|
+
} else {
|
|
967
|
+
constraints += [
|
|
968
|
+
firstButton.leadingAnchor.constraint(equalTo: footerContainer.leadingAnchor, constant: paddingH),
|
|
969
|
+
firstButton.trailingAnchor.constraint(equalTo: footerContainer.trailingAnchor, constant: -paddingH),
|
|
970
|
+
firstButton.topAnchor.constraint(equalTo: footerContainer.topAnchor, constant: paddingTop),
|
|
971
|
+
firstButton.heightAnchor.constraint(equalToConstant: buttonHeight),
|
|
972
|
+
|
|
973
|
+
secondButton.leadingAnchor.constraint(equalTo: firstButton.leadingAnchor),
|
|
974
|
+
secondButton.trailingAnchor.constraint(equalTo: firstButton.trailingAnchor),
|
|
975
|
+
secondButton.topAnchor.constraint(equalTo: firstButton.bottomAnchor, constant: gap),
|
|
976
|
+
secondButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor),
|
|
977
|
+
]
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
NSLayoutConstraint.activate(constraints)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private func createActionButton(title: String, style: ButtonStyleConfig) -> UIButton {
|
|
985
|
+
let button = UIButton(type: .system)
|
|
986
|
+
button.translatesAutoresizingMaskIntoConstraints = false
|
|
987
|
+
let resolvedTitleColor = parseColor(style.textColor, defaultLabelColor())
|
|
988
|
+
applyButtonContent(button: button, title: title, style: style, fallbackTitleColor: resolvedTitleColor)
|
|
989
|
+
button.setTitleColor(resolvedTitleColor, for: .normal)
|
|
990
|
+
button.titleLabel?.font = resolvedFont(
|
|
991
|
+
family: style.fontFamily,
|
|
992
|
+
size: CGFloat(style.fontSize),
|
|
993
|
+
fallback: UIFont.systemFont(ofSize: CGFloat(style.fontSize), weight: .semibold)
|
|
994
|
+
)
|
|
995
|
+
button.titleLabel?.numberOfLines = 1
|
|
996
|
+
button.titleLabel?.lineBreakMode = .byTruncatingTail
|
|
997
|
+
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
|
998
|
+
button.titleLabel?.minimumScaleFactor = 0.85
|
|
999
|
+
button.titleLabel?.allowsDefaultTighteningForTruncation = true
|
|
1000
|
+
if #available(iOS 14.0, *) {
|
|
1001
|
+
button.titleLabel?.lineBreakStrategy = []
|
|
1002
|
+
}
|
|
1003
|
+
button.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
1004
|
+
button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
1005
|
+
if #available(iOS 15.0, *) {
|
|
1006
|
+
// Style is applied via `UIButton.Configuration`.
|
|
1007
|
+
button.backgroundColor = .clear
|
|
1008
|
+
button.layer.borderWidth = 0
|
|
1009
|
+
button.layer.borderColor = UIColor.clear.cgColor
|
|
1010
|
+
} else {
|
|
1011
|
+
button.backgroundColor = parseColor(style.backgroundColor, .clear)
|
|
1012
|
+
button.layer.borderColor = parseColor(style.borderColor, .clear).cgColor
|
|
1013
|
+
button.layer.borderWidth = CGFloat(style.borderWidth)
|
|
1014
|
+
}
|
|
1015
|
+
button.clipsToBounds = true
|
|
1016
|
+
applyButtonRounding(button: button, style: style)
|
|
1017
|
+
return button
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private func applyButtonRounding(button: UIButton, style: ButtonStyleConfig) {
|
|
1021
|
+
button.clipsToBounds = true
|
|
1022
|
+
button.layer.masksToBounds = true
|
|
1023
|
+
button.layer.allowsEdgeAntialiasing = true
|
|
1024
|
+
if #available(iOS 13.0, *) {
|
|
1025
|
+
button.layer.cornerCurve = .continuous
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// If user provides a radius, respect it (but never exceed half height).
|
|
1029
|
+
// Otherwise default to a perfect "pill" radius.
|
|
1030
|
+
let half = max(button.bounds.height / 2.0, 0)
|
|
1031
|
+
let preferred = CGFloat(max(style.borderRadius, 0))
|
|
1032
|
+
let radius = preferred > 0 ? min(preferred, half) : half
|
|
1033
|
+
if button.layer.cornerRadius != radius {
|
|
1034
|
+
button.layer.cornerRadius = radius
|
|
1035
|
+
}
|
|
1036
|
+
if #available(iOS 15.0, *), var cfg = button.configuration {
|
|
1037
|
+
var bg = cfg.background
|
|
1038
|
+
bg.cornerRadius = radius
|
|
1039
|
+
cfg.background = bg
|
|
1040
|
+
button.configuration = cfg
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private func applyButtonContent(
|
|
1045
|
+
button: UIButton,
|
|
1046
|
+
title: String,
|
|
1047
|
+
style: ButtonStyleConfig,
|
|
1048
|
+
fallbackTitleColor: UIColor
|
|
1049
|
+
) {
|
|
1050
|
+
let raw = style.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1051
|
+
let content: String
|
|
1052
|
+
switch raw {
|
|
1053
|
+
case "iconText", "icon+text":
|
|
1054
|
+
content = "iconText"
|
|
1055
|
+
case "textIcon", "TextIcon", "text+icon":
|
|
1056
|
+
content = "textIcon"
|
|
1057
|
+
case "icon", "text":
|
|
1058
|
+
content = raw
|
|
1059
|
+
default:
|
|
1060
|
+
content = "text"
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
let wantsText = content != "icon"
|
|
1064
|
+
let wantsIcon = content != "text"
|
|
1065
|
+
|
|
1066
|
+
let icon = wantsIcon ? resolvedIcon(style: style) : nil
|
|
1067
|
+
let iconReady = icon != nil
|
|
1068
|
+
|
|
1069
|
+
if #available(iOS 15.0, *) {
|
|
1070
|
+
applyButtonConfiguration(
|
|
1071
|
+
button: button,
|
|
1072
|
+
title: title,
|
|
1073
|
+
content: content,
|
|
1074
|
+
style: style,
|
|
1075
|
+
icon: icon,
|
|
1076
|
+
fallbackTitleColor: fallbackTitleColor
|
|
1077
|
+
)
|
|
1078
|
+
// If icon is not ready, try remote loader (it will update config when done).
|
|
1079
|
+
if wantsIcon, !iconReady {
|
|
1080
|
+
loadRemoteIconIfNeeded(
|
|
1081
|
+
button: button,
|
|
1082
|
+
title: title,
|
|
1083
|
+
content: content,
|
|
1084
|
+
style: style,
|
|
1085
|
+
fallbackTitleColor: fallbackTitleColor
|
|
1086
|
+
)
|
|
1087
|
+
}
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Fallback for iOS < 15 (best-effort).
|
|
1092
|
+
button.titleEdgeInsets = .zero
|
|
1093
|
+
button.imageEdgeInsets = .zero
|
|
1094
|
+
button.contentHorizontalAlignment = .center
|
|
1095
|
+
button.semanticContentAttribute = content == "textIcon" ? .forceRightToLeft : .forceLeftToRight
|
|
1096
|
+
|
|
1097
|
+
if wantsText || !iconReady {
|
|
1098
|
+
button.setTitle(title, for: .normal)
|
|
1099
|
+
} else {
|
|
1100
|
+
button.setTitle(nil, for: .normal)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
let padH = CGFloat(max(style.paddingHorizontal, 0))
|
|
1104
|
+
let padV = CGFloat(max(style.paddingVertical, 0))
|
|
1105
|
+
button.contentEdgeInsets = UIEdgeInsets(top: padV, left: padH, bottom: padV, right: padH)
|
|
1106
|
+
|
|
1107
|
+
let hasTint = !style.iconTintColor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
1108
|
+
if iconReady, var img = icon {
|
|
1109
|
+
if hasTint {
|
|
1110
|
+
let tintColor = parseColor(style.iconTintColor, fallbackTitleColor)
|
|
1111
|
+
img = img.withRenderingMode(.alwaysTemplate)
|
|
1112
|
+
button.tintColor = tintColor
|
|
1113
|
+
} else {
|
|
1114
|
+
img = img.withRenderingMode(.alwaysOriginal)
|
|
1115
|
+
}
|
|
1116
|
+
let size = CGFloat(max(style.iconSize, 12))
|
|
1117
|
+
button.setImage(resizedImageToFit(img, size: size), for: .normal)
|
|
1118
|
+
button.imageView?.contentMode = .scaleAspectFit
|
|
1119
|
+
let gap = CGFloat(max(style.iconGap, 0))
|
|
1120
|
+
if gap > 0 {
|
|
1121
|
+
button.titleEdgeInsets = UIEdgeInsets(top: 0, left: gap / 2, bottom: 0, right: -(gap / 2))
|
|
1122
|
+
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -(gap / 2), bottom: 0, right: gap / 2)
|
|
1123
|
+
}
|
|
1124
|
+
} else if wantsIcon {
|
|
1125
|
+
button.setImage(nil, for: .normal)
|
|
1126
|
+
loadRemoteIconIfNeeded(
|
|
1127
|
+
button: button,
|
|
1128
|
+
title: title,
|
|
1129
|
+
content: content,
|
|
1130
|
+
style: style,
|
|
1131
|
+
fallbackTitleColor: fallbackTitleColor
|
|
1132
|
+
)
|
|
1133
|
+
} else {
|
|
1134
|
+
button.setImage(nil, for: .normal)
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
@available(iOS 15.0, *)
|
|
1139
|
+
private func applyButtonConfiguration(
|
|
1140
|
+
button: UIButton,
|
|
1141
|
+
title: String,
|
|
1142
|
+
content: String,
|
|
1143
|
+
style: ButtonStyleConfig,
|
|
1144
|
+
icon: UIImage?,
|
|
1145
|
+
fallbackTitleColor: UIColor
|
|
1146
|
+
) {
|
|
1147
|
+
var config = button.configuration ?? UIButton.Configuration.plain()
|
|
1148
|
+
config.titleAlignment = .center
|
|
1149
|
+
config.titleLineBreakMode = .byTruncatingTail
|
|
1150
|
+
|
|
1151
|
+
let padH = CGFloat(max(style.paddingHorizontal, 0))
|
|
1152
|
+
let padV = CGFloat(max(style.paddingVertical, 0))
|
|
1153
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: padV, leading: padH, bottom: padV, trailing: padH)
|
|
1154
|
+
|
|
1155
|
+
let wantsText = content != "icon"
|
|
1156
|
+
let wantsIcon = content != "text"
|
|
1157
|
+
|
|
1158
|
+
config.title = wantsText ? title : nil
|
|
1159
|
+
config.baseForegroundColor = fallbackTitleColor
|
|
1160
|
+
|
|
1161
|
+
let gap = CGFloat(max(style.iconGap, 0))
|
|
1162
|
+
config.imagePadding = gap
|
|
1163
|
+
config.imagePlacement = (content == "textIcon") ? .trailing : .leading
|
|
1164
|
+
|
|
1165
|
+
// Keep background/border consistent when using UIButton.Configuration.
|
|
1166
|
+
var bg = config.background
|
|
1167
|
+
bg.backgroundColor = parseColor(style.backgroundColor, .clear)
|
|
1168
|
+
bg.strokeColor = parseColor(style.borderColor, .clear)
|
|
1169
|
+
bg.strokeWidth = CGFloat(max(style.borderWidth, 0))
|
|
1170
|
+
// Use a generous initial corner radius; `applyButtonRounding` will clamp to a pill after layout.
|
|
1171
|
+
bg.cornerRadius = CGFloat(max(style.borderRadius, 999))
|
|
1172
|
+
config.background = bg
|
|
1173
|
+
|
|
1174
|
+
if wantsIcon, let icon {
|
|
1175
|
+
let hasTint = !style.iconTintColor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
1176
|
+
let size = CGFloat(max(style.iconSize, 12))
|
|
1177
|
+
let prepared = resizedImageToFit(icon, size: size)
|
|
1178
|
+
if hasTint {
|
|
1179
|
+
config.image = prepared.withRenderingMode(.alwaysTemplate)
|
|
1180
|
+
button.tintColor = parseColor(style.iconTintColor, fallbackTitleColor)
|
|
1181
|
+
} else {
|
|
1182
|
+
config.image = prepared.withRenderingMode(.alwaysOriginal)
|
|
1183
|
+
}
|
|
1184
|
+
} else {
|
|
1185
|
+
config.image = nil
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
button.configuration = config
|
|
1189
|
+
|
|
1190
|
+
// Ensure title never wraps when using configuration titles.
|
|
1191
|
+
button.titleLabel?.numberOfLines = 1
|
|
1192
|
+
button.titleLabel?.lineBreakMode = .byTruncatingTail
|
|
1193
|
+
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
|
1194
|
+
button.titleLabel?.minimumScaleFactor = 0.85
|
|
1195
|
+
if #available(iOS 14.0, *) {
|
|
1196
|
+
button.titleLabel?.lineBreakStrategy = []
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
private func resizedImageToFit(_ image: UIImage, size: CGFloat) -> UIImage {
|
|
1201
|
+
guard size > 0 else { return image }
|
|
1202
|
+
let target = CGSize(width: size, height: size)
|
|
1203
|
+
let renderer = UIGraphicsImageRenderer(size: target)
|
|
1204
|
+
return renderer.image { _ in
|
|
1205
|
+
// Aspect-fit into a square.
|
|
1206
|
+
let iw = image.size.width
|
|
1207
|
+
let ih = image.size.height
|
|
1208
|
+
if iw <= 0 || ih <= 0 {
|
|
1209
|
+
image.draw(in: CGRect(origin: .zero, size: target))
|
|
1210
|
+
return
|
|
1211
|
+
}
|
|
1212
|
+
let scale = min(target.width / iw, target.height / ih)
|
|
1213
|
+
let w = iw * scale
|
|
1214
|
+
let h = ih * scale
|
|
1215
|
+
let x = (target.width - w) / 2.0
|
|
1216
|
+
let y = (target.height - h) / 2.0
|
|
1217
|
+
image.draw(in: CGRect(x: x, y: y, width: w, height: h))
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
private func loadRemoteIconIfNeeded(
|
|
1222
|
+
button: UIButton,
|
|
1223
|
+
title: String,
|
|
1224
|
+
content: String,
|
|
1225
|
+
style: ButtonStyleConfig,
|
|
1226
|
+
fallbackTitleColor: UIColor
|
|
1227
|
+
) {
|
|
1228
|
+
let uri = style.iconUri.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1229
|
+
guard uri.hasPrefix("http://") || uri.hasPrefix("https://") else { return }
|
|
1230
|
+
guard let url = URL(string: uri) else { return }
|
|
1231
|
+
|
|
1232
|
+
let cacheKey = "\(uri)|\(max(style.iconSize, 12))" as NSString
|
|
1233
|
+
if let cached = Self.iconCache.object(forKey: cacheKey) {
|
|
1234
|
+
if #available(iOS 15.0, *) {
|
|
1235
|
+
applyButtonConfiguration(
|
|
1236
|
+
button: button,
|
|
1237
|
+
title: title,
|
|
1238
|
+
content: content,
|
|
1239
|
+
style: style,
|
|
1240
|
+
icon: cached,
|
|
1241
|
+
fallbackTitleColor: fallbackTitleColor
|
|
1242
|
+
)
|
|
1243
|
+
} else {
|
|
1244
|
+
button.setImage(cached, for: .normal)
|
|
1245
|
+
}
|
|
1246
|
+
return
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
URLSession.shared.dataTask(with: url) { [weak button] data, _, _ in
|
|
1250
|
+
guard let button, let data, let img = UIImage(data: data) else { return }
|
|
1251
|
+
DispatchQueue.main.async {
|
|
1252
|
+
if #available(iOS 15.0, *) {
|
|
1253
|
+
// Re-apply the full configuration so title/icon/layout stay consistent.
|
|
1254
|
+
self.applyButtonConfiguration(
|
|
1255
|
+
button: button,
|
|
1256
|
+
title: title,
|
|
1257
|
+
content: content,
|
|
1258
|
+
style: style,
|
|
1259
|
+
icon: img,
|
|
1260
|
+
fallbackTitleColor: fallbackTitleColor
|
|
1261
|
+
)
|
|
1262
|
+
} else {
|
|
1263
|
+
let hasTint = !style.iconTintColor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
1264
|
+
let size = CGFloat(max(style.iconSize, 12))
|
|
1265
|
+
let prepared = self.resizedImageToFit(img, size: size)
|
|
1266
|
+
if hasTint {
|
|
1267
|
+
let tintColor = self.parseColor(style.iconTintColor, fallbackTitleColor)
|
|
1268
|
+
button.tintColor = tintColor
|
|
1269
|
+
button.setImage(prepared.withRenderingMode(.alwaysTemplate), for: .normal)
|
|
1270
|
+
} else {
|
|
1271
|
+
button.setImage(prepared.withRenderingMode(.alwaysOriginal), for: .normal)
|
|
1272
|
+
}
|
|
1273
|
+
button.imageView?.contentMode = .scaleAspectFit
|
|
1274
|
+
// If the button is icon-only, remove the temporary/fallback title after icon is ready.
|
|
1275
|
+
if content == "icon" {
|
|
1276
|
+
button.setTitle(nil, for: .normal)
|
|
1277
|
+
} else if button.title(for: .normal) == nil {
|
|
1278
|
+
button.setTitle(title, for: .normal)
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Cache the final rendered image (already aspect-fit sized for our target).
|
|
1283
|
+
let size = CGFloat(max(style.iconSize, 12))
|
|
1284
|
+
let cached = self.resizedImageToFit(img, size: size)
|
|
1285
|
+
Self.iconCache.setObject(cached, forKey: cacheKey)
|
|
1286
|
+
}
|
|
1287
|
+
}.resume()
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
private func resolvedIcon(style: ButtonStyleConfig) -> UIImage? {
|
|
1291
|
+
let base64 = style.iconBase64.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1292
|
+
if !base64.isEmpty {
|
|
1293
|
+
let cleaned = base64.components(separatedBy: "base64,").last ?? base64
|
|
1294
|
+
if let data = Data(base64Encoded: cleaned), let img = UIImage(data: data) {
|
|
1295
|
+
return img
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
let uri = style.iconUri.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1299
|
+
if uri.isEmpty { return nil }
|
|
1300
|
+
if uri.hasPrefix("file://"), let url = URL(string: uri) {
|
|
1301
|
+
return UIImage(contentsOfFile: url.path)
|
|
1302
|
+
}
|
|
1303
|
+
if uri.hasPrefix("/") {
|
|
1304
|
+
return UIImage(contentsOfFile: uri)
|
|
1305
|
+
}
|
|
1306
|
+
return nil
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
private func resolvedFont(family: String, size: CGFloat, fallback: UIFont) -> UIFont {
|
|
1310
|
+
let cleaned = family.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1311
|
+
guard !cleaned.isEmpty else { return fallback }
|
|
1312
|
+
if let font = UIFont(name: cleaned, size: size) {
|
|
1313
|
+
return font
|
|
1314
|
+
}
|
|
1315
|
+
let candidates = [
|
|
1316
|
+
cleaned,
|
|
1317
|
+
cleaned.replacingOccurrences(of: "_", with: "-"),
|
|
1318
|
+
cleaned.replacingOccurrences(of: "_", with: "")
|
|
1319
|
+
]
|
|
1320
|
+
for name in candidates {
|
|
1321
|
+
if let font = UIFont(name: name, size: size) {
|
|
1322
|
+
return font
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return fallback
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
@objc private func onCancelTapped() {
|
|
1329
|
+
if let delegate {
|
|
1330
|
+
delegate.cropViewController?(self, didFinishCancelled: true)
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1333
|
+
dismiss(animated: true)
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
@objc private func onUploadTapped() {
|
|
1337
|
+
if isCommittingCrop { return }
|
|
1338
|
+
isCommittingCrop = true
|
|
1339
|
+
uploadButton.isEnabled = false
|
|
1340
|
+
commitCurrentCrop()
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// MARK: - Circular grid overlay (iOS circular crop)
|
|
1345
|
+
|
|
1346
|
+
private final class CircularGridOverlayView: UIView {
|
|
1347
|
+
private let h1 = UIView()
|
|
1348
|
+
private let h2 = UIView()
|
|
1349
|
+
private let v1 = UIView()
|
|
1350
|
+
private let v2 = UIView()
|
|
1351
|
+
|
|
1352
|
+
init(frame: CGRect, lineColor: UIColor) {
|
|
1353
|
+
super.init(frame: frame)
|
|
1354
|
+
[h1, h2, v1, v2].forEach {
|
|
1355
|
+
$0.backgroundColor = lineColor
|
|
1356
|
+
$0.isUserInteractionEnabled = false
|
|
1357
|
+
addSubview($0)
|
|
1358
|
+
}
|
|
1359
|
+
isUserInteractionEnabled = false
|
|
1360
|
+
backgroundColor = .clear
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
@available(*, unavailable)
|
|
1364
|
+
required init?(coder: NSCoder) {
|
|
1365
|
+
fatalError("init(coder:) has not been implemented")
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
override func layoutSubviews() {
|
|
1369
|
+
super.layoutSubviews()
|
|
1370
|
+
let scale = window?.screen.scale ?? UIScreen.main.scale
|
|
1371
|
+
let thickness = max(1.0 / scale, 0.5)
|
|
1372
|
+
let w = bounds.width
|
|
1373
|
+
let h = bounds.height
|
|
1374
|
+
let x1 = (w / 3.0)
|
|
1375
|
+
let x2 = (w * 2.0 / 3.0)
|
|
1376
|
+
let y1 = (h / 3.0)
|
|
1377
|
+
let y2 = (h * 2.0 / 3.0)
|
|
1378
|
+
|
|
1379
|
+
h1.frame = CGRect(x: 0, y: y1, width: w, height: thickness)
|
|
1380
|
+
h2.frame = CGRect(x: 0, y: y2, width: w, height: thickness)
|
|
1381
|
+
v1.frame = CGRect(x: x1, y: 0, width: thickness, height: h)
|
|
1382
|
+
v2.frame = CGRect(x: x2, y: 0, width: thickness, height: h)
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
private extension UIColor {
|
|
1387
|
+
convenience init?(hex: String) {
|
|
1388
|
+
var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1389
|
+
guard s.hasPrefix("#") else { return nil }
|
|
1390
|
+
s.removeFirst()
|
|
1391
|
+
if s.count == 6 { s = "FF" + s }
|
|
1392
|
+
guard s.count == 8, let v = UInt64(s, radix: 16) else { return nil }
|
|
1393
|
+
let a = CGFloat((v >> 24) & 0xFF) / 255.0
|
|
1394
|
+
let r = CGFloat((v >> 16) & 0xFF) / 255.0
|
|
1395
|
+
let g = CGFloat((v >> 8) & 0xFF) / 255.0
|
|
1396
|
+
let b = CGFloat(v & 0xFF) / 255.0
|
|
1397
|
+
self.init(red: r, green: g, blue: b, alpha: a)
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|