Merge branch 'video_crop' into 'master'
Crop videos See merge request pixeldroid/PixelDroid!482
This commit is contained in:
commit
8afd3b88df
@ -47,7 +47,6 @@ import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.collections.flatten
|
||||
|
||||
const val TAG = "Post Creation Activity"
|
||||
|
||||
@ -131,8 +130,13 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
|
||||
uiState.newEncodingJobVideoStart?.let { videoStart ->
|
||||
uiState.newEncodingJobVideoEnd?.let { videoEnd ->
|
||||
uiState.newEncodingJobSpeedIndex?.let { speedIndex ->
|
||||
startEncoding(position, muted, videoStart, videoEnd, speedIndex)
|
||||
model.encodingStarted()
|
||||
uiState.newEncodingJobVideoCrop?.let { crop ->
|
||||
startEncoding(position, muted,
|
||||
videoStart, videoEnd,
|
||||
speedIndex, crop
|
||||
)
|
||||
model.encodingStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -331,7 +335,8 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
|
||||
muted: Boolean,
|
||||
videoStart: Float?,
|
||||
videoEnd: Float?,
|
||||
speedIndex: Int
|
||||
speedIndex: Int,
|
||||
crop: VideoEditActivity.RelativeCropPosition
|
||||
) {
|
||||
val originalUri = model.getPhotoData().value!![position].imageUri
|
||||
|
||||
@ -352,29 +357,33 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
|
||||
|
||||
val speed = VideoEditActivity.speedChoices[speedIndex]
|
||||
|
||||
//TODO also have audio when speed is changed?
|
||||
val mutedString = if(muted || speedIndex != 1) "-an" else null
|
||||
val startString: List<String?> = if(videoStart != null) listOf("-ss", "${videoStart/speed.toFloat()}") else listOf(null, null)
|
||||
|
||||
val endString: List<String?> = if(videoEnd != null) listOf("-to", "${videoEnd/speed.toFloat() - (videoStart ?: 0f)/speed.toFloat()}") else listOf(null, null)
|
||||
|
||||
val speedString: List<String?> = if(speedIndex!= 1)
|
||||
listOf("-filter:v", "setpts=PTS/${speed}")
|
||||
// iw and ih are variables for the original width and height values, FFmpeg will know them
|
||||
val cropString = if(crop.notCropped()) "" else "crop=${crop.relativeWidth}*iw:${crop.relativeHeight}*ih:${crop.relativeX}*iw:${crop.relativeY}*ih"
|
||||
val separator = if(speedIndex != 1 && !crop.notCropped()) "," else ""
|
||||
val speedString = if(speedIndex != 1) "setpts=PTS/${speed}" else ""
|
||||
|
||||
val speedAndCropString: List<String?> = if(speedIndex!= 1 || !crop.notCropped())
|
||||
listOf("-filter:v", speedString + separator + cropString)
|
||||
// Stream copy is not compatible with filter, but when not filtering we can copy the stream without re-encoding
|
||||
else listOf("-c", "copy")
|
||||
|
||||
// This should be set when re-encoding is required (otherwise it defaults to mpeg which then doesn't play)
|
||||
val encodePreset: List<String?> = if(speedIndex != 1) listOf("-c:v", "libx264", "-preset", "ultrafast") else listOf(null, null, null, null)
|
||||
val encodePreset: List<String?> = if(speedIndex != 1 && !crop.notCropped()) listOf("-c:v", "libx264", "-preset", "ultrafast") else listOf(null, null, null, null)
|
||||
|
||||
val session: FFmpegSession =
|
||||
FFmpegKit.executeWithArgumentsAsync(listOfNotNull(
|
||||
startString[0], startString[1],
|
||||
"-i", ffmpegCompliantUri,
|
||||
speedString[0], speedString[1],
|
||||
speedAndCropString[0], speedAndCropString[1],
|
||||
endString[0], endString[1],
|
||||
mutedString, "-y",
|
||||
encodePreset[0], encodePreset[1], encodePreset[2], encodePreset[3],
|
||||
outputVideoPath
|
||||
outputVideoPath,
|
||||
).toTypedArray(),
|
||||
//val session: FFmpegSession = FFmpegKit.executeAsync("$startString -i $inputSafePath $endString -c:v libvpx-vp9 -c:a copy -an -y $outputVideoPath",
|
||||
{ session ->
|
||||
|
@ -24,6 +24,7 @@ import org.pixeldroid.app.MainActivity
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.postCreation.photoEdit.PhotoEditActivity
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity.RelativeCropPosition
|
||||
import org.pixeldroid.app.utils.PixelDroidApplication
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
|
||||
@ -61,6 +62,8 @@ data class PostCreationActivityUiState(
|
||||
val newEncodingJobSpeedIndex: Int? = null,
|
||||
val newEncodingJobVideoStart: Float? = null,
|
||||
val newEncodingJobVideoEnd: Float? = null,
|
||||
val newEncodingJobVideoCrop: RelativeCropPosition? = null,
|
||||
|
||||
)
|
||||
|
||||
class PostCreationViewModel(application: Application, clipdata: ClipData? = null, val instance: InstanceDatabaseEntity? = null) : AndroidViewModel(application) {
|
||||
@ -372,6 +375,8 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
||||
if(it == -1f) null else it
|
||||
}
|
||||
|
||||
val videoCrop: RelativeCropPosition = data.getSerializableExtra(VideoEditActivity.VIDEO_CROP) as RelativeCropPosition
|
||||
|
||||
videoEncodeProgress = 0
|
||||
sessionMap[position]?.let { FFmpegKit.cancel(it) }
|
||||
_uiState.update { currentUiState ->
|
||||
@ -380,7 +385,8 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
||||
newEncodingJobMuted = muted,
|
||||
newEncodingJobSpeedIndex = speedIndex,
|
||||
newEncodingJobVideoStart = videoStart,
|
||||
newEncodingJobVideoEnd = videoEnd
|
||||
newEncodingJobVideoEnd = videoEnd,
|
||||
newEncodingJobVideoCrop = videoCrop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -578,6 +578,7 @@ class ImageCarousel(
|
||||
null, null, null)
|
||||
}
|
||||
} else {
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
|
||||
binding.encodeProgress.visibility = VISIBLE
|
||||
binding.encodeInfoCard.visibility = VISIBLE
|
||||
binding.encodeProgress.progress = progress
|
||||
|
@ -3,14 +3,16 @@ package org.pixeldroid.app.postCreation.photoEdit
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
@ -18,12 +20,16 @@ import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.HandlerCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.media.AudioAttributesCompat
|
||||
import androidx.media2.common.MediaMetadata
|
||||
import androidx.media2.common.UriMediaItem
|
||||
import androidx.media2.player.MediaPlayer
|
||||
import androidx.media2.player.MediaPlayer.PlayerCallback
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.FFprobeKit
|
||||
import com.arthenica.ffmpegkit.MediaInformation
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import org.pixeldroid.app.R
|
||||
@ -33,14 +39,35 @@ import org.pixeldroid.app.postCreation.carousel.dpToPx
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.ffmpegCompliantUri
|
||||
import java.io.File
|
||||
import java.io.Serializable
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
|
||||
class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
|
||||
data class RelativeCropPosition(
|
||||
// Width of the selected part of the video, relative to the width of the video
|
||||
val relativeWidth: Float = 1f,
|
||||
// Height of the selected part of the video, relative to the height of the video
|
||||
val relativeHeight: Float = 1f,
|
||||
// Distance of left corner of selected part, relative to the width of the video
|
||||
val relativeX: Float = 0f,
|
||||
// Distance of top of selected part, relative to the height of the video
|
||||
val relativeY: Float = 0f,
|
||||
): Serializable {
|
||||
fun notCropped(): Boolean =
|
||||
(relativeWidth - 1f).absoluteValue < 0.001f
|
||||
&& (relativeHeight - 1f).absoluteValue < 0.001f
|
||||
&& relativeX.absoluteValue < 0.001f
|
||||
&& relativeY.absoluteValue < 0.001f
|
||||
|
||||
}
|
||||
|
||||
private lateinit var mediaPlayer: MediaPlayer
|
||||
private var videoPosition: Int = -1
|
||||
|
||||
//TODO react to change of playbackSpeed (when changed in the player itself)
|
||||
private var cropRelativeDimensions: RelativeCropPosition = RelativeCropPosition()
|
||||
|
||||
private var speed: Int = 1
|
||||
set(value) {
|
||||
field = value
|
||||
@ -72,15 +99,12 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())
|
||||
|
||||
val uri = intent.getParcelableExtra<Uri>(PhotoEditActivity.PICTURE_URI)!!
|
||||
|
||||
videoPosition = intent.getIntExtra(PhotoEditActivity.PICTURE_POSITION, -1)
|
||||
|
||||
val inputVideoPath = ffmpegCompliantUri(uri)
|
||||
val mediaInformation: MediaInformation? = FFprobeKit.getMediaInformation(inputVideoPath).mediaInformation
|
||||
|
||||
binding.muter.setOnClickListener {
|
||||
binding.muter.isSelected = !binding.muter.isSelected
|
||||
}
|
||||
|
||||
//Duration in seconds, or null
|
||||
val duration: Float? = mediaInformation?.duration?.toFloatOrNull()
|
||||
|
||||
@ -121,6 +145,50 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
binding.muter.isSelected = !binding.muter.isSelected
|
||||
}
|
||||
|
||||
binding.cropper.setOnClickListener {
|
||||
showCropInterface(show = true, uri = uri)
|
||||
}
|
||||
|
||||
binding.saveCropButton.setOnClickListener {
|
||||
// This is the rectangle selected by the crop
|
||||
val cropRect = binding.cropImageView.cropWindowRect
|
||||
|
||||
// This is the rectangle of the whole image
|
||||
val fullImageRect: Rect = binding.cropImageView.getInitialCropWindowRect()
|
||||
|
||||
// x, y are coordinates of top left, in the ImageView
|
||||
val x = cropRect.left - fullImageRect.left
|
||||
val y = cropRect.top - fullImageRect.top
|
||||
|
||||
// width and height selected by the crop
|
||||
val width = cropRect.width()
|
||||
val height = cropRect.height()
|
||||
|
||||
// To avoid having to calculate the dimensions of the video here, we pass
|
||||
// relative width, height and x, y back to be treated in FFmpeg
|
||||
cropRelativeDimensions = RelativeCropPosition(
|
||||
relativeWidth = width/fullImageRect.width(),
|
||||
relativeHeight = height/fullImageRect.height(),
|
||||
relativeX = x/fullImageRect.width(),
|
||||
relativeY = y/fullImageRect.height()
|
||||
)
|
||||
|
||||
// If a crop was saved, change the color of the crop button to give a visual indication
|
||||
if(!cropRelativeDimensions.notCropped()){
|
||||
val typedValue = TypedValue()
|
||||
val color: Int = if (binding.checkMarkCropped.context.theme
|
||||
.resolveAttribute(R.attr.colorOnPrimaryContainer, typedValue, true)
|
||||
) typedValue.data else Color.TRANSPARENT
|
||||
|
||||
binding.cropper.drawable.setTint(color)
|
||||
} else {
|
||||
// Else reset the tint
|
||||
binding.cropper.drawable.setTintList(null)
|
||||
}
|
||||
|
||||
showCropInterface(show = false)
|
||||
}
|
||||
|
||||
binding.videoView.setPlayer(mediaPlayer)
|
||||
|
||||
mediaPlayer.seekTo((binding.videoRangeSeekBar.values[1]*1000).toLong())
|
||||
@ -188,7 +256,6 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
|
||||
when(item.itemId) {
|
||||
android.R.id.home -> onBackPressed()
|
||||
R.id.action_save -> {
|
||||
returnWithValues()
|
||||
}
|
||||
@ -201,7 +268,9 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (noEdits()) super.onBackPressed()
|
||||
if(binding.cropImageView.isVisible) {
|
||||
showCropInterface(false)
|
||||
} else if (noEdits()) super.onBackPressed()
|
||||
else {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.apply {
|
||||
@ -225,9 +294,37 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
val muted = binding.muter.isSelected
|
||||
val speedUnchanged = speed == 1
|
||||
|
||||
return !muted && videoPositions && speedUnchanged
|
||||
return !muted && videoPositions && speedUnchanged && cropRelativeDimensions.notCropped()
|
||||
}
|
||||
|
||||
private fun showCropInterface(show: Boolean, uri: Uri? = null){
|
||||
val visibilityOfOthers = if(show) View.GONE else View.VISIBLE
|
||||
val visibilityOfCrop = if(show) View.VISIBLE else View.GONE
|
||||
|
||||
if(show) mediaPlayer.pause()
|
||||
|
||||
if(show) binding.cropSavedCard.visibility = View.GONE
|
||||
else if(!cropRelativeDimensions.notCropped()) binding.cropSavedCard.visibility = View.VISIBLE
|
||||
|
||||
binding.muter.visibility = visibilityOfOthers
|
||||
binding.speeder.visibility = visibilityOfOthers
|
||||
binding.cropper.visibility = visibilityOfOthers
|
||||
binding.videoRangeSeekBar.visibility = visibilityOfOthers
|
||||
binding.videoView.visibility = visibilityOfOthers
|
||||
binding.thumbnail1.visibility = visibilityOfOthers
|
||||
binding.thumbnail2.visibility = visibilityOfOthers
|
||||
binding.thumbnail3.visibility = visibilityOfOthers
|
||||
binding.thumbnail4.visibility = visibilityOfOthers
|
||||
binding.thumbnail5.visibility = visibilityOfOthers
|
||||
binding.thumbnail6.visibility = visibilityOfOthers
|
||||
binding.thumbnail7.visibility = visibilityOfOthers
|
||||
|
||||
|
||||
binding.cropImageView.visibility = visibilityOfCrop
|
||||
binding.saveCropButton.visibility = visibilityOfCrop
|
||||
|
||||
if(show && uri != null) binding.cropImageView.setImageUriAsync(uri, cropRelativeDimensions)
|
||||
}
|
||||
|
||||
private fun returnWithValues() {
|
||||
val intent = Intent(this, PostCreationActivity::class.java)
|
||||
@ -238,6 +335,7 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
putExtra(MODIFIED, !noEdits())
|
||||
putExtra(VIDEO_START, binding.videoRangeSeekBar.values.first())
|
||||
putExtra(VIDEO_END, binding.videoRangeSeekBar.values[2])
|
||||
putExtra(VIDEO_CROP, cropRelativeDimensions)
|
||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
}
|
||||
|
||||
@ -248,6 +346,11 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
private fun resetControls() {
|
||||
binding.videoRangeSeekBar.values = listOf(0f, binding.videoRangeSeekBar.valueTo/2, binding.videoRangeSeekBar.valueTo)
|
||||
binding.muter.isSelected = false
|
||||
|
||||
binding.cropImageView.resetCropRect()
|
||||
cropRelativeDimensions = RelativeCropPosition()
|
||||
binding.cropper.drawable.setTintList(null)
|
||||
binding.cropSavedCard.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@ -310,6 +413,7 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
val speedChoices: List<Number> = listOf(0.5, 1, 2, 4, 8)
|
||||
const val VIDEO_START = "VideoEditVideoStartTag"
|
||||
const val VIDEO_END = "VideoEditVideoEndTag"
|
||||
const val VIDEO_CROP = "VideoEditVideoCropTag"
|
||||
const val MODIFIED = "VideoEditModifiedTag"
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.graphics.toRect
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import org.pixeldroid.app.databinding.CropImageViewBinding
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
|
||||
|
||||
|
||||
/** Custom view that provides cropping capabilities to an image. */
|
||||
class CropImageView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) :
|
||||
FrameLayout(context!!, attrs) {
|
||||
|
||||
|
||||
private val binding: CropImageViewBinding =
|
||||
CropImageViewBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
init {
|
||||
binding.CropOverlayView.setInitialAttributeValues()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the crop window's position relative to the parent's view at screen.
|
||||
*
|
||||
* @return a Rect instance containing notCropped area boundaries of the source Bitmap
|
||||
*/
|
||||
val cropWindowRect: RectF
|
||||
get() = binding.CropOverlayView.cropWindowRect
|
||||
|
||||
|
||||
/** Reset crop window to initial rectangle. */
|
||||
fun resetCropRect() {
|
||||
binding.CropOverlayView.resetCropWindowRect()
|
||||
}
|
||||
|
||||
fun getInitialCropWindowRect(): Rect = binding.CropOverlayView.initialCropWindowRect
|
||||
|
||||
/**
|
||||
* Sets the image loaded from the given URI as the content of the CropImageView
|
||||
*
|
||||
* @param uri the URI to load the image from
|
||||
*/
|
||||
fun setImageUriAsync(uri: Uri, cropRelativeDimensions: VideoEditActivity.RelativeCropPosition) {
|
||||
// either no existing task is working or we canceled it, need to load new URI
|
||||
binding.CropOverlayView.initialCropWindowRect = Rect()
|
||||
|
||||
Glide.with(this).load(uri).fitCenter().listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
m: Any?,
|
||||
t: Target<Drawable>?,
|
||||
i: Boolean,
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: DataSource?,
|
||||
isFirstResource: Boolean,
|
||||
): Boolean {
|
||||
// Get width and height that the image will take on the screen
|
||||
val drawnWidth = resource?.intrinsicWidth ?: width
|
||||
val drawnHeight = resource?.intrinsicHeight ?: height
|
||||
|
||||
binding.CropOverlayView.initialCropWindowRect = RectF(
|
||||
(width - drawnWidth) / 2f,
|
||||
(height - drawnHeight) / 2f,
|
||||
(width + drawnWidth) / 2f,
|
||||
(height + drawnHeight) / 2f
|
||||
).toRect()
|
||||
binding.CropOverlayView.setCropWindowLimits(
|
||||
drawnWidth.toFloat(),
|
||||
drawnHeight.toFloat()
|
||||
)
|
||||
binding.CropOverlayView.invalidate()
|
||||
binding.CropOverlayView.setBounds(width, height)
|
||||
binding.CropOverlayView.resetCropOverlayView()
|
||||
if (!cropRelativeDimensions.notCropped()) binding.CropOverlayView.setRecordedCropWindowRect(cropRelativeDimensions)
|
||||
binding.CropOverlayView.visibility = VISIBLE
|
||||
|
||||
|
||||
// Indicate to Glide that the image hasn't been set yet
|
||||
return false
|
||||
}
|
||||
}).into(binding.ImageViewImage)
|
||||
}
|
||||
}
|
@ -0,0 +1,490 @@
|
||||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity.RelativeCropPosition
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/** A custom View representing the crop window and the shaded background outside the crop window. */
|
||||
class CropOverlayView // endregion
|
||||
@JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) : View(context, attrs) {
|
||||
// region: Fields and Consts
|
||||
/** Handler from crop window stuff, moving and knowing position. */
|
||||
private val mCropWindowHandler = CropWindowHandler()
|
||||
|
||||
/** The Paint used to draw the white rectangle around the crop area. */
|
||||
private var mBorderPaint: Paint? = null
|
||||
|
||||
/** The Paint used to draw the corners of the Border */
|
||||
private var mBorderCornerPaint: Paint? = null
|
||||
|
||||
/** The Paint used to draw the guidelines within the crop area when pressed. */
|
||||
private var mGuidelinePaint: Paint? = null
|
||||
|
||||
/** The bounding box around the Bitmap that we are cropping. */
|
||||
private val mCalcBounds = RectF()
|
||||
|
||||
/** The bounding image view width used to know the crop overlay is at view edges. */
|
||||
private var mViewWidth = 0
|
||||
|
||||
/** The bounding image view height used to know the crop overlay is at view edges. */
|
||||
private var mViewHeight = 0
|
||||
|
||||
/** The Handle that is currently pressed; null if no Handle is pressed. */
|
||||
private var mMoveHandler: CropWindowMoveHandler? = null
|
||||
|
||||
/** the initial crop window rectangle to set */
|
||||
private val mInitialCropWindowRect = Rect()
|
||||
|
||||
/** Whether the Crop View has been initialized for the first time */
|
||||
private var initializedCropWindow = false
|
||||
/** Get the left/top/right/bottom coordinates of the crop window. */
|
||||
/** Set the left/top/right/bottom coordinates of the crop window. */
|
||||
var cropWindowRect: RectF
|
||||
get() = mCropWindowHandler.rect
|
||||
set(rect) {
|
||||
mCropWindowHandler.rect = rect
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs the CropOverlayView of the image's position relative to the ImageView. This is
|
||||
* necessary to call in order to draw the crop window.
|
||||
*
|
||||
* @param viewWidth The bounding image view width.
|
||||
* @param viewHeight The bounding image view height.
|
||||
*/
|
||||
fun setBounds(viewWidth: Int, viewHeight: Int) {
|
||||
mViewWidth = viewWidth
|
||||
mViewHeight = viewHeight
|
||||
val cropRect = mCropWindowHandler.rect
|
||||
if (cropRect.width() == 0f || cropRect.height() == 0f) {
|
||||
initCropWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets the crop overlay view. */
|
||||
fun resetCropOverlayView() {
|
||||
if (initializedCropWindow) {
|
||||
cropWindowRect = RectF()
|
||||
initCropWindow()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the max width/height and scale factor of the shown image to original image to scale the
|
||||
* limits appropriately.
|
||||
*/
|
||||
fun setCropWindowLimits(maxWidth: Float, maxHeight: Float) {
|
||||
mCropWindowHandler.setCropWindowLimits(maxWidth, maxHeight)
|
||||
}
|
||||
/** Get crop window initial rectangle. */
|
||||
/** Set crop window initial rectangle to be used instead of default. */
|
||||
var initialCropWindowRect: Rect
|
||||
get() = mInitialCropWindowRect
|
||||
set(rect) {
|
||||
mInitialCropWindowRect.set(rect)
|
||||
if (initializedCropWindow) {
|
||||
initCropWindow()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setRecordedCropWindowRect(relativeCropPosition: RelativeCropPosition) {
|
||||
val rect = RectF(
|
||||
mInitialCropWindowRect.left + relativeCropPosition.relativeX * mInitialCropWindowRect.width(),
|
||||
mInitialCropWindowRect.top + relativeCropPosition.relativeY * mInitialCropWindowRect.height(),
|
||||
relativeCropPosition.relativeWidth * mInitialCropWindowRect.width() + mInitialCropWindowRect.left + relativeCropPosition.relativeX * mInitialCropWindowRect.width(),
|
||||
relativeCropPosition.relativeHeight * mInitialCropWindowRect.height() + mInitialCropWindowRect.top + relativeCropPosition.relativeY * mInitialCropWindowRect.height()
|
||||
)
|
||||
mCropWindowHandler.rect = rect
|
||||
}
|
||||
|
||||
/** Reset crop window to initial rectangle. */
|
||||
fun resetCropWindowRect() {
|
||||
if (initializedCropWindow) {
|
||||
initCropWindow()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all initial values, but does not call initCropWindow to reset the views.<br></br>
|
||||
* Used once at the very start to initialize the attributes.
|
||||
*/
|
||||
fun setInitialAttributeValues() {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
mBorderPaint = getNewPaintOfThickness(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3f, dm),
|
||||
Color.argb(170, 255, 255, 255)
|
||||
)
|
||||
mBorderCornerPaint = getNewPaintOfThickness(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, dm),
|
||||
Color.WHITE
|
||||
)
|
||||
mGuidelinePaint = getNewPaintOfThickness(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1f, dm),
|
||||
Color.argb(170, 255, 255, 255)
|
||||
)
|
||||
}
|
||||
// region: Private methods
|
||||
/**
|
||||
* Set the initial crop window size and position. This is dependent on the size and position of
|
||||
* the image being cropped.
|
||||
*/
|
||||
private fun initCropWindow() {
|
||||
val rect = RectF()
|
||||
|
||||
// Tells the attribute functions the crop window has already been initialized
|
||||
initializedCropWindow = true
|
||||
if (mInitialCropWindowRect.width() > 0 && mInitialCropWindowRect.height() > 0) {
|
||||
// Get crop window position relative to the displayed image.
|
||||
rect.left = mInitialCropWindowRect.left.toFloat()
|
||||
rect.top = mInitialCropWindowRect.top.toFloat()
|
||||
rect.right = rect.left + mInitialCropWindowRect.width()
|
||||
rect.bottom = rect.top + mInitialCropWindowRect.height()
|
||||
}
|
||||
fixCropWindowRectByRules(rect)
|
||||
mCropWindowHandler.rect = rect
|
||||
}
|
||||
|
||||
/** Fix the given rect to fit into bitmap rect and follow min, max and aspect ratio rules. */
|
||||
private fun fixCropWindowRectByRules(rect: RectF) {
|
||||
if (rect.width() < mCropWindowHandler.minCropWidth) {
|
||||
val adj = (mCropWindowHandler.minCropWidth - rect.width()) / 2
|
||||
rect.left -= adj
|
||||
rect.right += adj
|
||||
}
|
||||
if (rect.height() < mCropWindowHandler.minCropHeight) {
|
||||
val adj = (mCropWindowHandler.minCropHeight - rect.height()) / 2
|
||||
rect.top -= adj
|
||||
rect.bottom += adj
|
||||
}
|
||||
if (rect.width() > mCropWindowHandler.maxCropWidth) {
|
||||
val adj = (rect.width() - mCropWindowHandler.maxCropWidth) / 2
|
||||
rect.left += adj
|
||||
rect.right -= adj
|
||||
}
|
||||
if (rect.height() > mCropWindowHandler.maxCropHeight) {
|
||||
val adj = (rect.height() - mCropWindowHandler.maxCropHeight) / 2
|
||||
rect.top += adj
|
||||
rect.bottom -= adj
|
||||
}
|
||||
setBounds()
|
||||
if (mCalcBounds.width() > 0 && mCalcBounds.height() > 0) {
|
||||
val leftLimit = max(mCalcBounds.left, 0f)
|
||||
val topLimit = max(mCalcBounds.top, 0f)
|
||||
val rightLimit = min(mCalcBounds.right, width.toFloat())
|
||||
val bottomLimit = min(mCalcBounds.bottom, height.toFloat())
|
||||
if (rect.left < leftLimit) {
|
||||
rect.left = leftLimit
|
||||
}
|
||||
if (rect.top < topLimit) {
|
||||
rect.top = topLimit
|
||||
}
|
||||
if (rect.right > rightLimit) {
|
||||
rect.right = rightLimit
|
||||
}
|
||||
if (rect.bottom > bottomLimit) {
|
||||
rect.bottom = bottomLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw crop overview by drawing background over image not in the cropping area, then borders and
|
||||
* guidelines.
|
||||
*/
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
// Draw translucent background for the notCropped area.
|
||||
drawBackground(canvas)
|
||||
if (mCropWindowHandler.showGuidelines()) {
|
||||
// Determines whether guidelines should be drawn or not
|
||||
if (mMoveHandler != null) {
|
||||
// Draw only when resizing
|
||||
drawGuidelines(canvas)
|
||||
}
|
||||
}
|
||||
drawBorders(canvas)
|
||||
drawCorners(canvas)
|
||||
}
|
||||
|
||||
/** Draw shadow background over the image not including the crop area. */
|
||||
private fun drawBackground(canvas: Canvas) {
|
||||
val rect = mCropWindowHandler.rect
|
||||
val background = getNewPaint(Color.argb(119, 0, 0, 0))
|
||||
canvas.drawRect(
|
||||
mInitialCropWindowRect.left.toFloat(),
|
||||
mInitialCropWindowRect.top.toFloat(),
|
||||
rect.left,
|
||||
mInitialCropWindowRect.bottom.toFloat(),
|
||||
background
|
||||
)
|
||||
canvas.drawRect(
|
||||
rect.left,
|
||||
rect.bottom,
|
||||
mInitialCropWindowRect.right.toFloat(),
|
||||
mInitialCropWindowRect.bottom.toFloat(),
|
||||
background
|
||||
)
|
||||
canvas.drawRect(
|
||||
rect.right,
|
||||
mInitialCropWindowRect.top.toFloat(),
|
||||
mInitialCropWindowRect.right.toFloat(),
|
||||
rect.bottom,
|
||||
background
|
||||
)
|
||||
canvas.drawRect(
|
||||
rect.left,
|
||||
mInitialCropWindowRect.top.toFloat(),
|
||||
rect.right,
|
||||
rect.top,
|
||||
background
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw 2 vertical and 2 horizontal guidelines inside the cropping area to split it into 9 equal
|
||||
* parts.
|
||||
*/
|
||||
private fun drawGuidelines(canvas: Canvas) {
|
||||
if (mGuidelinePaint != null) {
|
||||
val sw: Float = if (mBorderPaint != null) mBorderPaint!!.strokeWidth else 0f
|
||||
val rect = mCropWindowHandler.rect
|
||||
rect.inset(sw, sw)
|
||||
val oneThirdCropWidth = rect.width() / 3
|
||||
val oneThirdCropHeight = rect.height() / 3
|
||||
|
||||
// Draw vertical guidelines.
|
||||
val x1 = rect.left + oneThirdCropWidth
|
||||
val x2 = rect.right - oneThirdCropWidth
|
||||
canvas.drawLine(x1, rect.top, x1, rect.bottom, mGuidelinePaint!!)
|
||||
canvas.drawLine(x2, rect.top, x2, rect.bottom, mGuidelinePaint!!)
|
||||
|
||||
// Draw horizontal guidelines.
|
||||
val y1 = rect.top + oneThirdCropHeight
|
||||
val y2 = rect.bottom - oneThirdCropHeight
|
||||
canvas.drawLine(rect.left, y1, rect.right, y1, mGuidelinePaint!!)
|
||||
canvas.drawLine(rect.left, y2, rect.right, y2, mGuidelinePaint!!)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw borders of the crop area. */
|
||||
private fun drawBorders(canvas: Canvas) {
|
||||
if (mBorderPaint != null) {
|
||||
val w = mBorderPaint!!.strokeWidth
|
||||
val rect = mCropWindowHandler.rect
|
||||
// Make the rectangle a bit smaller to accommodate for the border
|
||||
rect.inset(w / 2, w / 2)
|
||||
|
||||
// Draw rectangle crop window border.
|
||||
canvas.drawRect(rect, mBorderPaint!!)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw the corner of crop overlay. */
|
||||
private fun drawCorners(canvas: Canvas) {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
if (mBorderCornerPaint != null) {
|
||||
val lineWidth: Float = if (mBorderPaint != null) mBorderPaint!!.strokeWidth else 0f
|
||||
val cornerWidth = mBorderCornerPaint!!.strokeWidth
|
||||
|
||||
// The corners should be a bit offset from the borders
|
||||
val w = (cornerWidth / 2
|
||||
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, dm))
|
||||
val rect = mCropWindowHandler.rect
|
||||
rect.inset(w, w)
|
||||
val cornerOffset = (cornerWidth - lineWidth) / 2
|
||||
val cornerExtension = cornerWidth / 2 + cornerOffset
|
||||
|
||||
/* the length of the border corner to draw */
|
||||
val mBorderCornerLength =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14f, dm)
|
||||
|
||||
// Top left
|
||||
canvas.drawLine(
|
||||
rect.left - cornerOffset,
|
||||
rect.top - cornerExtension,
|
||||
rect.left - cornerOffset,
|
||||
rect.top + mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.left - cornerExtension,
|
||||
rect.top - cornerOffset,
|
||||
rect.left + mBorderCornerLength,
|
||||
rect.top - cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
|
||||
// Top right
|
||||
canvas.drawLine(
|
||||
rect.right + cornerOffset,
|
||||
rect.top - cornerExtension,
|
||||
rect.right + cornerOffset,
|
||||
rect.top + mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.right + cornerExtension,
|
||||
rect.top - cornerOffset,
|
||||
rect.right - mBorderCornerLength,
|
||||
rect.top - cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
|
||||
// Bottom left
|
||||
canvas.drawLine(
|
||||
rect.left - cornerOffset,
|
||||
rect.bottom + cornerExtension,
|
||||
rect.left - cornerOffset,
|
||||
rect.bottom - mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.left - cornerExtension,
|
||||
rect.bottom + cornerOffset,
|
||||
rect.left + mBorderCornerLength,
|
||||
rect.bottom + cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
|
||||
// Bottom left
|
||||
canvas.drawLine(
|
||||
rect.right + cornerOffset,
|
||||
rect.bottom + cornerExtension,
|
||||
rect.right + cornerOffset,
|
||||
rect.bottom - mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.right + cornerExtension,
|
||||
rect.bottom + cornerOffset,
|
||||
rect.right - mBorderCornerLength,
|
||||
rect.bottom + cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
// If this View is not enabled, don't allow for touch interactions.
|
||||
return if (isEnabled) {
|
||||
/* Boolean to see if multi touch is enabled for the crop rectangle */
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
onActionDown(event.x, event.y)
|
||||
true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
onActionUp()
|
||||
true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
onActionMove(event.x, event.y)
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On press down start crop window movement depending on the location of the press.<br></br>
|
||||
* if press is far from crop window then no move handler is returned (null).
|
||||
*/
|
||||
private fun onActionDown(x: Float, y: Float) {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
mMoveHandler = mCropWindowHandler.getMoveHandler(
|
||||
x,
|
||||
y,
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24f, dm)
|
||||
)
|
||||
if (mMoveHandler != null) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear move handler starting in [.onActionDown] if exists. */
|
||||
private fun onActionUp() {
|
||||
if (mMoveHandler != null) {
|
||||
mMoveHandler = null
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle move of crop window using the move handler created in [.onActionDown].<br></br>
|
||||
* The move handler will do the proper move/resize of the crop window.
|
||||
*/
|
||||
private fun onActionMove(x: Float, y: Float) {
|
||||
if (mMoveHandler != null) {
|
||||
val rect = mCropWindowHandler.rect
|
||||
setBounds()
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
val snapRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3f, dm)
|
||||
mMoveHandler!!.move(
|
||||
rect,
|
||||
x,
|
||||
y,
|
||||
mCalcBounds,
|
||||
mViewWidth,
|
||||
mViewHeight,
|
||||
snapRadius
|
||||
)
|
||||
mCropWindowHandler.rect = rect
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the bounding rectangle for current crop window
|
||||
* The bounds rectangle is the bitmap rectangle
|
||||
*/
|
||||
private fun setBounds() {
|
||||
mCalcBounds.set(mInitialCropWindowRect)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Creates the Paint object for drawing. */
|
||||
private fun getNewPaint(color: Int): Paint {
|
||||
val paint = Paint()
|
||||
paint.color = color
|
||||
return paint
|
||||
}
|
||||
|
||||
/** Creates the Paint object for given thickness and color */
|
||||
private fun getNewPaintOfThickness(thickness: Float, color: Int): Paint {
|
||||
val borderPaint = Paint()
|
||||
borderPaint.color = color
|
||||
borderPaint.strokeWidth = thickness
|
||||
borderPaint.style = Paint.Style.STROKE
|
||||
borderPaint.isAntiAlias = true
|
||||
return borderPaint
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,269 @@
|
||||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.RectF
|
||||
import android.util.TypedValue
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/** Handler from crop window stuff, moving and knowing position. */
|
||||
internal class CropWindowHandler {
|
||||
/** The 4 edges of the crop window defining its coordinates and size */
|
||||
private val mEdges = RectF()
|
||||
|
||||
/**
|
||||
* Rectangle used to return the edges rectangle without ability to change it and without
|
||||
* creating new all the time.
|
||||
*/
|
||||
private val mGetEdges = RectF()
|
||||
|
||||
/** Maximum width in pixels that the crop window can CURRENTLY get. */
|
||||
private var mMaxCropWindowWidth = 0f
|
||||
|
||||
/** Maximum height in pixels that the crop window can CURRENTLY get. */
|
||||
private var mMaxCropWindowHeight = 0f
|
||||
|
||||
/** The left/top/right/bottom coordinates of the crop window. */
|
||||
var rect: RectF
|
||||
get() {
|
||||
mGetEdges.set(mEdges)
|
||||
return mGetEdges
|
||||
}
|
||||
set(rect) {
|
||||
mEdges.set(rect)
|
||||
}
|
||||
|
||||
/** Minimum width in pixels that the crop window can get. */
|
||||
val minCropWidth: Float
|
||||
get() {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
val mMinCropResultWidth = 40f
|
||||
return max(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42f, dm).toInt().toFloat(),
|
||||
mMinCropResultWidth
|
||||
)
|
||||
}
|
||||
|
||||
/** Minimum height in pixels that the crop window can get. */
|
||||
val minCropHeight: Float
|
||||
get() {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
val mMinCropResultHeight = 40f
|
||||
return max(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42f, dm).toInt().toFloat(),
|
||||
mMinCropResultHeight
|
||||
)
|
||||
}
|
||||
|
||||
/** Maximum width in pixels that the crop window can get. */
|
||||
val maxCropWidth: Float
|
||||
get() {
|
||||
val mMaxCropResultWidth = 99999f
|
||||
return min(mMaxCropWindowWidth, mMaxCropResultWidth)
|
||||
}
|
||||
|
||||
/** Maximum height in pixels that the crop window can get. */
|
||||
val maxCropHeight: Float
|
||||
get() {
|
||||
val mMaxCropResultHeight = 99999f
|
||||
return min(mMaxCropWindowHeight, mMaxCropResultHeight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the max width/height of the shown image to original image to scale the limits appropriately
|
||||
*/
|
||||
fun setCropWindowLimits(maxWidth: Float, maxHeight: Float) {
|
||||
mMaxCropWindowWidth = maxWidth
|
||||
mMaxCropWindowHeight = maxHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the crop window is small enough that the guidelines should be shown. Public
|
||||
* because this function is also used to determine if the center handle should be focused.
|
||||
*
|
||||
* @return boolean Whether the guidelines should be shown or not
|
||||
*/
|
||||
fun showGuidelines(): Boolean {
|
||||
return !(mEdges.width() < 100 || mEdges.height() < 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
|
||||
* box, and the touch radius.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return the Handle that was pressed; null if no Handle was pressed
|
||||
*/
|
||||
fun getMoveHandler(x: Float, y: Float, targetRadius: Float): CropWindowMoveHandler? {
|
||||
val type = getRectanglePressedMoveType(x, y, targetRadius)
|
||||
return if (type != null) CropWindowMoveHandler(type, this, x, y) else null
|
||||
}
|
||||
// region: Private methods
|
||||
/**
|
||||
* Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
|
||||
* box, and the touch radius.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return the Handle that was pressed; null if no Handle was pressed
|
||||
*/
|
||||
private fun getRectanglePressedMoveType(
|
||||
x: Float, y: Float, targetRadius: Float
|
||||
): CropWindowMoveHandler.Type? {
|
||||
var moveType: CropWindowMoveHandler.Type? = null
|
||||
|
||||
// Note: corner-handles take precedence, then side-handles, then center.
|
||||
if (isInCornerTargetZone(x, y, mEdges.left, mEdges.top, targetRadius)) {
|
||||
moveType = CropWindowMoveHandler.Type.TOP_LEFT
|
||||
} else if (isInCornerTargetZone(
|
||||
x, y, mEdges.right, mEdges.top, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.TOP_RIGHT
|
||||
} else if (isInCornerTargetZone(
|
||||
x, y, mEdges.left, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT
|
||||
} else if (isInCornerTargetZone(
|
||||
x, y, mEdges.right, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT
|
||||
} else if (isInCenterTargetZone(
|
||||
x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom
|
||||
)
|
||||
&& focusCenter()
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.CENTER
|
||||
} else if (isInHorizontalTargetZone(
|
||||
x, y, mEdges.left, mEdges.right, mEdges.top, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.TOP
|
||||
} else if (isInHorizontalTargetZone(
|
||||
x, y, mEdges.left, mEdges.right, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.BOTTOM
|
||||
} else if (isInVerticalTargetZone(
|
||||
x, y, mEdges.left, mEdges.top, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.LEFT
|
||||
} else if (isInVerticalTargetZone(
|
||||
x, y, mEdges.right, mEdges.top, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.RIGHT
|
||||
} else if (isInCenterTargetZone(
|
||||
x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom
|
||||
)
|
||||
&& !focusCenter()
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.CENTER
|
||||
}
|
||||
return moveType
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the cropper should focus on the center handle or the side handles. If it is a
|
||||
* small image, focus on the center handle so the user can move it. If it is a large image, focus
|
||||
* on the side handles so user can grab them. Corresponds to the appearance of the
|
||||
* RuleOfThirdsGuidelines.
|
||||
*
|
||||
* @return true if it is small enough such that it should focus on the center; less than
|
||||
* show_guidelines limit
|
||||
*/
|
||||
private fun focusCenter(): Boolean = !showGuidelines()
|
||||
|
||||
// endregion
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Determines if the specified coordinate is in the target touch zone for a corner handle.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param handleX the x-coordinate of the corner handle
|
||||
* @param handleY the y-coordinate of the corner handle
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return true if the touch point is in the target touch zone; false otherwise
|
||||
*/
|
||||
private fun isInCornerTargetZone(
|
||||
x: Float, y: Float, handleX: Float, handleY: Float, targetRadius: Float
|
||||
): Boolean {
|
||||
return abs(x - handleX) <= targetRadius && abs(y - handleY) <= targetRadius
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the specified coordinate is in the target touch zone for a horizontal bar handle.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param handleXStart the left x-coordinate of the horizontal bar handle
|
||||
* @param handleXEnd the right x-coordinate of the horizontal bar handle
|
||||
* @param handleY the y-coordinate of the horizontal bar handle
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return true if the touch point is in the target touch zone; false otherwise
|
||||
*/
|
||||
private fun isInHorizontalTargetZone(
|
||||
x: Float,
|
||||
y: Float,
|
||||
handleXStart: Float,
|
||||
handleXEnd: Float,
|
||||
handleY: Float,
|
||||
targetRadius: Float
|
||||
): Boolean {
|
||||
return x > handleXStart && x < handleXEnd && abs(y - handleY) <= targetRadius
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the specified coordinate is in the target touch zone for a vertical bar handle.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param handleX the x-coordinate of the vertical bar handle
|
||||
* @param handleYStart the top y-coordinate of the vertical bar handle
|
||||
* @param handleYEnd the bottom y-coordinate of the vertical bar handle
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return true if the touch point is in the target touch zone; false otherwise
|
||||
*/
|
||||
private fun isInVerticalTargetZone(
|
||||
x: Float,
|
||||
y: Float,
|
||||
handleX: Float,
|
||||
handleYStart: Float,
|
||||
handleYEnd: Float,
|
||||
targetRadius: Float
|
||||
): Boolean {
|
||||
return abs(x - handleX) <= targetRadius && y > handleYStart && y < handleYEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the specified coordinate falls anywhere inside the given bounds.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param left the x-coordinate of the left bound
|
||||
* @param top the y-coordinate of the top bound
|
||||
* @param right the x-coordinate of the right bound
|
||||
* @param bottom the y-coordinate of the bottom bound
|
||||
* @return true if the touch point is inside the bounding rectangle; false otherwise
|
||||
*/
|
||||
private fun isInCenterTargetZone(
|
||||
x: Float, y: Float, left: Float, top: Float, right: Float, bottom: Float
|
||||
): Boolean {
|
||||
return x > left && x < right && y > top && y < bottom
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,405 @@
|
||||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.graphics.RectF
|
||||
|
||||
/**
|
||||
* Handler to update crop window edges by the move type - Horizontal, Vertical, Corner or Center.
|
||||
*/
|
||||
internal class CropWindowMoveHandler(
|
||||
/** The type of crop window move that is handled. */
|
||||
private val mType: Type,
|
||||
cropWindowHandler: CropWindowHandler, touchX: Float, touchY: Float
|
||||
) {
|
||||
/** Minimum width in pixels that the crop window can get. */
|
||||
private val mMinCropWidth: Float
|
||||
|
||||
/** Minimum width in pixels that the crop window can get. */
|
||||
private val mMinCropHeight: Float
|
||||
|
||||
/** Maximum height in pixels that the crop window can get. */
|
||||
private val mMaxCropWidth: Float
|
||||
|
||||
/** Maximum height in pixels that the crop window can get. */
|
||||
private val mMaxCropHeight: Float
|
||||
|
||||
/**
|
||||
* Holds the x and y offset between the exact touch location and the exact handle location that is
|
||||
* activated. There may be an offset because we allow for some leeway (specified by mHandleRadius)
|
||||
* in activating a handle. However, we want to maintain these offset values while the handle is
|
||||
* being dragged so that the handle doesn't jump.
|
||||
*/
|
||||
private val mTouchOffset = PointF()
|
||||
|
||||
init {
|
||||
mMinCropWidth = cropWindowHandler.minCropWidth
|
||||
mMinCropHeight = cropWindowHandler.minCropHeight
|
||||
mMaxCropWidth = cropWindowHandler.maxCropWidth
|
||||
mMaxCropHeight = cropWindowHandler.maxCropHeight
|
||||
calculateTouchOffset(cropWindowHandler.rect, touchX, touchY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the crop window by change in the touch location.
|
||||
* Move type handled by this instance, as initialized in creation, affects how the change in
|
||||
* touch location changes the crop window position and size.
|
||||
* After the crop window position/size is changed by touch move it may result in values that
|
||||
* violate constraints: outside the bounds of the shown bitmap, smaller/larger than min/max size or
|
||||
* mismatch in aspect ratio. So a series of fixes is executed on "secondary" edges to adjust it
|
||||
* by the "primary" edge movement.
|
||||
* Primary is the edge directly affected by move type, secondary is the other edge.
|
||||
* The crop window is changed by directly setting the Edge coordinates.
|
||||
*
|
||||
* @param x the new x-coordinate of this handle
|
||||
* @param y the new y-coordinate of this handle
|
||||
* @param bounds the bounding rectangle of the image
|
||||
* @param viewWidth The bounding image view width used to know the crop overlay is at view edges.
|
||||
* @param viewHeight The bounding image view height used to know the crop overlay is at view
|
||||
* edges.
|
||||
* @param snapMargin the maximum distance (in pixels) at which the crop window should snap to the
|
||||
* image
|
||||
*/
|
||||
fun move(
|
||||
rect: RectF,
|
||||
x: Float,
|
||||
y: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
|
||||
// Adjust the coordinates for the finger position's offset (i.e. the
|
||||
// distance from the initial touch to the precise handle location).
|
||||
// We want to maintain the initial touch's distance to the pressed
|
||||
// handle so that the crop window size does not "jump".
|
||||
val adjX = x + mTouchOffset.x
|
||||
val adjY = y + mTouchOffset.y
|
||||
if (mType == Type.CENTER) {
|
||||
moveCenter(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin)
|
||||
} else {
|
||||
changeSize(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin)
|
||||
}
|
||||
}
|
||||
// region: Private methods
|
||||
/**
|
||||
* Calculates the offset of the touch point from the precise location of the specified handle.<br></br>
|
||||
* Save these values in a member variable since we want to maintain this offset as we drag the
|
||||
* handle.
|
||||
*/
|
||||
private fun calculateTouchOffset(rect: RectF, touchX: Float, touchY: Float) {
|
||||
var touchOffsetX = 0f
|
||||
var touchOffsetY = 0f
|
||||
when (mType) {
|
||||
Type.TOP_LEFT -> {
|
||||
touchOffsetX = rect.left - touchX
|
||||
touchOffsetY = rect.top - touchY
|
||||
}
|
||||
|
||||
Type.TOP_RIGHT -> {
|
||||
touchOffsetX = rect.right - touchX
|
||||
touchOffsetY = rect.top - touchY
|
||||
}
|
||||
|
||||
Type.BOTTOM_LEFT -> {
|
||||
touchOffsetX = rect.left - touchX
|
||||
touchOffsetY = rect.bottom - touchY
|
||||
}
|
||||
|
||||
Type.BOTTOM_RIGHT -> {
|
||||
touchOffsetX = rect.right - touchX
|
||||
touchOffsetY = rect.bottom - touchY
|
||||
}
|
||||
|
||||
Type.LEFT -> {
|
||||
touchOffsetX = rect.left - touchX
|
||||
touchOffsetY = 0f
|
||||
}
|
||||
|
||||
Type.TOP -> {
|
||||
touchOffsetX = 0f
|
||||
touchOffsetY = rect.top - touchY
|
||||
}
|
||||
|
||||
Type.RIGHT -> {
|
||||
touchOffsetX = rect.right - touchX
|
||||
touchOffsetY = 0f
|
||||
}
|
||||
|
||||
Type.BOTTOM -> {
|
||||
touchOffsetX = 0f
|
||||
touchOffsetY = rect.bottom - touchY
|
||||
}
|
||||
|
||||
Type.CENTER -> {
|
||||
touchOffsetX = rect.centerX() - touchX
|
||||
touchOffsetY = rect.centerY() - touchY
|
||||
}
|
||||
}
|
||||
mTouchOffset.x = touchOffsetX
|
||||
mTouchOffset.y = touchOffsetY
|
||||
}
|
||||
|
||||
/** Center move only changes the position of the crop window without changing the size. */
|
||||
private fun moveCenter(
|
||||
rect: RectF,
|
||||
x: Float,
|
||||
y: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
snapRadius: Float
|
||||
) {
|
||||
var dx = x - rect.centerX()
|
||||
var dy = y - rect.centerY()
|
||||
if (rect.left + dx < 0 || rect.right + dx > viewWidth || rect.left + dx < bounds.left || rect.right + dx > bounds.right) {
|
||||
dx /= 1.05f
|
||||
mTouchOffset.x -= dx / 2
|
||||
}
|
||||
if (rect.top + dy < 0 || rect.bottom + dy > viewHeight || rect.top + dy < bounds.top || rect.bottom + dy > bounds.bottom) {
|
||||
dy /= 1.05f
|
||||
mTouchOffset.y -= dy / 2
|
||||
}
|
||||
rect.offset(dx, dy)
|
||||
snapEdgesToBounds(rect, bounds, snapRadius)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the size of the crop window on the required edge (or edges in the case of a corner)
|
||||
*/
|
||||
private fun changeSize(
|
||||
rect: RectF,
|
||||
x: Float,
|
||||
y: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
when (mType) {
|
||||
Type.TOP_LEFT -> {
|
||||
adjustTop(rect, y, bounds, snapMargin)
|
||||
adjustLeft(rect, x, bounds, snapMargin)
|
||||
}
|
||||
|
||||
Type.TOP_RIGHT -> {
|
||||
adjustTop(rect, y, bounds, snapMargin)
|
||||
adjustRight(rect, x, bounds, viewWidth, snapMargin)
|
||||
}
|
||||
|
||||
Type.BOTTOM_LEFT -> {
|
||||
adjustBottom(rect, y, bounds, viewHeight, snapMargin)
|
||||
adjustLeft(rect, x, bounds, snapMargin)
|
||||
}
|
||||
|
||||
Type.BOTTOM_RIGHT -> {
|
||||
adjustBottom(rect, y, bounds, viewHeight, snapMargin)
|
||||
adjustRight(rect, x, bounds, viewWidth, snapMargin)
|
||||
}
|
||||
|
||||
Type.LEFT -> adjustLeft(rect, x, bounds, snapMargin)
|
||||
Type.TOP -> adjustTop(rect, y, bounds, snapMargin)
|
||||
Type.RIGHT -> adjustRight(rect, x, bounds, viewWidth, snapMargin)
|
||||
Type.BOTTOM -> adjustBottom(rect, y, bounds, viewHeight, snapMargin)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if edges have gone out of bounds (including snap margin), and fix if needed. */
|
||||
private fun snapEdgesToBounds(edges: RectF, bounds: RectF, margin: Float) {
|
||||
if (edges.left < bounds.left + margin) {
|
||||
edges.offset(bounds.left - edges.left, 0f)
|
||||
}
|
||||
if (edges.top < bounds.top + margin) {
|
||||
edges.offset(0f, bounds.top - edges.top)
|
||||
}
|
||||
if (edges.right > bounds.right - margin) {
|
||||
edges.offset(bounds.right - edges.right, 0f)
|
||||
}
|
||||
if (edges.bottom > bounds.bottom - margin) {
|
||||
edges.offset(0f, bounds.bottom - edges.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting x-position of the left edge of the crop window given the handle's position
|
||||
* and the image's bounding box and snap radius.
|
||||
*
|
||||
* @param left the position that the left edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustLeft(
|
||||
rect: RectF,
|
||||
left: Float,
|
||||
bounds: RectF,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newLeft = left
|
||||
if (newLeft < 0) {
|
||||
newLeft /= 1.05f
|
||||
mTouchOffset.x -= newLeft / 1.1f
|
||||
}
|
||||
if (newLeft < bounds.left) {
|
||||
mTouchOffset.x -= (newLeft - bounds.left) / 2f
|
||||
}
|
||||
if (newLeft - bounds.left < snapMargin) {
|
||||
newLeft = bounds.left
|
||||
}
|
||||
|
||||
// Checks if the window is too small horizontally
|
||||
if (rect.right - newLeft < mMinCropWidth) {
|
||||
newLeft = rect.right - mMinCropWidth
|
||||
}
|
||||
|
||||
// Checks if the window is too large horizontally
|
||||
if (rect.right - newLeft > mMaxCropWidth) {
|
||||
newLeft = rect.right - mMaxCropWidth
|
||||
}
|
||||
if (newLeft - bounds.left < snapMargin) {
|
||||
newLeft = bounds.left
|
||||
}
|
||||
rect.left = newLeft
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting x-position of the right edge of the crop window given the handle's position
|
||||
* and the image's bounding box and snap radius.
|
||||
*
|
||||
* @param right the position that the right edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param viewWidth
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustRight(
|
||||
rect: RectF,
|
||||
right: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newRight = right
|
||||
if (newRight > viewWidth) {
|
||||
newRight = viewWidth + (newRight - viewWidth) / 1.05f
|
||||
mTouchOffset.x -= (newRight - viewWidth) / 1.1f
|
||||
}
|
||||
if (newRight > bounds.right) {
|
||||
mTouchOffset.x -= (newRight - bounds.right) / 2f
|
||||
}
|
||||
|
||||
// If close to the edge
|
||||
if (bounds.right - newRight < snapMargin) {
|
||||
newRight = bounds.right
|
||||
}
|
||||
|
||||
// Checks if the window is too small horizontally
|
||||
if (newRight - rect.left < mMinCropWidth) {
|
||||
newRight = rect.left + mMinCropWidth
|
||||
}
|
||||
|
||||
// Checks if the window is too large horizontally
|
||||
if (newRight - rect.left > mMaxCropWidth) {
|
||||
newRight = rect.left + mMaxCropWidth
|
||||
}
|
||||
|
||||
// If close to the edge
|
||||
if (bounds.right - newRight < snapMargin) {
|
||||
newRight = bounds.right
|
||||
}
|
||||
rect.right = newRight
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting y-position of the top edge of the crop window given the handle's position and
|
||||
* the image's bounding box and snap radius.
|
||||
*
|
||||
* @param top the x-position that the top edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustTop(
|
||||
rect: RectF,
|
||||
top: Float,
|
||||
bounds: RectF,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newTop = top
|
||||
if (newTop < 0) {
|
||||
newTop /= 1.05f
|
||||
mTouchOffset.y -= newTop / 1.1f
|
||||
}
|
||||
if (newTop < bounds.top) {
|
||||
mTouchOffset.y -= (newTop - bounds.top) / 2f
|
||||
}
|
||||
if (newTop - bounds.top < snapMargin) {
|
||||
newTop = bounds.top
|
||||
}
|
||||
|
||||
// Checks if the window is too small vertically
|
||||
if (rect.bottom - newTop < mMinCropHeight) {
|
||||
newTop = rect.bottom - mMinCropHeight
|
||||
}
|
||||
|
||||
// Checks if the window is too large vertically
|
||||
if (rect.bottom - newTop > mMaxCropHeight) {
|
||||
newTop = rect.bottom - mMaxCropHeight
|
||||
}
|
||||
if (newTop - bounds.top < snapMargin) {
|
||||
newTop = bounds.top
|
||||
}
|
||||
rect.top = newTop
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting y-position of the bottom edge of the crop window given the handle's position
|
||||
* and the image's bounding box and snap radius.
|
||||
*
|
||||
* @param bottom the position that the bottom edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param viewHeight
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustBottom(
|
||||
rect: RectF,
|
||||
bottom: Float,
|
||||
bounds: RectF,
|
||||
viewHeight: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newBottom = bottom
|
||||
if (newBottom > viewHeight) {
|
||||
newBottom = viewHeight + (newBottom - viewHeight) / 1.05f
|
||||
mTouchOffset.y -= (newBottom - viewHeight) / 1.1f
|
||||
}
|
||||
if (newBottom > bounds.bottom) {
|
||||
mTouchOffset.y -= (newBottom - bounds.bottom) / 2f
|
||||
}
|
||||
if (bounds.bottom - newBottom < snapMargin) {
|
||||
newBottom = bounds.bottom
|
||||
}
|
||||
|
||||
// Checks if the window is too small vertically
|
||||
if (newBottom - rect.top < mMinCropHeight) {
|
||||
newBottom = rect.top + mMinCropHeight
|
||||
}
|
||||
|
||||
// Checks if the window is too small vertically
|
||||
if (newBottom - rect.top > mMaxCropHeight) {
|
||||
newBottom = rect.top + mMaxCropHeight
|
||||
}
|
||||
if (bounds.bottom - newBottom < snapMargin) {
|
||||
newBottom = bounds.bottom
|
||||
}
|
||||
rect.bottom = newBottom
|
||||
}
|
||||
// endregion
|
||||
|
||||
/** The type of crop window move that is handled. */
|
||||
enum class Type {
|
||||
TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, LEFT, TOP, RIGHT, BOTTOM, CENTER
|
||||
}
|
||||
}
|
@ -4,6 +4,6 @@
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnBackground"
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17,15h2V7c0,-1.1 -0.9,-2 -2,-2H9v2h8v8zM7,17V1H5v4H1v2h4v10c0,1.1 0.9,2 2,2h10v4h2v-4h4v-2H7z"/>
|
||||
</vector>
|
||||
|
@ -79,6 +79,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="411dp"
|
||||
android:tint="?attr/colorOnBackground"
|
||||
android:src="@drawable/ic_crop_black_24dp"
|
||||
app:layout_constraintLeft_toLeftOf="@+id/left_guideline"
|
||||
app:layout_constraintRight_toRightOf="@+id/right_guideline"
|
||||
|
@ -19,6 +19,30 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<org.pixeldroid.app.postCreation.photoEdit.cropper.CropImageView
|
||||
android:id="@+id/cropImageView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/save_crop_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_margin="16dp"
|
||||
android:visibility="gone"
|
||||
android:text="@string/save_crop"
|
||||
android:contentDescription="@string/save_crop"
|
||||
app:icon="@drawable/ic_crop_black_24dp"/>
|
||||
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/muter"
|
||||
android:layout_width="60dp"
|
||||
@ -32,7 +56,6 @@
|
||||
app:layout_constraintBottom_toTopOf="@+id/thumbnail1"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/speeder"
|
||||
android:layout_width="60dp"
|
||||
@ -46,6 +69,58 @@
|
||||
app:layout_constraintBottom_toTopOf="@+id/thumbnail1"
|
||||
app:layout_constraintStart_toEndOf="@+id/muter" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/cropper"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginBottom="48dp"
|
||||
android:contentDescription="@string/video_crop"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:padding="4dp"
|
||||
android:src="@drawable/ic_crop_black_24dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/thumbnail1"
|
||||
app:layout_constraintStart_toEndOf="@+id/speeder" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:visibility="invisible"
|
||||
tools:visibility="visible"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toStartOf="@+id/cropper"
|
||||
app:layout_constraintEnd_toEndOf="@+id/cropper"
|
||||
app:layout_constraintBottom_toTopOf="@+id/cropper"
|
||||
android:id="@+id/cropSavedCard"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/checkMarkCropped"
|
||||
android:importantForAccessibility="no"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/check_circle_24"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/encodeInfoText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/checkMarkCropped"
|
||||
app:layout_constraintTop_toTopOf="@id/checkMarkCropped"
|
||||
app:layout_constraintStart_toEndOf="@id/checkMarkCropped"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/crop_saved" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.slider.RangeSlider
|
||||
android:id="@+id/videoRangeSeekBar"
|
||||
android:layout_width="match_parent"
|
||||
|
6
app/src/main/res/layout/crop_image_activity.xml
Normal file
6
app/src/main/res/layout/crop_image_activity.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.pixeldroid.app.postCreation.photoEdit.cropper.CropImageView
|
||||
android:id="@+id/cropImageView"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"/>
|
31
app/src/main/res/layout/crop_image_view.xml
Normal file
31
app/src/main/res/layout/crop_image_view.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_width="match_parent">
|
||||
|
||||
|
||||
<org.pixeldroid.app.postCreation.photoEdit.cropper.CropOverlayView
|
||||
android:id="@+id/CropOverlayView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:elevation="2dp"
|
||||
android:visibility="visible"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ImageView_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -271,6 +271,9 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
|
||||
<string name="select_video_range">Select what to keep of the video</string>
|
||||
<string name="mute_video">Mute video</string>
|
||||
<string name="video_speed">Change video speed</string>
|
||||
<string name="video_crop">Crop video</string>
|
||||
<string name="save_crop">Save crop</string>
|
||||
<string name="crop_saved">Crop saved</string>
|
||||
<string name="still_encoding">One or more videos are still encoding. Wait for them to finish before uploading</string>
|
||||
<string name="new_post_shortcut_long">Create new post</string>
|
||||
<string name="new_post_shortcut_short">New post</string>
|
||||
|
Loading…
x
Reference in New Issue
Block a user