diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt index 3981b82f..868e9409 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt @@ -2,12 +2,14 @@ package org.pixeldroid.app.postCreation.photoEdit import android.app.Activity import android.app.AlertDialog +import android.content.ContentUris import android.content.Intent import android.media.AudioManager import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper +import android.provider.MediaStore import android.text.format.DateUtils import android.util.Log import android.view.Menu @@ -59,6 +61,9 @@ class VideoEditActivity : BaseThemedWithBarActivity() { val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper()) val uri = intent.getParcelableExtra(PhotoEditActivity.PICTURE_URI)!! + + binding.cropImageView.setImageUriAsync(uri) + videoPosition = intent.getIntExtra(PhotoEditActivity.PICTURE_POSITION, -1) val inputVideoPath = ffmpegCompliantUri(uri) diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropImageView.kt b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropImageView.kt new file mode 100644 index 00000000..21616b97 --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropImageView.kt @@ -0,0 +1,115 @@ +package org.pixeldroid.app.postCreation.photoEdit.cropper + +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 android.widget.ImageView +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.R + + +/** Custom view that provides cropping capabilities to an image. */ +class CropImageView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) : + FrameLayout(context!!, attrs) { + /** Image view widget used to show the image for cropping. */ + private val mImageView: ImageView + + /** Overlay over the image view to show cropping UI. */ + private val mCropOverlayView: CropOverlayView? + + /** The sample size the image was loaded by if was loaded by URI */ + private var mLoadedSampleSize = 1 + + init { + val inflater = LayoutInflater.from(context) + val v = inflater.inflate(R.layout.crop_image_view, this, true) + mImageView = v.findViewById(R.id.ImageView_image) + mCropOverlayView = v.findViewById(R.id.CropOverlayView) + mCropOverlayView.setInitialAttributeValues() + } + + /** + * Gets the crop window's position relative to the parent's view at screen. + * + * @return a Rect instance containing cropped area boundaries of the source Bitmap + */ + val cropWindowRect: RectF? + get() = mCropOverlayView?.cropWindowRect// Get crop window position relative to the displayed image. + + /** + * Set the crop window position and size to the given rectangle. + * Image to crop must be first set before invoking this, for async - after complete callback. + * + * @param rect window rectangle (position and size) relative to source bitmap + */ + fun setCropRect(rect: Rect?) { + mCropOverlayView!!.initialCropWindowRect = rect + } + + /** Reset crop window to initial rectangle. */ + fun resetCropRect() { + mCropOverlayView!!.resetCropWindowRect() + } + + /** + * Sets a bitmap loaded from the given Android URI as the content of the CropImageView.

+ * Can be used with URI from gallery or camera source.

+ * Will rotate the image by exif data.

+ * + * @param uri the URI to load the image from + */ + fun setImageUriAsync(uri: Uri) { + // either no existing task is working or we canceled it, need to load new URI + mCropOverlayView!!.initialCropWindowRect = null + + Glide.with(this).load(uri).fitCenter().listener(object : RequestListener { + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean {return false } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + 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 + + mCropOverlayView.cropWindowRect = + RectF((width - drawnWidth)/2f, (height - drawnHeight)/2f, (width + drawnWidth)/2f, (height + drawnHeight)/2f) + mCropOverlayView.initialCropWindowRect = mCropOverlayView.cropWindowRect.toRect() + mCropOverlayView.setCropWindowLimits(drawnWidth.toFloat(), drawnHeight.toFloat(), 1f, 1f) + setBitmap() + + // Indicate to Glide that the image hasn't been set yet + return false + } + }).into(mImageView) + } + + /** + * Set the given bitmap to be used in for cropping

+ * Optionally clear full if the bitmap is new, or partial clear if the bitmap has been + * manipulated. + */ + private fun setBitmap() { + mLoadedSampleSize = 1 + if (mCropOverlayView != null) { + mCropOverlayView.invalidate() + mCropOverlayView.setBounds(width, height) + mCropOverlayView.resetCropOverlayView() + mCropOverlayView.visibility = VISIBLE + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropOverlayView.java b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropOverlayView.java new file mode 100644 index 00000000..82205c8d --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropOverlayView.java @@ -0,0 +1,533 @@ +package org.pixeldroid.app.postCreation.photoEdit.cropper; + +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.DisplayMetrics; +import android.util.TypedValue; +import android.view.MotionEvent; +import android.view.View; + +/** A custom View representing the crop window and the shaded background outside the crop window. */ +public class CropOverlayView extends View { + + // region: Fields and Consts + + /** Handler from crop window stuff, moving and knowing position. */ + private final CropWindowHandler mCropWindowHandler = new CropWindowHandler(); + + /** The Paint used to draw the white rectangle around the crop area. */ + private Paint mBorderPaint; + + /** The Paint used to draw the corners of the Border */ + private Paint mBorderCornerPaint; + + /** The Paint used to draw the guidelines within the crop area when pressed. */ + private Paint mGuidelinePaint; + + /** The Paint used to darken the surrounding areas outside the crop area. */ + private final Paint mBackgroundPaint = getNewPaint(Color.argb(119, 0, 0, 0)); + + /** The bounding box around the Bitmap that we are cropping. */ + private final RectF mCalcBounds = new RectF(); + + /** The bounding image view width used to know the crop overlay is at view edges. */ + private int mViewWidth; + + /** The bounding image view height used to know the crop overlay is at view edges. */ + private int mViewHeight; + + /** The initial crop window padding from image borders */ + private float mInitialCropWindowPaddingRatio; + + /** The Handle that is currently pressed; null if no Handle is pressed. */ + private CropWindowMoveHandler mMoveHandler; + + /** save the current aspect ratio of the image */ + private int mAspectRatioX; + + /** save the current aspect ratio of the image */ + private int mAspectRatioY; + + /** the initial crop window rectangle to set */ + private final Rect mInitialCropWindowRect = new Rect(); + + /** Whether the Crop View has been initialized for the first time */ + private boolean initializedCropWindow; + + // endregion + + public CropOverlayView(Context context) { + this(context, null); + } + + public CropOverlayView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + /** Get the left/top/right/bottom coordinates of the crop window. */ + public RectF getCropWindowRect() { + return mCropWindowHandler.getRect(); + } + + /** Set the left/top/right/bottom coordinates of the crop window. */ + public void setCropWindowRect(RectF rect) { + mCropWindowHandler.setRect(rect); + } + + /** Fix the current crop window rectangle if it is outside of cropping image or view bounds. */ + public void fixCurrentCropWindowRect() { + RectF rect = getCropWindowRect(); + fixCropWindowRectByRules(rect); + mCropWindowHandler.setRect(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. + */ + public void setBounds(int viewWidth, int viewHeight) { + mViewWidth = viewWidth; + mViewHeight = viewHeight; + RectF cropRect = mCropWindowHandler.getRect(); + if (cropRect.width() == 0 || cropRect.height() == 0) { + initCropWindow(); + } + } + + + /** Resets the crop overlay view. */ + public void resetCropOverlayView() { + if (initializedCropWindow) { + setCropWindowRect(new RectF()); + initCropWindow(); + invalidate(); + } + } + + /** the X value of the aspect ratio; */ + public int getAspectRatioX() { + return mAspectRatioX; + } + + /** Sets the X value of the aspect ratio to 1. */ + public void setAspectRatioX() { + if (mAspectRatioX != 1) { + mAspectRatioX = 1; + + if (initializedCropWindow) { + initCropWindow(); + invalidate(); + } + } + } + + /** the Y value of the aspect ratio; */ + public int getAspectRatioY() { + return mAspectRatioY; + } + + /** + * Sets the Y value of the aspect ratio to 1. + * + */ + public void setAspectRatioY() { + if (mAspectRatioY != 1) { + mAspectRatioY = 1; + + if (initializedCropWindow) { + initCropWindow(); + invalidate(); + } + } + } + /** + * set the max width/height and scale factor of the shown image to original image to scale the + * limits appropriately. + */ + public void setCropWindowLimits( + float maxWidth, float maxHeight, float scaleFactorWidth, float scaleFactorHeight) { + mCropWindowHandler.setCropWindowLimits( + maxWidth, maxHeight, scaleFactorWidth, scaleFactorHeight); + } + + /** Get crop window initial rectangle. */ + public Rect getInitialCropWindowRect() { + return mInitialCropWindowRect; + } + + /** Set crop window initial rectangle to be used instead of default. */ + public void setInitialCropWindowRect(Rect rect) { + mInitialCropWindowRect.set(rect != null ? rect : new Rect()); + if (initializedCropWindow) { + initCropWindow(); + invalidate(); + } + } + + /** Reset crop window to initial rectangle. */ + public void resetCropWindowRect() { + if (initializedCropWindow) { + initCropWindow(); + invalidate(); + } + } + + /** + * Sets all initial values, but does not call initCropWindow to reset the views.
+ * Used once at the very start to initialize the attributes. + */ + public void setInitialAttributeValues() { + DisplayMetrics dm = Resources.getSystem().getDisplayMetrics(); + + setAspectRatioX(); + + setAspectRatioY(); + + mInitialCropWindowPaddingRatio = 0.1f; + + mBorderPaint = getNewPaintOrNull(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3, dm), Color.argb(170, 255, 255, 255)); + + mBorderCornerPaint = + getNewPaintOrNull(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2, dm), Color.WHITE); + + mGuidelinePaint = getNewPaintOrNull(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, 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 void initCropWindow() { + + RectF rect = new 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; + rect.top = mInitialCropWindowRect.top; + rect.right = rect.left + mInitialCropWindowRect.width(); + rect.bottom = rect.top + mInitialCropWindowRect.height(); + } + + fixCropWindowRectByRules(rect); + + mCropWindowHandler.setRect(rect); + } + + /** Fix the given rect to fit into bitmap rect and follow min, max and aspect ratio rules. */ + private void fixCropWindowRectByRules(RectF rect) { + if (rect.width() < mCropWindowHandler.getMinCropWidth()) { + float adj = (mCropWindowHandler.getMinCropWidth() - rect.width()) / 2; + rect.left -= adj; + rect.right += adj; + } + if (rect.height() < mCropWindowHandler.getMinCropHeight()) { + float adj = (mCropWindowHandler.getMinCropHeight() - rect.height()) / 2; + rect.top -= adj; + rect.bottom += adj; + } + if (rect.width() > mCropWindowHandler.getMaxCropWidth()) { + float adj = (rect.width() - mCropWindowHandler.getMaxCropWidth()) / 2; + rect.left += adj; + rect.right -= adj; + } + if (rect.height() > mCropWindowHandler.getMaxCropHeight()) { + float adj = (rect.height() - mCropWindowHandler.getMaxCropHeight()) / 2; + rect.top += adj; + rect.bottom -= adj; + } + + calculateBounds(rect); + if (mCalcBounds.width() > 0 && mCalcBounds.height() > 0) { + float leftLimit = Math.max(mCalcBounds.left, 0); + float topLimit = Math.max(mCalcBounds.top, 0); + float rightLimit = Math.min(mCalcBounds.right, getWidth()); + float bottomLimit = Math.min(mCalcBounds.bottom, getHeight()); + 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 + protected void onDraw(Canvas canvas) { + + super.onDraw(canvas); + + // Draw translucent background for the cropped 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 void drawBackground(Canvas canvas) { + + RectF rect = mCropWindowHandler.getRect(); + + canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, mBackgroundPaint); + } + + /** + * Draw 2 vertical and 2 horizontal guidelines inside the cropping area to split it into 9 equal + * parts. + */ + private void drawGuidelines(Canvas canvas) { + if (mGuidelinePaint != null) { + float sw = mBorderPaint != null ? mBorderPaint.getStrokeWidth() : 0; + RectF rect = mCropWindowHandler.getRect(); + rect.inset(sw, sw); + + float oneThirdCropWidth = rect.width() / 3; + float oneThirdCropHeight = rect.height() / 3; + + // Draw vertical guidelines. + float x1 = rect.left + oneThirdCropWidth; + float 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. + float y1 = rect.top + oneThirdCropHeight; + float 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 void drawBorders(Canvas canvas) { + if (mBorderPaint != null) { + float w = mBorderPaint.getStrokeWidth(); + RectF rect = mCropWindowHandler.getRect(); + // 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 void drawCorners(Canvas canvas) { + DisplayMetrics dm = Resources.getSystem().getDisplayMetrics(); + + if (mBorderCornerPaint != null) { + + float lineWidth = mBorderPaint != null ? mBorderPaint.getStrokeWidth() : 0; + float cornerWidth = mBorderCornerPaint.getStrokeWidth(); + + // The corners should be a bit offset from the borders + float w = (cornerWidth / 2) + + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5, dm); + + RectF rect = mCropWindowHandler.getRect(); + rect.inset(w, w); + + float cornerOffset = (cornerWidth - lineWidth) / 2; + float cornerExtension = cornerWidth / 2 + cornerOffset; + + /* the length of the border corner to draw */ + float mBorderCornerLength = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, 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); + } + } + + /** Creates the Paint object for drawing. */ + private static Paint getNewPaint(int color) { + Paint paint = new Paint(); + paint.setColor(color); + return paint; + } + + /** Creates the Paint object for given thickness and color, if thickness < 0 return null. */ + private static Paint getNewPaintOrNull(float thickness, int color) { + if (thickness > 0) { + Paint borderPaint = new Paint(); + borderPaint.setColor(color); + borderPaint.setStrokeWidth(thickness); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setAntiAlias(true); + return borderPaint; + } else { + return null; + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + // If this View is not enabled, don't allow for touch interactions. + if (isEnabled()) { + /* Boolean to see if multi touch is enabled for the crop rectangle */ + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + onActionDown(event.getX(), event.getY()); + return true; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + getParent().requestDisallowInterceptTouchEvent(false); + onActionUp(); + return true; + case MotionEvent.ACTION_MOVE: + onActionMove(event.getX(), event.getY()); + getParent().requestDisallowInterceptTouchEvent(true); + return true; + default: + return false; + } + } else { + return false; + } + } + + /** + * On press down start crop window movement depending on the location of the press.
+ * if press is far from crop window then no move handler is returned (null). + */ + private void onActionDown(float x, float y) { + DisplayMetrics dm = Resources.getSystem().getDisplayMetrics(); + mMoveHandler = mCropWindowHandler.getMoveHandler(x, y, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24, dm)); + if (mMoveHandler != null) { + invalidate(); + } + } + + /** Clear move handler starting in {@link #onActionDown(float, float)} if exists. */ + private void onActionUp() { + if (mMoveHandler != null) { + mMoveHandler = null; + invalidate(); + } + } + + /** + * Handle move of crop window using the move handler created in {@link #onActionDown(float, + * float)}.
+ * The move handler will do the proper move/resize of the crop window. + */ + private void onActionMove(float x, float y) { + if (mMoveHandler != null) { + RectF rect = mCropWindowHandler.getRect(); + + calculateBounds(rect); + DisplayMetrics dm = Resources.getSystem().getDisplayMetrics(); + float snapRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3, dm); + + mMoveHandler.move( + rect, + x, + y, + mCalcBounds, + mViewWidth, + mViewHeight, + snapRadius + ); + mCropWindowHandler.setRect(rect); + invalidate(); + } + } + + /** + * Calculate the bounding rectangle for current crop window + * The bounds rectangle is the bitmap rectangle + * + * @param rect the crop window rectangle to start finding bounded rectangle from + */ + private void calculateBounds(RectF rect) { + mCalcBounds.set(mInitialCropWindowRect); + } + // endregion +} diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropWindowHandler.java b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropWindowHandler.java new file mode 100644 index 00000000..6226811b --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropWindowHandler.java @@ -0,0 +1,263 @@ +package org.pixeldroid.app.postCreation.photoEdit.cropper; + +import android.content.res.Resources; +import android.graphics.RectF; +import android.util.DisplayMetrics; +import android.util.TypedValue; + +/** Handler from crop window stuff, moving and knowing position. */ +final class CropWindowHandler { + + // region: Fields and Const + + /** The 4 edges of the crop window defining its coordinates and size */ + private final RectF mEdges = new RectF(); + + /** + * Rectangle used to return the edges rectangle without ability to change it and without creating + * new all the time. + */ + private final RectF mGetEdges = new RectF(); + + /** Maximum width in pixels that the crop window can CURRENTLY get. */ + private float mMaxCropWindowWidth; + + /** Maximum height in pixels that the crop window can CURRENTLY get. */ + private float mMaxCropWindowHeight; + + /** The width scale factor of shown image and actual image */ + private float mScaleFactorWidth = 1; + + /** The height scale factor of shown image and actual image */ + private float mScaleFactorHeight = 1; + // endregion + + /** Get the left/top/right/bottom coordinates of the crop window. */ + public RectF getRect() { + mGetEdges.set(mEdges); + return mGetEdges; + } + + /** Minimum width in pixels that the crop window can get. */ + public float getMinCropWidth() { + /* + * Minimum width in pixels that the result of cropping an image can get, affects crop window width + * adjusted by width scale factor. + */ + DisplayMetrics dm = Resources.getSystem().getDisplayMetrics(); + float mMinCropResultWidth = 40; + return Math.max((int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42, dm), mMinCropResultWidth / mScaleFactorWidth); + } + + /** Minimum height in pixels that the crop window can get. */ + public float getMinCropHeight() { + /* + * Minimum height in pixels that the result of cropping an image can get, affects crop window + * height adjusted by height scale factor. + */ + DisplayMetrics dm = Resources.getSystem().getDisplayMetrics(); + float mMinCropResultHeight = 40; + return Math.max((int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42, dm), mMinCropResultHeight / mScaleFactorHeight); + } + + /** Maximum width in pixels that the crop window can get. */ + public float getMaxCropWidth() { + /* + * Maximum width in pixels that the result of cropping an image can get, affects crop window width + * adjusted by width scale factor. + */ + float mMaxCropResultWidth = 99999; + return Math.min(mMaxCropWindowWidth, mMaxCropResultWidth / mScaleFactorWidth); + } + + /** Maximum height in pixels that the crop window can get. */ + public float getMaxCropHeight() { + /* + * Maximum height in pixels that the result of cropping an image can get, affects crop window + * height adjusted by height scale factor. + */ + float mMaxCropResultHeight = 99999; + return Math.min(mMaxCropWindowHeight, mMaxCropResultHeight / mScaleFactorHeight); + } + + /** get the scale factor (on width) of the shown image to original image. */ + public float getScaleFactorWidth() { + return mScaleFactorWidth; + } + + /** get the scale factor (on height) of the shown image to original image. */ + public float getScaleFactorHeight() { + return mScaleFactorHeight; + } + + /** + * set the max width/height and scale factor of the shown image to original image to scale the + * limits appropriately. + */ + public void setCropWindowLimits( + float maxWidth, float maxHeight, float scaleFactorWidth, float scaleFactorHeight) { + mMaxCropWindowWidth = maxWidth; + mMaxCropWindowHeight = maxHeight; + mScaleFactorWidth = scaleFactorWidth; + mScaleFactorHeight = scaleFactorHeight; + } + + /** Set the left/top/right/bottom coordinates of the crop window. */ + public void setRect(RectF rect) { + mEdges.set(rect); + } + + /** + * 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 + */ + public boolean showGuidelines() { + 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 + */ + public CropWindowMoveHandler getMoveHandler( + float x, float y, float targetRadius) { + CropWindowMoveHandler.Type type = getRectanglePressedMoveType(x, y, targetRadius); + return type != null ? new CropWindowMoveHandler(type, this, x, y) : 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 CropWindowMoveHandler.Type getRectanglePressedMoveType( + float x, float y, float targetRadius) { + CropWindowMoveHandler.Type moveType = null; + + // Note: corner-handles take precedence, then side-handles, then center. + if (CropWindowHandler.isInCornerTargetZone(x, y, mEdges.left, mEdges.top, targetRadius)) { + moveType = CropWindowMoveHandler.Type.TOP_LEFT; + } else if (CropWindowHandler.isInCornerTargetZone( + x, y, mEdges.right, mEdges.top, targetRadius)) { + moveType = CropWindowMoveHandler.Type.TOP_RIGHT; + } else if (CropWindowHandler.isInCornerTargetZone( + x, y, mEdges.left, mEdges.bottom, targetRadius)) { + moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT; + } else if (CropWindowHandler.isInCornerTargetZone( + x, y, mEdges.right, mEdges.bottom, targetRadius)) { + moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT; + } else if (CropWindowHandler.isInCenterTargetZone( + x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom) + && focusCenter()) { + moveType = CropWindowMoveHandler.Type.CENTER; + } else if (CropWindowHandler.isInHorizontalTargetZone( + x, y, mEdges.left, mEdges.right, mEdges.top, targetRadius)) { + moveType = CropWindowMoveHandler.Type.TOP; + } else if (CropWindowHandler.isInHorizontalTargetZone( + x, y, mEdges.left, mEdges.right, mEdges.bottom, targetRadius)) { + moveType = CropWindowMoveHandler.Type.BOTTOM; + } else if (CropWindowHandler.isInVerticalTargetZone( + x, y, mEdges.left, mEdges.top, mEdges.bottom, targetRadius)) { + moveType = CropWindowMoveHandler.Type.LEFT; + } else if (CropWindowHandler.isInVerticalTargetZone( + x, y, mEdges.right, mEdges.top, mEdges.bottom, targetRadius)) { + moveType = CropWindowMoveHandler.Type.RIGHT; + } else if (CropWindowHandler.isInCenterTargetZone( + x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom) + && !focusCenter()) { + moveType = CropWindowMoveHandler.Type.CENTER; + } + + return moveType; + } + + /** + * 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 static boolean isInCornerTargetZone( + float x, float y, float handleX, float handleY, float targetRadius) { + return Math.abs(x - handleX) <= targetRadius && Math.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 static boolean isInHorizontalTargetZone( + float x, float y, float handleXStart, float handleXEnd, float handleY, float targetRadius) { + return x > handleXStart && x < handleXEnd && Math.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 static boolean isInVerticalTargetZone( + float x, float y, float handleX, float handleYStart, float handleYEnd, float targetRadius) { + return Math.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 static boolean isInCenterTargetZone( + float x, float y, float left, float top, float right, float bottom) { + return x > left && x < right && y > top && y < bottom; + } + + /** + * 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 boolean focusCenter() { + return !showGuidelines(); + } + // endregion +} diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropWindowMoveHandler.java b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropWindowMoveHandler.java new file mode 100644 index 00000000..41f5409b --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/cropper/CropWindowMoveHandler.java @@ -0,0 +1,442 @@ +package org.pixeldroid.app.postCreation.photoEdit.cropper; + +import android.graphics.PointF; +import android.graphics.RectF; + +/** + * Handler to update crop window edges by the move type - Horizontal, Vertical, Corner or Center. + *
+ */ +final class CropWindowMoveHandler { + + // region: Fields and Consts + + /** Minimum width in pixels that the crop window can get. */ + private final float mMinCropWidth; + + /** Minimum width in pixels that the crop window can get. */ + private final float mMinCropHeight; + + /** Maximum height in pixels that the crop window can get. */ + private final float mMaxCropWidth; + + /** Maximum height in pixels that the crop window can get. */ + private final float mMaxCropHeight; + + /** The type of crop window move that is handled. */ + private final Type mType; + + /** + * 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 final PointF mTouchOffset = new PointF(); + // endregion + + /** + * @param cropWindowHandler main crop window handle to get and update the crop window edges + * @param touchX the location of the initial touch position to measure move distance + * @param touchY the location of the initial touch position to measure move distance + */ + public CropWindowMoveHandler( + Type type, CropWindowHandler cropWindowHandler, float touchX, float touchY) { + mType = type; + mMinCropWidth = cropWindowHandler.getMinCropWidth(); + mMinCropHeight = cropWindowHandler.getMinCropHeight(); + mMaxCropWidth = cropWindowHandler.getMaxCropWidth(); + mMaxCropHeight = cropWindowHandler.getMaxCropHeight(); + calculateTouchOffset(cropWindowHandler.getRect(), 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 toch + * 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 + */ + public void move( + RectF rect, + float x, + float y, + RectF bounds, + int viewWidth, + int viewHeight, + float snapMargin) { + + // 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". + float adjX = x + mTouchOffset.x; + float adjY = y + mTouchOffset.y; + + if (mType == Type.CENTER) { + moveCenter(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin); + } else { + moveSizeWithFreeAspectRatio(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.
+ * Save these values in a member variable since we want to maintain this offset as we drag the + * handle. + */ + private void calculateTouchOffset(RectF rect, float touchX, float touchY) { + + float touchOffsetX = 0; + float touchOffsetY = 0; + + // Calculate the offset from the appropriate handle. + switch (mType) { + case TOP_LEFT: + touchOffsetX = rect.left - touchX; + touchOffsetY = rect.top - touchY; + break; + case TOP_RIGHT: + touchOffsetX = rect.right - touchX; + touchOffsetY = rect.top - touchY; + break; + case BOTTOM_LEFT: + touchOffsetX = rect.left - touchX; + touchOffsetY = rect.bottom - touchY; + break; + case BOTTOM_RIGHT: + touchOffsetX = rect.right - touchX; + touchOffsetY = rect.bottom - touchY; + break; + case LEFT: + touchOffsetX = rect.left - touchX; + touchOffsetY = 0; + break; + case TOP: + touchOffsetX = 0; + touchOffsetY = rect.top - touchY; + break; + case RIGHT: + touchOffsetX = rect.right - touchX; + touchOffsetY = 0; + break; + case BOTTOM: + touchOffsetX = 0; + touchOffsetY = rect.bottom - touchY; + break; + case CENTER: + touchOffsetX = rect.centerX() - touchX; + touchOffsetY = rect.centerY() - touchY; + break; + default: + break; + } + + mTouchOffset.x = touchOffsetX; + mTouchOffset.y = touchOffsetY; + } + + /** Center move only changes the position of the crop window without changing the size. */ + private void moveCenter( + RectF rect, float x, float y, RectF bounds, int viewWidth, int viewHeight, float snapRadius) { + float dx = x - rect.centerX(); + float 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 for corner size move) without + * affecting "secondary" edges.
+ * Only the primary edge(s) are fixed to stay within limits. + */ + private void moveSizeWithFreeAspectRatio( + RectF rect, float x, float y, RectF bounds, int viewWidth, int viewHeight, float snapMargin) { + switch (mType) { + case TOP_LEFT: + adjustTop(rect, y, bounds, snapMargin); + adjustLeft(rect, x, bounds, snapMargin); + break; + case TOP_RIGHT: + adjustTop(rect, y, bounds, snapMargin); + adjustRight(rect, x, bounds, viewWidth, snapMargin); + break; + case BOTTOM_LEFT: + adjustBottom(rect, y, bounds, viewHeight, snapMargin); + adjustLeft(rect, x, bounds, snapMargin); + break; + case BOTTOM_RIGHT: + adjustBottom(rect, y, bounds, viewHeight, snapMargin); + adjustRight(rect, x, bounds, viewWidth, snapMargin); + break; + case LEFT: + adjustLeft(rect, x, bounds, snapMargin); + break; + case TOP: + adjustTop(rect, y, bounds, snapMargin); + break; + case RIGHT: + adjustRight(rect, x, bounds, viewWidth, snapMargin); + break; + case BOTTOM: + adjustBottom(rect, y, bounds, viewHeight, snapMargin); + break; + default: + break; + } + } + + /** Check if edges have gone out of bounds (including snap margin), and fix if needed. */ + private void snapEdgesToBounds(RectF edges, RectF bounds, float margin) { + if (edges.left < bounds.left + margin) { + edges.offset(bounds.left - edges.left, 0); + } + if (edges.top < bounds.top + margin) { + edges.offset(0, bounds.top - edges.top); + } + if (edges.right > bounds.right - margin) { + edges.offset(bounds.right - edges.right, 0); + } + if (edges.bottom > bounds.bottom - margin) { + edges.offset(0, 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 cropped + * @param snapMargin the snap distance to the image edge (in pixels) + */ + private void adjustLeft( + RectF rect, + float left, + RectF bounds, + float snapMargin) { + + float 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 cropped + * @param viewWidth + * @param snapMargin the snap distance to the image edge (in pixels) + */ + private void adjustRight( + RectF rect, + float right, + RectF bounds, + int viewWidth, + float snapMargin) { + + float 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 cropped + * @param snapMargin the snap distance to the image edge (in pixels) + */ + private void adjustTop( + RectF rect, + float top, + RectF bounds, + float snapMargin) { + + float 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 cropped + * @param viewHeight + * @param snapMargin the snap distance to the image edge (in pixels) + */ + private void adjustBottom( + RectF rect, + float bottom, + RectF bounds, + int viewHeight, + float snapMargin) { + + float 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 + + // region: Inner class: Type + + /** The type of crop window move that is handled. */ + public enum Type { + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT, + LEFT, + TOP, + RIGHT, + BOTTOM, + CENTER + } + // endregion +} diff --git a/app/src/main/res/layout/activity_video_edit.xml b/app/src/main/res/layout/activity_video_edit.xml index fe3feb98..a3e1faef 100644 --- a/app/src/main/res/layout/activity_video_edit.xml +++ b/app/src/main/res/layout/activity_video_edit.xml @@ -19,6 +19,16 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/crop_image_view.xml b/app/src/main/res/layout/crop_image_view.xml new file mode 100644 index 00000000..48b90bb4 --- /dev/null +++ b/app/src/main/res/layout/crop_image_view.xml @@ -0,0 +1,31 @@ + + + + + + + + + \ No newline at end of file