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 +34 -9
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +38 -13
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +77 -13
- package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +293 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +540 -21
- package/android/src/main/res/drawable/arrow_trianglehead_left_and_right_righttriangle_left_righttriangle_right.xml +19 -0
- package/android/src/main/res/drawable/arrow_uturn_backward.xml +15 -0
- package/android/src/main/res/drawable/arrow_uturn_forward.xml +15 -0
- package/android/src/main/res/drawable/crop.xml +15 -0
- package/android/src/main/res/drawable/rotate_left.xml +19 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +115 -37
- package/ios/CropOverlayView.swift +285 -0
- package/ios/VideoTrim.mm +2 -4
- package/ios/VideoTrim.swift +160 -31
- package/ios/VideoTrimmerViewController.swift +441 -22
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +1 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +10 -4
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +10 -4
- package/src/index.tsx +1 -2
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
|
|
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** |
|
|
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
|
-
| `
|
|
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
|
|
445
|
+
### Video Transforms (Flip, Rotate, Crop)
|
|
442
446
|
|
|
443
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
"
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|