From 381137cbe1319a0e14e457ab08ee3b1a84d66a6c Mon Sep 17 00:00:00 2001 From: rubenalfon Date: Fri, 6 Feb 2026 17:01:36 +0100 Subject: [PATCH 1/3] feat: improve crop mathematics and expand touch padding This commit overhauls the gesture logic to allow precise resizing. It also improves UX by expanding touch areas. Specific changes: - Implemented `getNewRectMeasuresLocked` for aspect-ratio locked resizing. - Added `calculateNewCropRect` as a unified entry point. - Refactored `CropStateManager` to track `dragOffset`. - Updated canvas to include `contentPadding` for better edge interaction. - Increased default `touchPadding` to 20.dp. This implementation effectively covers the goals of PR #9. Closes #9 --- .../tanishranjan/cropkit/CropController.kt | 1 - .../com/tanishranjan/cropkit/CropDefaults.kt | 2 +- .../com/tanishranjan/cropkit/ImageCropper.kt | 2 - .../cropkit/internal/CropState.kt | 3 + .../cropkit/internal/CropStateManager.kt | 134 ++----- .../tanishranjan/cropkit/internal/DragMode.kt | 2 - .../tanishranjan/cropkit/util/GestureUtils.kt | 379 ++++++++++++------ 7 files changed, 313 insertions(+), 210 deletions(-) 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/CropState.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropState.kt index 8a8af54..a53d27f 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropState.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropState.kt @@ -1,6 +1,7 @@ package com.tanishranjan.cropkit.internal import android.graphics.Bitmap +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap @@ -11,6 +12,7 @@ import com.tanishranjan.cropkit.HandlesRect * * @param bitmap The bitmap of the image. * @param imageBitmap The image bitmap of the image. + * @param dragOffset The offset of the drag. * @param cropRect The crop rectangle. * @param imageRect The image rectangle. * @param handles The handles rectangles of the crop rectangle. @@ -22,6 +24,7 @@ import com.tanishranjan.cropkit.HandlesRect internal data class CropState( val bitmap: Bitmap, val imageBitmap: ImageBitmap? = null, + val dragOffset: Offset = Offset.Zero, val cropRect: Rect = Rect.Zero, val imageRect: Rect = Rect.Zero, val handles: HandlesRect = HandlesRect(), 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..8ecac06 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, @@ -77,17 +76,24 @@ 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 } + val 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( + dragOffset = dragOffset, isDragging = dragMode != DragMode.None, gridlinesActive = if (gridLinesVisibility == GridLinesVisibility.ON_TOUCH) { dragMode != DragMode.None @@ -113,10 +119,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 +145,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 = state.value.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 ) @@ -163,29 +171,30 @@ internal class CropStateManager( _state.update { it.copy( + dragOffset = correctedDragAmount, 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 = state.value.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 { it.copy( + dragOffset = correctedDragAmount, cropRect = newRect, handles = GestureUtils.getNewHandleMeasures( newRect, @@ -196,74 +205,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 +221,6 @@ internal class CropStateManager( } return null - } private fun reset(bitmap: Bitmap) { @@ -301,18 +244,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..12ed548 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,119 +86,213 @@ 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 + } + + // Calculate raw distances from the pivot to the target finger position + val distanceX = abs(handleOffset.x - pivot.x) + val distanceY = abs(handleOffset.y - pivot.y) + + // Generate Candidates (Projections) + val widthBasedOnX = distanceX.coerceAtLeast(minCropSize) + val heightDerivedFromX = widthBasedOnX / aspectRatio + + val heightBasedOnY = distanceY.coerceAtLeast(minCropSize) + 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 (handleOffset.x < pivot.x) -1f else 1f + val directionY = if (handleOffset.y < pivot.y) -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 + ) + } + /** * Calculate the new handle rectangles based on the crop rectangle. * @@ -233,5 +389,4 @@ internal object GestureUtils { ) } - } \ No newline at end of file From a0dec02a7f7101555f9b8974d6a12fd15dafd9b4 Mon Sep 17 00:00:00 2001 From: rubenalfon Date: Sat, 7 Feb 2026 17:12:12 +0100 Subject: [PATCH 2/3] fix: prevent crop rectangle inversion when dragging past pivot Specifically: - Updated `getNewRectMeasuresLocked` to pre-calculate `minWidth` and `minHeight` based on the aspect ratio and `minCropSize`. - Added logic to constrain the `handleOffset` before calculating distances, preventing the crop rectangle from shrinking below minimum dimensions. - Refactored `directionX` and `directionY` calculation to rely on the `activeHandle` type rather than coordinate comparisons for more reliable growth direction tracking. - Refined horizontal and vertical bounds checking to ensure the aspect ratio is maintained when scaling down to fit within the image boundaries. --- .../tanishranjan/cropkit/util/GestureUtils.kt | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) 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 12ed548..851b26e 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/util/GestureUtils.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/util/GestureUtils.kt @@ -237,15 +237,40 @@ internal object GestureUtils { else -> return cropRect } - // Calculate raw distances from the pivot to the target finger position - val distanceX = abs(handleOffset.x - pivot.x) - val distanceY = abs(handleOffset.y - pivot.y) + // 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(minCropSize) + val widthBasedOnX = distanceX.coerceAtLeast(minWidth) val heightDerivedFromX = widthBasedOnX / aspectRatio - val heightBasedOnY = distanceY.coerceAtLeast(minCropSize) + val heightBasedOnY = distanceY.coerceAtLeast(minHeight) val widthDerivedFromY = heightBasedOnY * aspectRatio // We pick the candidate that produces the smallest rectangle @@ -254,19 +279,19 @@ internal object GestureUtils { 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 (handleOffset.x < pivot.x) -1f else 1f - val directionY = if (handleOffset.y < pivot.y) -1f else 1f + 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) + val maxWidthAvailable = + if (directionX < 0) (pivot.x - imageRect.left) + else (imageRect.right - pivot.x) finalWidth = maxWidthAvailable finalHeight = finalWidth / aspectRatio @@ -284,7 +309,6 @@ internal object GestureUtils { 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, From 0f2117a97b00f18f416abbef0810fb541ea53016 Mon Sep 17 00:00:00 2001 From: rubenalfon Date: Sat, 7 Feb 2026 19:32:11 +0100 Subject: [PATCH 3/3] refactor: Relocate `dragOffset` from `CropState` to `CropStateManager` This commit moves the `dragOffset` state from the `CropState` data class to a local variable within `CropStateManager`. This change centralizes drag-related logic within the `CropStateManager` and simplifies the `CropState` data class, which no longer needs to track this transient UI state. The `dragOffset` is now managed directly where drag gestures are handled. --- .../com/tanishranjan/cropkit/internal/CropState.kt | 3 --- .../cropkit/internal/CropStateManager.kt | 13 ++++++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropState.kt b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropState.kt index a53d27f..8a8af54 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropState.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropState.kt @@ -1,7 +1,6 @@ package com.tanishranjan.cropkit.internal import android.graphics.Bitmap -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap @@ -12,7 +11,6 @@ import com.tanishranjan.cropkit.HandlesRect * * @param bitmap The bitmap of the image. * @param imageBitmap The image bitmap of the image. - * @param dragOffset The offset of the drag. * @param cropRect The crop rectangle. * @param imageRect The image rectangle. * @param handles The handles rectangles of the crop rectangle. @@ -24,7 +22,6 @@ import com.tanishranjan.cropkit.HandlesRect internal data class CropState( val bitmap: Bitmap, val imageBitmap: ImageBitmap? = null, - val dragOffset: Offset = Offset.Zero, val cropRect: Rect = Rect.Zero, val imageRect: Rect = Rect.Zero, val handles: HandlesRect = HandlesRect(), 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 8ecac06..1eb3d57 100644 --- a/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropStateManager.kt +++ b/cropkit/src/main/java/com/tanishranjan/cropkit/internal/CropStateManager.kt @@ -31,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) @@ -85,7 +85,7 @@ internal class CropStateManager( else -> DragMode.None } - val dragOffset = when (val mode = dragMode) { + dragOffset = when (val mode = dragMode) { DragMode.None -> Offset.Zero DragMode.Move -> Offset(currentRect.left, currentRect.top) is DragMode.Handle -> GestureUtils.getHandleOffset(mode.handle, currentRect) @@ -93,7 +93,6 @@ internal class CropStateManager( _state.update { cropState -> cropState.copy( - dragOffset = dragOffset, isDragging = dragMode != DragMode.None, gridlinesActive = if (gridLinesVisibility == GridLinesVisibility.ON_TOUCH) { dragMode != DragMode.None @@ -149,7 +148,7 @@ internal class CropStateManager( val imageRect = state.value.imageRect val correctedDragAmount = GestureUtils.getCorrectDragAmount( - dragOffset = state.value.dragOffset, + dragOffset = dragOffset, dragAmount = dragAmount ) @@ -170,8 +169,8 @@ internal class CropStateManager( ) _state.update { + dragOffset = correctedDragAmount it.copy( - dragOffset = correctedDragAmount, cropRect = newRect, handles = GestureUtils.getNewHandleMeasures(newRect, handleRadiusPx) ) @@ -180,7 +179,7 @@ internal class CropStateManager( private fun moveDragHandle(activeHandle: DragHandle, dragAmount: Offset) { val correctedDragAmount = GestureUtils.getCorrectDragAmount( - dragOffset = state.value.dragOffset, + dragOffset = dragOffset, dragAmount = dragAmount ) @@ -193,8 +192,8 @@ internal class CropStateManager( aspectRatio = state.value.aspectRatio ).let { newRect -> _state.update { + dragOffset = correctedDragAmount it.copy( - dragOffset = correctedDragAmount, cropRect = newRect, handles = GestureUtils.getNewHandleMeasures( newRect,