react-native-video-trim 6.2.3 → 7.0.0

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 CHANGED
@@ -14,7 +14,8 @@
14
14
  - [Advanced Features](#advanced-features)
15
15
  * [Audio Trimming](#audio-trimming)
16
16
  * [Remote Files (HTTPS)](#remote-files-https)
17
- * [Video Rotation](#video-rotation)
17
+ * [Video Transforms (Flip, Rotate, Crop)](#video-transforms-flip-rotate-crop)
18
+ * [Precise Frame Trimming](#precise-frame-trimming)
18
19
  - [Examples](#examples)
19
20
  - [Troubleshooting](#troubleshooting)
20
21
 
@@ -40,6 +41,8 @@ A powerful, easy-to-use video and audio trimming library for React Native applic
40
41
  ### ✨ Key Features
41
42
 
42
43
  - **📹 Video & Audio Support** - Trim both video and audio files
44
+ - **🔄 Flip, Rotate & Crop** - Built-in video transforms with undo/redo support
45
+ - **🎯 Precise Trimming** - Optional frame-accurate cuts via hardware-accelerated re-encoding
43
46
  - **🌐 Local & Remote Files** - Support for local storage and HTTPS URLs
44
47
  - **💾 Multiple Save Options** - Photos, Documents, or Share to other apps
45
48
  - **✅ File Validation** - Built-in validation for media files
@@ -50,7 +53,9 @@ A powerful, easy-to-use video and audio trimming library for React Native applic
50
53
 
51
54
  | Feature | Description |
52
55
  |---------|-------------|
53
- | **Trimming** | Precise video/audio trimming with visual controls |
56
+ | **Trimming** | Video/audio trimming with visual timeline controls |
57
+ | **Transforms** | Horizontal flip, 90° rotation, and freeform crop with undo/redo |
58
+ | **Precise Trimming** | Frame-accurate cuts using hardware re-encoding (opt-in) |
54
59
  | **Validation** | Check if files are valid video/audio before processing |
55
60
  | **Save Options** | Photos, Documents, Share sheet integration |
56
61
  | **File Management** | Complete file lifecycle management |
@@ -344,8 +349,7 @@ All configuration options are optional. Here are the most commonly used ones:
344
349
  |--------|------|---------|-------------|
345
350
  | `enableHapticFeedback` | `boolean` | `true` | Enable haptic feedback |
346
351
  | `closeWhenFinish` | `boolean` | `true` | Close editor when done |
347
- | `enableRotation` | `boolean` | `false` | Enable video rotation |
348
- | `rotationAngle` | `number` | `0` | Rotation angle in degrees |
352
+ | `enablePreciseTrimming` | `boolean` | `false` | Re-encode for frame-accurate cuts (slower, see [Precise Frame Trimming](#precise-frame-trimming)) |
349
353
  | `changeStatusBarColorOnOpen` | `boolean` | `false` | Change status bar color (Android only) |
350
354
  | `zoomOnWaitingDuration` | `number` | `5000` | Duration for zoom-on-waiting feature in milliseconds (default: 5000) |
351
355
 
@@ -438,18 +442,39 @@ showEditor('https://example.com/video.mp4', {
438
442
  });
439
443
  ```
440
444
 
441
- ### Video Rotation
445
+ ### Video Transforms (Flip, Rotate, Crop)
442
446
 
443
- Rotate videos during trimming using metadata (doesn't re-encode):
447
+ The editor includes built-in transform controls horizontal flip, 90° left rotation, and freeform crop — with full undo/redo support. These appear as toolbar buttons in the editor UI on both iOS and Android.
448
+
449
+ When any transform is applied, FFmpeg automatically re-encodes the video using the platform's hardware encoder (`h264_videotoolbox` on iOS, `h264_mediacodec` on Android) at the source bitrate to preserve quality. No additional configuration is needed.
450
+
451
+ ### Precise Frame Trimming
452
+
453
+ By default, trimming uses FFmpeg's stream copy (`-c copy`), which is very fast but can only cut at keyframes. The actual start/end points may drift by several seconds from what the user selected.
454
+
455
+ Enable `enablePreciseTrimming` for frame-accurate cuts:
444
456
 
445
457
  ```javascript
458
+ // Editor mode
446
459
  showEditor(videoUrl, {
447
- enableRotation: true,
448
- rotationAngle: 90, // 90, 180, 270 degrees
460
+ enablePreciseTrimming: true,
461
+ });
462
+
463
+ // Headless mode
464
+ const result = await trim(videoUrl, {
465
+ startTime: 5000,
466
+ endTime: 15000,
467
+ enablePreciseTrimming: true,
449
468
  });
450
469
  ```
451
470
 
452
- **Note:** Uses `display_rotation` metadata - playback may vary by platform/player.
471
+ | | `enablePreciseTrimming: false` (default) | `enablePreciseTrimming: true` |
472
+ |---|---|---|
473
+ | **Speed** | Very fast (stream copy) | Slower (hardware re-encode) |
474
+ | **Accuracy** | Keyframe-aligned (may drift 1-5s) | Frame-accurate |
475
+ | **Quality** | Lossless (original bitstream) | Near-lossless (matched bitrate) |
476
+
477
+ **Note:** When transforms (flip/rotate/crop) are applied, re-encoding already happens regardless of this flag, so precise trimming comes for free in that case.
453
478
 
454
479
  ### Trimming Progress & Cancellation
455
480
 
@@ -11,6 +11,8 @@ import android.content.Intent
11
11
  import android.content.pm.PackageManager
12
12
  import android.content.res.ColorStateList
13
13
  import android.graphics.Color
14
+ import android.media.MediaMetadataRetriever
15
+ import android.net.Uri
14
16
  import android.os.Build
15
17
  import android.util.Log
16
18
  import android.util.TypedValue
@@ -581,10 +583,6 @@ open class BaseVideoTrimModule internal constructor(
581
583
  "${endTime}ms",
582
584
  )
583
585
 
584
- if (options?.getBoolean("enableRotation") == true) {
585
- cmds += arrayOf("-display_rotation", "${options.getDouble("rotationAngle")}")
586
- }
587
-
588
586
  val outputFile = StorageUtil.getOutputPath(reactApplicationContext, options?.getString("outputExt") ?: "mp4")
589
587
 
590
588
  val resolvedOutputFile = outputFile ?: run {
@@ -592,15 +590,42 @@ open class BaseVideoTrimModule internal constructor(
592
590
  return
593
591
  }
594
592
 
595
- cmds += arrayOf(
596
- "-i",
597
- url,
598
- "-c",
599
- "copy",
600
- "-metadata",
601
- "creation_time=$formattedDateTime",
602
- resolvedOutputFile
603
- )
593
+ // Headless trim: no editor UI, so no transforms (flip/rotate/crop) are possible.
594
+ // The only reason to re-encode here is enablePreciseTrimming for frame-accurate cuts.
595
+ val enablePrecise = options?.hasKey("enablePreciseTrimming") == true &&
596
+ options.getBoolean("enablePreciseTrimming")
597
+
598
+ if (enablePrecise) {
599
+ // Match source bitrate to preserve quality; fall back to 10 Mbps.
600
+ var bitrateStr = "10M"
601
+ try {
602
+ val retriever = MediaMetadataRetriever()
603
+ retriever.setDataSource(reactApplicationContext, Uri.parse(url))
604
+ val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
605
+ ?.toLongOrNull() ?: 0L
606
+ if (bitrate > 0) bitrateStr = "$bitrate"
607
+ retriever.release()
608
+ } catch (_: Exception) {}
609
+
610
+ // h264_mediacodec: Android's hardware H.264 encoder.
611
+ // No -noautorotate needed — FFmpegKit on Android auto-rotates correctly.
612
+ cmds += arrayOf(
613
+ "-i", url,
614
+ "-c:v", "h264_mediacodec",
615
+ "-b:v", bitrateStr,
616
+ "-c:a", "copy",
617
+ "-metadata", "creation_time=$formattedDateTime",
618
+ resolvedOutputFile
619
+ )
620
+ } else {
621
+ // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
622
+ cmds += arrayOf(
623
+ "-i", url,
624
+ "-c", "copy",
625
+ "-metadata", "creation_time=$formattedDateTime",
626
+ resolvedOutputFile
627
+ )
628
+ }
604
629
 
605
630
  Log.d(TAG, "Command: ${cmds.joinToString(",")}")
606
631
 
@@ -2,6 +2,7 @@ package com.videotrim.utils
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.graphics.Bitmap
5
+ import android.graphics.RectF
5
6
  import android.media.MediaMetadataRetriever
6
7
  import android.util.Log
7
8
  import com.arthenica.ffmpegkit.FFmpegKit
@@ -18,6 +19,7 @@ import iknow.android.utils.thread.BackgroundExecutor
18
19
  import java.text.SimpleDateFormat
19
20
  import java.util.Date
20
21
  import java.util.TimeZone
22
+ import kotlin.math.roundToInt
21
23
 
22
24
  object VideoTrimmerUtil {
23
25
 
@@ -42,8 +44,13 @@ object VideoTrimmerUtil {
42
44
  videoDuration: Int,
43
45
  startMs: Long,
44
46
  endMs: Long,
45
- enableRotation: Boolean,
46
- rotationAngle: Double,
47
+ userRotationCount: Int,
48
+ userIsFlipped: Boolean,
49
+ cropNormalized: RectF?,
50
+ videoWidth: Int,
51
+ videoHeight: Int,
52
+ videoBitrate: Long,
53
+ enablePreciseTrimming: Boolean,
47
54
  callback: VideoTrimListener
48
55
  ): FFmpegSession {
49
56
  val currentDate = Date()
@@ -58,18 +65,75 @@ object VideoTrimmerUtil {
58
65
  cmds.add("-to")
59
66
  cmds.add("${endMs}ms")
60
67
 
61
- if (enableRotation) {
62
- cmds.add("-display_rotation")
63
- cmds.add(rotationAngle.toString())
64
- }
68
+ val hasUserTransform = userRotationCount != 0 || userIsFlipped
69
+ // Re-encode is required when: (1) user applied flip/rotate, (2) user cropped, or
70
+ // (3) enablePreciseTrimming is on. In all three cases, -c copy won't work because
71
+ // either we need video filters or we need frame-accurate cut points.
72
+ val needsReEncode = hasUserTransform || cropNormalized != null || enablePreciseTrimming
73
+
74
+ if (needsReEncode) {
75
+ val videoFilters = mutableListOf<String>()
76
+
77
+ when (userRotationCount) {
78
+ 1 -> videoFilters.add("transpose=2")
79
+ 2 -> { videoFilters.add("transpose=2"); videoFilters.add("transpose=2") }
80
+ 3 -> videoFilters.add("transpose=1")
81
+ }
82
+ if (userIsFlipped) {
83
+ videoFilters.add("hflip")
84
+ }
85
+
86
+ // Convert normalized crop rect [0..1] to pixel coordinates in the post-rotation frame.
87
+ if (cropNormalized != null && videoWidth > 0 && videoHeight > 0) {
88
+ val postW: Int
89
+ val postH: Int
90
+ // After 90°/270° rotation the width and height are swapped.
91
+ if (userRotationCount % 2 != 0) {
92
+ postW = videoHeight; postH = videoWidth
93
+ } else {
94
+ postW = videoWidth; postH = videoHeight
95
+ }
96
+ val cx = (cropNormalized.left * postW).roundToInt()
97
+ val cy = (cropNormalized.top * postH).roundToInt()
98
+ var cw = (cropNormalized.width() * postW).roundToInt()
99
+ var ch = (cropNormalized.height() * postH).roundToInt()
100
+ // H.264 requires even dimensions; round down to nearest even number.
101
+ cw = cw and 1.inv()
102
+ ch = ch and 1.inv()
103
+ if (cw > 0 && ch > 0) {
104
+ videoFilters.add("crop=$cw:$ch:$cx:$cy")
105
+ }
106
+ }
65
107
 
66
- cmds.add("-i")
67
- cmds.add(inputFile)
68
- cmds.add("-c")
69
- cmds.add("copy")
70
- cmds.add("-metadata")
71
- cmds.add("creation_time=$formattedDateTime")
72
- cmds.add(outputFile)
108
+ val filterString = videoFilters.joinToString(",")
109
+ // Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
110
+ val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
111
+
112
+ cmds.addAll(listOf("-i", inputFile))
113
+ // When enablePreciseTrimming is the only reason for re-encode (no transforms),
114
+ // videoFilters is empty — skip -vf entirely to avoid FFmpeg error on empty filter.
115
+ if (filterString.isNotEmpty()) {
116
+ cmds.addAll(listOf("-vf", filterString))
117
+ }
118
+ // h264_mediacodec: Android's hardware H.264 encoder — fast and energy-efficient.
119
+ // Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
120
+ // The transpose filters above only handle user-initiated rotation, not source metadata.
121
+ cmds.addAll(listOf(
122
+ "-c:v", "h264_mediacodec",
123
+ "-b:v", bitrateStr,
124
+ "-c:a", "copy",
125
+ "-metadata", "creation_time=$formattedDateTime",
126
+ outputFile
127
+ ))
128
+ } else {
129
+ // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
130
+ cmds.addAll(listOf(
131
+ "-i", inputFile,
132
+ "-c", "copy",
133
+ "-metadata", "creation_time=$formattedDateTime",
134
+ outputFile
135
+ ))
136
+ }
73
137
 
74
138
  val command = cmds.toTypedArray()
75
139
  val cmdStr = "Command: ${command.joinToString(" ")}"
@@ -0,0 +1,293 @@
1
+ package com.videotrim.widgets
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Path
8
+ import android.graphics.RectF
9
+ import android.graphics.Region
10
+ import android.os.Build
11
+ import android.util.AttributeSet
12
+ import android.util.TypedValue
13
+ import android.view.MotionEvent
14
+ import android.view.ScaleGestureDetector
15
+ import android.view.View
16
+ import kotlin.math.abs
17
+ import kotlin.math.max
18
+ import kotlin.math.min
19
+
20
+ class CropOverlayView @JvmOverloads constructor(
21
+ context: Context,
22
+ attrs: AttributeSet? = null,
23
+ defStyleAttr: Int = 0
24
+ ) : View(context, attrs, defStyleAttr) {
25
+
26
+ var cropRect = RectF()
27
+ set(value) {
28
+ field.set(value)
29
+ invalidate()
30
+ }
31
+
32
+ var allowedRect = RectF()
33
+ set(value) {
34
+ field.set(value)
35
+ if (cropRect.isEmpty) {
36
+ cropRect = RectF(value)
37
+ } else {
38
+ val clamped = RectF()
39
+ if (!clamped.setIntersect(cropRect, value) || clamped.width() < minCropSize || clamped.height() < minCropSize) {
40
+ cropRect = RectF(value)
41
+ }
42
+ }
43
+ }
44
+
45
+ var onCropChanged: (() -> Unit)? = null
46
+ var onCropBegan: (() -> Unit)? = null
47
+ var onCropEnded: (() -> Unit)? = null
48
+
49
+ private val minCropSize = dpToPx(60f)
50
+ private val borderWidth = dpToPx(1f)
51
+ private val cornerLength = dpToPx(20f)
52
+ private val cornerWidth = dpToPx(4f)
53
+ private val gridLineWidth = 1f / resources.displayMetrics.density
54
+ private val edgeHitZone = dpToPx(30f)
55
+
56
+ private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
57
+ style = Paint.Style.STROKE
58
+ color = Color.argb(153, 255, 255, 255)
59
+ strokeWidth = borderWidth
60
+ }
61
+ private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
62
+ style = Paint.Style.STROKE
63
+ color = Color.WHITE
64
+ strokeWidth = gridLineWidth
65
+ }
66
+ private val cornerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
67
+ style = Paint.Style.STROKE
68
+ color = Color.WHITE
69
+ strokeWidth = cornerWidth
70
+ strokeCap = Paint.Cap.ROUND
71
+ }
72
+ private val dimmingPaint = Paint().apply {
73
+ color = Color.argb(140, 0, 0, 0)
74
+ style = Paint.Style.FILL
75
+ }
76
+
77
+ private enum class DragEdge {
78
+ TOP, BOTTOM, LEFT, RIGHT,
79
+ TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT,
80
+ MOVE
81
+ }
82
+
83
+ private var activeEdge: DragEdge? = null
84
+ private var dragStartX = 0f
85
+ private var dragStartY = 0f
86
+ private var dragStartRect = RectF()
87
+ private var gestureStarted = false
88
+
89
+ private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
90
+ private var pinchStartRect = RectF()
91
+
92
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
93
+ pinchStartRect.set(cropRect)
94
+ if (!gestureStarted) {
95
+ gestureStarted = true
96
+ onCropBegan?.invoke()
97
+ }
98
+ return true
99
+ }
100
+
101
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
102
+ val scale = detector.scaleFactor
103
+ val cx = pinchStartRect.centerX()
104
+ val cy = pinchStartRect.centerY()
105
+ var newW = max(pinchStartRect.width() * scale, minCropSize)
106
+ var newH = max(pinchStartRect.height() * scale, minCropSize)
107
+ newW = min(newW, allowedRect.width())
108
+ newH = min(newH, allowedRect.height())
109
+ val r = RectF(cx - newW / 2, cy - newH / 2, cx + newW / 2, cy + newH / 2)
110
+ clamp(r, isMove = true)
111
+ cropRect = r
112
+ onCropChanged?.invoke()
113
+ return true
114
+ }
115
+
116
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
117
+ if (gestureStarted) {
118
+ gestureStarted = false
119
+ onCropEnded?.invoke()
120
+ }
121
+ }
122
+ })
123
+
124
+ override fun onDraw(canvas: Canvas) {
125
+ super.onDraw(canvas)
126
+ if (cropRect.isEmpty) return
127
+ val cr = cropRect
128
+
129
+ drawDimming(canvas, cr)
130
+
131
+ canvas.drawRect(cr, borderPaint)
132
+
133
+ for (i in 1..2) {
134
+ val x = cr.left + cr.width() * i / 3f
135
+ canvas.drawLine(x, cr.top, x, cr.bottom, gridPaint)
136
+ }
137
+ for (i in 1..2) {
138
+ val y = cr.top + cr.height() * i / 3f
139
+ canvas.drawLine(cr.left, y, cr.right, y, gridPaint)
140
+ }
141
+
142
+ val cl = cornerLength
143
+ val path = Path()
144
+ fun addCorner(sx: Float, sy: Float, cx: Float, cy: Float, ex: Float, ey: Float) {
145
+ path.moveTo(sx, sy); path.lineTo(cx, cy); path.lineTo(ex, ey)
146
+ }
147
+ addCorner(cr.left, cr.top + cl, cr.left, cr.top, cr.left + cl, cr.top)
148
+ addCorner(cr.right - cl, cr.top, cr.right, cr.top, cr.right, cr.top + cl)
149
+ addCorner(cr.left, cr.bottom - cl, cr.left, cr.bottom, cr.left + cl, cr.bottom)
150
+ addCorner(cr.right - cl, cr.bottom, cr.right, cr.bottom, cr.right, cr.bottom - cl)
151
+ canvas.drawPath(path, cornerPaint)
152
+ }
153
+
154
+ private fun drawDimming(canvas: Canvas, cr: RectF) {
155
+ canvas.save()
156
+ val fullPath = Path()
157
+ fullPath.addRect(0f, 0f, width.toFloat(), height.toFloat(), Path.Direction.CW)
158
+ val cropPath = Path()
159
+ cropPath.addRect(cr, Path.Direction.CW)
160
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
161
+ fullPath.op(cropPath, Path.Op.DIFFERENCE)
162
+ canvas.drawPath(fullPath, dimmingPaint)
163
+ } else {
164
+ @Suppress("DEPRECATION")
165
+ canvas.clipPath(cropPath, Region.Op.DIFFERENCE)
166
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), dimmingPaint)
167
+ }
168
+ canvas.restore()
169
+ }
170
+
171
+ override fun onTouchEvent(event: MotionEvent): Boolean {
172
+ if (event.pointerCount > 1) {
173
+ scaleDetector.onTouchEvent(event)
174
+ return true
175
+ }
176
+
177
+ val x = event.x
178
+ val y = event.y
179
+
180
+ when (event.actionMasked) {
181
+ MotionEvent.ACTION_DOWN -> {
182
+ activeEdge = detectEdge(x, y) ?: return false
183
+ dragStartX = x
184
+ dragStartY = y
185
+ dragStartRect.set(cropRect)
186
+ gestureStarted = true
187
+ onCropBegan?.invoke()
188
+ return true
189
+ }
190
+ MotionEvent.ACTION_MOVE -> {
191
+ val edge = activeEdge ?: return false
192
+ val dx = x - dragStartX
193
+ val dy = y - dragStartY
194
+ cropRect = computeNewRect(edge, dx, dy)
195
+ onCropChanged?.invoke()
196
+ return true
197
+ }
198
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
199
+ activeEdge = null
200
+ if (gestureStarted) {
201
+ gestureStarted = false
202
+ onCropEnded?.invoke()
203
+ }
204
+ return true
205
+ }
206
+ }
207
+ return false
208
+ }
209
+
210
+ private fun detectEdge(px: Float, py: Float): DragEdge? {
211
+ val r = cropRect
212
+ val z = edgeHitZone
213
+
214
+ val nearT = abs(py - r.top) < z
215
+ val nearB = abs(py - r.bottom) < z
216
+ val nearL = abs(px - r.left) < z
217
+ val nearR = abs(px - r.right) < z
218
+ val inH = px > r.left - z && px < r.right + z
219
+ val inV = py > r.top - z && py < r.bottom + z
220
+
221
+ if (nearT && nearL) return DragEdge.TOP_LEFT
222
+ if (nearT && nearR) return DragEdge.TOP_RIGHT
223
+ if (nearB && nearL) return DragEdge.BOTTOM_LEFT
224
+ if (nearB && nearR) return DragEdge.BOTTOM_RIGHT
225
+ if (nearT && inH) return DragEdge.TOP
226
+ if (nearB && inH) return DragEdge.BOTTOM
227
+ if (nearL && inV) return DragEdge.LEFT
228
+ if (nearR && inV) return DragEdge.RIGHT
229
+ if (r.contains(px, py)) return DragEdge.MOVE
230
+ return null
231
+ }
232
+
233
+ private fun computeNewRect(edge: DragEdge, dx: Float, dy: Float): RectF {
234
+ val r = RectF(dragStartRect)
235
+
236
+ when (edge) {
237
+ DragEdge.TOP_LEFT -> { r.left += dx; r.top += dy }
238
+ DragEdge.TOP_RIGHT -> { r.right += dx; r.top += dy }
239
+ DragEdge.BOTTOM_LEFT -> { r.left += dx; r.bottom += dy }
240
+ DragEdge.BOTTOM_RIGHT -> { r.right += dx; r.bottom += dy }
241
+ DragEdge.TOP -> r.top += dy
242
+ DragEdge.BOTTOM -> r.bottom += dy
243
+ DragEdge.LEFT -> r.left += dx
244
+ DragEdge.RIGHT -> r.right += dx
245
+ DragEdge.MOVE -> {
246
+ r.offset(dx, dy)
247
+ clamp(r, isMove = true)
248
+ return r
249
+ }
250
+ }
251
+
252
+ if (r.width() < minCropSize) {
253
+ val anchorsRight = edge == DragEdge.LEFT || edge == DragEdge.TOP_LEFT || edge == DragEdge.BOTTOM_LEFT
254
+ if (anchorsRight) r.left = r.right - minCropSize else r.right = r.left + minCropSize
255
+ }
256
+ if (r.height() < minCropSize) {
257
+ val anchorsBottom = edge == DragEdge.TOP || edge == DragEdge.TOP_LEFT || edge == DragEdge.TOP_RIGHT
258
+ if (anchorsBottom) r.top = r.bottom - minCropSize else r.bottom = r.top + minCropSize
259
+ }
260
+
261
+ clamp(r, isMove = false)
262
+ return r
263
+ }
264
+
265
+ private fun clamp(r: RectF, isMove: Boolean) {
266
+ val a = allowedRect
267
+ if (a.isEmpty) return
268
+
269
+ if (isMove) {
270
+ val w = min(r.width(), a.width())
271
+ val h = min(r.height(), a.height())
272
+ r.right = r.left + w
273
+ r.bottom = r.top + h
274
+ if (r.left < a.left) r.offset(a.left - r.left, 0f)
275
+ if (r.top < a.top) r.offset(0f, a.top - r.top)
276
+ if (r.right > a.right) r.offset(a.right - r.right, 0f)
277
+ if (r.bottom > a.bottom) r.offset(0f, a.bottom - r.bottom)
278
+ } else {
279
+ r.left = max(r.left, a.left)
280
+ r.top = max(r.top, a.top)
281
+ if (r.right > a.right) r.right = a.right
282
+ if (r.bottom > a.bottom) r.bottom = a.bottom
283
+ }
284
+ }
285
+
286
+ fun resetCrop() {
287
+ cropRect = RectF(allowedRect)
288
+ }
289
+
290
+ private fun dpToPx(dp: Float): Float {
291
+ return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
292
+ }
293
+ }