diff --git a/cropkit/src/main/java/com/tanishranjan/cropkit/CropController.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/CropController.kt index 28999c0..f84f810 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/CropController.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/CropController.kt @@ -64,5 +64,4 @@ class CropController( is CropStateChangeActions.CanvasSizeChanged -> stateManager.updateCanvasSize(action.size) } } - } \ No newline at end of file diff --git a/cropkit/src/main/java/com/tanishranjan/cropkit/CropDefaults.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/CropDefaults.kt index b3c5f1d..e455345 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/CropDefaults.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/CropDefaults.kt @@ -16,7 +16,7 @@ object CropDefaults { gridLinesVisibility: GridLinesVisibility = GridLinesVisibility.ON_TOUCH, gridLinesType: GridLinesType = GridLinesType.GRID, handleRadius: Dp = 8.dp, - touchPadding: Dp = 10.dp + touchPadding: Dp = 20.dp ) = CropOptions( cropShape = cropShape, contentScale = contentScale, diff --git a/cropkit/src/main/java/com/tanishranjan/cropkit/ImageCropper.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/ImageCropper.kt index 3b51b80..a33a592 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/ImageCropper.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/ImageCropper.kt @@ -203,9 +203,7 @@ fun ImageCropper( style = Fill ) } - } - } } diff --git a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropStateManager.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropStateManager.kt index 47c1033..1eb3d57 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropStateManager.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropStateManager.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlin.math.abs internal class CropStateManager( bitmap: Bitmap, @@ -32,13 +31,13 @@ internal class CropStateManager( private val handleRadius: Dp, private val touchPadding: Dp ) { - private val _state = MutableStateFlow(CropState(bitmap)) val state = _state.asStateFlow() private val coroutineScope = CoroutineScope(Dispatchers.Main) private var dragMode: DragMode = DragMode.None private val density get() = Resources.getSystem().displayMetrics.density private val handleRadiusPx: Float get() = handleRadius.value * density + private var dragOffset: Offset = Offset.Zero init { reset(bitmap) @@ -77,15 +76,21 @@ internal class CropStateManager( } fun onDragStart(offset: Offset) { - val activeHandle = findActiveHandle(offset) + val currentRect = state.value.cropRect dragMode = when { activeHandle != null -> DragMode.Handle(activeHandle) - offset.isInsideRect(state.value.cropRect) -> DragMode.Move + offset.isInsideRect(currentRect) -> DragMode.Move else -> DragMode.None } + dragOffset = when (val mode = dragMode) { + DragMode.None -> Offset.Zero + DragMode.Move -> Offset(currentRect.left, currentRect.top) + is DragMode.Handle -> GestureUtils.getHandleOffset(mode.handle, currentRect) + } + _state.update { cropState -> cropState.copy( isDragging = dragMode != DragMode.None, @@ -113,10 +118,8 @@ internal class CropStateManager( fun onDrag(dragAmount: Offset) { when (val dragMode = dragMode) { - is DragMode.Handle -> dragHandles(dragMode.handle, dragAmount) - + is DragMode.Handle -> moveDragHandle(dragMode.handle, dragAmount) DragMode.Move -> moveCropRect(dragAmount) - DragMode.None -> {} } } @@ -141,15 +144,19 @@ internal class CropStateManager( } private fun moveCropRect(dragAmount: Offset) { - val currentRect = state.value.cropRect val imageRect = state.value.imageRect - val newLeft = (currentRect.left + dragAmount.x).coerceInOrderAgnostic( + val correctedDragAmount = GestureUtils.getCorrectDragAmount( + dragOffset = dragOffset, + dragAmount = dragAmount + ) + + val newLeft = correctedDragAmount.x.coerceInOrderAgnostic( imageRect.left, imageRect.right - currentRect.width ) - val newTop = (currentRect.top + dragAmount.y).coerceInOrderAgnostic( + val newTop = correctedDragAmount.y.coerceInOrderAgnostic( imageRect.top, imageRect.bottom - currentRect.height ) @@ -162,29 +169,30 @@ internal class CropStateManager( ) _state.update { + dragOffset = correctedDragAmount it.copy( cropRect = newRect, handles = GestureUtils.getNewHandleMeasures(newRect, handleRadiusPx) ) } - } - private fun dragHandles(activeHandle: DragHandle, dragAmount: Offset) { - val adjustedDragAmount = if (cropShape is CropShape.FreeForm) { - dragAmount - } else { - getDragAmountForShape(dragAmount, activeHandle) - } + private fun moveDragHandle(activeHandle: DragHandle, dragAmount: Offset) { + val correctedDragAmount = GestureUtils.getCorrectDragAmount( + dragOffset = dragOffset, + dragAmount = dragAmount + ) - GestureUtils.getNewRectMeasures( + GestureUtils.calculateNewCropRect( activeHandle = activeHandle, - dragAmount = adjustedDragAmount, + handleOffset = correctedDragAmount, imageRect = state.value.imageRect, cropRect = state.value.cropRect, - minCropSize = MIN_CROP_SIZE - )?.let { newRect -> + minCropSize = MIN_CROP_SIZE, + aspectRatio = state.value.aspectRatio + ).let { newRect -> _state.update { + dragOffset = correctedDragAmount it.copy( cropRect = newRect, handles = GestureUtils.getNewHandleMeasures( @@ -196,74 +204,9 @@ internal class CropStateManager( } } - private fun getDragAmountForShape(dragAmount: Offset, handle: DragHandle): Offset { - - val aspectRatio = state.value.aspectRatio - val dx = dragAmount.x - val dy = dragAmount.y - val xConstraint = abs(dragAmount.x) - val xConstraintDeltaY = xConstraint / aspectRatio - val yConstraint = abs(dragAmount.y) - val yConstraintDeltaX = yConstraint * aspectRatio - - return when (handle) { - DragHandle.TopLeft -> { - val sign = if (dx < 0 && dy < 0) -1 else 1 // prioritize cropping in - val xConstraintCropIn = minOf(xConstraint, xConstraintDeltaY) - val yConstraintCropIn = minOf(yConstraint, yConstraintDeltaX) - return if (xConstraintCropIn <= yConstraintCropIn) { - Offset(sign * xConstraint, sign * xConstraintDeltaY) - } else { - Offset(sign * yConstraintDeltaX, sign * yConstraint) - } - } - - DragHandle.TopRight -> { - val sign = if (dx > 0 && dy < 0) -1 else 1 // prioritize cropping in - val xConstraintCropIn = minOf(xConstraint, xConstraintDeltaY) - val yConstraintCropIn = minOf(yConstraint, yConstraintDeltaX) - return if (xConstraintCropIn <= yConstraintCropIn) { - Offset(-sign * xConstraint, sign * xConstraintDeltaY) - } else { - Offset(-sign * yConstraintDeltaX, sign * yConstraint) - } - - } - - DragHandle.BottomLeft -> { - val sign = if (dx < 0 && dy > 0) -1 else 1 // prioritize cropping in - val xConstraintCropIn = minOf(xConstraint, xConstraintDeltaY) - val yConstraintCropIn = minOf(yConstraint, yConstraintDeltaX) - return if (xConstraintCropIn <= yConstraintCropIn) { - Offset(sign * xConstraint, -sign * xConstraintDeltaY) - } else { - Offset(sign * yConstraintDeltaX, -sign * yConstraint) - } - } - - DragHandle.BottomRight -> { - val sign = if (dx > 0 && dy > 0) -1 else 1 // prioritize cropping in - val xConstraintCropIn = minOf(xConstraint, xConstraintDeltaY) - val yConstraintCropIn = minOf(yConstraint, yConstraintDeltaX) - return if (xConstraintCropIn <= yConstraintCropIn) { - Offset(-sign * xConstraint, -sign * xConstraintDeltaY) - } else { - Offset(-sign * yConstraintDeltaX, -sign * yConstraint) - } - } - - else -> Offset.Zero - } - - } - private fun findActiveHandle(offset: Offset): DragHandle? { - // TODO: Allow cropping with all handles in locked aspect ratios - val handles = if (cropShape is CropShape.FreeForm) { - state.value.handles.getAllNamedHandles() - } else { - state.value.handles.getCornerNamedHandles() - } + val handles = if (cropShape is CropShape.FreeForm) state.value.handles.getAllNamedHandles() + else state.value.handles.getCornerNamedHandles() handles.forEach { (handle, handleType) -> val padding = touchPadding.value * density @@ -277,7 +220,6 @@ internal class CropStateManager( } return null - } private fun reset(bitmap: Bitmap) { @@ -301,18 +243,25 @@ internal class CropStateManager( val imageWidth = bitmap.width.toFloat() val imageHeight = bitmap.height.toFloat() + // Add content padding equal to touchPadding so handles at edges + // always have their full touch area within the canvas bounds + val contentPadding = touchPadding.value * density + val availableWidth = canvasSize.width - contentPadding * 2 + val availableHeight = canvasSize.height - contentPadding * 2 + val scaledSize = MathUtils.calculateScaledSize( srcWidth = imageWidth, srcHeight = imageHeight, - dstWidth = canvasSize.width, - dstHeight = canvasSize.height, + dstWidth = availableWidth, + dstHeight = availableHeight, contentScale = contentScale ) val newBitmap = bitmap.scale(scaledSize.width.toInt(), scaledSize.height.toInt()) - val offsetX = (canvasSize.width - scaledSize.width) / 2f - val offsetY = (canvasSize.height - scaledSize.height) / 2f + // Center within available space, then add padding offset + val offsetX = contentPadding + (availableWidth - scaledSize.width) / 2f + val offsetY = contentPadding + (availableHeight - scaledSize.height) / 2f val aspectRatio = when (cropShape) { is CropShape.FreeForm -> null diff --git a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/DragMode.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/DragMode.kt index e68da1c..cd773af 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/DragMode.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/DragMode.kt @@ -1,9 +1,7 @@ package com.tanishranjan.cropkit.internal internal sealed interface DragMode { - data object None : DragMode data object Move : DragMode data class Handle(val handle: DragHandle) : DragMode - } \ No newline at end of file diff --git a/cropkit/src/main/java/com/tanishranjan/cropkit/util/GestureUtils.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/util/GestureUtils.kt index 4f685c1..851b26e 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/util/GestureUtils.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/util/GestureUtils.kt @@ -4,8 +4,71 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import com.tanishranjan.cropkit.internal.DragHandle import com.tanishranjan.cropkit.HandlesRect +import com.tanishranjan.cropkit.internal.DragHandle.* +import com.tanishranjan.cropkit.util.Extensions.coerceInOrderAgnostic +import kotlin.math.abs internal object GestureUtils { + /** + * Get the offset of a handle based on its type and the crop rectangle. + * + * @param activeHandle The handle type. + * @param cropRect The current crop rectangle. + * @return The offset of the handle. + */ + fun getHandleOffset( + activeHandle: DragHandle, + cropRect: Rect + ): Offset = + when (activeHandle) { + TopLeft -> Offset(cropRect.left, cropRect.top) + TopRight -> Offset(cropRect.right, cropRect.top) + BottomLeft -> Offset(cropRect.left, cropRect.bottom) + BottomRight -> Offset(cropRect.right, cropRect.bottom) + Left -> Offset(cropRect.left, 0f) + Right -> Offset(cropRect.right, 0f) + Top -> Offset(0f, cropRect.top) + Bottom -> Offset(0f, cropRect.bottom) + } + + /** + * Correct the drag amount based on the drag offset. + * + * @param dragOffset The current drag offset. + * @param dragAmount The original drag amount. + * @return The corrected drag amount. + */ + fun getCorrectDragAmount( + dragOffset: Offset, + dragAmount: Offset + ): Offset = Offset(dragOffset.x + dragAmount.x, dragOffset.y + dragAmount.y) + + /** + * Main entry point to calculate the new crop rectangle. + * Decides whether to use Free or Locked logic based on the [aspectRatio]. + * + * @param activeHandle The handle that is currently being dragged. + * @param handleOffset The offset by which the handle is dragged. + * @param imageRect The current image rect. + * @param cropRect The current crop rect. + * @param minCropSize The minimum size of crop rectangle that should be maintained. + * @param aspectRatio The desired aspect ratio (width / height). + * @return The new crop rectangle if the drag is valid, null otherwise. + */ + fun calculateNewCropRect( + activeHandle: DragHandle, + handleOffset: Offset, + imageRect: Rect, + cropRect: Rect, + minCropSize: Float, + aspectRatio: Float + ): Rect = + if (aspectRatio > 0f) getNewRectMeasuresLocked( + activeHandle, handleOffset, imageRect, cropRect, minCropSize, aspectRatio + ) + else getNewRectMeasures( + activeHandle, handleOffset, imageRect, cropRect, minCropSize + ) /** * Calculate the new crop rectangle based on the drag amount and the active handle. @@ -15,7 +78,6 @@ internal object GestureUtils { * @param imageRect The current image rect. * @param cropRect The current crop rect. * @param minCropSize The minimum size of crop rectangle that should be maintained. - * * @return The new crop rectangle if the drag is valid, null otherwise. */ fun getNewRectMeasures( @@ -24,117 +86,235 @@ internal object GestureUtils { imageRect: Rect, cropRect: Rect, minCropSize: Float - ): Rect? { - return when (activeHandle) { - - DragHandle.TopLeft -> { - val newOffset = cropRect.topLeft + dragAmount - if (newOffset.x !in imageRect.left..cropRect.right - minCropSize - || newOffset.y !in imageRect.top..cropRect.bottom - minCropSize - ) return null - Rect( - left = newOffset.x, - top = newOffset.y, - right = cropRect.right, - bottom = cropRect.bottom - ) - } - - DragHandle.TopRight -> { - val newOffset = cropRect.topRight + dragAmount - if (newOffset.x !in cropRect.left + minCropSize..imageRect.right - || newOffset.y !in imageRect.top..cropRect.bottom - minCropSize - ) return cropRect - Rect( - left = cropRect.left, - top = newOffset.y, - right = newOffset.x, - bottom = cropRect.bottom - ) - } - - DragHandle.BottomLeft -> { - val newOffset = cropRect.bottomLeft + dragAmount - if (newOffset.x !in imageRect.left..cropRect.right - minCropSize - || newOffset.y !in cropRect.top + minCropSize..imageRect.bottom - ) return cropRect - Rect( - left = newOffset.x, - top = cropRect.top, - right = cropRect.right, - bottom = newOffset.y - ) - } - - DragHandle.BottomRight -> { - val newOffset = cropRect.bottomRight + dragAmount - if (newOffset.x !in cropRect.left + minCropSize..imageRect.right - || newOffset.y !in cropRect.top + minCropSize..imageRect.bottom - ) return cropRect - Rect( - left = cropRect.left, - top = cropRect.top, - right = newOffset.x, - bottom = newOffset.y - ) - } - - DragHandle.Top -> { - val newOffset = cropRect.topLeft + dragAmount - val newTop = newOffset.y.coerceIn( - imageRect.top, - cropRect.bottom - minCropSize - ) - Rect( - left = cropRect.left, - top = newTop, - right = cropRect.right, - bottom = cropRect.bottom - ) - } - - DragHandle.Bottom -> { - val newOffset = cropRect.bottomLeft + dragAmount - val newBottom = newOffset.y.coerceIn( - cropRect.top + minCropSize, - imageRect.bottom - ) - Rect( - left = cropRect.left, - top = cropRect.top, - right = cropRect.right, - bottom = newBottom - ) - } - - DragHandle.Left -> { - val newOffset = cropRect.bottomLeft + dragAmount - val newLeft = newOffset.x.coerceIn( - imageRect.left, - cropRect.right - minCropSize - ) - Rect( - left = newLeft, - top = cropRect.top, - right = cropRect.right, - bottom = cropRect.bottom - ) - } - - DragHandle.Right -> { - val newOffset = cropRect.topRight + dragAmount - val newRight = newOffset.x.coerceIn( - cropRect.left + minCropSize, - imageRect.right - ) - Rect( - left = cropRect.left, - top = cropRect.top, - right = newRight, - bottom = cropRect.bottom - ) - } + ): Rect = when (activeHandle) { + TopLeft -> { + val newLeft = dragAmount.x.coerceInOrderAgnostic( + imageRect.left, + cropRect.right - minCropSize + ) + val newTop = dragAmount.y.coerceInOrderAgnostic( + imageRect.top, + cropRect.bottom - minCropSize + ) + Rect( + left = newLeft, + top = newTop, + right = cropRect.right, + bottom = cropRect.bottom + ) + } + + TopRight -> { + val newRight = dragAmount.x.coerceInOrderAgnostic( + cropRect.left + minCropSize, + imageRect.right + ) + val newTop = dragAmount.y.coerceInOrderAgnostic( + imageRect.top, + cropRect.bottom - minCropSize + ) + Rect( + left = cropRect.left, + top = newTop, + right = newRight, + bottom = cropRect.bottom + ) + } + + BottomLeft -> { + val newLeft = dragAmount.x.coerceInOrderAgnostic( + imageRect.left, + cropRect.right - minCropSize + ) + val newBottom = dragAmount.y.coerceInOrderAgnostic( + cropRect.top + minCropSize, + imageRect.bottom + ) + Rect( + left = newLeft, + top = cropRect.top, + right = cropRect.right, + bottom = newBottom + ) + } + + BottomRight -> { + val newRight = dragAmount.x.coerceInOrderAgnostic( + cropRect.left + minCropSize, + imageRect.right + ) + val newBottom = dragAmount.y.coerceInOrderAgnostic( + cropRect.top + minCropSize, + imageRect.bottom + ) + Rect( + left = cropRect.left, + top = cropRect.top, + right = newRight, + bottom = newBottom + ) + } + + Top -> { + val newTop = dragAmount.y.coerceInOrderAgnostic( + imageRect.top, + cropRect.bottom - minCropSize + ) + Rect( + left = cropRect.left, + top = newTop, + right = cropRect.right, + bottom = cropRect.bottom + ) + } + + Bottom -> { + val newBottom = dragAmount.y.coerceInOrderAgnostic( + cropRect.top + minCropSize, + imageRect.bottom + ) + Rect( + left = cropRect.left, + top = cropRect.top, + right = cropRect.right, + bottom = newBottom + ) + } + + Left -> { + val newLeft = dragAmount.x.coerceInOrderAgnostic( + imageRect.left, + cropRect.right - minCropSize + ) + Rect( + left = newLeft, + top = cropRect.top, + right = cropRect.right, + bottom = cropRect.bottom + ) + } + + Right -> { + val newRight = dragAmount.x.coerceInOrderAgnostic( + cropRect.left + minCropSize, + imageRect.right + ) + Rect( + left = cropRect.left, + top = cropRect.top, + right = newRight, + bottom = cropRect.bottom + ) + } + } + + /** + * Calculates the new crop rectangle with locked Aspect Ratio. + * It projects the user's gesture onto the aspect ratio diagonal, ensuring the + * rectangle grows/shrinks naturally while staying within image bounds. + * + * @param activeHandle The handle that is currently being dragged. + * @param handleOffset The offset by which the handle is dragged. + * @param imageRect The current image rect. + * @param cropRect The current crop rect. + * @param minCropSize The minimum size of crop rectangle that should be maintained. + * @param aspectRatio The desired aspect ratio (width / height). + * @return The new crop rectangle if the drag is valid, null otherwise. + */ + fun getNewRectMeasuresLocked( + activeHandle: DragHandle, + handleOffset: Offset, + imageRect: Rect, + cropRect: Rect, + minCropSize: Float, + aspectRatio: Float + ): Rect { + val pivot = when (activeHandle) { + TopLeft -> Offset(cropRect.right, cropRect.bottom) + TopRight -> Offset(cropRect.left, cropRect.bottom) + BottomLeft -> Offset(cropRect.right, cropRect.top) + BottomRight -> Offset(cropRect.left, cropRect.top) + else -> return cropRect + } + + // Pre-calculate minimum limits based on the Aspect Ratio + val minWidth: Float + val minHeight: Float + + if (aspectRatio >= 1f) { + minHeight = minCropSize + minWidth = minHeight * aspectRatio + } else { + minWidth = minCropSize + minHeight = minWidth / aspectRatio + } + + // Constrain the Handle Offset + val constrainedX = when (activeHandle) { + TopLeft, BottomLeft -> handleOffset.x.coerceAtMost(pivot.x - minWidth) + TopRight, BottomRight -> handleOffset.x.coerceAtLeast(pivot.x + minWidth) + else -> handleOffset.x + } + + val constrainedY = when (activeHandle) { + TopLeft, TopRight -> handleOffset.y.coerceAtMost(pivot.y - minHeight) + BottomLeft, BottomRight -> handleOffset.y.coerceAtLeast(pivot.y + minHeight) + else -> handleOffset.y } + + // Calculate distances using the CONSTRAINED position + val distanceX = abs(constrainedX - pivot.x) + val distanceY = abs(constrainedY - pivot.y) + + // Generate Candidates (Projections) + val widthBasedOnX = distanceX.coerceAtLeast(minWidth) + val heightDerivedFromX = widthBasedOnX / aspectRatio + + val heightBasedOnY = distanceY.coerceAtLeast(minHeight) + val widthDerivedFromY = heightBasedOnY * aspectRatio + + // We pick the candidate that produces the smallest rectangle + var finalWidth = if (widthBasedOnX < widthDerivedFromY) + widthBasedOnX else widthDerivedFromY + var finalHeight = if (widthBasedOnX < widthDerivedFromY) + heightDerivedFromX else heightBasedOnY + + // Bounds Constraint + // Determine the direction of growth relative to the pivot (-1 for Left/Up, 1 for Right/Down) + val directionX = if (activeHandle == TopRight || activeHandle == BottomRight) 1f else -1f + val directionY = if (activeHandle == BottomLeft || activeHandle == BottomRight) 1f else -1f + + // Check Horizontal Bounds + val proposedLeft = if (directionX < 0) pivot.x - finalWidth else pivot.x + val proposedRight = if (directionX < 0) pivot.x else pivot.x + finalWidth + + if (proposedLeft < imageRect.left || proposedRight > imageRect.right) { + val maxWidthAvailable = + if (directionX < 0) (pivot.x - imageRect.left) + else (imageRect.right - pivot.x) + + finalWidth = maxWidthAvailable + finalHeight = finalWidth / aspectRatio + } + + // Check Vertical Bounds + val proposedTop = if (directionY < 0) pivot.y - finalHeight else pivot.y + val proposedBottom = if (directionY < 0) pivot.y else pivot.y + finalHeight + + if (proposedTop < imageRect.top || proposedBottom > imageRect.bottom) { + val maxHeightAvailable = if (directionY < 0) (pivot.y - imageRect.top) + else (imageRect.bottom - pivot.y) + + finalHeight = maxHeightAvailable + finalWidth = finalHeight * aspectRatio + } + + return Rect( + left = if (directionX < 0) pivot.x - finalWidth else pivot.x, + top = if (directionY < 0) pivot.y - finalHeight else pivot.y, + right = if (directionX < 0) pivot.x else pivot.x + finalWidth, + bottom = if (directionY < 0) pivot.y else pivot.y + finalHeight + ) } /** @@ -233,5 +413,4 @@ internal object GestureUtils { ) } - } \ No newline at end of file