react-native-photo-video-editor 0.1.1 → 0.2.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/CHANGELOG.md +11 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +760 -590
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
- package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +3 -1
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +109 -151
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +42 -13
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
- package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
- package/docs/editor-ui-validation.md +22 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +778 -443
- package/ios/Editor/UI/CropPanel.swift +97 -0
- package/ios/Editor/UI/EditorPanel.swift +334 -0
- package/ios/Editor/UI/EditorToolSlider.swift +22 -0
- package/ios/Editor/UI/TextEditorSheet.swift +84 -0
- package/ios/Photo/Export/PhotoExporter.swift +3 -3
- package/ios/Photo/Rendering/CropGeometry.swift +48 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
- package/ios/Photo/Rendering/PhotoLayer.swift +9 -1
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +2 -1
- package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
- package/ios/Photo/UI/CropOverlayView.swift +96 -169
- package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
- package/ios/Photo/UI/LayerOverlayView.swift +27 -10
- package/ios/Photo/UI/ZoomableImageView.swift +19 -1
- package/ios/Video/Export/VideoExporter.swift +10 -1
- package/ios/Video/Render/VideoCropGeometry.swift +48 -0
- package/ios/Video/Render/VideoTransformState.swift +1 -0
- package/package.json +1 -1
|
@@ -7,194 +7,152 @@ import android.graphics.Paint
|
|
|
7
7
|
import android.graphics.RectF
|
|
8
8
|
import android.view.MotionEvent
|
|
9
9
|
import android.view.View
|
|
10
|
+
import com.photovideoeditor.photo.render.CropGeometry
|
|
11
|
+
import com.photovideoeditor.photo.render.PhotoTransformState
|
|
10
12
|
import kotlin.math.abs
|
|
13
|
+
import kotlin.math.hypot
|
|
11
14
|
|
|
12
|
-
/**
|
|
13
|
-
* Draws a draggable/resizable crop rectangle over [imageBounds] (the on-screen
|
|
14
|
-
* rect of the preview image, in this view's own coordinate space) and reports
|
|
15
|
-
* the crop edges back normalized to that rect via [onCropChanged].
|
|
16
|
-
*/
|
|
15
|
+
/** Crop handles own resize gestures; interior drags/pinches move the media behind a fixed box. */
|
|
17
16
|
class CropOverlayView(context: Context) : View(context) {
|
|
18
17
|
private var imageBounds = RectF()
|
|
18
|
+
private var fittedBounds = RectF()
|
|
19
19
|
private var cropRect = RectF()
|
|
20
20
|
private var aspectRatio: Float? = null
|
|
21
|
-
var onCropChanged: ((
|
|
21
|
+
var onCropChanged: ((Float, Float, Float, Float) -> Unit)? = null
|
|
22
|
+
var onMediaBoundsChanged: ((RectF) -> Unit)? = null
|
|
23
|
+
var onViewportChanged: ((Float, Float, Float) -> Unit)? = null
|
|
24
|
+
private val density = resources.displayMetrics.density
|
|
25
|
+
private val dimPaint = Paint().apply { color = Color.argb(165, 0, 0, 0) }
|
|
26
|
+
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = density }
|
|
27
|
+
private val gridPaint = Paint().apply { color = Color.argb(85, 255, 255, 255); strokeWidth = density * .5f }
|
|
28
|
+
private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE; strokeWidth = 3 * density; strokeCap = Paint.Cap.ROUND; style = Paint.Style.STROKE }
|
|
29
|
+
private var activeHandle: CropGeometry.Handle? = null
|
|
30
|
+
private var lastX = 0f; private var lastY = 0f; private var lastDistance = 0f
|
|
31
|
+
private var movingMedia = false
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
fun restore(bounds: RectF, state: PhotoTransformState) {
|
|
34
|
+
if (bounds.width() <= 0 || bounds.height() <= 0) return
|
|
35
|
+
fittedBounds = RectF(bounds)
|
|
36
|
+
val zoom = state.zoom.coerceIn(1f, 8f)
|
|
37
|
+
val cx = bounds.centerX() + state.panX * bounds.width()
|
|
38
|
+
val cy = bounds.centerY() + state.panY * bounds.height()
|
|
39
|
+
imageBounds = RectF(cx - bounds.width() * zoom / 2, cy - bounds.height() * zoom / 2,
|
|
40
|
+
cx + bounds.width() * zoom / 2, cy + bounds.height() * zoom / 2)
|
|
41
|
+
aspectRatio = state.aspectRatio
|
|
42
|
+
setCrop(state.cropLeft, state.cropTop, state.cropRight, state.cropBottom)
|
|
43
|
+
onMediaBoundsChanged?.invoke(RectF(imageBounds))
|
|
44
|
+
invalidate()
|
|
45
|
+
}
|
|
35
46
|
|
|
36
|
-
/** Sets the on-screen image rect this overlay crops against. Resets the crop to full-frame when [resetCrop] is true. */
|
|
37
47
|
fun setImageBounds(bounds: RectF, resetCrop: Boolean) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
reportCrop()
|
|
42
|
-
}
|
|
48
|
+
val old = normalized()
|
|
49
|
+
imageBounds = RectF(bounds); fittedBounds = RectF(bounds)
|
|
50
|
+
if (resetCrop || cropRect.isEmpty) cropRect = RectF(bounds) else setCrop(old[0], old[1], old[2], old[3])
|
|
43
51
|
invalidate()
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
/** Restores a normalized crop rectangle, used when reopening or cancelling crop mode. */
|
|
47
54
|
fun setCrop(left: Float, top: Float, right: Float, bottom: Float) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
imageBounds.top + top * imageBounds.height(),
|
|
52
|
-
imageBounds.left + right * imageBounds.width(),
|
|
53
|
-
imageBounds.top + bottom * imageBounds.height()
|
|
54
|
-
)
|
|
55
|
-
clampToImageBounds(); invalidate()
|
|
55
|
+
cropRect = RectF(imageBounds.left + left * imageBounds.width(), imageBounds.top + top * imageBounds.height(),
|
|
56
|
+
imageBounds.left + right * imageBounds.width(), imageBounds.top + bottom * imageBounds.height())
|
|
57
|
+
invalidate()
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
fun setAspectRatio(ratio: Float?) {
|
|
59
61
|
aspectRatio = ratio
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (height > imageBounds.height()) {
|
|
66
|
-
height = cropRect.height()
|
|
67
|
-
width = height * ratio
|
|
68
|
-
}
|
|
69
|
-
cropRect = RectF(centerX - width / 2, centerY - height / 2, centerX + width / 2, centerY + height / 2)
|
|
70
|
-
clampToImageBounds()
|
|
71
|
-
invalidate()
|
|
72
|
-
}
|
|
73
|
-
reportCrop()
|
|
62
|
+
// A new preset starts with the largest centered rectangle in the visible media.
|
|
63
|
+
val available = RectF(imageBounds)
|
|
64
|
+
available.intersect(0f, 0f, width.toFloat(), height.toFloat())
|
|
65
|
+
cropRect = CropGeometry.fit(available.geometry(), ratio).native()
|
|
66
|
+
reportCrop(); invalidate()
|
|
74
67
|
}
|
|
75
68
|
|
|
76
69
|
override fun onDraw(canvas: Canvas) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
canvas.drawRect(
|
|
80
|
-
canvas.drawRect(
|
|
81
|
-
canvas.drawRect(
|
|
82
|
-
canvas.drawRect(cropRect.right, cropRect.top, imageBounds.right, cropRect.bottom, dimPaint)
|
|
70
|
+
if (cropRect.isEmpty) return
|
|
71
|
+
canvas.drawRect(0f, 0f, width.toFloat(), cropRect.top, dimPaint)
|
|
72
|
+
canvas.drawRect(0f, cropRect.bottom, width.toFloat(), height.toFloat(), dimPaint)
|
|
73
|
+
canvas.drawRect(0f, cropRect.top, cropRect.left, cropRect.bottom, dimPaint)
|
|
74
|
+
canvas.drawRect(cropRect.right, cropRect.top, width.toFloat(), cropRect.bottom, dimPaint)
|
|
83
75
|
canvas.drawRect(cropRect, borderPaint)
|
|
84
|
-
val thirdWidth = cropRect.width() / 3
|
|
85
|
-
val thirdHeight = cropRect.height() / 3
|
|
86
76
|
for (i in 1..2) {
|
|
87
|
-
|
|
88
|
-
canvas.drawLine(
|
|
77
|
+
val x = cropRect.left + cropRect.width() * i / 3; val y = cropRect.top + cropRect.height() * i / 3
|
|
78
|
+
canvas.drawLine(x, cropRect.top, x, cropRect.bottom, gridPaint)
|
|
79
|
+
canvas.drawLine(cropRect.left, y, cropRect.right, y, gridPaint)
|
|
80
|
+
}
|
|
81
|
+
val corner = 14 * density
|
|
82
|
+
for ((x, y) in listOf(cropRect.left to cropRect.top, cropRect.right to cropRect.top, cropRect.left to cropRect.bottom, cropRect.right to cropRect.bottom)) {
|
|
83
|
+
canvas.drawLine(x, y, x + if (x == cropRect.left) corner else -corner, y, handlePaint)
|
|
84
|
+
canvas.drawLine(x, y, x, y + if (y == cropRect.top) corner else -corner, handlePaint)
|
|
89
85
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
cropRect.right to cropRect.top,
|
|
94
|
-
cropRect.right to cropRect.centerY(),
|
|
95
|
-
cropRect.right to cropRect.bottom,
|
|
96
|
-
cropRect.centerX() to cropRect.bottom,
|
|
97
|
-
cropRect.left to cropRect.bottom,
|
|
98
|
-
cropRect.left to cropRect.centerY()
|
|
99
|
-
).forEach { (x, y) -> canvas.drawCircle(x, y, 8f, handlePaint) }
|
|
86
|
+
val edge = 8 * density
|
|
87
|
+
for (y in listOf(cropRect.top, cropRect.bottom)) canvas.drawLine(cropRect.centerX() - edge, y, cropRect.centerX() + edge, y, handlePaint)
|
|
88
|
+
for (x in listOf(cropRect.left, cropRect.right)) canvas.drawLine(x, cropRect.centerY() - edge, x, cropRect.centerY() + edge, handlePaint)
|
|
100
89
|
}
|
|
101
90
|
|
|
102
91
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
92
|
+
if (imageBounds.isEmpty) return false
|
|
103
93
|
when (event.actionMasked) {
|
|
104
94
|
MotionEvent.ACTION_DOWN -> {
|
|
105
|
-
activeHandle = hitTest(event.x, event.y)
|
|
106
|
-
lastX = event.x
|
|
107
|
-
|
|
108
|
-
|
|
95
|
+
activeHandle = hitTest(event.x, event.y); movingMedia = activeHandle == null
|
|
96
|
+
lastX = event.x; lastY = event.y; lastDistance = 0f
|
|
97
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
98
|
+
}
|
|
99
|
+
MotionEvent.ACTION_POINTER_DOWN -> {
|
|
100
|
+
activeHandle = null; movingMedia = true
|
|
101
|
+
lastX = (event.getX(0) + event.getX(1)) / 2; lastY = (event.getY(0) + event.getY(1)) / 2
|
|
102
|
+
lastDistance = hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1))
|
|
109
103
|
}
|
|
110
104
|
MotionEvent.ACTION_MOVE -> {
|
|
111
|
-
|
|
112
|
-
val
|
|
113
|
-
val
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
105
|
+
val multi = event.pointerCount > 1
|
|
106
|
+
val x = if (multi) (event.getX(0) + event.getX(1)) / 2 else event.x
|
|
107
|
+
val y = if (multi) (event.getY(0) + event.getY(1)) / 2 else event.y
|
|
108
|
+
val distance = if (multi) hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1)) else 0f
|
|
109
|
+
val handle = activeHandle
|
|
110
|
+
if (handle != null) {
|
|
111
|
+
val valid = RectF(imageBounds).apply { intersect(0f, 0f, width.toFloat(), height.toFloat()) }
|
|
112
|
+
cropRect = CropGeometry.resize(cropRect.geometry(), valid.geometry(), handle, x - lastX, y - lastY, aspectRatio, 48 * density).native()
|
|
113
|
+
} else if (movingMedia) {
|
|
114
|
+
moveMedia(x - lastX, y - lastY, if (lastDistance > 0 && distance > 0) distance / lastDistance else 1f, x, y)
|
|
115
|
+
}
|
|
116
|
+
lastX = x; lastY = y; lastDistance = distance
|
|
117
|
+
reportCrop(); invalidate()
|
|
120
118
|
}
|
|
121
|
-
MotionEvent.
|
|
122
|
-
val
|
|
123
|
-
|
|
124
|
-
return handled
|
|
119
|
+
MotionEvent.ACTION_POINTER_UP -> {
|
|
120
|
+
val index = if (event.actionIndex == 0) 1 else 0
|
|
121
|
+
lastX = event.getX(index); lastY = event.getY(index); lastDistance = 0f
|
|
125
122
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
private fun hitTest(x: Float, y: Float): Handle {
|
|
131
|
-
fun near(px: Float, py: Float) = abs(x - px) < handleTouchRadius && abs(y - py) < handleTouchRadius
|
|
132
|
-
return when {
|
|
133
|
-
near(cropRect.left, cropRect.top) -> Handle.TOP_LEFT
|
|
134
|
-
near(cropRect.right, cropRect.top) -> Handle.TOP_RIGHT
|
|
135
|
-
near(cropRect.left, cropRect.bottom) -> Handle.BOTTOM_LEFT
|
|
136
|
-
near(cropRect.right, cropRect.bottom) -> Handle.BOTTOM_RIGHT
|
|
137
|
-
near(cropRect.centerX(), cropRect.top) -> Handle.TOP
|
|
138
|
-
near(cropRect.right, cropRect.centerY()) -> Handle.RIGHT
|
|
139
|
-
near(cropRect.centerX(), cropRect.bottom) -> Handle.BOTTOM
|
|
140
|
-
near(cropRect.left, cropRect.centerY()) -> Handle.LEFT
|
|
141
|
-
cropRect.contains(x, y) -> Handle.MOVE
|
|
142
|
-
else -> Handle.NONE
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private fun applyDrag(handle: Handle, dx: Float, dy: Float) {
|
|
147
|
-
val proposed = RectF(cropRect)
|
|
148
|
-
when (handle) {
|
|
149
|
-
Handle.MOVE -> proposed.offset(dx, dy)
|
|
150
|
-
Handle.TOP_LEFT -> { proposed.left += dx; proposed.top += dy }
|
|
151
|
-
Handle.TOP_RIGHT -> { proposed.right += dx; proposed.top += dy }
|
|
152
|
-
Handle.BOTTOM_LEFT -> { proposed.left += dx; proposed.bottom += dy }
|
|
153
|
-
Handle.BOTTOM_RIGHT -> { proposed.right += dx; proposed.bottom += dy }
|
|
154
|
-
Handle.TOP -> proposed.top += dy
|
|
155
|
-
Handle.RIGHT -> proposed.right += dx
|
|
156
|
-
Handle.BOTTOM -> proposed.bottom += dy
|
|
157
|
-
Handle.LEFT -> proposed.left += dx
|
|
158
|
-
Handle.NONE -> return
|
|
159
|
-
}
|
|
160
|
-
if (proposed.width() < minSizePx || proposed.height() < minSizePx) return
|
|
161
|
-
aspectRatio?.let { ratio ->
|
|
162
|
-
when (handle) {
|
|
163
|
-
Handle.TOP_LEFT, Handle.BOTTOM_RIGHT -> proposed.bottom = proposed.top + proposed.width() / ratio
|
|
164
|
-
Handle.TOP_RIGHT -> proposed.top = proposed.bottom - proposed.width() / ratio
|
|
165
|
-
Handle.BOTTOM_LEFT -> proposed.bottom = proposed.top + proposed.width() / ratio
|
|
166
|
-
Handle.LEFT, Handle.RIGHT -> {
|
|
167
|
-
val centerY = cropRect.centerY(); val height = proposed.width() / ratio
|
|
168
|
-
proposed.top = centerY - height / 2; proposed.bottom = centerY + height / 2
|
|
169
|
-
}
|
|
170
|
-
Handle.TOP, Handle.BOTTOM -> {
|
|
171
|
-
val centerX = cropRect.centerX(); val width = proposed.height() * ratio
|
|
172
|
-
proposed.left = centerX - width / 2; proposed.right = centerX + width / 2
|
|
173
|
-
}
|
|
174
|
-
else -> {}
|
|
123
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
124
|
+
activeHandle = null; movingMedia = false; lastDistance = 0f
|
|
125
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
175
126
|
}
|
|
176
127
|
}
|
|
177
|
-
|
|
178
|
-
clampToImageBounds()
|
|
128
|
+
return true
|
|
179
129
|
}
|
|
180
130
|
|
|
181
|
-
private fun
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
131
|
+
private fun moveMedia(dx: Float, dy: Float, factor: Float, focusX: Float, focusY: Float) {
|
|
132
|
+
val minimumZoom = maxOf(1f, cropRect.width() / fittedBounds.width(), cropRect.height() / fittedBounds.height())
|
|
133
|
+
val zoom = imageBounds.width() / fittedBounds.width()
|
|
134
|
+
val scale = (zoom * factor).coerceIn(minimumZoom, maxOf(8f, minimumZoom)) / zoom
|
|
135
|
+
val w = imageBounds.width() * scale; val h = imageBounds.height() * scale
|
|
136
|
+
val left = (focusX + (imageBounds.left - focusX) * scale + dx).coerceIn(cropRect.right - w, cropRect.left)
|
|
137
|
+
val top = (focusY + (imageBounds.top - focusY) * scale + dy).coerceIn(cropRect.bottom - h, cropRect.top)
|
|
138
|
+
imageBounds = RectF(left, top, left + w, top + h)
|
|
139
|
+
onMediaBoundsChanged?.invoke(RectF(imageBounds))
|
|
140
|
+
onViewportChanged?.invoke(w / fittedBounds.width(), (imageBounds.centerX() - fittedBounds.centerX()) / fittedBounds.width(), (imageBounds.centerY() - fittedBounds.centerY()) / fittedBounds.height())
|
|
190
141
|
}
|
|
191
142
|
|
|
192
|
-
private fun
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
143
|
+
private fun hitTest(x: Float, y: Float): CropGeometry.Handle? {
|
|
144
|
+
val points = listOf(
|
|
145
|
+
Triple(cropRect.left, cropRect.top, CropGeometry.Handle.TOP_LEFT), Triple(cropRect.right, cropRect.top, CropGeometry.Handle.TOP_RIGHT),
|
|
146
|
+
Triple(cropRect.left, cropRect.bottom, CropGeometry.Handle.BOTTOM_LEFT), Triple(cropRect.right, cropRect.bottom, CropGeometry.Handle.BOTTOM_RIGHT),
|
|
147
|
+
Triple(cropRect.centerX(), cropRect.top, CropGeometry.Handle.TOP), Triple(cropRect.right, cropRect.centerY(), CropGeometry.Handle.RIGHT),
|
|
148
|
+
Triple(cropRect.centerX(), cropRect.bottom, CropGeometry.Handle.BOTTOM), Triple(cropRect.left, cropRect.centerY(), CropGeometry.Handle.LEFT))
|
|
149
|
+
val nearest = points.minByOrNull { hypot(x - it.first, y - it.second) } ?: return null
|
|
150
|
+
return nearest.third.takeIf { hypot(x - nearest.first, y - nearest.second) <= 24 * density }
|
|
199
151
|
}
|
|
152
|
+
private fun normalized(): FloatArray = if (imageBounds.isEmpty) floatArrayOf(0f, 0f, 1f, 1f) else floatArrayOf(
|
|
153
|
+
(cropRect.left - imageBounds.left) / imageBounds.width(), (cropRect.top - imageBounds.top) / imageBounds.height(),
|
|
154
|
+
(cropRect.right - imageBounds.left) / imageBounds.width(), (cropRect.bottom - imageBounds.top) / imageBounds.height())
|
|
155
|
+
private fun reportCrop() { val r = normalized(); onCropChanged?.invoke(r[0].coerceIn(0f, 1f), r[1].coerceIn(0f, 1f), r[2].coerceIn(0f, 1f), r[3].coerceIn(0f, 1f)) }
|
|
156
|
+
private fun RectF.geometry() = CropGeometry.Rect(left, top, right, bottom)
|
|
157
|
+
private fun CropGeometry.Rect.native() = RectF(left, top, right, bottom)
|
|
200
158
|
}
|
|
@@ -3,12 +3,13 @@ package com.photovideoeditor.photo.ui
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.graphics.Canvas
|
|
5
5
|
import android.graphics.Color
|
|
6
|
-
import android.graphics.DashPathEffect
|
|
7
6
|
import android.graphics.Paint
|
|
8
7
|
import android.graphics.RectF
|
|
9
8
|
import android.view.MotionEvent
|
|
10
9
|
import android.view.View
|
|
11
10
|
import android.text.TextPaint
|
|
11
|
+
import com.photovideoeditor.R
|
|
12
|
+
import com.photovideoeditor.photo.render.OverlayGeometry
|
|
12
13
|
import com.photovideoeditor.photo.render.PhotoLayer
|
|
13
14
|
import com.photovideoeditor.photo.render.LayerType
|
|
14
15
|
import kotlin.math.atan2
|
|
@@ -27,6 +28,7 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
27
28
|
set(value) { field = value; invalidate() }
|
|
28
29
|
var selectedLayerId: String? = null
|
|
29
30
|
set(value) { field = value; invalidate() }
|
|
31
|
+
var onLayerDelete: ((String) -> Unit)? = null
|
|
30
32
|
var onLayerTapped: ((String?) -> Unit)? = null
|
|
31
33
|
var onLayerDoubleTapped: ((String) -> Unit)? = null
|
|
32
34
|
var onLayerTransformChanged: ((id: String, x: Float, y: Float, scale: Float, rotationDegrees: Float) -> Unit)? = null
|
|
@@ -48,13 +50,10 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
48
50
|
private val handleRadius = 11f * density
|
|
49
51
|
private val handleTouchRadius = 30f * density
|
|
50
52
|
private val selectionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
51
|
-
color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth =
|
|
52
|
-
pathEffect = DashPathEffect(floatArrayOf(8f * density, 5f * density), 0f)
|
|
53
|
+
color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 1f * density
|
|
53
54
|
}
|
|
54
55
|
private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
|
|
55
|
-
|
|
56
|
-
color = Color.rgb(87, 55, 245); style = Paint.Style.STROKE; strokeWidth = 2f * density
|
|
57
|
-
}
|
|
56
|
+
|
|
58
57
|
|
|
59
58
|
fun setImageBounds(bounds: RectF) { imageBounds = RectF(bounds); invalidate() }
|
|
60
59
|
|
|
@@ -67,17 +66,36 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
67
66
|
canvas.translate(center.first, center.second)
|
|
68
67
|
canvas.rotate(selected.rotationDegrees)
|
|
69
68
|
canvas.drawRect(-half.first, -half.second, half.first, half.second, selectionPaint)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
for ((x, y) in listOf(Pair(-half.first, -half.second), Pair(half.first, -half.second), Pair(-half.first, half.second), Pair(half.first, half.second))) {
|
|
70
|
+
canvas.drawCircle(x, y, 4f * density, handlePaint)
|
|
71
|
+
}
|
|
72
|
+
drawControl(canvas, -half.first, -half.second, R.drawable.ic_close, Color.rgb(255, 100, 115))
|
|
73
|
+
drawControl(canvas, half.first, half.second, R.drawable.ic_fullscreen, Color.rgb(167, 139, 250))
|
|
73
74
|
canvas.restore()
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
private fun drawControl(canvas: Canvas, x: Float, y: Float, icon: Int, tint: Int) {
|
|
78
|
+
canvas.drawCircle(x, y, handleRadius, handlePaint)
|
|
79
|
+
context.getDrawable(icon)?.mutate()?.let { drawable ->
|
|
80
|
+
drawable.setTint(tint)
|
|
81
|
+
val size = (handleRadius * 0.7f).toInt()
|
|
82
|
+
drawable.setBounds(x.toInt() - size, y.toInt() - size, x.toInt() + size, y.toInt() + size)
|
|
83
|
+
drawable.draw(canvas)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
77
88
|
if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return false
|
|
78
89
|
when (event.actionMasked) {
|
|
79
90
|
MotionEvent.ACTION_DOWN -> {
|
|
80
91
|
val selected = selectedLayer()
|
|
92
|
+
if (selected != null && !selected.locked) {
|
|
93
|
+
val handle = handlePoint(selected)
|
|
94
|
+
val center = centerOf(selected)
|
|
95
|
+
if (hypot(event.x - (2 * center.first - handle.first), event.y - (2 * center.second - handle.second)) <= handleTouchRadius) {
|
|
96
|
+
onLayerDelete?.invoke(selected.id); resetGesture(); return true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
81
99
|
val handleHit = selected?.takeIf { !it.locked && isHandleHit(it, event.x, event.y) }
|
|
82
100
|
val hit = handleHit ?: hitTest(event.x, event.y)
|
|
83
101
|
onLayerTapped?.invoke(hit?.id)
|
|
@@ -102,6 +120,7 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
102
120
|
val metrics = multiMetrics(event)
|
|
103
121
|
startDistance = metrics.distance.coerceAtLeast(1f); startAngle = metrics.angle
|
|
104
122
|
startMidX = metrics.midX; startMidY = metrics.midY
|
|
123
|
+
movedDuringGesture = true
|
|
105
124
|
gesture = Gesture.MULTI
|
|
106
125
|
return true
|
|
107
126
|
}
|
|
@@ -143,7 +162,17 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
143
162
|
}
|
|
144
163
|
finishGesture(); return true
|
|
145
164
|
}
|
|
146
|
-
MotionEvent.ACTION_POINTER_UP ->
|
|
165
|
+
MotionEvent.ACTION_POINTER_UP -> {
|
|
166
|
+
// Rebase to the remaining finger so lifting a pinch finger does not jump/freeze the layer.
|
|
167
|
+
val remaining = if (event.actionIndex == 0) 1 else 0
|
|
168
|
+
if (remaining < event.pointerCount) {
|
|
169
|
+
startLayer = currentGestureLayer()?.copy()
|
|
170
|
+
startPointX = event.getX(remaining); startPointY = event.getY(remaining)
|
|
171
|
+
gesture = Gesture.DRAG
|
|
172
|
+
}
|
|
173
|
+
movedDuringGesture = true
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
147
176
|
}
|
|
148
177
|
return false
|
|
149
178
|
}
|
|
@@ -173,8 +202,8 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
173
202
|
Pair((lines.maxOfOrNull { paint.measureText(it) } ?: paint.textSize) / 2f + 8f * density, paint.fontSpacing * lines.size / 2f + 6f * density)
|
|
174
203
|
}
|
|
175
204
|
LayerType.STICKER -> {
|
|
176
|
-
val
|
|
177
|
-
Pair(
|
|
205
|
+
val (width, height) = OverlayGeometry.stickerSize(shortSide, layer.overlayAspectRatio ?: 1f)
|
|
206
|
+
Pair(width / 2f + 4f * density, height / 2f + 4f * density)
|
|
178
207
|
}
|
|
179
208
|
LayerType.OVERLAY -> {
|
|
180
209
|
val aspect = layer.overlayAspectRatio ?: 1f
|
|
@@ -187,7 +216,7 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
187
216
|
Pair(maxX * imageBounds.width() + handleRadius, maxY * imageBounds.height() + handleRadius)
|
|
188
217
|
}
|
|
189
218
|
}
|
|
190
|
-
return Pair((base.first * layer.scale).coerceAtLeast(
|
|
219
|
+
return Pair((base.first * layer.scale).coerceAtLeast(24f * density), (base.second * layer.scale).coerceAtLeast(24f * density))
|
|
191
220
|
}
|
|
192
221
|
|
|
193
222
|
private fun handlePoint(layer: PhotoLayer): Pair<Float, Float> {
|
|
@@ -59,6 +59,16 @@ class ZoomableImageView(context: Context) : ImageView(context) {
|
|
|
59
59
|
onBoundsChanged?.invoke(currentImageBounds())
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
63
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
64
|
+
if (w != oldw || h != oldh) resetToFit()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fun setRenderedBounds(bounds: RectF) {
|
|
68
|
+
val image = drawable ?: return
|
|
69
|
+
imageMatrix = Matrix().apply { setRectToRect(RectF(0f, 0f, image.intrinsicWidth.toFloat(), image.intrinsicHeight.toFloat()), bounds, Matrix.ScaleToFit.FILL) }
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
fun currentImageBounds(): RectF {
|
|
63
73
|
val bitmapDrawable = drawable ?: return RectF(0f, 0f, width.toFloat(), height.toFloat())
|
|
64
74
|
val rect = RectF(0f, 0f, bitmapDrawable.intrinsicWidth.toFloat(), bitmapDrawable.intrinsicHeight.toFloat())
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
package com.photovideoeditor.ui.components
|
|
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.drawable.GradientDrawable
|
|
8
|
+
import android.view.Gravity
|
|
9
|
+
import android.widget.*
|
|
10
|
+
import com.photovideoeditor.R
|
|
11
|
+
import com.photovideoeditor.photo.render.PhotoTransformState
|
|
12
|
+
import com.photovideoeditor.ui.DesignTokens
|
|
13
|
+
import kotlin.math.roundToInt
|
|
14
|
+
|
|
15
|
+
/** Shared photo/video crop surface. Only UI state lives here; the controller owns the draft. */
|
|
16
|
+
class CropPanel(
|
|
17
|
+
context: Context,
|
|
18
|
+
private val accent: Int,
|
|
19
|
+
surface: Int,
|
|
20
|
+
textColor: Int,
|
|
21
|
+
originalRatio: () -> Float,
|
|
22
|
+
onReset: () -> Unit,
|
|
23
|
+
onDone: () -> Unit,
|
|
24
|
+
onCancel: () -> Unit,
|
|
25
|
+
onStraighten: (Float) -> Unit,
|
|
26
|
+
onRotate: () -> Unit,
|
|
27
|
+
onRatio: (String, Float?) -> Unit
|
|
28
|
+
) : LinearLayout(context) {
|
|
29
|
+
private fun dp(value: Int) = dpToPx(value, context)
|
|
30
|
+
private val valueLabel = TextView(context)
|
|
31
|
+
private val slider: SeekBar
|
|
32
|
+
private val presets = mutableMapOf<String, LinearLayout>()
|
|
33
|
+
private var syncing = false
|
|
34
|
+
|
|
35
|
+
init {
|
|
36
|
+
orientation = VERTICAL
|
|
37
|
+
setPadding(dp(12), dp(8), dp(12), dp(8))
|
|
38
|
+
background = GradientDrawable().apply {
|
|
39
|
+
setColor(surface); cornerRadii = floatArrayOf(dp(20).toFloat(), dp(20).toFloat(), dp(20).toFloat(), dp(20).toFloat(), 0f, 0f, 0f, 0f)
|
|
40
|
+
}
|
|
41
|
+
fun icon(resource: Int, label: String, action: () -> Unit) = ImageButton(context).apply {
|
|
42
|
+
setImageResource(resource); setColorFilter(textColor); contentDescription = label
|
|
43
|
+
background = roundedDrawable(Color.TRANSPARENT, 12, context)
|
|
44
|
+
setOnClickListener { action() }; applyPressScale()
|
|
45
|
+
}
|
|
46
|
+
val header = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
|
|
47
|
+
val reset = LinearLayout(context).apply {
|
|
48
|
+
gravity = Gravity.CENTER_VERTICAL; isClickable = true; contentDescription = "Reset crop"
|
|
49
|
+
addView(ImageView(context).apply { setImageResource(R.drawable.ic_undo); setColorFilter(textColor) }, LayoutParams(dp(20), dp(20)))
|
|
50
|
+
addView(TextView(context).apply { text = "Reset"; textSize = 13f; setTextColor(textColor); setPadding(dp(6), 0, 0, 0) })
|
|
51
|
+
setOnClickListener { onReset() }; applyPressScale()
|
|
52
|
+
}
|
|
53
|
+
header.addView(reset, LayoutParams(dp(88), dp(48)))
|
|
54
|
+
header.addView(TextView(context).apply { text = "Crop"; textSize = 17f; gravity = Gravity.CENTER; setTextColor(textColor); setTypeface(typeface, 1) }, LayoutParams(0, dp(48), 1f))
|
|
55
|
+
header.addView(icon(R.drawable.ic_close, "Cancel crop", onCancel), LayoutParams(dp(44), dp(48)))
|
|
56
|
+
header.addView(icon(R.drawable.ic_check, "Apply crop", onDone).apply { setColorFilter(accent) }, LayoutParams(dp(44), dp(48)))
|
|
57
|
+
addView(header)
|
|
58
|
+
val labels = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
|
|
59
|
+
labels.addView(TextView(context).apply { text = "Straighten"; textSize = 13f; setTextColor(textColor) }, LayoutParams(0, dp(44), 1f))
|
|
60
|
+
valueLabel.setTextColor(accent); valueLabel.textSize = 13f; valueLabel.gravity = Gravity.CENTER
|
|
61
|
+
labels.addView(valueLabel, LayoutParams(dp(56), dp(44)))
|
|
62
|
+
labels.addView(icon(R.drawable.ic_undo, "Reset straighten") { onStraighten(0f) }, LayoutParams(dp(44), dp(44)))
|
|
63
|
+
labels.addView(icon(R.drawable.ic_rotate_right, "Rotate 90 degrees", onRotate), LayoutParams(dp(44), dp(44)))
|
|
64
|
+
addView(labels)
|
|
65
|
+
slider = object : SeekBar(context) {
|
|
66
|
+
private val tick = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeWidth = resources.displayMetrics.density; color = DesignTokens.outline }
|
|
67
|
+
override fun onDraw(canvas: Canvas) {
|
|
68
|
+
val start = paddingLeft.toFloat(); val end = width - paddingRight.toFloat()
|
|
69
|
+
for (i in 0..90) {
|
|
70
|
+
val x = start + (end - start) * i / 90
|
|
71
|
+
tick.alpha = if (i % 5 == 0) 160 else 65
|
|
72
|
+
canvas.drawLine(x, 6f, x, if (i % 5 == 0) dp(12).toFloat() else dp(7).toFloat(), tick)
|
|
73
|
+
}
|
|
74
|
+
super.onDraw(canvas)
|
|
75
|
+
}
|
|
76
|
+
}.apply {
|
|
77
|
+
max = 900; progress = 450; contentDescription = "Straighten, minus 45 to plus 45 degrees"
|
|
78
|
+
progressTintList = android.content.res.ColorStateList.valueOf(accent); thumbTintList = progressTintList
|
|
79
|
+
setPadding(dp(12), dp(12), dp(12), 0)
|
|
80
|
+
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
|
81
|
+
override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser && !syncing) onStraighten((progress - 450) / 10f) }
|
|
82
|
+
override fun onStartTrackingTouch(bar: SeekBar?) {}
|
|
83
|
+
override fun onStopTrackingTouch(bar: SeekBar?) {}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
addView(slider, LayoutParams(LayoutParams.MATCH_PARENT, dp(48)))
|
|
87
|
+
val rail = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
|
|
88
|
+
val ratios = listOf("Free" to null, "Original" to null, "1:1" to 1f, "9:16" to 9f / 16, "16:9" to 16f / 9, "10:16" to 10f / 16, "16:10" to 16f / 10, "4:5" to 4f / 5, "5:4" to 5f / 4, "3:4" to 3f / 4, "4:3" to 4f / 3)
|
|
89
|
+
ratios.forEach { (name, ratio) ->
|
|
90
|
+
val tile = LinearLayout(context).apply {
|
|
91
|
+
orientation = VERTICAL; gravity = Gravity.CENTER; isClickable = true; isFocusable = true; contentDescription = "$name crop ratio"
|
|
92
|
+
addView(object : android.view.View(context) {
|
|
93
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = dp(1).toFloat(); color = textColor }
|
|
94
|
+
override fun onDraw(canvas: Canvas) {
|
|
95
|
+
paint.color = if (this@apply.isSelected) accent else textColor
|
|
96
|
+
val aspect = if (name == "Original") originalRatio() else ratio ?: 1.25f
|
|
97
|
+
val w = minOf(dp(24).toFloat(), dp(24) * aspect); val h = w / aspect
|
|
98
|
+
canvas.drawRoundRect((width-w)/2, (height-h)/2, (width+w)/2, (height+h)/2, dp(2).toFloat(), dp(2).toFloat(), paint)
|
|
99
|
+
}
|
|
100
|
+
}, LayoutParams(dp(40), dp(38)))
|
|
101
|
+
addView(TextView(context).apply { text = name; textSize = 11f; gravity = Gravity.CENTER; setTextColor(textColor) })
|
|
102
|
+
setOnClickListener { onRatio(name, if (name == "Original") originalRatio() else ratio) }; applyPressScale()
|
|
103
|
+
}
|
|
104
|
+
presets[name] = tile
|
|
105
|
+
rail.addView(tile, LayoutParams(dp(64), dp(68)))
|
|
106
|
+
}
|
|
107
|
+
addView(HorizontalScrollView(context).apply { isHorizontalScrollBarEnabled = false; addView(rail) }, LayoutParams(LayoutParams.MATCH_PARENT, dp(72)))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fun sync(state: PhotoTransformState) {
|
|
111
|
+
syncing = true
|
|
112
|
+
slider.progress = (state.straightenDegrees * 10 + 450).roundToInt()
|
|
113
|
+
valueLabel.text = String.format(java.util.Locale.US, "%.1f°", state.straightenDegrees)
|
|
114
|
+
presets.forEach { (name, tile) ->
|
|
115
|
+
tile.isSelected = name == state.aspectPreset
|
|
116
|
+
tile.background = roundedDrawable(if (tile.isSelected) (accent and 0x00FFFFFF) or 0x22000000 else Color.TRANSPARENT, 12, context)
|
|
117
|
+
(tile.getChildAt(1) as TextView).setTextColor(if (tile.isSelected) accent else DesignTokens.textSecondary)
|
|
118
|
+
tile.getChildAt(0).invalidate()
|
|
119
|
+
}
|
|
120
|
+
syncing = false
|
|
121
|
+
}
|
|
122
|
+
}
|