@onekeyfe/react-native-image-crop-picker 3.0.147 → 3.0.148
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/README.md +48 -5
- package/ReactNativeImageCropPicker.podspec +5 -3
- package/android/src/main/AndroidManifest.xml +2 -2
- package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerImageProcessor.kt +8 -18
- package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerSession.kt +21 -46
- package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperActivity.kt +636 -0
- package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperTheme.kt +188 -0
- package/android/src/main/res/drawable/image_crop_picker_rotate.xml +11 -0
- package/ios/ImageCropPickerImageProcessor.swift +8 -4
- package/ios/ImageCropPickerSession.swift +43 -46
- package/ios/ImageCropperTheme.swift +155 -0
- package/ios/ImageCropperViewController.swift +462 -0
- package/ios/TOCropViewController/Views/TOCropOverlayView.h +9 -0
- package/ios/TOCropViewController/Views/TOCropOverlayView.m +47 -3
- package/lib/typescript/src/ReactNativeImageCropPicker.nitro.d.ts +17 -11
- package/lib/typescript/src/ReactNativeImageCropPicker.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativeImageCropPickerSpec.cpp +8 -0
- package/nitrogen/generated/android/c++/JImageCropPickerOptions.hpp +9 -45
- package/nitrogen/generated/android/c++/JImageCropperAppearance.hpp +108 -0
- package/nitrogen/generated/android/c++/JImageCropperColorScheme.hpp +58 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerOptions.kt +5 -55
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperAppearance.kt +111 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperColorScheme.kt +23 -0
- package/nitrogen/generated/ios/ReactNativeImageCropPicker-Swift-Cxx-Bridge.hpp +36 -0
- package/nitrogen/generated/ios/ReactNativeImageCropPicker-Swift-Cxx-Umbrella.hpp +6 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeImageCropPickerSpecSwift.hpp +6 -0
- package/nitrogen/generated/ios/swift/ImageCropPickerOptions.swift +6 -193
- package/nitrogen/generated/ios/swift/ImageCropperAppearance.swift +251 -0
- package/nitrogen/generated/ios/swift/ImageCropperColorScheme.swift +40 -0
- package/nitrogen/generated/shared/c++/ImageCropPickerOptions.hpp +8 -46
- package/nitrogen/generated/shared/c++/ImageCropperAppearance.hpp +134 -0
- package/nitrogen/generated/shared/c++/ImageCropperColorScheme.hpp +76 -0
- package/package.json +1 -1
- package/src/ReactNativeImageCropPicker.nitro.ts +29 -13
package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropperActivity.kt
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativeimagecroppicker
|
|
2
|
+
|
|
3
|
+
import android.animation.Animator
|
|
4
|
+
import android.animation.AnimatorListenerAdapter
|
|
5
|
+
import android.animation.ValueAnimator
|
|
6
|
+
import android.annotation.SuppressLint
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.content.Intent
|
|
9
|
+
import android.content.pm.ActivityInfo
|
|
10
|
+
import android.content.res.ColorStateList
|
|
11
|
+
import android.graphics.Bitmap
|
|
12
|
+
import android.graphics.Color
|
|
13
|
+
import android.graphics.Typeface
|
|
14
|
+
import android.graphics.drawable.ColorDrawable
|
|
15
|
+
import android.graphics.drawable.Drawable
|
|
16
|
+
import android.graphics.drawable.GradientDrawable
|
|
17
|
+
import android.graphics.drawable.StateListDrawable
|
|
18
|
+
import android.net.Uri
|
|
19
|
+
import android.os.Build
|
|
20
|
+
import android.os.Bundle
|
|
21
|
+
import android.text.TextUtils
|
|
22
|
+
import android.util.TypedValue
|
|
23
|
+
import android.view.Gravity
|
|
24
|
+
import android.view.MotionEvent
|
|
25
|
+
import android.view.View
|
|
26
|
+
import android.view.ViewGroup
|
|
27
|
+
import android.view.accessibility.AccessibilityNodeInfo
|
|
28
|
+
import android.view.animation.DecelerateInterpolator
|
|
29
|
+
import android.widget.Button
|
|
30
|
+
import android.widget.FrameLayout
|
|
31
|
+
import android.widget.ImageView
|
|
32
|
+
import android.widget.LinearLayout
|
|
33
|
+
import android.widget.ProgressBar
|
|
34
|
+
import android.widget.TextView
|
|
35
|
+
import androidx.activity.OnBackPressedCallback
|
|
36
|
+
import androidx.activity.SystemBarStyle
|
|
37
|
+
import androidx.activity.enableEdgeToEdge
|
|
38
|
+
import androidx.appcompat.app.AppCompatActivity
|
|
39
|
+
import androidx.core.view.ViewCompat
|
|
40
|
+
import androidx.core.view.WindowInsetsCompat
|
|
41
|
+
import com.facebook.react.common.assets.ReactFontManager
|
|
42
|
+
import com.yalantis.ucrop.callback.BitmapCropCallback
|
|
43
|
+
import com.yalantis.ucrop.view.CropImageView
|
|
44
|
+
import com.yalantis.ucrop.view.GestureCropImageView
|
|
45
|
+
import com.yalantis.ucrop.view.OverlayView
|
|
46
|
+
import com.yalantis.ucrop.view.TransformImageView
|
|
47
|
+
import com.yalantis.ucrop.view.UCropView
|
|
48
|
+
import kotlin.math.roundToInt
|
|
49
|
+
|
|
50
|
+
// Full-screen cropper laid out like a OneKey page: a header with the title and
|
|
51
|
+
// a rotate button, the crop area, and a Cancel / Confirm footer. It draws the
|
|
52
|
+
// same screen, with the same metrics, as the iOS ImageCropperViewController.
|
|
53
|
+
class ImageCropperActivity : AppCompatActivity() {
|
|
54
|
+
private lateinit var cropperTheme: ImageCropperTheme
|
|
55
|
+
private lateinit var cropView: UCropView
|
|
56
|
+
private lateinit var cropImageView: GestureCropImageView
|
|
57
|
+
private lateinit var overlayView: OverlayView
|
|
58
|
+
private lateinit var rotateButton: IconButton
|
|
59
|
+
private lateinit var confirmButton: CapsuleButton
|
|
60
|
+
|
|
61
|
+
private var showsGrid = false
|
|
62
|
+
private var gridAlpha = 0f
|
|
63
|
+
private var gridAnimator: ValueAnimator? = null
|
|
64
|
+
private var rotationAnimator: ValueAnimator? = null
|
|
65
|
+
private var isImageLoaded = false
|
|
66
|
+
private var isProcessing = false
|
|
67
|
+
private val hideGrid = Runnable { animateGrid(visible = false) }
|
|
68
|
+
|
|
69
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
70
|
+
super.onCreate(savedInstanceState)
|
|
71
|
+
cropperTheme = ImageCropperTheme.readFrom(intent)
|
|
72
|
+
val barStyle = if (cropperTheme.isDark) {
|
|
73
|
+
SystemBarStyle.dark(Color.TRANSPARENT)
|
|
74
|
+
} else {
|
|
75
|
+
SystemBarStyle.light(Color.TRANSPARENT, DARK_SCRIM)
|
|
76
|
+
}
|
|
77
|
+
enableEdgeToEdge(barStyle, barStyle)
|
|
78
|
+
requestedOrientation = intent.getIntExtra(
|
|
79
|
+
EXTRA_REQUESTED_ORIENTATION,
|
|
80
|
+
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
val inputUri = intent.uriExtra(EXTRA_INPUT_URI)
|
|
84
|
+
val outputUri = intent.uriExtra(EXTRA_OUTPUT_URI)
|
|
85
|
+
if (inputUri == null || outputUri == null) {
|
|
86
|
+
finishWithError("Missing image")
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
window.setBackgroundDrawable(ColorDrawable(cropperTheme.backgroundColor))
|
|
91
|
+
setContentView(buildContent())
|
|
92
|
+
configureCropView()
|
|
93
|
+
|
|
94
|
+
onBackPressedDispatcher.addCallback(
|
|
95
|
+
this,
|
|
96
|
+
object : OnBackPressedCallback(true) {
|
|
97
|
+
override fun handleOnBackPressed() = cancel()
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
cropView.alpha = 0f
|
|
102
|
+
cropImageView.setTransformImageListener(
|
|
103
|
+
object : TransformImageView.TransformImageListener {
|
|
104
|
+
override fun onLoadComplete() {
|
|
105
|
+
isImageLoaded = true
|
|
106
|
+
cropView.animate().alpha(1f).setDuration(FADE_IN_DURATION).start()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override fun onLoadFailure(error: Exception) {
|
|
110
|
+
finishWithError(error.message ?: "Cannot load image")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
override fun onRotate(currentAngle: Float) = Unit
|
|
114
|
+
|
|
115
|
+
override fun onScale(currentScale: Float) = Unit
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
try {
|
|
119
|
+
cropImageView.setImageUri(inputUri, outputUri)
|
|
120
|
+
} catch (error: Exception) {
|
|
121
|
+
finishWithError(error.message ?: "Cannot load image")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
override fun onStop() {
|
|
126
|
+
super.onStop()
|
|
127
|
+
if (::cropImageView.isInitialized) {
|
|
128
|
+
cropImageView.cancelAllAnimations()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override fun onDestroy() {
|
|
133
|
+
super.onDestroy()
|
|
134
|
+
gridAnimator?.cancel()
|
|
135
|
+
rotationAnimator?.cancel()
|
|
136
|
+
if (::cropView.isInitialized) {
|
|
137
|
+
cropView.removeCallbacks(hideGrid)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private fun buildContent(): View {
|
|
142
|
+
val theme = cropperTheme
|
|
143
|
+
val spacing = dp(20f)
|
|
144
|
+
val headerHeight = dp(56f)
|
|
145
|
+
val iconButtonSize = dp(40f)
|
|
146
|
+
val buttonHeight = dp(50f)
|
|
147
|
+
|
|
148
|
+
val column = LinearLayout(this).apply {
|
|
149
|
+
orientation = LinearLayout.VERTICAL
|
|
150
|
+
setBackgroundColor(theme.backgroundColor)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
val header = FrameLayout(this)
|
|
154
|
+
val title = intent.getStringExtra(EXTRA_TITLE)
|
|
155
|
+
if (!title.isNullOrEmpty()) {
|
|
156
|
+
val titleView = TextView(this).apply {
|
|
157
|
+
text = title
|
|
158
|
+
setTextColor(theme.titleColor)
|
|
159
|
+
setTextSize(TypedValue.COMPLEX_UNIT_DIP, (18 * theme.scale).roundToInt().toFloat())
|
|
160
|
+
typeface = loadTypeface(theme.titleFontFamily, fallbackWeight = 600)
|
|
161
|
+
gravity = Gravity.CENTER
|
|
162
|
+
maxLines = 1
|
|
163
|
+
ellipsize = TextUtils.TruncateAt.END
|
|
164
|
+
includeFontPadding = false
|
|
165
|
+
ViewCompat.setAccessibilityHeading(this, true)
|
|
166
|
+
}
|
|
167
|
+
// Centered, clear of the rotate button on both sides.
|
|
168
|
+
header.addView(
|
|
169
|
+
titleView,
|
|
170
|
+
FrameLayout.LayoutParams(
|
|
171
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
172
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
173
|
+
).apply {
|
|
174
|
+
marginStart = spacing + iconButtonSize
|
|
175
|
+
marginEnd = spacing + iconButtonSize
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
rotateButton = IconButton(
|
|
180
|
+
this,
|
|
181
|
+
iconSize = dp(24f),
|
|
182
|
+
iconColor = theme.iconColor,
|
|
183
|
+
pressedColor = theme.cancelButtonColor,
|
|
184
|
+
).apply {
|
|
185
|
+
contentDescription = "Rotate"
|
|
186
|
+
visibility = if (intent.getBooleanExtra(EXTRA_ROTATE_BUTTON_HIDDEN, false)) {
|
|
187
|
+
View.GONE
|
|
188
|
+
} else {
|
|
189
|
+
View.VISIBLE
|
|
190
|
+
}
|
|
191
|
+
setOnClickListener { rotate() }
|
|
192
|
+
}
|
|
193
|
+
header.addView(
|
|
194
|
+
rotateButton,
|
|
195
|
+
FrameLayout.LayoutParams(iconButtonSize, iconButtonSize, Gravity.END or Gravity.CENTER_VERTICAL)
|
|
196
|
+
.apply { marginEnd = spacing - dp(8f) },
|
|
197
|
+
)
|
|
198
|
+
column.addView(
|
|
199
|
+
header,
|
|
200
|
+
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, headerHeight),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
cropView = UCropView(this, null)
|
|
204
|
+
cropImageView = cropView.cropImageView
|
|
205
|
+
overlayView = cropView.overlayView
|
|
206
|
+
// The crop box keeps `spacing` from every edge of the crop area.
|
|
207
|
+
cropImageView.setPadding(spacing, spacing, spacing, spacing)
|
|
208
|
+
overlayView.setPadding(spacing, spacing, spacing, spacing)
|
|
209
|
+
val cropContainer = CropContainer(this).apply { addView(cropView) }
|
|
210
|
+
column.addView(
|
|
211
|
+
cropContainer,
|
|
212
|
+
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
val footer = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
|
|
216
|
+
val buttonPadding = dp(20f) + dp(1f)
|
|
217
|
+
val buttonTextSize = (16 * theme.scale).roundToInt().toFloat()
|
|
218
|
+
val buttonTypeface = loadTypeface(theme.buttonFontFamily, fallbackWeight = 500)
|
|
219
|
+
val cancelButton = CapsuleButton(
|
|
220
|
+
this,
|
|
221
|
+
title = intent.getStringExtra(EXTRA_CANCEL_TEXT)?.takeIf { it.isNotEmpty() }
|
|
222
|
+
?: getString(android.R.string.cancel),
|
|
223
|
+
textSize = buttonTextSize,
|
|
224
|
+
typeface = buttonTypeface,
|
|
225
|
+
textColor = theme.cancelButtonTextColor,
|
|
226
|
+
backgroundColor = theme.cancelButtonColor,
|
|
227
|
+
pressedColor = theme.cancelButtonPressedColor,
|
|
228
|
+
horizontalPadding = buttonPadding,
|
|
229
|
+
spinnerSize = dp(20f),
|
|
230
|
+
spinnerSpacing = dp(8f),
|
|
231
|
+
).apply { setOnClickListener { cancel() } }
|
|
232
|
+
confirmButton = CapsuleButton(
|
|
233
|
+
this,
|
|
234
|
+
title = intent.getStringExtra(EXTRA_CONFIRM_TEXT)?.takeIf { it.isNotEmpty() }
|
|
235
|
+
?: getString(android.R.string.ok),
|
|
236
|
+
textSize = buttonTextSize,
|
|
237
|
+
typeface = buttonTypeface,
|
|
238
|
+
textColor = theme.confirmButtonTextColor,
|
|
239
|
+
backgroundColor = theme.confirmButtonColor,
|
|
240
|
+
pressedColor = theme.confirmButtonPressedColor,
|
|
241
|
+
horizontalPadding = buttonPadding,
|
|
242
|
+
spinnerSize = dp(20f),
|
|
243
|
+
spinnerSpacing = dp(8f),
|
|
244
|
+
).apply { setOnClickListener { confirm() } }
|
|
245
|
+
footer.addView(cancelButton, LinearLayout.LayoutParams(0, buttonHeight, 1f))
|
|
246
|
+
footer.addView(
|
|
247
|
+
confirmButton,
|
|
248
|
+
LinearLayout.LayoutParams(0, buttonHeight, 1f).apply { marginStart = dp(10f) },
|
|
249
|
+
)
|
|
250
|
+
column.addView(
|
|
251
|
+
footer,
|
|
252
|
+
LinearLayout.LayoutParams(
|
|
253
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
254
|
+
ViewGroup.LayoutParams.WRAP_CONTENT,
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
val navigationBarReduction = (10 * resources.displayMetrics.density).roundToInt()
|
|
259
|
+
ViewCompat.setOnApplyWindowInsetsListener(column) { _, windowInsets ->
|
|
260
|
+
val insets = windowInsets.getInsets(
|
|
261
|
+
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
|
|
262
|
+
)
|
|
263
|
+
column.setPadding(insets.left, 0, insets.right, 0)
|
|
264
|
+
header.setPadding(0, insets.top, 0, 0)
|
|
265
|
+
header.layoutParams = header.layoutParams.apply { height = headerHeight + insets.top }
|
|
266
|
+
// Same as OneKey's page footer: 20dp of padding, plus the navigation bar
|
|
267
|
+
// inset less 10dp.
|
|
268
|
+
val bottomInset = if (insets.bottom > navigationBarReduction) {
|
|
269
|
+
insets.bottom - navigationBarReduction
|
|
270
|
+
} else {
|
|
271
|
+
insets.bottom
|
|
272
|
+
}
|
|
273
|
+
footer.setPadding(spacing, 0, spacing, spacing + bottomInset)
|
|
274
|
+
WindowInsetsCompat.CONSUMED
|
|
275
|
+
}
|
|
276
|
+
return column
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private fun configureCropView() {
|
|
280
|
+
val isCircular = intent.getBooleanExtra(EXTRA_CIRCLE_OVERLAY, false)
|
|
281
|
+
val isFreeStyle = intent.getBooleanExtra(EXTRA_FREE_STYLE, false) && !isCircular
|
|
282
|
+
val aspectRatioX = intent.getFloatExtra(EXTRA_ASPECT_RATIO_X, 0f)
|
|
283
|
+
val aspectRatioY = intent.getFloatExtra(EXTRA_ASPECT_RATIO_Y, 0f)
|
|
284
|
+
|
|
285
|
+
cropImageView.isRotateEnabled = false
|
|
286
|
+
cropImageView.isScaleEnabled = true
|
|
287
|
+
cropImageView.targetAspectRatio = when {
|
|
288
|
+
isCircular -> 1f
|
|
289
|
+
aspectRatioX > 0f && aspectRatioY > 0f -> aspectRatioX / aspectRatioY
|
|
290
|
+
else -> CropImageView.SOURCE_IMAGE_ASPECT_RATIO
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
val density = resources.displayMetrics.density
|
|
294
|
+
overlayView.freestyleCropMode = if (isFreeStyle) {
|
|
295
|
+
OverlayView.FREESTYLE_CROP_MODE_ENABLE
|
|
296
|
+
} else {
|
|
297
|
+
OverlayView.FREESTYLE_CROP_MODE_DISABLE
|
|
298
|
+
}
|
|
299
|
+
overlayView.setCircleDimmedLayer(isCircular)
|
|
300
|
+
overlayView.setDimmedColor(withAlpha(cropperTheme.backgroundColor, DIMMED_ALPHA))
|
|
301
|
+
overlayView.setShowCropFrame(!isCircular)
|
|
302
|
+
overlayView.setCropFrameColor(cropperTheme.titleColor)
|
|
303
|
+
overlayView.setCropGridCornerColor(cropperTheme.titleColor)
|
|
304
|
+
overlayView.setCropFrameStrokeWidth(density.roundToInt().coerceAtLeast(1))
|
|
305
|
+
// A hairline grid that only shows while the image is moved, like iOS.
|
|
306
|
+
showsGrid = intent.getBooleanExtra(EXTRA_SHOW_GRID, true) && !isCircular
|
|
307
|
+
overlayView.setShowCropGrid(showsGrid)
|
|
308
|
+
overlayView.setCropGridStrokeWidth(1)
|
|
309
|
+
overlayView.setCropGridColor(withAlpha(GRID_COLOR, 0f))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private fun onCropTouchStart() {
|
|
313
|
+
cropView.removeCallbacks(hideGrid)
|
|
314
|
+
animateGrid(visible = true)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private fun onCropTouchEnd() {
|
|
318
|
+
cropView.removeCallbacks(hideGrid)
|
|
319
|
+
cropView.postDelayed(hideGrid, GRID_HIDE_DELAY)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private fun animateGrid(visible: Boolean) {
|
|
323
|
+
if (!showsGrid) {
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
val target = if (visible) 1f else 0f
|
|
327
|
+
gridAnimator?.cancel()
|
|
328
|
+
if (gridAlpha == target) {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
gridAnimator = ValueAnimator.ofFloat(gridAlpha, target).apply {
|
|
332
|
+
duration = if (visible) GRID_FADE_IN_DURATION else GRID_FADE_OUT_DURATION
|
|
333
|
+
addUpdateListener { animator ->
|
|
334
|
+
gridAlpha = animator.animatedValue as Float
|
|
335
|
+
overlayView.setCropGridColor(withAlpha(GRID_COLOR, gridAlpha))
|
|
336
|
+
overlayView.invalidate()
|
|
337
|
+
}
|
|
338
|
+
start()
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private fun rotate() {
|
|
343
|
+
if (!isImageLoaded || isProcessing || rotationAnimator != null) {
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
cropImageView.cancelAllAnimations()
|
|
347
|
+
var appliedAngle = 0f
|
|
348
|
+
rotationAnimator = ValueAnimator.ofFloat(0f, -90f).apply {
|
|
349
|
+
duration = ROTATION_DURATION
|
|
350
|
+
interpolator = DecelerateInterpolator()
|
|
351
|
+
addUpdateListener { animator ->
|
|
352
|
+
val angle = animator.animatedValue as Float
|
|
353
|
+
cropImageView.postRotate(angle - appliedAngle)
|
|
354
|
+
appliedAngle = angle
|
|
355
|
+
}
|
|
356
|
+
addListener(
|
|
357
|
+
object : AnimatorListenerAdapter() {
|
|
358
|
+
override fun onAnimationEnd(animation: Animator) {
|
|
359
|
+
rotationAnimator = null
|
|
360
|
+
cropImageView.setImageToWrapCropBounds()
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
start()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private fun cancel() {
|
|
369
|
+
if (isProcessing) {
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
setResult(RESULT_CANCELED)
|
|
373
|
+
finish()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private fun confirm() {
|
|
377
|
+
if (!isImageLoaded || isProcessing || rotationAnimator != null) {
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
isProcessing = true
|
|
381
|
+
confirmButton.isLoading = true
|
|
382
|
+
rotateButton.alpha = DISABLED_ALPHA
|
|
383
|
+
cropImageView.cropAndSaveImage(
|
|
384
|
+
Bitmap.CompressFormat.JPEG,
|
|
385
|
+
100,
|
|
386
|
+
object : BitmapCropCallback {
|
|
387
|
+
override fun onBitmapCropped(
|
|
388
|
+
resultUri: Uri,
|
|
389
|
+
offsetX: Int,
|
|
390
|
+
offsetY: Int,
|
|
391
|
+
imageWidth: Int,
|
|
392
|
+
imageHeight: Int,
|
|
393
|
+
) {
|
|
394
|
+
setResult(
|
|
395
|
+
RESULT_OK,
|
|
396
|
+
Intent()
|
|
397
|
+
.putExtra(EXTRA_OUTPUT_URI, resultUri)
|
|
398
|
+
.putExtra(EXTRA_OUTPUT_OFFSET_X, offsetX)
|
|
399
|
+
.putExtra(EXTRA_OUTPUT_OFFSET_Y, offsetY)
|
|
400
|
+
.putExtra(EXTRA_OUTPUT_WIDTH, imageWidth)
|
|
401
|
+
.putExtra(EXTRA_OUTPUT_HEIGHT, imageHeight),
|
|
402
|
+
)
|
|
403
|
+
finish()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
override fun onCropFailure(error: Throwable) {
|
|
407
|
+
finishWithError(error.message ?: "Cannot crop image")
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private fun finishWithError(message: String) {
|
|
414
|
+
setResult(RESULT_ERROR, Intent().putExtra(EXTRA_ERROR_MESSAGE, message))
|
|
415
|
+
finish()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private fun dp(value: Float): Int =
|
|
419
|
+
(value * cropperTheme.scale * resources.displayMetrics.density).roundToInt()
|
|
420
|
+
|
|
421
|
+
private fun loadTypeface(family: String?, fallbackWeight: Int): Typeface {
|
|
422
|
+
if (family != null) {
|
|
423
|
+
return ReactFontManager.getInstance().getTypeface(family, Typeface.NORMAL, assets)
|
|
424
|
+
}
|
|
425
|
+
return when {
|
|
426
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ->
|
|
427
|
+
Typeface.create(Typeface.DEFAULT, fallbackWeight, false)
|
|
428
|
+
fallbackWeight >= 600 -> Typeface.DEFAULT_BOLD
|
|
429
|
+
else -> Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Shows the grid while the image is being moved, and blocks gestures while
|
|
434
|
+
// the image is cropped.
|
|
435
|
+
@SuppressLint("ViewConstructor")
|
|
436
|
+
private inner class CropContainer(context: Context) : FrameLayout(context) {
|
|
437
|
+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
|
438
|
+
when (event.actionMasked) {
|
|
439
|
+
MotionEvent.ACTION_DOWN -> onCropTouchStart()
|
|
440
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> onCropTouchEnd()
|
|
441
|
+
}
|
|
442
|
+
return super.dispatchTouchEvent(event)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
override fun onInterceptTouchEvent(event: MotionEvent): Boolean =
|
|
446
|
+
isProcessing || rotationAnimator != null || super.onInterceptTouchEvent(event)
|
|
447
|
+
|
|
448
|
+
@SuppressLint("ClickableViewAccessibility")
|
|
449
|
+
override fun onTouchEvent(event: MotionEvent): Boolean =
|
|
450
|
+
isProcessing || rotationAnimator != null || super.onTouchEvent(event)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// OneKey's large Button: a capsule with a 16dp medium label, a pressed
|
|
454
|
+
// color, and a spinner next to the label while loading.
|
|
455
|
+
@SuppressLint("ViewConstructor")
|
|
456
|
+
private class CapsuleButton(
|
|
457
|
+
context: Context,
|
|
458
|
+
title: String,
|
|
459
|
+
textSize: Float,
|
|
460
|
+
typeface: Typeface,
|
|
461
|
+
textColor: Int,
|
|
462
|
+
backgroundColor: Int,
|
|
463
|
+
pressedColor: Int,
|
|
464
|
+
horizontalPadding: Int,
|
|
465
|
+
spinnerSize: Int,
|
|
466
|
+
spinnerSpacing: Int,
|
|
467
|
+
) : LinearLayout(context) {
|
|
468
|
+
private val spinner = ProgressBar(context).apply {
|
|
469
|
+
isIndeterminate = true
|
|
470
|
+
indeterminateTintList = ColorStateList.valueOf(textColor)
|
|
471
|
+
visibility = View.GONE
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
var isLoading = false
|
|
475
|
+
set(value) {
|
|
476
|
+
field = value
|
|
477
|
+
spinner.visibility = if (value) View.VISIBLE else View.GONE
|
|
478
|
+
// OneKey dims disabled and loading buttons alike.
|
|
479
|
+
alpha = if (value) DISABLED_ALPHA else 1f
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
init {
|
|
483
|
+
orientation = HORIZONTAL
|
|
484
|
+
gravity = Gravity.CENTER
|
|
485
|
+
setPadding(horizontalPadding, 0, horizontalPadding, 0)
|
|
486
|
+
background = pressable(capsule(backgroundColor), capsule(pressedColor))
|
|
487
|
+
contentDescription = title
|
|
488
|
+
addView(
|
|
489
|
+
spinner,
|
|
490
|
+
LayoutParams(spinnerSize, spinnerSize).apply { marginEnd = spinnerSpacing },
|
|
491
|
+
)
|
|
492
|
+
addView(
|
|
493
|
+
TextView(context).apply {
|
|
494
|
+
text = title
|
|
495
|
+
setTextColor(textColor)
|
|
496
|
+
setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
|
|
497
|
+
this.typeface = typeface
|
|
498
|
+
maxLines = 1
|
|
499
|
+
ellipsize = TextUtils.TruncateAt.END
|
|
500
|
+
includeFontPadding = false
|
|
501
|
+
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
502
|
+
},
|
|
503
|
+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT),
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
override fun getAccessibilityClassName(): CharSequence = Button::class.java.name
|
|
508
|
+
|
|
509
|
+
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
|
|
510
|
+
super.onInitializeAccessibilityNodeInfo(info)
|
|
511
|
+
info.isEnabled = !isLoading
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// OneKey's header icon button: a 24dp icon with a round pressed background.
|
|
516
|
+
@SuppressLint("ViewConstructor")
|
|
517
|
+
private class IconButton(
|
|
518
|
+
context: Context,
|
|
519
|
+
iconSize: Int,
|
|
520
|
+
iconColor: Int,
|
|
521
|
+
pressedColor: Int,
|
|
522
|
+
) : FrameLayout(context) {
|
|
523
|
+
init {
|
|
524
|
+
background = pressable(
|
|
525
|
+
ColorDrawable(Color.TRANSPARENT),
|
|
526
|
+
GradientDrawable().apply {
|
|
527
|
+
shape = GradientDrawable.OVAL
|
|
528
|
+
setColor(pressedColor)
|
|
529
|
+
},
|
|
530
|
+
)
|
|
531
|
+
addView(
|
|
532
|
+
ImageView(context).apply {
|
|
533
|
+
setImageResource(R.drawable.image_crop_picker_rotate)
|
|
534
|
+
imageTintList = ColorStateList.valueOf(iconColor)
|
|
535
|
+
scaleType = ImageView.ScaleType.FIT_CENTER
|
|
536
|
+
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
537
|
+
},
|
|
538
|
+
LayoutParams(iconSize, iconSize, Gravity.CENTER),
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
override fun getAccessibilityClassName(): CharSequence = Button::class.java.name
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
companion object {
|
|
546
|
+
private const val PREFIX = "com.margelo.nitro.reactnativeimagecroppicker.cropper."
|
|
547
|
+
private const val EXTRA_INPUT_URI = "${PREFIX}inputUri"
|
|
548
|
+
private const val EXTRA_ASPECT_RATIO_X = "${PREFIX}aspectRatioX"
|
|
549
|
+
private const val EXTRA_ASPECT_RATIO_Y = "${PREFIX}aspectRatioY"
|
|
550
|
+
private const val EXTRA_FREE_STYLE = "${PREFIX}freeStyle"
|
|
551
|
+
private const val EXTRA_CIRCLE_OVERLAY = "${PREFIX}circleOverlay"
|
|
552
|
+
private const val EXTRA_SHOW_GRID = "${PREFIX}showGrid"
|
|
553
|
+
private const val EXTRA_ROTATE_BUTTON_HIDDEN = "${PREFIX}rotateButtonHidden"
|
|
554
|
+
private const val EXTRA_TITLE = "${PREFIX}title"
|
|
555
|
+
private const val EXTRA_CANCEL_TEXT = "${PREFIX}cancelText"
|
|
556
|
+
private const val EXTRA_CONFIRM_TEXT = "${PREFIX}confirmText"
|
|
557
|
+
private const val EXTRA_REQUESTED_ORIENTATION = "${PREFIX}requestedOrientation"
|
|
558
|
+
|
|
559
|
+
const val EXTRA_OUTPUT_URI = "${PREFIX}outputUri"
|
|
560
|
+
const val EXTRA_OUTPUT_OFFSET_X = "${PREFIX}outputOffsetX"
|
|
561
|
+
const val EXTRA_OUTPUT_OFFSET_Y = "${PREFIX}outputOffsetY"
|
|
562
|
+
const val EXTRA_OUTPUT_WIDTH = "${PREFIX}outputWidth"
|
|
563
|
+
const val EXTRA_OUTPUT_HEIGHT = "${PREFIX}outputHeight"
|
|
564
|
+
const val EXTRA_ERROR_MESSAGE = "${PREFIX}errorMessage"
|
|
565
|
+
const val RESULT_ERROR = RESULT_FIRST_USER + 1
|
|
566
|
+
|
|
567
|
+
// The area outside the crop box shows the page background at this opacity.
|
|
568
|
+
private const val DIMMED_ALPHA = 0.7f
|
|
569
|
+
private const val DISABLED_ALPHA = 0.4f
|
|
570
|
+
private const val GRID_COLOR = Color.WHITE
|
|
571
|
+
private const val GRID_HIDE_DELAY = 800L
|
|
572
|
+
private const val GRID_FADE_IN_DURATION = 200L
|
|
573
|
+
private const val GRID_FADE_OUT_DURATION = 350L
|
|
574
|
+
private const val ROTATION_DURATION = 250L
|
|
575
|
+
private const val FADE_IN_DURATION = 300L
|
|
576
|
+
private val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
|
|
577
|
+
|
|
578
|
+
internal fun createIntent(
|
|
579
|
+
context: Context,
|
|
580
|
+
source: Uri,
|
|
581
|
+
destination: Uri,
|
|
582
|
+
config: ImageCropPickerConfig,
|
|
583
|
+
theme: ImageCropperTheme,
|
|
584
|
+
requestedOrientation: Int,
|
|
585
|
+
): Intent {
|
|
586
|
+
val intent = Intent(context, ImageCropperActivity::class.java)
|
|
587
|
+
.putExtra(EXTRA_INPUT_URI, source)
|
|
588
|
+
.putExtra(EXTRA_OUTPUT_URI, destination)
|
|
589
|
+
.putExtra(EXTRA_FREE_STYLE, config.freeStyleCropEnabled)
|
|
590
|
+
.putExtra(EXTRA_CIRCLE_OVERLAY, config.cropperCircleOverlay)
|
|
591
|
+
.putExtra(EXTRA_SHOW_GRID, config.showCropGuidelines)
|
|
592
|
+
.putExtra(EXTRA_ROTATE_BUTTON_HIDDEN, config.cropperRotateButtonsHidden)
|
|
593
|
+
.putExtra(EXTRA_TITLE, config.cropperToolbarTitle)
|
|
594
|
+
.putExtra(EXTRA_CANCEL_TEXT, config.cropperCancelText)
|
|
595
|
+
.putExtra(EXTRA_CONFIRM_TEXT, config.cropperChooseText)
|
|
596
|
+
.putExtra(EXTRA_REQUESTED_ORIENTATION, requestedOrientation)
|
|
597
|
+
if (config.width != null && config.height != null) {
|
|
598
|
+
intent.putExtra(EXTRA_ASPECT_RATIO_X, config.width.toFloat())
|
|
599
|
+
.putExtra(EXTRA_ASPECT_RATIO_Y, config.height.toFloat())
|
|
600
|
+
}
|
|
601
|
+
theme.writeTo(intent)
|
|
602
|
+
return intent
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
internal fun getOutputUri(data: Intent): Uri? = data.uriExtra(EXTRA_OUTPUT_URI)
|
|
606
|
+
|
|
607
|
+
private fun Intent.uriExtra(name: String): Uri? =
|
|
608
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
609
|
+
getParcelableExtra(name, Uri::class.java)
|
|
610
|
+
} else {
|
|
611
|
+
@Suppress("DEPRECATION")
|
|
612
|
+
getParcelableExtra(name)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private fun withAlpha(color: Int, alpha: Float): Int =
|
|
616
|
+
Color.argb(
|
|
617
|
+
(Color.alpha(color) * alpha).roundToInt(),
|
|
618
|
+
Color.red(color),
|
|
619
|
+
Color.green(color),
|
|
620
|
+
Color.blue(color),
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
private fun capsule(color: Int): Drawable =
|
|
624
|
+
GradientDrawable().apply {
|
|
625
|
+
// Clamped to half the height, which makes a capsule.
|
|
626
|
+
cornerRadius = 10_000f
|
|
627
|
+
setColor(color)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private fun pressable(normal: Drawable, pressed: Drawable): Drawable =
|
|
631
|
+
StateListDrawable().apply {
|
|
632
|
+
addState(intArrayOf(android.R.attr.state_pressed), pressed)
|
|
633
|
+
addState(intArrayOf(), normal)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|