chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:31 +08:00
commit f06de36198
4585 changed files with 588418 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -0,0 +1,336 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import com.t8rin.cropper.model.CropData
import com.t8rin.cropper.state.CropState
import com.t8rin.cropper.state.cropData
import com.t8rin.cropper.util.ZoomLevel
import com.t8rin.cropper.util.getNextZoomLevel
import com.t8rin.cropper.util.update
import com.t8rin.gesture.detectMotionEventsAsList
import com.t8rin.gesture.detectTransformGestures
import kotlinx.coroutines.launch
/**
* Modifier that zooms in or out of Composable set to. This zoom modifier has option
* to move back to bounds with an animation or option to have fling gesture when user removes
* from screen while velocity is higher than threshold to have smooth touch effect.
*
* @param keys are used for [Modifier.pointerInput] to restart closure when any keys assigned
* change
* empty space on sides or edges of parent.
* @param cropState State of the zoom that contains option to set initial, min, max zoom,
* enabling rotation, pan or zoom and contains current [CropData]
* event propagations. Also contains [Rect] of visible area based on pan, zoom and rotation
* @param zoomOnDoubleTap lambda that returns current [ZoomLevel] and based on current level
* enables developer to define zoom on double tap gesture
* @param onGestureStart callback to to notify gesture has started and return current
* [CropData] of this modifier
* @param onGesture callback to notify about ongoing gesture and return current
* [CropData] of this modifier
* @param onGestureEnd callback to notify that gesture finished return current
* [CropData] of this modifier
*/
fun Modifier.crop(
vararg keys: Any?,
cropState: CropState,
enableOneFingerZoom: Boolean = true,
zoomOnDoubleTap: (ZoomLevel) -> Float = cropState.DefaultOnDoubleTap,
onDown: ((CropData) -> Unit)? = null,
onMove: ((CropData) -> Unit)? = null,
onUp: ((CropData) -> Unit)? = null,
onGestureStart: ((CropData) -> Unit)? = null,
onGesture: ((CropData) -> Unit)? = null,
onGestureEnd: ((CropData) -> Unit)? = null
) = composed(
factory = {
LaunchedEffect(key1 = cropState) {
cropState.init()
}
val coroutineScope = rememberCoroutineScope()
// Current Zoom level
var zoomLevel by remember { mutableStateOf(ZoomLevel.Min) }
val transformModifier = Modifier.pointerInput(*keys) {
detectTransformGestures(
consume = false,
onGestureStart = {
onGestureStart?.invoke(cropState.cropData)
},
onGestureEnd = {
coroutineScope.launch {
cropState.onGestureEnd {
onGestureEnd?.invoke(cropState.cropData)
}
}
},
onGesture = { centroid, pan, zoom, rotate, mainPointer, pointerList ->
coroutineScope.launch {
cropState.onGesture(
centroid = centroid,
panChange = pan,
zoomChange = zoom,
rotationChange = rotate,
mainPointer = mainPointer,
changes = pointerList
)
}
onGesture?.invoke(cropState.cropData)
mainPointer.consume()
}
)
}
val tapModifier = Modifier.pointerInput(*keys, enableOneFingerZoom) {
fun onDoubleTap(offset: Offset) {
coroutineScope.launch {
zoomLevel = getNextZoomLevel(zoomLevel)
val newZoom = zoomOnDoubleTap(zoomLevel)
cropState.onDoubleTap(
offset = offset,
zoom = newZoom
) {
onGestureEnd?.invoke(cropState.cropData)
}
}
}
if (enableOneFingerZoom) {
detectOneFingerZoomGestures(
onDoubleTap = ::onDoubleTap,
onGestureStart = {
onGestureStart?.invoke(cropState.cropData)
},
onGestureEnd = {
coroutineScope.launch {
cropState.onGestureEnd {
onGestureEnd?.invoke(cropState.cropData)
}
}
},
onZoom = { centroid, zoom, mainPointer, pointerList ->
coroutineScope.launch {
cropState.onGesture(
centroid = centroid,
panChange = Offset.Zero,
zoomChange = zoom,
rotationChange = 0f,
mainPointer = mainPointer,
changes = pointerList
)
}
onGesture?.invoke(cropState.cropData)
}
)
} else {
detectTapGestures(onDoubleTap = ::onDoubleTap)
}
}
val touchModifier = Modifier.pointerInput(*keys) {
detectMotionEventsAsList(
onDown = {
coroutineScope.launch {
cropState.onDown(it)
onDown?.invoke(cropState.cropData)
}
},
onMove = {
coroutineScope.launch {
cropState.onMove(it)
onMove?.invoke(cropState.cropData)
}
},
onUp = {
coroutineScope.launch {
cropState.onUp(it)
onUp?.invoke(cropState.cropData)
}
}
)
}
val graphicsModifier = Modifier.graphicsLayer {
this.update(cropState)
}
this.then(
Modifier
.clipToBounds()
.then(tapModifier)
.then(transformModifier)
.then(touchModifier)
.then(graphicsModifier)
)
},
inspectorInfo = debugInspectorInfo {
name = "crop"
// add name and value of each argument
properties["keys"] = keys
properties["onDown"] = onGestureStart
properties["onMove"] = onGesture
properties["onUp"] = onGestureEnd
properties["enableOneFingerZoom"] = enableOneFingerZoom
}
)
internal val CropState.DefaultOnDoubleTap: (ZoomLevel) -> Float
get() = { zoomLevel: ZoomLevel ->
when (zoomLevel) {
ZoomLevel.Min -> 1f
ZoomLevel.Mid -> 3f.coerceIn(zoomMin, zoomMax)
ZoomLevel.Max -> 5f.coerceAtLeast(zoomMax)
}
}
private suspend fun PointerInputScope.detectOneFingerZoomGestures(
onDoubleTap: (Offset) -> Unit,
onGestureStart: () -> Unit,
onZoom: (
centroid: Offset,
zoom: Float,
mainPointer: PointerInputChange,
changes: List<PointerInputChange>
) -> Unit,
onGestureEnd: () -> Unit
) = awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
var firstUp = firstDown
var hasMoved = false
var isMultiTouch = false
var isCanceled = false
do {
val event = awaitPointerEvent()
if (event.changes.fastAny { it.isConsumed }) {
isCanceled = true
break
}
if (event.changes.fastAny { it.positionChanged() }) {
hasMoved = true
}
if (event.changes.size > 1) {
isMultiTouch = true
}
firstUp = event.changes.first()
} while (event.changes.fastAny { it.pressed })
val isTap = !hasMoved &&
!isMultiTouch &&
!isCanceled &&
firstUp.uptimeMillis - firstDown.uptimeMillis <= viewConfiguration.longPressTimeoutMillis
if (isTap) {
val secondDown = awaitSecondDown(firstUp)
if (secondDown != null) {
var secondUp: PointerInputChange = secondDown
var isZooming = false
var isSecondMultiTouch = false
var isSecondCanceled = false
do {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (event.changes.fastAny { it.isConsumed }) {
isSecondCanceled = true
break
}
if (event.changes.size > 1) {
isSecondMultiTouch = true
}
val panChange = event.calculatePan()
if (panChange != Offset.Zero) {
if (!isZooming) {
onGestureStart()
}
isZooming = true
val zoomChange = 1f + panChange.y * 0.004f
if (zoomChange != 1f) {
onZoom(
event.calculateCentroid(useCurrent = true),
zoomChange,
event.changes.first(),
event.changes
)
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
}
secondUp = event.changes.first()
} while (event.changes.fastAny { it.pressed })
if (isZooming && !isSecondMultiTouch && !isSecondCanceled) {
onGestureEnd()
} else if (!isSecondMultiTouch &&
!isSecondCanceled &&
secondUp.uptimeMillis - secondDown.uptimeMillis <=
viewConfiguration.longPressTimeoutMillis
) {
onDoubleTap(secondUp.position)
}
}
}
}
private suspend fun AwaitPointerEventScope.awaitSecondDown(
firstUp: PointerInputChange
): PointerInputChange? =
withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
var change: PointerInputChange
do {
change = awaitFirstDown()
} while (change.uptimeMillis < minUptime)
change
}
@@ -0,0 +1,440 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper
import android.content.res.Configuration
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import com.t8rin.cropper.crop.CropAgent
import com.t8rin.cropper.draw.DrawingOverlay
import com.t8rin.cropper.draw.ImageDrawCanvas
import com.t8rin.cropper.image.ImageWithConstraints
import com.t8rin.cropper.image.getScaledImageBitmap
import com.t8rin.cropper.model.CropOutline
import com.t8rin.cropper.settings.CropDefaults
import com.t8rin.cropper.settings.CropProperties
import com.t8rin.cropper.settings.CropStyle
import com.t8rin.cropper.settings.CropType
import com.t8rin.cropper.state.DynamicCropState
import com.t8rin.cropper.state.rememberCropState
import com.t8rin.imagetoolbox.core.resources.utils.animation.animateColorAsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
@Composable
fun ImageCropper(
modifier: Modifier = Modifier,
imageBitmap: ImageBitmap,
sourceImageUri: Uri? = null,
sourceImageSize: IntSize = IntSize(imageBitmap.width, imageBitmap.height),
contentDescription: String? = null,
cropStyle: CropStyle = CropDefaults.style(),
cropProperties: CropProperties,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
crop: Boolean = false,
enableOneFingerZoom: Boolean = true,
isOverlayDraggable: Boolean = true,
onCropStart: () -> Unit,
onZoomChange: (Float) -> Unit,
onCropSuccess: (Uri?) -> Unit,
backgroundModifier: Modifier = Modifier
) {
ImageWithConstraints(
modifier = modifier.clipToBounds(),
contentScale = cropProperties.contentScale,
contentDescription = contentDescription,
filterQuality = filterQuality,
imageBitmap = imageBitmap,
drawImage = false
) {
// No crop operation is applied by ScalableImage so rect points to bounds of original
// bitmap
val scaledImageBitmap = getScaledImageBitmap(
imageWidth = imageWidth,
imageHeight = imageHeight,
rect = rect,
bitmap = imageBitmap,
contentScale = cropProperties.contentScale,
)
// Container Dimensions
val containerWidthPx = constraints.maxWidth
val containerHeightPx = constraints.maxHeight
val containerWidth: Dp
val containerHeight: Dp
// Bitmap Dimensions
val bitmapWidth = scaledImageBitmap.width
val bitmapHeight = scaledImageBitmap.height
// Dimensions of Composable that displays Bitmap
val imageWidthPx: Int
val imageHeightPx: Int
with(LocalDensity.current) {
imageWidthPx = imageWidth.roundToPx()
imageHeightPx =
imageHeight.roundToPx() - if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) 0
else WindowInsets.navigationBars.getBottom(LocalDensity.current)
containerWidth = containerWidthPx.toDp()
containerHeight = containerHeightPx.toDp()
}
val cropType = cropProperties.cropType
val contentScale = cropProperties.contentScale
val fixedAspectRatio = cropProperties.fixedAspectRatio
val cropOutline = cropProperties.cropOutlineProperty.cropOutline
// these keys are for resetting cropper when image width/height, contentScale or
// overlay aspect ratio changes
val resetKeys =
getResetKeys(
scaledImageBitmap,
imageWidthPx,
imageHeightPx,
contentScale,
cropType,
fixedAspectRatio
)
val cropState = rememberCropState(
imageSize = IntSize(bitmapWidth, bitmapHeight),
containerSize = IntSize(containerWidthPx, containerHeightPx),
drawAreaSize = IntSize(imageWidthPx, imageHeightPx),
cropProperties = cropProperties,
isOverlayDraggable = isOverlayDraggable,
keys = resetKeys
)
LaunchedEffect(cropState, isOverlayDraggable) {
if (cropState is DynamicCropState) {
cropState.isOverlayDraggable = isOverlayDraggable
}
}
onZoomChange(cropState.zoom)
val isHandleTouched by remember(cropState) {
derivedStateOf {
cropState is DynamicCropState && handlesTouched(cropState.touchRegion)
}
}
val pressedStateColor = remember(cropStyle.backgroundColor) {
cropStyle.backgroundColor
.copy(cropStyle.backgroundColor.alpha * .7f)
}
val transparentColor by animateColorAsState(
animationSpec = tween(300, easing = LinearEasing),
targetValue = if (isHandleTouched) pressedStateColor else cropStyle.backgroundColor
)
// Crops image when user invokes crop operation
Crop(
crop = crop,
scaledImageBitmap = scaledImageBitmap,
sourceImageUri = sourceImageUri,
sourceImageSize = sourceImageSize,
sourceImageVisibleRect = rect,
previewImageSize = IntSize(imageBitmap.width, imageBitmap.height),
cropRect = cropState.cropRect,
cropOutline = cropOutline,
onCropStart = onCropStart,
onCropSuccess = onCropSuccess
)
val imageModifier = Modifier
.size(containerWidth, containerHeight)
.crop(
keys = resetKeys,
cropState = cropState,
enableOneFingerZoom = enableOneFingerZoom
)
LaunchedEffect(key1 = cropProperties) {
cropState.updateProperties(cropProperties)
}
/// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(100)
visible = true
}
ImageCropper(
modifier = imageModifier,
visible = visible,
imageBitmap = imageBitmap,
containerWidth = containerWidth,
containerHeight = containerHeight,
imageWidthPx = imageWidthPx,
imageHeightPx = imageHeightPx,
handleSize = cropProperties.handleSize,
middleHandleSize = cropProperties.middleHandleSize,
overlayRect = cropState.overlayRect,
cropType = cropType,
cropOutline = cropOutline,
cropStyle = cropStyle,
transparentColor = transparentColor,
backgroundModifier = backgroundModifier
)
}
}
@Composable
private fun ImageCropper(
modifier: Modifier,
visible: Boolean,
imageBitmap: ImageBitmap,
containerWidth: Dp,
containerHeight: Dp,
imageWidthPx: Int,
imageHeightPx: Int,
handleSize: Float,
middleHandleSize: Float,
cropType: CropType,
cropOutline: CropOutline,
cropStyle: CropStyle,
overlayRect: Rect,
transparentColor: Color,
backgroundModifier: Modifier
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(backgroundModifier)
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500))
) {
ImageCropperImpl(
modifier = modifier,
imageBitmap = imageBitmap,
containerWidth = containerWidth,
containerHeight = containerHeight,
imageWidthPx = imageWidthPx,
imageHeightPx = imageHeightPx,
cropType = cropType,
cropOutline = cropOutline,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
cropStyle = cropStyle,
rectOverlay = overlayRect,
transparentColor = transparentColor
)
}
}
}
@Composable
private fun ImageCropperImpl(
modifier: Modifier,
imageBitmap: ImageBitmap,
containerWidth: Dp,
containerHeight: Dp,
imageWidthPx: Int,
imageHeightPx: Int,
cropType: CropType,
cropOutline: CropOutline,
handleSize: Float,
middleHandleSize: Float,
cropStyle: CropStyle,
transparentColor: Color,
rectOverlay: Rect
) {
Box(contentAlignment = Alignment.Center) {
// Draw Image
ImageDrawCanvas(
modifier = modifier,
imageBitmap = imageBitmap,
imageWidth = imageWidthPx,
imageHeight = imageHeightPx
)
val drawOverlay = cropStyle.drawOverlay
val drawGrid = cropStyle.drawGrid
val overlayColor = cropStyle.overlayColor
val handleColor = cropStyle.handleColor
val drawHandles = cropType == CropType.Dynamic
val strokeWidth = cropStyle.strokeWidth
DrawingOverlay(
modifier = Modifier.size(containerWidth, containerHeight),
drawOverlay = drawOverlay,
rect = rectOverlay,
cropOutline = cropOutline,
drawGrid = drawGrid,
overlayColor = overlayColor,
handleColor = handleColor,
strokeWidth = strokeWidth,
drawHandles = drawHandles,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
transparentColor = transparentColor,
)
}
}
@Composable
private fun Crop(
crop: Boolean,
scaledImageBitmap: ImageBitmap,
sourceImageUri: Uri?,
sourceImageSize: IntSize,
sourceImageVisibleRect: IntRect,
previewImageSize: IntSize,
cropRect: Rect,
cropOutline: CropOutline,
onCropStart: () -> Unit,
onCropSuccess: (Uri?) -> Unit
) {
val context = LocalContext.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
// Crop Agent is responsible for cropping image
val cropAgent = remember { CropAgent() }
LaunchedEffect(crop) {
if (crop) {
flow {
val previewSize = IntSize(scaledImageBitmap.width, scaledImageBitmap.height)
emit(
cropAgent.cropToCache(
context = context,
imageUri = sourceImageUri,
fallbackImageBitmap = scaledImageBitmap,
fallbackCropRect = cropRect,
sourceCropRect = cropRect.scaleTo(
sourceSize = sourceImageSize,
previewSize = previewSize,
sourceImageVisibleRect = sourceImageVisibleRect,
previewImageSize = previewImageSize
),
cropOutline = cropOutline,
layoutDirection = layoutDirection,
density = density
)
)
}
.flowOn(Dispatchers.Default)
.onStart {
onCropStart()
delay(400)
}
.onEach {
onCropSuccess(it)
}
.launchIn(this)
}
}
}
private fun Rect.scaleTo(
sourceSize: IntSize,
previewSize: IntSize,
sourceImageVisibleRect: IntRect,
previewImageSize: IntSize
): Rect {
if (sourceSize == IntSize.Zero || previewSize == IntSize.Zero) return this
val widthScale = sourceSize.width.toFloat() / previewImageSize.width.coerceAtLeast(1)
val heightScale = sourceSize.height.toFloat() / previewImageSize.height.coerceAtLeast(1)
return Rect(
left = (sourceImageVisibleRect.left + left) * widthScale,
top = (sourceImageVisibleRect.top + top) * heightScale,
right = (sourceImageVisibleRect.left + right) * widthScale,
bottom = (sourceImageVisibleRect.top + bottom) * heightScale
)
}
@Composable
private fun getResetKeys(
scaledImageBitmap: ImageBitmap,
imageWidthPx: Int,
imageHeightPx: Int,
contentScale: ContentScale,
cropType: CropType,
fixedAspectRatio: Boolean,
) = remember(
scaledImageBitmap,
imageWidthPx,
imageHeightPx,
contentScale,
cropType,
fixedAspectRatio,
) {
arrayOf(
scaledImageBitmap,
imageWidthPx,
imageHeightPx,
contentScale,
cropType,
fixedAspectRatio,
)
}
@@ -0,0 +1,30 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper
/**
* Enum for detecting which section of Composable user has initially touched
*/
enum class TouchRegion {
TopLeft, TopRight, BottomLeft, BottomRight,
TopCenter, CenterRight, BottomCenter, CenterLeft,
Inside, None
}
fun handlesTouched(touchRegion: TouchRegion) =
touchRegion != TouchRegion.None && touchRegion != TouchRegion.Inside
@@ -0,0 +1,325 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.crop
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.net.Uri
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toComposeRect
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.core.graphics.scale
import androidx.core.net.toUri
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.toBitmap
import com.t8rin.cropper.model.CropImageMask
import com.t8rin.cropper.model.CropOutline
import com.t8rin.cropper.model.CropPath
import com.t8rin.cropper.model.CropShape
import com.t8rin.exif.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import android.graphics.Rect as AndroidRect
import coil3.size.Size as CoilSize
/**
* Crops imageBitmap based on path that is passed in [crop] function
*/
internal class CropAgent {
private val imagePaint = Paint().apply {
blendMode = BlendMode.SrcIn
}
private val paint = Paint()
fun crop(
imageBitmap: ImageBitmap,
cropRect: Rect,
cropOutline: CropOutline,
layoutDirection: LayoutDirection,
density: Density,
): ImageBitmap {
return runCatching {
val croppedBitmap: Bitmap = Bitmap.createBitmap(
imageBitmap.asAndroidBitmap(),
cropRect.left.toInt(),
cropRect.top.toInt(),
cropRect.width.toInt(),
cropRect.height.toInt(),
)
val imageToCrop = croppedBitmap
.copy(Bitmap.Config.ARGB_8888, true)!!
.asImageBitmap()
drawCroppedImage(cropOutline, cropRect, layoutDirection, density, imageToCrop)
imageToCrop
}.getOrNull() ?: imageBitmap
}
suspend fun cropToCache(
context: Context,
imageUri: Uri?,
fallbackImageBitmap: ImageBitmap,
fallbackCropRect: Rect,
sourceCropRect: Rect,
cropOutline: CropOutline,
layoutDirection: LayoutDirection,
density: Density,
): Uri? = withContext(Dispatchers.Default) {
runCatching {
context.loadCroppedBitmap(imageUri, sourceCropRect)?.let { sourceBitmap ->
crop(
imageBitmap = sourceBitmap.asImageBitmap(),
cropRect = Rect(
left = 0f,
top = 0f,
right = sourceBitmap.width.toFloat(),
bottom = sourceBitmap.height.toFloat()
),
cropOutline = cropOutline,
layoutDirection = layoutDirection,
density = density
).asAndroidBitmap().cacheAsPng(context)
} ?: run {
val sourceBitmap = context.loadBitmap(imageUri)?.asImageBitmap()
crop(
imageBitmap = sourceBitmap ?: fallbackImageBitmap,
cropRect = if (sourceBitmap != null) {
sourceCropRect.coerceIn(sourceBitmap.width, sourceBitmap.height)
} else {
fallbackCropRect.coerceIn(
fallbackImageBitmap.width,
fallbackImageBitmap.height
)
},
cropOutline = cropOutline,
layoutDirection = layoutDirection,
density = density
).asAndroidBitmap().cacheAsPng(context)
}
}.getOrNull() ?: runCatching {
crop(
imageBitmap = fallbackImageBitmap,
cropRect = fallbackCropRect.coerceIn(
fallbackImageBitmap.width,
fallbackImageBitmap.height
),
cropOutline = cropOutline,
layoutDirection = layoutDirection,
density = density
).asAndroidBitmap().cacheAsPng(context)
}.getOrNull()
}
private fun drawCroppedImage(
cropOutline: CropOutline,
cropRect: Rect,
layoutDirection: LayoutDirection,
density: Density,
imageToCrop: ImageBitmap,
) {
when (cropOutline) {
is CropShape -> {
val path = Path().apply {
val outline =
cropOutline.shape.createOutline(cropRect.size, layoutDirection, density)
addOutline(outline)
}
Canvas(image = imageToCrop).run {
saveLayer(nativeCanvas.clipBounds.toComposeRect(), imagePaint)
// Destination
drawPath(path, paint)
// Source
drawImage(
image = imageToCrop,
topLeftOffset = Offset.Zero,
paint = imagePaint
)
restore()
}
}
is CropPath -> {
val path = Path().apply {
addPath(cropOutline.path)
val pathSize = getBounds().size
val rectSize = cropRect.size
val matrix = android.graphics.Matrix()
matrix.postScale(
rectSize.width / pathSize.width,
cropRect.height / pathSize.height
)
this.asAndroidPath().transform(matrix)
val left = getBounds().left
val top = getBounds().top
translate(Offset(-left, -top))
}
Canvas(image = imageToCrop).run {
saveLayer(nativeCanvas.clipBounds.toComposeRect(), imagePaint)
// Destination
drawPath(path, paint)
// Source
drawImage(image = imageToCrop, topLeftOffset = Offset.Zero, imagePaint)
restore()
}
}
is CropImageMask -> {
val imageMask = cropOutline.image.asAndroidBitmap()
.scale(cropRect.width.toInt(), cropRect.height.toInt()).asImageBitmap()
Canvas(image = imageToCrop).run {
saveLayer(nativeCanvas.clipBounds.toComposeRect(), imagePaint)
// Destination
drawImage(imageMask, topLeftOffset = Offset.Zero, paint)
// Source
drawImage(image = imageToCrop, topLeftOffset = Offset.Zero, imagePaint)
restore()
}
}
}
}
}
@Suppress("DEPRECATION")
private fun Context.loadCroppedBitmap(
uri: Uri?,
cropRect: Rect
): Bitmap? {
uri ?: return null
if (!hasNormalExifOrientation(uri)) return null
return runCatching {
contentResolver.openInputStream(uri)?.use { input ->
val decoder = BitmapRegionDecoder.newInstance(input, false) ?: return null
try {
decoder.decodeRegion(
cropRect.toAndroidRect(decoder.width, decoder.height),
BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
}
)
} finally {
decoder.recycle()
}
}
}.getOrNull()
}
private fun Context.hasNormalExifOrientation(uri: Uri): Boolean {
return runCatching {
contentResolver.openInputStream(uri)?.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} == ExifInterface.ORIENTATION_NORMAL
}.getOrDefault(true)
}
private suspend fun Context.loadBitmap(uri: Uri?): Bitmap? {
uri ?: return null
return imageLoader.execute(
ImageRequest.Builder(this)
.data(uri)
.size(CoilSize.ORIGINAL)
.allowHardware(false)
.build()
).image?.toBitmap()
}
private fun Bitmap.cacheAsPng(context: Context): Uri {
val file = File(
File(context.cacheDir, "temp").apply(File::mkdirs),
"temp_crop_${System.currentTimeMillis()}.png"
)
FileOutputStream(file).use { output ->
check(compress(Bitmap.CompressFormat.PNG, 100, output))
}
return file.toUri()
}
private fun Rect.coerceIn(
width: Int,
height: Int
): Rect {
val left = left.coerceIn(0f, (width - 1).coerceAtLeast(0).toFloat())
val top = top.coerceIn(0f, (height - 1).coerceAtLeast(0).toFloat())
val right = right.coerceIn(left + 1f, width.toFloat())
val bottom = bottom.coerceIn(top + 1f, height.toFloat())
return Rect(left, top, right, bottom)
}
private fun Rect.toAndroidRect(
width: Int,
height: Int
): AndroidRect {
val safeRect = coerceIn(width, height)
return AndroidRect(
safeRect.left.toInt(),
safeRect.top.toInt(),
safeRect.right.toInt(),
safeRect.bottom.toInt()
)
}
@@ -0,0 +1,53 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.draw
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt
/**
* Draw image to be cropped
*/
@Composable
internal fun ImageDrawCanvas(
modifier: Modifier,
imageBitmap: ImageBitmap,
imageWidth: Int,
imageHeight: Int
) {
Canvas(modifier = modifier) {
val canvasWidth = size.width.roundToInt()
val canvasHeight = size.height.roundToInt()
drawImage(
image = imageBitmap,
srcSize = IntSize(imageBitmap.width, imageBitmap.height),
dstSize = IntSize(imageWidth, imageHeight),
dstOffset = IntOffset(
x = (canvasWidth - imageWidth) / 2,
y = (canvasHeight - imageHeight) / 2
)
)
}
}
@@ -0,0 +1,427 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.draw
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import com.t8rin.cropper.model.CropImageMask
import com.t8rin.cropper.model.CropOutline
import com.t8rin.cropper.model.CropPath
import com.t8rin.cropper.model.CropShape
import com.t8rin.cropper.util.drawGrid
import com.t8rin.cropper.util.drawWithLayer
import com.t8rin.cropper.util.scaleAndTranslatePath
import com.t8rin.imagetoolbox.core.resources.utils.compositeOverSafe
/**
* Draw overlay composed of 9 rectangles. When [drawHandles]
* is set draw handles for changing drawing rectangle
*/
@Composable
internal fun DrawingOverlay(
modifier: Modifier,
drawOverlay: Boolean,
rect: Rect,
cropOutline: CropOutline,
drawGrid: Boolean,
transparentColor: Color,
overlayColor: Color,
handleColor: Color,
strokeWidth: Dp,
drawHandles: Boolean,
handleSize: Float,
middleHandleSize: Float,
) {
val density = LocalDensity.current
val layoutDirection: LayoutDirection = LocalLayoutDirection.current
val strokeWidthPx = LocalDensity.current.run { strokeWidth.toPx() }
val pathHandles = remember {
Path()
}
val middlePathHandles = remember {
Path()
}
when (cropOutline) {
is CropShape -> {
val outline = remember(rect, cropOutline) {
cropOutline.shape.createOutline(rect.size, layoutDirection, density)
}
DrawingOverlayImpl(
modifier = modifier,
drawOverlay = drawOverlay,
rect = rect,
drawGrid = drawGrid,
transparentColor = transparentColor,
overlayColor = overlayColor,
handleColor = handleColor,
strokeWidth = strokeWidthPx,
drawHandles = drawHandles,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
pathHandles = pathHandles,
middlePathHandles = middlePathHandles,
outline = outline
)
}
is CropPath -> {
val path = remember(rect, cropOutline) {
Path().apply {
addPath(cropOutline.path)
scaleAndTranslatePath(rect.width, rect.height)
}
}
DrawingOverlayImpl(
modifier = modifier,
drawOverlay = drawOverlay,
rect = rect,
drawGrid = drawGrid,
transparentColor = transparentColor,
overlayColor = overlayColor,
handleColor = handleColor,
strokeWidth = strokeWidthPx,
drawHandles = drawHandles,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
pathHandles = pathHandles,
middlePathHandles = middlePathHandles,
path = path
)
}
is CropImageMask -> {
val imageBitmap = cropOutline.image
DrawingOverlayImpl(
modifier = modifier,
drawOverlay = drawOverlay,
rect = rect,
drawGrid = drawGrid,
transparentColor = transparentColor,
overlayColor = overlayColor,
handleColor = handleColor,
strokeWidth = strokeWidthPx,
drawHandles = drawHandles,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
pathHandles = pathHandles,
middlePathHandles = middlePathHandles,
image = imageBitmap
)
}
}
}
@Composable
private fun DrawingOverlayImpl(
modifier: Modifier,
drawOverlay: Boolean,
rect: Rect,
drawGrid: Boolean,
transparentColor: Color,
overlayColor: Color,
handleColor: Color,
strokeWidth: Float,
drawHandles: Boolean,
handleSize: Float,
middleHandleSize: Float,
pathHandles: Path,
middlePathHandles: Path,
outline: Outline,
) {
Canvas(modifier = modifier) {
drawOverlay(
drawOverlay = drawOverlay,
rect = rect,
drawGrid = drawGrid,
transparentColor = transparentColor,
overlayColor = overlayColor,
handleColor = handleColor,
strokeWidth = strokeWidth,
drawHandles = drawHandles,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
pathHandles = pathHandles,
middlePathHandles = middlePathHandles
) {
drawCropOutline(outline = outline)
}
}
}
@Composable
private fun DrawingOverlayImpl(
modifier: Modifier,
drawOverlay: Boolean,
rect: Rect,
drawGrid: Boolean,
transparentColor: Color,
overlayColor: Color,
handleColor: Color,
strokeWidth: Float,
drawHandles: Boolean,
handleSize: Float,
middleHandleSize: Float,
pathHandles: Path,
middlePathHandles: Path,
path: Path,
) {
Canvas(modifier = modifier) {
drawOverlay(
drawOverlay = drawOverlay,
rect = rect,
drawGrid = drawGrid,
transparentColor = transparentColor,
overlayColor = overlayColor,
handleColor = handleColor,
strokeWidth = strokeWidth,
drawHandles = drawHandles,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
pathHandles = pathHandles,
middlePathHandles = middlePathHandles
) {
drawCropPath(path)
}
}
}
@Composable
private fun DrawingOverlayImpl(
modifier: Modifier,
drawOverlay: Boolean,
rect: Rect,
drawGrid: Boolean,
transparentColor: Color,
overlayColor: Color,
handleColor: Color,
strokeWidth: Float,
drawHandles: Boolean,
handleSize: Float,
middleHandleSize: Float,
pathHandles: Path,
middlePathHandles: Path,
image: ImageBitmap,
) {
Canvas(modifier = modifier) {
drawOverlay(
drawOverlay = drawOverlay,
rect = rect,
drawGrid = drawGrid,
transparentColor = transparentColor,
overlayColor = overlayColor,
handleColor = handleColor,
strokeWidth = strokeWidth,
drawHandles = drawHandles,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
pathHandles = pathHandles,
middlePathHandles = middlePathHandles
) {
drawCropImage(rect, image)
}
}
}
private fun DrawScope.drawOverlay(
drawOverlay: Boolean,
rect: Rect,
drawGrid: Boolean,
transparentColor: Color,
overlayColor: Color,
handleColor: Color,
strokeWidth: Float,
drawHandles: Boolean,
handleSize: Float,
middleHandleSize: Float,
pathHandles: Path,
middlePathHandles: Path,
drawBlock: DrawScope.() -> Unit
) {
drawWithLayer {
// Destination
drawRect(transparentColor)
// Source
translate(left = rect.left, top = rect.top) {
drawBlock()
}
if (drawGrid) {
drawGrid(
rect = rect,
strokeWidth = strokeWidth,
color = overlayColor
)
}
}
if (drawOverlay) {
drawRect(
topLeft = rect.topLeft,
size = rect.size,
color = overlayColor,
style = Stroke(width = strokeWidth)
)
if (drawHandles) {
pathHandles.apply {
reset()
updateHandlePath(rect, handleSize)
}
middlePathHandles.apply {
reset()
updateMiddleHandlePath(rect, middleHandleSize)
}
drawPath(
path = middlePathHandles,
color = handleColor.copy(0.9f).compositeOverSafe(Color.Black),
style = Stroke(
width = strokeWidth * 4,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
drawPath(
path = pathHandles,
color = handleColor,
style = Stroke(
width = strokeWidth * 4,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}
}
}
private fun DrawScope.drawCropImage(
rect: Rect,
imageBitmap: ImageBitmap,
blendMode: BlendMode = BlendMode.DstOut
) {
drawImage(
image = imageBitmap,
dstSize = IntSize(rect.size.width.toInt(), rect.size.height.toInt()),
blendMode = blendMode
)
}
private fun DrawScope.drawCropOutline(
outline: Outline,
blendMode: BlendMode = BlendMode.SrcOut
) {
drawOutline(
outline = outline,
color = Color.Transparent,
blendMode = blendMode
)
}
private fun DrawScope.drawCropPath(
path: Path,
blendMode: BlendMode = BlendMode.SrcOut
) {
drawPath(
path = path,
color = Color.Transparent,
blendMode = blendMode
)
}
private fun Path.updateHandlePath(
rect: Rect,
handleSize: Float
) {
if (rect != Rect.Zero) {
// Top left lines
moveTo(rect.topLeft.x, rect.topLeft.y + handleSize)
lineTo(rect.topLeft.x, rect.topLeft.y)
lineTo(rect.topLeft.x + handleSize, rect.topLeft.y)
// Top right lines
moveTo(rect.topRight.x - handleSize, rect.topRight.y)
lineTo(rect.topRight.x, rect.topRight.y)
lineTo(rect.topRight.x, rect.topRight.y + handleSize)
// Bottom right lines
moveTo(rect.bottomRight.x, rect.bottomRight.y - handleSize)
lineTo(rect.bottomRight.x, rect.bottomRight.y)
lineTo(rect.bottomRight.x - handleSize, rect.bottomRight.y)
// Bottom left lines
moveTo(rect.bottomLeft.x + handleSize, rect.bottomLeft.y)
lineTo(rect.bottomLeft.x, rect.bottomLeft.y)
lineTo(rect.bottomLeft.x, rect.bottomLeft.y - handleSize)
}
}
private fun Path.updateMiddleHandlePath(
rect: Rect,
handleSize: Float
) {
if (rect != Rect.Zero) {
// Top middle lines
moveTo(rect.topCenter.x - handleSize / 2, rect.topCenter.y)
lineTo(rect.topCenter.x + handleSize / 2, rect.topCenter.y)
// Right middle lines
moveTo(rect.centerRight.x, rect.centerRight.y - handleSize / 2)
lineTo(rect.centerRight.x, rect.centerRight.y + handleSize / 2)
// Bottom middle lines
moveTo(rect.bottomCenter.x - handleSize / 2, rect.bottomCenter.y)
lineTo(rect.bottomCenter.x + handleSize / 2, rect.bottomCenter.y)
// Left middle lines
moveTo(rect.centerLeft.x, rect.centerLeft.y - handleSize / 2)
lineTo(rect.centerLeft.x, rect.centerLeft.y + handleSize / 2)
}
}
@@ -0,0 +1,145 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.image
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntRect
/**
* Receiver scope being used by the children parameter of [ImageWithConstraints]
*/
@Stable
interface ImageScope {
/**
* The constraints given by the parent layout in pixels.
*
* Use [minWidth], [maxWidth], [minHeight] or [maxHeight] if you need value in [Dp].
*/
val constraints: Constraints
/**
* The minimum width in [Dp].
*
* @see constraints for the values in pixels.
*/
val minWidth: Dp
/**
* The maximum width in [Dp].
*
* @see constraints for the values in pixels.
*/
val maxWidth: Dp
/**
* The minimum height in [Dp].
*
* @see constraints for the values in pixels.
*/
val minHeight: Dp
/**
* The maximum height in [Dp].
*
* @see constraints for the values in pixels.
*/
val maxHeight: Dp
/**
* Width of area inside BoxWithConstraints that is scaled based on [ContentScale]
* This is width of the [Canvas] draw [ImageBitmap]
*/
val imageWidth: Dp
/**
* Height of area inside BoxWithConstraints that is scaled based on [ContentScale]
* This is height of the [Canvas] draw [ImageBitmap]
*/
val imageHeight: Dp
/**
* [IntRect] that covers boundaries of [ImageBitmap]
*/
val rect: IntRect
}
internal data class ImageScopeImpl(
private val density: Density,
override val constraints: Constraints,
override val imageWidth: Dp,
override val imageHeight: Dp,
override val rect: IntRect,
) : ImageScope {
override val minWidth: Dp get() = with(density) { constraints.minWidth.toDp() }
override val maxWidth: Dp
get() = with(density) {
if (constraints.hasBoundedWidth) constraints.maxWidth.toDp() else Dp.Infinity
}
override val minHeight: Dp get() = with(density) { constraints.minHeight.toDp() }
override val maxHeight: Dp
get() = with(density) {
if (constraints.hasBoundedHeight) constraints.maxHeight.toDp() else Dp.Infinity
}
}
@Composable
internal fun getScaledImageBitmap(
imageWidth: Dp,
imageHeight: Dp,
rect: IntRect,
bitmap: ImageBitmap,
contentScale: ContentScale
): ImageBitmap {
val scaledBitmap =
remember(bitmap, rect, imageWidth, imageHeight, contentScale) {
// This bitmap is needed when we crop original bitmap due to scaling mode
// and aspect ratio result of cropping
// We might have center section of the image after cropping, and
// because of that thumbLayout either should have rectangle and some
// complex calculation for srcOffset and srcSide along side with touch offset
// or we can create a new bitmap that only contains area bounded by rectangle
runCatching {
Bitmap.createBitmap(
bitmap.asAndroidBitmap(),
rect.left,
rect.top,
rect.width,
rect.height
).asImageBitmap()
}.getOrNull() ?: bitmap
}
return scaledBitmap
}
@@ -0,0 +1,250 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.image
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import com.t8rin.cropper.util.getParentSize
import com.t8rin.cropper.util.getScaledBitmapRect
/**
* A composable that lays out and draws a given [ImageBitmap]. This will attempt to
* size the composable according to the [ImageBitmap]'s given width and height. However, an
* optional [Modifier] parameter can be provided to adjust sizing or draw additional content (ex.
* background). Any unspecified dimension will leverage the [ImageBitmap]'s size as a minimum
* constraint.
*
* [ImageScope] returns constraints, width and height of the drawing area based on [contentScale]
* and rectangle of [imageBitmap] drawn. When a bitmap is displayed scaled to fit area of Composable
* space used for drawing image is represented with [ImageScope.imageWidth] and
* [ImageScope.imageHeight].
*
* When we display a bitmap 1000x1000px with [ContentScale.Crop] if it's cropped to 500x500px
* [ImageScope.rect] returns `IntRect(250,250,750,750)`.
*
* @param alignment determines where image will be aligned inside [BoxWithConstraints]
* This is observable when bitmap image/width ratio differs from [Canvas] that draws [ImageBitmap]
* @param contentDescription text used by accessibility services to describe what this image
* represents. This should always be provided unless this image is used for decorative purposes,
* and does not represent a meaningful action that a user can take. This text should be
* localized, such as by using [androidx.compose.ui.res.stringResource] or similar
* @param contentScale how image should be scaled inside Canvas to match parent dimensions.
* [ContentScale.Fit] for instance maintains src ratio and scales image to fit inside the parent.
* @param alpha Opacity to be applied to [imageBitmap] from 0.0f to 1.0f representing
* fully transparent to fully opaque respectively
* @param colorFilter ColorFilter to apply to the [imageBitmap] when drawn into the destination
* @param filterQuality Sampling algorithm applied to the [imageBitmap] when it is scaled and drawn
* into the destination. The default is [FilterQuality.Low] which scales using a bilinear
* sampling algorithm
* @param content is a Composable that can be matched at exact position where [imageBitmap] is drawn.
* This is useful for drawing thumbs, cropping or another layout that should match position
* with the image that is scaled is drawn
* @param drawImage flag to draw image on canvas. Some Composables might only require
* the calculation and rectangle bounds of image after scaling but not drawing.
* Composables like image cropper that scales or
* rotates image. Drawing here again have 2 drawings overlap each other.
*/
@Composable
internal fun ImageWithConstraints(
modifier: Modifier = Modifier,
imageBitmap: ImageBitmap,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String? = null,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
drawImage: Boolean = true,
content: @Composable ImageScope.() -> Unit = {}
) {
val semantics = if (contentDescription != null) {
Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
}
} else {
Modifier
}
BoxWithConstraints(
modifier = modifier
.then(semantics),
contentAlignment = alignment,
) {
val bitmapWidth = imageBitmap.width
val bitmapHeight = imageBitmap.height
val (boxWidth: Int, boxHeight: Int) = getParentSize(bitmapWidth, bitmapHeight)
// Src is Bitmap, Dst is the container(Image) that Bitmap will be displayed
val srcSize = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat())
val dstSize = Size(boxWidth.toFloat(), boxHeight.toFloat())
val scaleFactor = contentScale.computeScaleFactor(srcSize, dstSize)
// Image is the container for bitmap that is located inside Box
// image bounds can be smaller or bigger than its parent based on how it's scaled
val imageWidth = bitmapWidth * scaleFactor.scaleX
val imageHeight = bitmapHeight * scaleFactor.scaleY
val bitmapRect = getScaledBitmapRect(
boxWidth = boxWidth,
boxHeight = boxHeight,
imageWidth = imageWidth,
imageHeight = imageHeight,
bitmapWidth = bitmapWidth,
bitmapHeight = bitmapHeight
)
ImageLayout(
constraints = constraints,
imageBitmap = imageBitmap,
bitmapRect = bitmapRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
boxWidth = boxWidth,
boxHeight = boxHeight,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
drawImage = drawImage,
content = content
)
}
}
@Composable
private fun ImageLayout(
constraints: Constraints,
imageBitmap: ImageBitmap,
bitmapRect: IntRect,
imageWidth: Float,
imageHeight: Float,
boxWidth: Int,
boxHeight: Int,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
drawImage: Boolean = true,
content: @Composable ImageScope.() -> Unit
) {
val density = LocalDensity.current
// Dimensions of canvas that will draw this Bitmap
val canvasWidthInDp: Dp
val canvasHeightInDp: Dp
with(density) {
canvasWidthInDp = imageWidth.coerceAtMost(boxWidth.toFloat()).toDp()
canvasHeightInDp = imageHeight.coerceAtMost(boxHeight.toFloat()).toDp()
}
// Send rectangle of Bitmap drawn to Canvas as bitmapRect, content scale modes like
// crop might crop image from center so Rect can be such as IntRect(250,250,500,500)
// canvasWidthInDp, and canvasHeightInDp are Canvas dimensions coerced to Box size
// that covers Canvas
val imageScopeImpl = ImageScopeImpl(
density = density,
constraints = constraints,
imageWidth = canvasWidthInDp,
imageHeight = canvasHeightInDp,
rect = bitmapRect
)
// width and height params for translating draw position if scaled Image dimensions are
// bigger than Canvas dimensions
if (drawImage) {
ImageImpl(
modifier = Modifier.size(canvasWidthInDp, canvasHeightInDp),
imageBitmap = imageBitmap,
alpha = alpha,
width = imageWidth.toInt(),
height = imageHeight.toInt(),
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
imageScopeImpl.content()
}
@Composable
private fun ImageImpl(
modifier: Modifier,
imageBitmap: ImageBitmap,
width: Int,
height: Int,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
) {
val bitmapWidth = imageBitmap.width
val bitmapHeight = imageBitmap.height
Canvas(modifier = modifier.clipToBounds()) {
val canvasWidth = size.width.toInt()
val canvasHeight = size.height.toInt()
// Translate to left or down when Image size is bigger than this canvas.
// ImageSize is bigger when scale modes like Crop is used which enlarges image
// For instance 1000x1000 image can be 1000x2000 for a Canvas with 1000x1000
// so top is translated -500 to draw center of ImageBitmap
translate(
top = (-height + canvasHeight) / 2f,
left = (-width + canvasWidth) / 2f,
) {
drawImage(
imageBitmap,
srcSize = IntSize(bitmapWidth, bitmapHeight),
dstSize = IntSize(width, height),
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}
}
@@ -0,0 +1,71 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.model
import com.t8rin.cropper.util.createRectShape
/**
* Aspect ratio list with pre-defined aspect ratios
*/
val aspectRatios = listOf(
CropAspectRatio(
title = "9:16",
shape = createRectShape(AspectRatio(9 / 16f)),
aspectRatio = AspectRatio(9 / 16f)
),
CropAspectRatio(
title = "2:3",
shape = createRectShape(AspectRatio(2 / 3f)),
aspectRatio = AspectRatio(2 / 3f)
),
CropAspectRatio(
title = "Original",
shape = createRectShape(AspectRatio.Original),
aspectRatio = AspectRatio.Original
),
CropAspectRatio(
title = "1:1",
shape = createRectShape(AspectRatio(1 / 1f)),
aspectRatio = AspectRatio(1 / 1f)
),
CropAspectRatio(
title = "16:9",
shape = createRectShape(AspectRatio(16 / 9f)),
aspectRatio = AspectRatio(16 / 9f)
),
CropAspectRatio(
title = "1.91:1",
shape = createRectShape(AspectRatio(1.91f / 1f)),
aspectRatio = AspectRatio(1.91f / 1f)
),
CropAspectRatio(
title = "3:2",
shape = createRectShape(AspectRatio(3 / 2f)),
aspectRatio = AspectRatio(3 / 2f)
),
CropAspectRatio(
title = "3:4",
shape = createRectShape(AspectRatio(3 / 4f)),
aspectRatio = AspectRatio(3 / 4f)
),
CropAspectRatio(
title = "3:5",
shape = createRectShape(AspectRatio(3 / 5f)),
aspectRatio = AspectRatio(3 / 5f)
)
)
@@ -0,0 +1,44 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Shape
/**
* Model for drawing title with shape for crop selection menu. Aspect ratio is used
* for setting overlay in state and UI
*/
@Immutable
data class CropAspectRatio(
val title: String,
val shape: Shape,
val aspectRatio: AspectRatio = AspectRatio.Original,
val icons: List<Int> = listOf()
)
/**
* Value class for containing aspect ratio
* and [AspectRatio.Original] for comparing
*/
@Immutable
data class AspectRatio(val value: Float) {
companion object {
val Original = AspectRatio(-1f)
}
}
@@ -0,0 +1,38 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
/**
* Class that contains information about
* current zoom, pan and rotation, and rectangle of zoomed and panned area for cropping [cropRect],
* and area of overlay as[overlayRect]
*
*/
@Immutable
data class CropData(
val zoom: Float = 1f,
val pan: Offset = Offset.Zero,
val rotation: Float = 0f,
val overlayRect: Rect,
val cropRect: Rect
)
@@ -0,0 +1,151 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.model
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import com.t8rin.cropper.util.createPolygonShape
/**
* Common ancestor for list of shapes, paths or images to crop inside [CropOutlineContainer]
*/
interface CropOutline {
val id: Int
val title: String
}
/**
* Crop outline that contains a [Shape] like [RectangleShape] to draw frame for cropping
*/
interface CropShape : CropOutline {
val shape: Shape
}
/**
* Crop outline that contains a [Path] to draw frame for cropping
*/
interface CropPath : CropOutline {
val path: Path
}
/**
* Crop outline that contains a [ImageBitmap] to draw frame for cropping. And blend modes
* to draw
*/
interface CropImageMask : CropOutline {
val image: ImageBitmap
}
/**
* Wrapper class that implements [CropOutline] and is a shape
* wrapper that contains [RectangleShape]
*/
@Immutable
data class RectCropShape(
override val id: Int,
override val title: String,
) : CropShape {
override val shape: Shape = RectangleShape
}
/**
* Wrapper class that implements [CropOutline] and is a shape
* wrapper that contains [RoundedCornerShape]
*/
@Immutable
data class RoundedCornerCropShape(
override val id: Int,
override val title: String,
val cornerRadius: CornerRadiusProperties = CornerRadiusProperties(),
override val shape: RoundedCornerShape = RoundedCornerShape(
topStartPercent = cornerRadius.topStartPercent,
topEndPercent = cornerRadius.topEndPercent,
bottomEndPercent = cornerRadius.bottomEndPercent,
bottomStartPercent = cornerRadius.bottomStartPercent
)
) : CropShape
/**
* Wrapper class that implements [CropOutline] and is a shape
* wrapper that contains [CutCornerShape]
*/
@Immutable
data class CutCornerCropShape(
override val id: Int,
override val title: String,
val cornerRadius: CornerRadiusProperties = CornerRadiusProperties(),
override val shape: CutCornerShape = CutCornerShape(
topStartPercent = cornerRadius.topStartPercent,
topEndPercent = cornerRadius.topEndPercent,
bottomEndPercent = cornerRadius.bottomEndPercent,
bottomStartPercent = cornerRadius.bottomStartPercent
)
) : CropShape
/**
* Wrapper class that implements [CropOutline] and is a shape
* wrapper that contains [CircleShape]
*/
@Immutable
data class OvalCropShape(
override val id: Int,
override val title: String,
val ovalProperties: OvalProperties = OvalProperties(),
override val shape: Shape = CircleShape
) : CropShape
/**
* Wrapper class that implements [CropOutline] and is a shape
* wrapper that contains [CircleShape]
*/
@Immutable
data class PolygonCropShape(
override val id: Int,
override val title: String,
val polygonProperties: PolygonProperties = PolygonProperties(),
override val shape: Shape = createPolygonShape(polygonProperties.sides, polygonProperties.angle)
) : CropShape
/**
* Wrapper class that implements [CropOutline] and is a [Path] wrapper to crop using drawable
* files converted fom svg or Vector Drawable to [Path]
*/
@Immutable
data class CustomPathOutline(
override val id: Int,
override val title: String,
override val path: Path
) : CropPath
/**
* Wrapper class that implements [CropOutline] and is a [ImageBitmap] wrapper to crop
* using a reference png and blend modes to crop
*/
@Immutable
data class ImageMaskOutline(
override val id: Int,
override val title: String,
override val image: ImageBitmap,
) : CropImageMask
@@ -0,0 +1,87 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.model
/**
* Interface for containing multiple [CropOutline]s, currently selected item and index
* for displaying on settings UI
*/
interface CropOutlineContainer<O : CropOutline> {
var selectedIndex: Int
val outlines: List<O>
val selectedItem: O
get() = outlines[selectedIndex]
val size: Int
get() = outlines.size
}
/**
* Container for [RectCropShape]
*/
data class RectOutlineContainer(
override var selectedIndex: Int = 0,
override val outlines: List<RectCropShape>
) : CropOutlineContainer<RectCropShape>
/**
* Container for [RoundedCornerCropShape]s
*/
data class RoundedRectOutlineContainer(
override var selectedIndex: Int = 0,
override val outlines: List<RoundedCornerCropShape>
) : CropOutlineContainer<RoundedCornerCropShape>
/**
* Container for [CutCornerCropShape]s
*/
data class CutCornerRectOutlineContainer(
override var selectedIndex: Int = 0,
override val outlines: List<CutCornerCropShape>
) : CropOutlineContainer<CutCornerCropShape>
/**
* Container for [OvalCropShape]s
*/
data class OvalOutlineContainer(
override var selectedIndex: Int = 0,
override val outlines: List<OvalCropShape>
) : CropOutlineContainer<OvalCropShape>
/**
* Container for [PolygonCropShape]s
*/
data class PolygonOutlineContainer(
override var selectedIndex: Int = 0,
override val outlines: List<PolygonCropShape>
) : CropOutlineContainer<PolygonCropShape>
/**
* Container for [CustomPathOutline]s
*/
data class CustomOutlineContainer(
override var selectedIndex: Int = 0,
override val outlines: List<CustomPathOutline>
) : CropOutlineContainer<CustomPathOutline>
/**
* Container for [ImageMaskOutline]s
*/
data class ImageMaskOutlineContainer(
override var selectedIndex: Int = 0,
override val outlines: List<ImageMaskOutline>
) : CropOutlineContainer<ImageMaskOutline>
@@ -0,0 +1,43 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.geometry.Offset
@Immutable
data class CornerRadiusProperties(
val topStartPercent: Int = 20,
val topEndPercent: Int = 20,
val bottomStartPercent: Int = 20,
val bottomEndPercent: Int = 20
)
@Immutable
data class PolygonProperties(
val sides: Int = 6,
val angle: Float = 0f,
val offset: Offset = Offset.Zero
)
@Immutable
data class OvalProperties(
val startAngle: Float = 0f,
val sweepAngle: Float = 360f,
val offset: Offset = Offset.Zero
)
@@ -0,0 +1,22 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.model
enum class OutlineType {
Rect, RoundedRect, CutCorner, Oval, Polygon, Custom, ImageMask
}
@@ -0,0 +1,158 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.settings
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.t8rin.cropper.ImageCropper
import com.t8rin.cropper.crop
import com.t8rin.cropper.model.AspectRatio
import com.t8rin.cropper.model.CropOutline
import com.t8rin.cropper.model.OutlineType
import com.t8rin.cropper.model.aspectRatios
import com.t8rin.cropper.state.CropState
/**
* Contains the default values used by [ImageCropper]
*/
object CropDefaults {
/**
* Properties effect crop behavior that should be passed to [CropState]
*/
fun properties(
cropType: CropType = CropType.Dynamic,
handleSize: Float = 60f,
middleHandleSize: Float = handleSize * 1.5f,
maxZoom: Float = 10f,
contentScale: ContentScale = ContentScale.Fit,
cropOutlineProperty: CropOutlineProperty,
aspectRatio: AspectRatio = aspectRatios[2].aspectRatio,
overlayRatio: Float = .9f,
pannable: Boolean = true,
fling: Boolean = false,
zoomable: Boolean = true,
rotatable: Boolean = false,
minDimension: IntSize? = null,
fixedAspectRatio: Boolean = false,
): CropProperties {
return CropProperties(
cropType = cropType,
handleSize = handleSize,
middleHandleSize = middleHandleSize,
contentScale = contentScale,
cropOutlineProperty = cropOutlineProperty,
maxZoom = maxZoom,
aspectRatio = aspectRatio,
overlayRatio = overlayRatio,
pannable = pannable,
fling = fling,
zoomable = zoomable,
rotatable = rotatable,
minDimension = minDimension,
fixedAspectRatio = fixedAspectRatio,
)
}
/**
* Style is cosmetic changes that don't effect how [CropState] behaves because of that
* none of these properties are passed to [CropState]
*/
fun style(
drawOverlay: Boolean = true,
drawGrid: Boolean = true,
strokeWidth: Dp = 1.dp,
overlayColor: Color = DefaultOverlayColor,
handleColor: Color = DefaultHandleColor,
backgroundColor: Color = DefaultBackgroundColor
): CropStyle {
return CropStyle(
drawOverlay = drawOverlay,
drawGrid = drawGrid,
strokeWidth = strokeWidth,
overlayColor = overlayColor,
handleColor = handleColor,
backgroundColor = backgroundColor
)
}
}
/**
* Data class for selecting cropper properties. Fields of this class control inner work
* of [CropState] while some such as [cropType], [aspectRatio], [handleSize]
* is shared between ui and state.
*/
@Immutable
data class CropProperties(
val cropType: CropType,
val handleSize: Float,
val middleHandleSize: Float,
val contentScale: ContentScale,
val cropOutlineProperty: CropOutlineProperty,
val aspectRatio: AspectRatio,
val overlayRatio: Float,
val pannable: Boolean,
val fling: Boolean,
val rotatable: Boolean,
val zoomable: Boolean,
val maxZoom: Float,
val minDimension: IntSize? = null,
val fixedAspectRatio: Boolean = false,
)
/**
* Data class for cropper styling only. None of the properties of this class is used
* by [CropState] or [Modifier.crop]
*/
@Immutable
data class CropStyle(
val drawOverlay: Boolean,
val drawGrid: Boolean,
val strokeWidth: Dp,
val overlayColor: Color,
val handleColor: Color,
val backgroundColor: Color,
val cropTheme: CropTheme = CropTheme.Dark
)
/**
* Property for passing [CropOutline] between settings UI to [ImageCropper]
*/
@Immutable
data class CropOutlineProperty(
val outlineType: OutlineType,
val cropOutline: CropOutline
)
/**
* Light, Dark or system controlled theme
*/
enum class CropTheme {
Light,
Dark,
System
}
private val DefaultBackgroundColor = Color.Black.copy(0.55f)
private val DefaultOverlayColor = Color.Gray
private val DefaultHandleColor = Color.White
@@ -0,0 +1,28 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.settings
/**
* Type of cropping operation
*
* If [CropType.Static] is selected overlay is stationary, image is movable.
* If [CropType.Dynamic] is selected overlay can be moved, resized, image is stationary.
*/
enum class CropType {
Static, Dynamic
}
@@ -0,0 +1,51 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.settings
import androidx.compose.ui.graphics.Path
object Paths {
val Favorite
get() = Path().apply {
moveTo(12.0f, 21.35f)
relativeLineTo(-1.45f, -1.32f)
cubicTo(5.4f, 15.36f, 2.0f, 12.28f, 2.0f, 8.5f)
cubicTo(2.0f, 5.42f, 4.42f, 3.0f, 7.5f, 3.0f)
relativeCubicTo(1.74f, 0.0f, 3.41f, 0.81f, 4.5f, 2.09f)
cubicTo(13.09f, 3.81f, 14.76f, 3.0f, 16.5f, 3.0f)
cubicTo(19.58f, 3.0f, 22.0f, 5.42f, 22.0f, 8.5f)
relativeCubicTo(0.0f, 3.78f, -3.4f, 6.86f, -8.55f, 11.54f)
lineTo(12.0f, 21.35f)
close()
}
val Star = Path().apply {
moveTo(12.0f, 17.27f)
lineTo(18.18f, 21.0f)
relativeLineTo(-1.64f, -7.03f)
lineTo(22.0f, 9.24f)
relativeLineTo(-7.19f, -0.61f)
lineTo(12.0f, 2.0f)
lineTo(9.19f, 8.63f)
lineTo(2.0f, 9.24f)
relativeLineTo(5.46f, 4.73f)
lineTo(5.82f, 21.0f)
close()
}
}
@@ -0,0 +1,100 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.state
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.IntSize
import com.t8rin.cropper.settings.CropProperties
import com.t8rin.cropper.settings.CropType
/**
* Create and [remember] the [CropState] based on the currently appropriate transform
* configuration to allow changing pan, zoom, and rotation.
* @param imageSize size of the **Bitmap**
* @param containerSize size of the Composable that draws **Bitmap**
* @param cropProperties wrapper class that contains crop state properties such as
* crop type,
* @param keys are used to reset remember block to initial calculations. This can be used
* when image, contentScale or any property changes which requires values to be reset to initial
* values
*/
@Composable
fun rememberCropState(
imageSize: IntSize,
containerSize: IntSize,
drawAreaSize: IntSize,
cropProperties: CropProperties,
isOverlayDraggable: Boolean = true,
vararg keys: Any?
): CropState {
// Properties of crop state
val handleSize = cropProperties.handleSize
val cropType = cropProperties.cropType
val aspectRatio = cropProperties.aspectRatio
val overlayRatio = cropProperties.overlayRatio
val maxZoom = cropProperties.maxZoom
val fling = cropProperties.fling
val zoomable = cropProperties.zoomable
val pannable = cropProperties.pannable
val rotatable = cropProperties.rotatable
val minDimension = cropProperties.minDimension
val fixedAspectRatio = cropProperties.fixedAspectRatio
return remember(*keys) {
when (cropType) {
CropType.Static -> {
StaticCropState(
imageSize = imageSize,
containerSize = containerSize,
drawAreaSize = drawAreaSize,
aspectRatio = aspectRatio,
overlayRatio = overlayRatio,
maxZoom = maxZoom,
fling = fling,
zoomable = zoomable,
pannable = pannable,
rotatable = rotatable,
limitPan = false
)
}
else -> {
DynamicCropState(
imageSize = imageSize,
containerSize = containerSize,
drawAreaSize = drawAreaSize,
aspectRatio = aspectRatio,
overlayRatio = overlayRatio,
maxZoom = maxZoom,
handleSize = handleSize,
fling = fling,
zoomable = zoomable,
pannable = pannable,
rotatable = rotatable,
limitPan = true,
minDimension = minDimension,
fixedAspectRatio = fixedAspectRatio,
isOverlayDraggable = isOverlayDraggable,
)
}
}
}
}
@@ -0,0 +1,497 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.state
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.unit.IntSize
import com.t8rin.cropper.TouchRegion
import com.t8rin.cropper.model.AspectRatio
import com.t8rin.cropper.model.CropData
import com.t8rin.cropper.settings.CropProperties
val CropState.cropData: CropData
get() = CropData(
zoom = animatableZoom.targetValue,
pan = Offset(animatablePanX.targetValue, animatablePanY.targetValue),
rotation = animatableRotation.targetValue,
overlayRect = overlayRect,
cropRect = cropRect
)
/**
* Base class for crop operations. Any class that extends this class gets access to pan, zoom,
* rotation values and animations via [TransformState], fling and moving back to bounds animations.
* @param imageSize size of the **Bitmap**
* @param containerSize size of the Composable that draws **Bitmap**. This is full size
* of the Composable. [drawAreaSize] can be smaller than [containerSize] initially based
* on content scale of Image composable
* @param drawAreaSize size of the area that **Bitmap** is drawn
* @param maxZoom maximum zoom value
* @param fling when set to true dragging pointer builds up velocity. When last
* pointer leaves Composable a movement invoked against friction till velocity drops below
* to threshold
* @param zoomable when set to true zoom is enabled
* @param pannable when set to true pan is enabled
* @param rotatable when set to true rotation is enabled
* @param limitPan limits pan to bounds of parent Composable. Using this flag prevents creating
* empty space on sides or edges of parent
*/
abstract class CropState internal constructor(
imageSize: IntSize,
containerSize: IntSize,
drawAreaSize: IntSize,
maxZoom: Float,
internal var fling: Boolean = true,
internal var aspectRatio: AspectRatio,
internal var overlayRatio: Float,
zoomable: Boolean = true,
pannable: Boolean = true,
rotatable: Boolean = false,
limitPan: Boolean = false
) : TransformState(
imageSize = imageSize,
containerSize = containerSize,
drawAreaSize = drawAreaSize,
initialZoom = 1f,
initialRotation = 0f,
maxZoom = maxZoom,
zoomable = zoomable,
pannable = pannable,
rotatable = rotatable,
limitPan = limitPan
) {
private val animatableRectOverlay = Animatable(
getOverlayFromAspectRatio(
containerSize.width.toFloat(),
containerSize.height.toFloat(),
drawAreaSize.width.toFloat(),
aspectRatio,
overlayRatio
),
Rect.VectorConverter
)
val overlayRect: Rect
get() = animatableRectOverlay.value
var cropRect: Rect = Rect.Zero
get() = getCropRectangle(
imageSize.width,
imageSize.height,
drawAreaRect,
animatableRectOverlay.targetValue
)
private set
private var initialized: Boolean = false
/**
* Region of touch inside, corners of or outside of overlay rectangle
*/
var touchRegion by mutableStateOf(TouchRegion.None)
internal suspend fun init() {
// When initial aspect ratio doesn't match drawable area
// overlay gets updated so updates draw area as well
animateTransformationToOverlayBounds(overlayRect, animate = true)
initialized = true
}
/**
* Update properties of [CropState] and animate to valid intervals if required
*/
internal open suspend fun updateProperties(
cropProperties: CropProperties,
forceUpdate: Boolean = false
) {
if (!initialized) return
fling = cropProperties.fling
pannable = cropProperties.pannable
zoomable = cropProperties.zoomable
rotatable = cropProperties.rotatable
val maxZoom = cropProperties.maxZoom
// Update overlay rectangle
val aspectRatio = cropProperties.aspectRatio
// Ratio of overlay to screen
val overlayRatio = cropProperties.overlayRatio
if (
this.aspectRatio.value != aspectRatio.value ||
maxZoom != zoomMax ||
this.overlayRatio != overlayRatio ||
forceUpdate
) {
this.aspectRatio = aspectRatio
this.overlayRatio = overlayRatio
zoomMax = maxZoom
animatableZoom.updateBounds(zoomMin, zoomMax)
val currentZoom = if (zoom > zoomMax) zoomMax else zoom
// Set new zoom
snapZoomTo(currentZoom)
// Calculate new region of image is drawn. It can be drawn left of 0 and right
// of container width depending on transformation
drawAreaRect = updateImageDrawRectFromTransformation()
// Update overlay rectangle based on current draw area and new aspect ratio
animateOverlayRectTo(
getOverlayFromAspectRatio(
containerSize.width.toFloat(),
containerSize.height.toFloat(),
drawAreaSize.width.toFloat(),
aspectRatio,
overlayRatio
)
)
}
// Animate zoom, pan, rotation to move draw area to cover overlay rect
// inside draw area rect
animateTransformationToOverlayBounds(overlayRect, animate = true)
}
/**
* Animate overlay rectangle to target value
*/
internal suspend fun animateOverlayRectTo(
rect: Rect,
animationSpec: AnimationSpec<Rect> = tween(250)
) {
animatableRectOverlay.animateTo(
targetValue = rect,
animationSpec = animationSpec
)
}
/**
* Snap overlay rectangle to target value
*/
internal suspend fun snapOverlayRectTo(rect: Rect) {
animatableRectOverlay.snapTo(rect)
}
/*
Touch gestures
*/
internal abstract suspend fun onDown(change: PointerInputChange)
internal abstract suspend fun onMove(changes: List<PointerInputChange>)
internal abstract suspend fun onUp(change: PointerInputChange)
/*
Transform gestures
*/
internal abstract suspend fun onGesture(
centroid: Offset,
panChange: Offset,
zoomChange: Float,
rotationChange: Float,
mainPointer: PointerInputChange,
changes: List<PointerInputChange>
)
internal abstract suspend fun onGestureStart()
internal abstract suspend fun onGestureEnd(onBoundsCalculated: () -> Unit)
// Double Tap
internal abstract suspend fun onDoubleTap(
offset: Offset,
zoom: Float = 1f,
onAnimationEnd: () -> Unit
)
/**
* Check if area that image is drawn covers [overlayRect]
*/
internal fun isOverlayInImageDrawBounds(): Boolean {
return drawAreaRect.left <= overlayRect.left &&
drawAreaRect.top <= overlayRect.top &&
drawAreaRect.right >= overlayRect.right &&
drawAreaRect.bottom >= overlayRect.bottom
}
/**
* Check if [rect] is inside container bounds
*/
internal fun isRectInContainerBounds(rect: Rect): Boolean {
return rect.left >= 0 &&
rect.right <= containerSize.width &&
rect.top >= 0 &&
rect.bottom <= containerSize.height
}
/**
* Update rectangle for area that image is drawn. This rect changes when zoom and
* pan changes and position of image changes on screen as result of transformation.
*
* This function is called
*
* * when [onGesture] is called to update rect when zoom or pan changes
* and if [fling] is true just after **fling** gesture starts with target
* value in [StaticCropState].
*
* * when [updateProperties] is called in [CropState]
*
* * when [onUp] is called in [DynamicCropState] to match [overlayRect] that could be
* changed and animated if it's out of [containerSize] bounds or its grow
* bigger than previous size
*/
internal fun updateImageDrawRectFromTransformation(): Rect {
val containerWidth = containerSize.width
val containerHeight = containerSize.height
val originalDrawWidth = drawAreaSize.width
val originalDrawHeight = drawAreaSize.height
val panX = animatablePanX.targetValue
val panY = animatablePanY.targetValue
val left = (containerWidth - originalDrawWidth) / 2
val top = (containerHeight - originalDrawHeight) / 2
val zoom = animatableZoom.targetValue
val newWidth = originalDrawWidth * zoom
val newHeight = originalDrawHeight * zoom
return Rect(
offset = Offset(
left - (newWidth - originalDrawWidth) / 2 + panX,
top - (newHeight - originalDrawHeight) / 2 + panY,
),
size = Size(newWidth, newHeight)
)
}
/**
* Resets to bounds with animation and resets tracking for fling animation.
* Changes pan, zoom and rotation to valid bounds based on [drawAreaRect] and [overlayRect]
*/
internal suspend fun animateTransformationToOverlayBounds(
overlayRect: Rect,
animate: Boolean,
animationSpec: AnimationSpec<Float> = tween(250)
) {
// Keep current zoom
// val zoom = zoom.coerceAtLeast(1f)
// Calculate new pan based on overlay
val newDrawAreaRect = calculateValidImageDrawRect(overlayRect, drawAreaRect)
val newZoom =
calculateNewZoom(oldRect = drawAreaRect, newRect = newDrawAreaRect, zoom = zoom)
val leftChange = newDrawAreaRect.left - drawAreaRect.left
val topChange = newDrawAreaRect.top - drawAreaRect.top
val widthChange = newDrawAreaRect.width - drawAreaRect.width
val heightChange = newDrawAreaRect.height - drawAreaRect.height
val panXChange = leftChange + widthChange / 2
val panYChange = topChange + heightChange / 2
val newPanX = pan.x + panXChange
val newPanY = pan.y + panYChange
// Update draw area based on new pan and zoom values
drawAreaRect = newDrawAreaRect
if (animate) {
resetWithAnimation(
pan = Offset(newPanX, newPanY),
zoom = newZoom,
animationSpec = animationSpec
)
} else {
snapPanXto(newPanX)
snapPanYto(newPanY)
snapZoomTo(newZoom)
}
resetTracking()
}
/**
* If new overlay is bigger, when crop type is dynamic, we need to increase zoom at least
* size of bigger dimension for image draw area([drawAreaRect]) to cover overlay([overlayRect])
*/
private fun calculateNewZoom(oldRect: Rect, newRect: Rect, zoom: Float): Float {
if (oldRect.size == Size.Zero || newRect.size == Size.Zero) return zoom
val widthChange = (newRect.width / oldRect.width)
.coerceAtLeast(1f)
val heightChange = (newRect.height / oldRect.height)
.coerceAtLeast(1f)
return widthChange.coerceAtLeast(heightChange) * zoom
}
/**
* Calculate valid position for image draw rectangle when pointer is up. Overlay rectangle
* should fit inside draw image rectangle to have valid bounds when calculation is completed.
*
* @param rectOverlay rectangle of overlay that is used for cropping
* @param rectDrawArea rectangle of image that is being drawn
*/
private fun calculateValidImageDrawRect(rectOverlay: Rect, rectDrawArea: Rect): Rect {
var width = rectDrawArea.width
var height = rectDrawArea.height
if (width < rectOverlay.width) {
width = rectOverlay.width
}
if (height < rectOverlay.height) {
height = rectOverlay.height
}
var rectImageArea = Rect(offset = rectDrawArea.topLeft, size = Size(width, height))
if (rectImageArea.left > rectOverlay.left) {
rectImageArea = rectImageArea.translate(rectOverlay.left - rectImageArea.left, 0f)
}
if (rectImageArea.right < rectOverlay.right) {
rectImageArea = rectImageArea.translate(rectOverlay.right - rectImageArea.right, 0f)
}
if (rectImageArea.top > rectOverlay.top) {
rectImageArea = rectImageArea.translate(0f, rectOverlay.top - rectImageArea.top)
}
if (rectImageArea.bottom < rectOverlay.bottom) {
rectImageArea = rectImageArea.translate(0f, rectOverlay.bottom - rectImageArea.bottom)
}
return rectImageArea
}
/**
* Create [Rect] to draw overlay based on selected aspect ratio
*/
internal fun getOverlayFromAspectRatio(
containerWidth: Float,
containerHeight: Float,
drawAreaWidth: Float,
aspectRatio: AspectRatio,
coefficient: Float
): Rect {
if (aspectRatio == AspectRatio.Original) {
val imageAspectRatio = imageSize.width.toFloat() / imageSize.height.toFloat()
// Maximum width and height overlay rectangle can be measured with
val overlayWidthMax = drawAreaWidth.coerceAtMost(containerWidth * coefficient)
val overlayHeightMax =
(overlayWidthMax / imageAspectRatio).coerceAtMost(containerHeight * coefficient)
val offsetX = (containerWidth - overlayWidthMax) / 2f
val offsetY = (containerHeight - overlayHeightMax) / 2f
return Rect(
offset = Offset(offsetX, offsetY),
size = Size(overlayWidthMax, overlayHeightMax)
)
}
val overlayWidthMax = containerWidth * coefficient
val overlayHeightMax = containerHeight * coefficient
val aspectRatioValue = aspectRatio.value
var width = overlayWidthMax
var height = overlayWidthMax / aspectRatioValue
if (height > overlayHeightMax) {
height = overlayHeightMax
width = height * aspectRatioValue
}
val offsetX = (containerWidth - width) / 2f
val offsetY = (containerHeight - height) / 2f
return Rect(offset = Offset(offsetX, offsetY), size = Size(width, height))
}
/**
* Get crop rectangle
*/
private fun getCropRectangle(
bitmapWidth: Int,
bitmapHeight: Int,
drawAreaRect: Rect,
overlayRect: Rect
): Rect {
if (drawAreaRect == Rect.Zero || overlayRect == Rect.Zero) return Rect(
offset = Offset.Zero,
Size(bitmapWidth.toFloat(), bitmapHeight.toFloat())
)
// Calculate latest image draw area based on overlay position
// This is valid rectangle that contains crop area inside overlay
val newRect = calculateValidImageDrawRect(overlayRect, drawAreaRect)
val overlayWidth = overlayRect.width
val overlayHeight = overlayRect.height
val drawAreaWidth = newRect.width
val drawAreaHeight = newRect.height
val widthRatio = overlayWidth / drawAreaWidth
val heightRatio = overlayHeight / drawAreaHeight
val diffLeft = overlayRect.left - newRect.left
val diffTop = overlayRect.top - newRect.top
val croppedBitmapLeft = (diffLeft * (bitmapWidth / drawAreaWidth))
val croppedBitmapTop = (diffTop * (bitmapHeight / drawAreaHeight))
val croppedBitmapWidth = bitmapWidth * widthRatio
val croppedBitmapHeight = bitmapHeight * heightRatio
return Rect(
offset = Offset(croppedBitmapLeft, croppedBitmapTop),
size = Size(croppedBitmapWidth, croppedBitmapHeight)
)
}
}
@@ -0,0 +1,826 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.state
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed
import androidx.compose.ui.unit.IntSize
import com.t8rin.cropper.TouchRegion
import com.t8rin.cropper.model.AspectRatio
import com.t8rin.cropper.settings.CropProperties
import kotlinx.coroutines.coroutineScope
import kotlin.math.roundToInt
/**
* State for cropper with dynamic overlay. Overlay of this state can be moved or resized
* using handles or touching inner position of overlay. When overlay overflow out of image bounds
* or moves out of bounds it animates back to valid size and position
*
* @param handleSize size of the handle to control, move or scale dynamic overlay
* @param imageSize size of the **Bitmap**
* @param containerSize size of the Composable that draws **Bitmap**
* @param maxZoom maximum zoom value
* @param fling when set to true dragging pointer builds up velocity. When last
* pointer leaves Composable a movement invoked against friction till velocity drops below
* to threshold
* @param zoomable when set to true zoom is enabled
* @param pannable when set to true pan is enabled
* @param rotatable when set to true rotation is enabled
* @param limitPan limits pan to bounds of parent Composable. Using this flag prevents creating
* @param minDimension minimum size of the overlay, if null defaults to handleSize * 2
* @param fixedAspectRatio when set to true aspect ratio of overlay is fixed
* empty space on sides or edges of parent
*/
class DynamicCropState internal constructor(
private var handleSize: Float,
imageSize: IntSize,
containerSize: IntSize,
drawAreaSize: IntSize,
aspectRatio: AspectRatio,
overlayRatio: Float,
maxZoom: Float,
fling: Boolean,
zoomable: Boolean,
pannable: Boolean,
rotatable: Boolean,
limitPan: Boolean,
private val minDimension: IntSize?,
private val fixedAspectRatio: Boolean,
internal var isOverlayDraggable: Boolean,
) : CropState(
imageSize = imageSize,
containerSize = containerSize,
drawAreaSize = drawAreaSize,
aspectRatio = aspectRatio,
overlayRatio = overlayRatio,
maxZoom = maxZoom,
fling = fling,
zoomable = zoomable,
pannable = pannable,
rotatable = rotatable,
limitPan = limitPan
) {
/**
* Rectangle that covers bounds of Composable. This is a rectangle uses [containerSize] as
* size and [Offset.Zero] as top left corner
*/
private val rectBounds = Rect(
offset = Offset.Zero,
size = Size(containerSize.width.toFloat(), containerSize.height.toFloat())
)
// This rectangle is needed to set bounds set at first touch position while
// moving to constraint current bounds to temp one from first down
// When pointer is up
private var rectTemp = Rect.Zero
// Touch position for edge of the rectangle, used for not jumping to edge of rectangle
// when user moves a handle. We set positionActual as position of selected handle
// and using this distance as offset to not have a jump from touch position
private var distanceToEdgeFromTouch = Offset.Zero
private var doubleTapped = false
// Check if transform gesture has been invoked
// inside overlay but with multiple pointers to zoom
private var gestureInvoked = false
override suspend fun updateProperties(cropProperties: CropProperties, forceUpdate: Boolean) {
handleSize = cropProperties.handleSize
super.updateProperties(cropProperties, forceUpdate)
}
override suspend fun onDown(change: PointerInputChange) {
rectTemp = overlayRect.copy()
val position = change.position
val touchPositionScreenX = position.x
val touchPositionScreenY = position.y
val touchPositionOnScreen = Offset(touchPositionScreenX, touchPositionScreenY)
// Get whether user touched outside, handles of rectangle or inner region or overlay
// rectangle. Depending on where is touched we can move or scale overlay
touchRegion = getTouchRegion(
position = touchPositionOnScreen,
rect = overlayRect,
threshold = handleSize * 1.5f
).let {
if (it == TouchRegion.Inside && !isOverlayDraggable) TouchRegion.None else it
}
// This is the difference between touch position and edge
// This is required for not moving edge of draw rect to touch position on move
distanceToEdgeFromTouch =
getDistanceToEdgeFromTouch(touchRegion, rectTemp, touchPositionOnScreen)
}
override suspend fun onMove(changes: List<PointerInputChange>) {
if (changes.isEmpty()) {
touchRegion = TouchRegion.None
return
}
gestureInvoked = changes.size > 1 && (touchRegion == TouchRegion.Inside)
// If overlay is touched and pointer size is one update
// or pointer size is bigger than one but touched any handles update
if (touchRegion != TouchRegion.None && changes.size == 1 && !gestureInvoked) {
val change = changes.first()
// Default min dimension is handle size * 5
val doubleHandleSize = handleSize * 5
val defaultMinDimension =
IntSize(doubleHandleSize.roundToInt(), doubleHandleSize.roundToInt())
// update overlay rectangle based on where its touched and touch position to corners
// This function moves and/or scales overlay rectangle
val newRect = updateOverlayRect(
distanceToEdgeFromTouch = distanceToEdgeFromTouch,
touchRegion = touchRegion,
minDimension = minDimension ?: defaultMinDimension,
rectTemp = rectTemp,
overlayRect = overlayRect,
change = change,
aspectRatio = getAspectRatio(),
fixedAspectRatio = fixedAspectRatio,
)
snapOverlayRectTo(newRect)
}
}
private fun getAspectRatio(): Float {
return if (aspectRatio == AspectRatio.Original) {
imageSize.width / imageSize.height.toFloat()
} else {
aspectRatio.value
}
}
override suspend fun onUp(change: PointerInputChange) = coroutineScope {
if (touchRegion != TouchRegion.None) {
val isInContainerBounds = isRectInContainerBounds(overlayRect)
if (!isInContainerBounds) {
// Calculate new overlay since it's out of Container bounds
rectTemp = calculateOverlayRectInBounds(rectBounds, overlayRect)
// Animate overlay to new bounds inside container
animateOverlayRectTo(rectTemp)
}
// Update and animate pan, zoom and image draw area after overlay position is updated
animateTransformationToOverlayBounds(overlayRect, true)
// Update image draw area after animating pan, zoom or rotation is completed
drawAreaRect = updateImageDrawRectFromTransformation()
touchRegion = TouchRegion.None
}
gestureInvoked = false
}
override suspend fun onGesture(
centroid: Offset,
panChange: Offset,
zoomChange: Float,
rotationChange: Float,
mainPointer: PointerInputChange,
changes: List<PointerInputChange>
) {
if (touchRegion == TouchRegion.None || gestureInvoked) {
doubleTapped = false
val newPan = if (gestureInvoked) Offset.Zero else panChange
updateTransformState(
centroid = centroid,
zoomChange = zoomChange,
panChange = newPan,
rotationChange = rotationChange
)
// Update image draw rectangle based on pan, zoom or rotation change
drawAreaRect = updateImageDrawRectFromTransformation()
// Fling Gesture
if (pannable && fling) {
if (changes.size == 1) {
addPosition(mainPointer.uptimeMillis, mainPointer.position)
}
}
}
}
override suspend fun onGestureStart() = Unit
override suspend fun onGestureEnd(onBoundsCalculated: () -> Unit) {
if (touchRegion == TouchRegion.None || gestureInvoked) {
// Gesture end might be called after second tap and we don't want to fling
// or animate back to valid bounds when doubled tapped
if (!doubleTapped) {
if (pannable && fling && !gestureInvoked && zoom > 1) {
fling {
// We get target value on start instead of updating bounds after
// gesture has finished
drawAreaRect = updateImageDrawRectFromTransformation()
onBoundsCalculated()
}
} else {
onBoundsCalculated()
}
animateTransformationToOverlayBounds(overlayRect, animate = true)
}
}
}
override suspend fun onDoubleTap(
offset: Offset,
zoom: Float,
onAnimationEnd: () -> Unit
) {
doubleTapped = true
if (fling) {
resetTracking()
}
resetWithAnimation(pan = pan, zoom = zoom, rotation = rotation)
// We get target value on start instead of updating bounds after
// gesture has finished
drawAreaRect = updateImageDrawRectFromTransformation()
if (!isOverlayInImageDrawBounds()) {
// Moves rectangle to bounds inside drawArea Rect while keeping aspect ratio
// of current overlay rect
animateOverlayRectTo(
getOverlayFromAspectRatio(
containerSize.width.toFloat(),
containerSize.height.toFloat(),
drawAreaSize.width.toFloat(),
aspectRatio,
overlayRatio
)
)
animateTransformationToOverlayBounds(overlayRect, false)
}
onAnimationEnd()
}
// //TODO Change pan when zoom is bigger than 1f and touchRegion is inside overlay rect
// private suspend fun moveOverlayToBounds(change: PointerInputChange, newRect: Rect) {
// val bounds = drawAreaRect
//
// val positionChange = change.positionChangeIgnoreConsumed()
//
// // When zoom is bigger than 100% and dynamic overlay is not at any edge of
// // image we can pan in the same direction motion goes towards when touch region
// // of rectangle is not one of the handles but region inside
// val isPanRequired = touchRegion == TouchRegion.Inside && zoom > 1f
//
// // Overlay moving right
// if (isPanRequired && newRect.right < bounds.right) {
// println("Moving right newRect $newRect, bounds: $bounds")
// snapOverlayRectTo(newRect.translate(-positionChange.x, 0f))
// snapPanXto(pan.x - positionChange.x * zoom)
// // Overlay moving left
// } else if (isPanRequired && pan.x < bounds.left && newRect.left <= 0f) {
// snapOverlayRectTo(newRect.translate(-positionChange.x, 0f))
// snapPanXto(pan.x - positionChange.x * zoom)
// } else if (isPanRequired && pan.y < bounds.top && newRect.top <= 0f) {
// // Overlay moving top
// snapOverlayRectTo(newRect.translate(0f, -positionChange.y))
// snapPanYto(pan.y - positionChange.y * zoom)
// } else if (isPanRequired && -pan.y < bounds.bottom && newRect.bottom >= containerSize.height) {
// // Overlay moving bottom
// snapOverlayRectTo(newRect.translate(0f, -positionChange.y))
// snapPanYto(pan.y - positionChange.y * zoom)
// } else {
// snapOverlayRectTo(newRect)
// }
// if (touchRegion != TouchRegion.None) {
// change.consume()
// }
// }
/**
* When pointer is up calculate valid position and size overlay can be updated to inside
* a virtual rect between `topLeft = (0,0)` to `bottomRight=(containerWidth, containerHeight)`
*
* [overlayRect] might be shrunk or moved up/down/left/right to container bounds when
* it's out of Composable region
*/
private fun calculateOverlayRectInBounds(rectBounds: Rect, rectCurrent: Rect): Rect {
var width = rectCurrent.width
var height = rectCurrent.height
if (width > rectBounds.width) {
width = rectBounds.width
}
if (height > rectBounds.height) {
height = rectBounds.height
}
var rect = Rect(offset = rectCurrent.topLeft, size = Size(width, height))
if (rect.left < rectBounds.left) {
rect = rect.translate(rectBounds.left - rect.left, 0f)
}
if (rect.top < rectBounds.top) {
rect = rect.translate(0f, rectBounds.top - rect.top)
}
if (rect.right > rectBounds.right) {
rect = rect.translate(rectBounds.right - rect.right, 0f)
}
if (rect.bottom > rectBounds.bottom) {
rect = rect.translate(0f, rectBounds.bottom - rect.bottom)
}
return rect
}
private fun updateOverlayRect(
distanceToEdgeFromTouch: Offset,
touchRegion: TouchRegion,
minDimension: IntSize,
rectTemp: Rect,
overlayRect: Rect,
change: PointerInputChange,
aspectRatio: Float,
fixedAspectRatio: Boolean,
): Rect {
val position = change.position
val screenX = position.x + distanceToEdgeFromTouch.x
val screenY = position.y + distanceToEdgeFromTouch.y
val minW = minDimension.width.toFloat()
val minH = minDimension.height.toFloat()
val bounds = rectBounds
fun clampNonFixed(left: Float, top: Float, right: Float, bottom: Float): Rect {
val l = left.coerceIn(bounds.left, bounds.right - minW)
val t = top.coerceIn(bounds.top, bounds.bottom - minH)
val r = right.coerceIn(l + minW, bounds.right)
val b = bottom.coerceIn(t + minH, bounds.bottom)
return Rect(l, t, r, b)
}
fun anchoredCornerTopLeft(anchorR: Float, anchorB: Float, candidateLeft: Float): Rect {
var width = (anchorR - candidateLeft).coerceAtLeast(minW)
val maxWidth = (anchorR - bounds.left).coerceAtLeast(minW)
width = width.coerceAtMost(maxWidth)
var height = width / aspectRatio
val maxHeight = (anchorB - bounds.top).coerceAtLeast(minH)
if (height > maxHeight) {
height = maxHeight
width = (height * aspectRatio).coerceAtLeast(minW)
}
val left = anchorR - width
val top = anchorB - height
return Rect(
left.coerceAtLeast(bounds.left),
top.coerceAtLeast(bounds.top),
anchorR.coerceAtMost(bounds.right),
anchorB.coerceAtMost(bounds.bottom)
)
}
fun anchoredCornerBottomLeft(anchorR: Float, anchorT: Float, candidateLeft: Float): Rect {
var width = (anchorR - candidateLeft).coerceAtLeast(minW)
val maxWidth = (anchorR - bounds.left).coerceAtLeast(minW)
width = width.coerceAtMost(maxWidth)
var height = width / aspectRatio
val maxHeight = (bounds.bottom - anchorT).coerceAtLeast(minH)
if (height > maxHeight) {
height = maxHeight
width = (height * aspectRatio).coerceAtLeast(minW)
}
val left = anchorR - width
val bottom = anchorT + height
return Rect(
left.coerceAtLeast(bounds.left),
anchorT.coerceAtLeast(bounds.top),
anchorR.coerceAtMost(bounds.right),
bottom.coerceAtMost(bounds.bottom)
)
}
fun anchoredCornerTopRight(anchorL: Float, anchorB: Float, candidateRight: Float): Rect {
var width = (candidateRight - anchorL).coerceAtLeast(minW)
val maxWidth = (bounds.right - anchorL).coerceAtLeast(minW)
width = width.coerceAtMost(maxWidth)
var height = width / aspectRatio
val maxHeight = (anchorB - bounds.top).coerceAtLeast(minH)
if (height > maxHeight) {
height = maxHeight
width = (height * aspectRatio).coerceAtLeast(minW)
}
val right = anchorL + width
val top = anchorB - height
return Rect(
anchorL.coerceAtLeast(bounds.left),
top.coerceAtLeast(bounds.top),
right.coerceAtMost(bounds.right),
anchorB.coerceAtMost(bounds.bottom)
)
}
fun anchoredCornerBottomRight(anchorL: Float, anchorT: Float, candidateRight: Float): Rect {
var width = (candidateRight - anchorL).coerceAtLeast(minW)
val maxWidth = (bounds.right - anchorL).coerceAtLeast(minW)
width = width.coerceAtMost(maxWidth)
var height = width / aspectRatio
val maxHeight = (bounds.bottom - anchorT).coerceAtLeast(minH)
if (height > maxHeight) {
height = maxHeight
width = (height * aspectRatio).coerceAtLeast(minW)
}
val right = anchorL + width
val bottom = anchorT + height
return Rect(
anchorL.coerceAtLeast(bounds.left),
anchorT.coerceAtLeast(bounds.top),
right.coerceAtMost(bounds.right),
bottom.coerceAtMost(bounds.bottom)
)
}
fun centerTop(anchorB: Float, candidateTop: Float): Rect {
var height = (anchorB - candidateTop).coerceAtLeast(minH)
val maxHeight = (anchorB - bounds.top).coerceAtLeast(minH)
height = height.coerceAtMost(maxHeight)
var width = height * aspectRatio
val halfMaxWidth =
minOf(bounds.right - rectTemp.center.x, rectTemp.center.x - bounds.left)
val maxWidth = halfMaxWidth * 2f
if (width > maxWidth) {
width = maxWidth
height = (width / aspectRatio).coerceAtLeast(minH)
}
val left = rectTemp.center.x - width / 2f
val right = rectTemp.center.x + width / 2f
val top = anchorB - height
return Rect(
left.coerceAtLeast(bounds.left),
top.coerceAtLeast(bounds.top),
right.coerceAtMost(bounds.right),
anchorB.coerceAtMost(bounds.bottom)
)
}
fun centerBottom(anchorT: Float, candidateBottom: Float): Rect {
var height = (candidateBottom - anchorT).coerceAtLeast(minH)
val maxHeight = (bounds.bottom - anchorT).coerceAtLeast(minH)
height = height.coerceAtMost(maxHeight)
var width = height * aspectRatio
val halfMaxWidth =
minOf(bounds.right - rectTemp.center.x, rectTemp.center.x - bounds.left)
val maxWidth = halfMaxWidth * 2f
if (width > maxWidth) {
width = maxWidth
height = (width / aspectRatio).coerceAtLeast(minH)
}
val left = rectTemp.center.x - width / 2f
val right = rectTemp.center.x + width / 2f
val bottom = anchorT + height
return Rect(
left.coerceAtLeast(bounds.left),
anchorT.coerceAtLeast(bounds.top),
right.coerceAtMost(bounds.right),
bottom.coerceAtMost(bounds.bottom)
)
}
fun centerLeft(anchorR: Float, candidateLeft: Float): Rect {
var width = (anchorR - candidateLeft).coerceAtLeast(minW)
val maxWidth = (anchorR - bounds.left).coerceAtLeast(minW)
width = width.coerceAtMost(maxWidth)
var height = width / aspectRatio
val halfMaxHeight =
minOf(rectTemp.center.y - bounds.top, bounds.bottom - rectTemp.center.y)
val maxHeight = halfMaxHeight * 2f
if (height > maxHeight) {
height = maxHeight
width = (height * aspectRatio).coerceAtLeast(minW)
}
val left = anchorR - width
val top = rectTemp.center.y - height / 2f
val bottom = rectTemp.center.y + height / 2f
return Rect(
left.coerceAtLeast(bounds.left),
top.coerceAtLeast(bounds.top),
anchorR.coerceAtMost(bounds.right),
bottom.coerceAtMost(bounds.bottom)
)
}
fun centerRight(anchorL: Float, candidateRight: Float): Rect {
var width = (candidateRight - anchorL).coerceAtLeast(minW)
val maxWidth = (bounds.right - anchorL).coerceAtLeast(minW)
width = width.coerceAtMost(maxWidth)
var height = width / aspectRatio
val halfMaxHeight =
minOf(rectTemp.center.y - bounds.top, bounds.bottom - rectTemp.center.y)
val maxHeight = halfMaxHeight * 2f
if (height > maxHeight) {
height = maxHeight
width = (height * aspectRatio).coerceAtLeast(minW)
}
val right = anchorL + width
val top = rectTemp.center.y - height / 2f
val bottom = rectTemp.center.y + height / 2f
return Rect(
anchorL.coerceAtLeast(bounds.left),
top.coerceAtLeast(bounds.top),
right.coerceAtMost(bounds.right),
bottom.coerceAtMost(bounds.bottom)
)
}
val result = when (touchRegion) {
TouchRegion.TopLeft -> {
val anchorR = rectTemp.right.coerceAtMost(bounds.right)
if (fixedAspectRatio) anchoredCornerTopLeft(
anchorR,
rectTemp.bottom.coerceAtMost(bounds.bottom),
screenX.coerceAtMost(anchorR - minW)
)
else {
val left = screenX.coerceIn(bounds.left, anchorR - minW)
val top = screenY.coerceIn(
bounds.top,
rectTemp.bottom.coerceAtMost(bounds.bottom) - minH
)
clampNonFixed(left, top, anchorR, rectTemp.bottom.coerceAtMost(bounds.bottom))
}
}
TouchRegion.BottomLeft -> {
val anchorR = rectTemp.right.coerceAtMost(bounds.right)
if (fixedAspectRatio) anchoredCornerBottomLeft(
anchorR,
rectTemp.top.coerceAtLeast(bounds.top),
screenX.coerceAtMost(anchorR - minW)
)
else {
val left = screenX.coerceIn(bounds.left, anchorR - minW)
val bottom = screenY.coerceIn(
rectTemp.top.coerceAtLeast(bounds.top) + minH,
bounds.bottom
)
clampNonFixed(left, rectTemp.top.coerceAtLeast(bounds.top), anchorR, bottom)
}
}
TouchRegion.TopRight -> {
val anchorL = rectTemp.left.coerceAtLeast(bounds.left)
if (fixedAspectRatio) anchoredCornerTopRight(
anchorL,
rectTemp.bottom.coerceAtMost(bounds.bottom),
screenX.coerceAtLeast(anchorL + minW)
)
else {
val right = screenX.coerceIn(anchorL + minW, bounds.right)
val top = screenY.coerceIn(
bounds.top,
rectTemp.bottom.coerceAtMost(bounds.bottom) - minH
)
clampNonFixed(anchorL, top, right, rectTemp.bottom.coerceAtMost(bounds.bottom))
}
}
TouchRegion.BottomRight -> {
val anchorL = rectTemp.left.coerceAtLeast(bounds.left)
if (fixedAspectRatio) anchoredCornerBottomRight(
anchorL,
rectTemp.top.coerceAtLeast(bounds.top),
screenX.coerceAtLeast(anchorL + minW)
)
else {
val right = screenX.coerceIn(anchorL + minW, bounds.right)
val bottom = screenY.coerceIn(
rectTemp.top.coerceAtLeast(bounds.top) + minH,
bounds.bottom
)
clampNonFixed(anchorL, rectTemp.top.coerceAtLeast(bounds.top), right, bottom)
}
}
TouchRegion.TopCenter -> {
if (fixedAspectRatio) centerTop(
rectTemp.bottom.coerceAtMost(bounds.bottom),
screenY.coerceAtMost(rectTemp.bottom - minH)
)
else {
val top = screenY.coerceIn(
bounds.top,
rectTemp.bottom.coerceAtMost(bounds.bottom) - minH
)
clampNonFixed(
rectTemp.left.coerceAtLeast(bounds.left),
top,
rectTemp.right.coerceAtMost(bounds.right),
rectTemp.bottom.coerceAtMost(bounds.bottom)
)
}
}
TouchRegion.BottomCenter -> {
if (fixedAspectRatio) centerBottom(
rectTemp.top.coerceAtLeast(bounds.top),
screenY.coerceAtLeast(rectTemp.top + minH)
)
else {
val bottom = screenY.coerceIn(
rectTemp.top.coerceAtLeast(bounds.top) + minH,
bounds.bottom
)
clampNonFixed(
rectTemp.left.coerceAtLeast(bounds.left),
rectTemp.top.coerceAtLeast(bounds.top),
rectTemp.right.coerceAtMost(bounds.right),
bottom
)
}
}
TouchRegion.CenterLeft -> {
val anchorR = rectTemp.right.coerceAtMost(bounds.right)
if (fixedAspectRatio) centerLeft(anchorR, screenX.coerceAtMost(anchorR - minW))
else {
val left = screenX.coerceIn(bounds.left, anchorR - minW)
clampNonFixed(
left,
rectTemp.top.coerceAtLeast(bounds.top),
anchorR,
rectTemp.bottom.coerceAtMost(bounds.bottom)
)
}
}
TouchRegion.CenterRight -> {
val anchorL = rectTemp.left.coerceAtLeast(bounds.left)
if (fixedAspectRatio) centerRight(anchorL, screenX.coerceAtLeast(anchorL + minW))
else {
val right = screenX.coerceIn(anchorL + minW, bounds.right)
clampNonFixed(
anchorL,
rectTemp.top.coerceAtLeast(bounds.top),
right,
rectTemp.bottom.coerceAtMost(bounds.bottom)
)
}
}
TouchRegion.Inside -> {
val drag = change.positionChangeIgnoreConsumed()
val newLeft = (overlayRect.left + drag.x).coerceIn(
bounds.left,
bounds.right - overlayRect.width
)
val newTop = (overlayRect.top + drag.y).coerceIn(
bounds.top,
bounds.bottom - overlayRect.height
)
Rect(newLeft, newTop, newLeft + overlayRect.width, newTop + overlayRect.height)
}
else -> overlayRect
}
return result
}
/**
* get [TouchRegion] based on touch position on screen relative to [overlayRect].
*/
private fun getTouchRegion(
position: Offset,
rect: Rect,
threshold: Float
): TouchRegion {
val closedTouchRange = -threshold / 2..threshold
val centerX = rect.left + rect.width / 2
val centerY = rect.top + rect.height / 2
return when {
position.x - rect.left in closedTouchRange &&
position.y - rect.top in closedTouchRange -> TouchRegion.TopLeft
rect.right - position.x in closedTouchRange &&
position.y - rect.top in closedTouchRange -> TouchRegion.TopRight
rect.right - position.x in closedTouchRange &&
rect.bottom - position.y in closedTouchRange -> TouchRegion.BottomRight
position.x - rect.left in closedTouchRange &&
rect.bottom - position.y in closedTouchRange -> TouchRegion.BottomLeft
centerX - position.x in closedTouchRange &&
position.y - rect.top in closedTouchRange -> TouchRegion.TopCenter
rect.right - position.x in closedTouchRange &&
position.y - centerY in closedTouchRange -> TouchRegion.CenterRight
position.x - rect.left in closedTouchRange &&
position.y - centerY in closedTouchRange -> TouchRegion.CenterLeft
centerX - position.x in closedTouchRange &&
rect.bottom - position.y in closedTouchRange -> TouchRegion.BottomCenter
else -> {
if (rect.contains(offset = position)) TouchRegion.Inside
else TouchRegion.None
}
}
}
/**
* Returns how far user touched to corner or center of sides of the screen. [TouchRegion]
* where user exactly has touched is already passed to this function. For instance user
* touched top left then this function returns distance to top left from user's position so
* we can add an offset to not jump edge to position user touched.
*/
private fun getDistanceToEdgeFromTouch(
touchRegion: TouchRegion,
rect: Rect,
touchPosition: Offset
) = when (touchRegion) {
TouchRegion.TopLeft -> {
rect.topLeft - touchPosition
}
TouchRegion.TopRight -> {
rect.topRight - touchPosition
}
TouchRegion.BottomLeft -> {
rect.bottomLeft - touchPosition
}
TouchRegion.BottomRight -> {
rect.bottomRight - touchPosition
}
TouchRegion.TopCenter -> {
rect.topCenter - touchPosition
}
TouchRegion.CenterRight -> {
rect.centerRight - touchPosition
}
TouchRegion.BottomCenter -> {
rect.bottomCenter - touchPosition
}
TouchRegion.CenterLeft -> {
rect.centerLeft - touchPosition
}
else -> {
Offset.Zero
}
}
}
@@ -0,0 +1,144 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.state
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.unit.IntSize
import com.t8rin.cropper.model.AspectRatio
import kotlinx.coroutines.coroutineScope
/**
* * State for cropper with dynamic overlay. When this state is selected instead of overlay
* image is moved while overlay is stationary.
*
* @param imageSize size of the **Bitmap**
* @param containerSize size of the Composable that draws **Bitmap**
* @param maxZoom maximum zoom value
* @param fling when set to true dragging pointer builds up velocity. When last
* pointer leaves Composable a movement invoked against friction till velocity drops below
* to threshold
* @param zoomable when set to true zoom is enabled
* @param pannable when set to true pan is enabled
* @param rotatable when set to true rotation is enabled
* @param limitPan limits pan to bounds of parent Composable. Using this flag prevents creating
* empty space on sides or edges of parent
*/
class StaticCropState internal constructor(
imageSize: IntSize,
containerSize: IntSize,
drawAreaSize: IntSize,
aspectRatio: AspectRatio,
overlayRatio: Float,
maxZoom: Float = 5f,
fling: Boolean = false,
zoomable: Boolean = true,
pannable: Boolean = true,
rotatable: Boolean = false,
limitPan: Boolean = false
) : CropState(
imageSize = imageSize,
containerSize = containerSize,
drawAreaSize = drawAreaSize,
aspectRatio = aspectRatio,
overlayRatio = overlayRatio,
maxZoom = maxZoom,
fling = fling,
zoomable = zoomable,
pannable = pannable,
rotatable = rotatable,
limitPan = limitPan
) {
override suspend fun onDown(change: PointerInputChange) = Unit
override suspend fun onMove(changes: List<PointerInputChange>) = Unit
override suspend fun onUp(change: PointerInputChange) = Unit
private var doubleTapped = false
/*
Transform gestures
*/
override suspend fun onGesture(
centroid: Offset,
panChange: Offset,
zoomChange: Float,
rotationChange: Float,
mainPointer: PointerInputChange,
changes: List<PointerInputChange>
) = coroutineScope {
doubleTapped = false
updateTransformState(
centroid = centroid,
zoomChange = zoomChange,
panChange = panChange,
rotationChange = rotationChange
)
// Update image draw rectangle based on pan, zoom or rotation change
drawAreaRect = updateImageDrawRectFromTransformation()
// Fling Gesture
if (pannable && fling) {
if (changes.size == 1) {
addPosition(mainPointer.uptimeMillis, mainPointer.position)
}
}
}
override suspend fun onGestureStart() = coroutineScope {}
override suspend fun onGestureEnd(onBoundsCalculated: () -> Unit) {
// Gesture end might be called after second tap and we don't want to fling
// or animate back to valid bounds when doubled tapped
if (!doubleTapped) {
if (pannable && fling && zoom > 1) {
fling {
// We get target value on start instead of updating bounds after
// gesture has finished
drawAreaRect = updateImageDrawRectFromTransformation()
onBoundsCalculated()
}
} else {
onBoundsCalculated()
}
animateTransformationToOverlayBounds(overlayRect, animate = true)
}
}
// Double Tap
override suspend fun onDoubleTap(
offset: Offset,
zoom: Float,
onAnimationEnd: () -> Unit
) {
doubleTapped = true
if (fling) {
resetTracking()
}
resetWithAnimation(pan = pan, zoom = zoom, rotation = rotation)
drawAreaRect = updateImageDrawRectFromTransformation()
animateTransformationToOverlayBounds(overlayRect, true)
onAnimationEnd()
}
}
@@ -0,0 +1,272 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.state
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
* State of the pan, zoom and rotation. Allows to change zoom, pan via [Animatable]
* objects' [Animatable.animateTo], [Animatable.snapTo].
* @param initialZoom initial zoom level
* @param initialRotation initial angle in degrees
* @param minZoom minimum zoom
* @param maxZoom maximum zoom
* @param zoomable when set to true zoom is enabled
* @param pannable when set to true pan is enabled
* @param rotatable when set to true rotation is enabled
* @param limitPan limits pan to bounds of parent Composable. Using this flag prevents creating
* empty space on sides or edges of parent.
*
*/
@Stable
open class TransformState(
internal val imageSize: IntSize,
val containerSize: IntSize,
val drawAreaSize: IntSize,
initialZoom: Float = 1f,
initialRotation: Float = 0f,
minZoom: Float = 0.5f, // Definitely
maxZoom: Float = 10f,
internal var zoomable: Boolean = true,
internal var pannable: Boolean = true,
internal var rotatable: Boolean = true,
internal var limitPan: Boolean = false
) {
var drawAreaRect: Rect by mutableStateOf(
Rect(
offset = Offset(
x = ((containerSize.width - drawAreaSize.width) / 2).toFloat(),
y = ((containerSize.height - drawAreaSize.height) / 2).toFloat()
),
size = Size(drawAreaSize.width.toFloat(), drawAreaSize.height.toFloat())
)
)
internal val zoomMin = minZoom.coerceAtLeast(0.5f) // Definitely
internal var zoomMax = maxZoom.coerceAtLeast(1f)
private val zoomInitial = initialZoom.coerceIn(zoomMin, zoomMax)
private val rotationInitial = initialRotation % 360
internal val animatablePanX = Animatable(0f)
internal val animatablePanY = Animatable(0f)
internal val animatableZoom = Animatable(zoomInitial)
internal val animatableRotation = Animatable(rotationInitial)
private val velocityTracker = VelocityTracker()
init {
animatableZoom.updateBounds(zoomMin, zoomMax)
require(zoomMax >= zoomMin)
}
val pan: Offset
get() = Offset(animatablePanX.value, animatablePanY.value)
val zoom: Float
get() = animatableZoom.value
val rotation: Float
get() = animatableRotation.value
val isZooming: Boolean
get() = animatableZoom.isRunning
val isPanning: Boolean
get() = animatablePanX.isRunning || animatablePanY.isRunning
val isRotating: Boolean
get() = animatableRotation.isRunning
val isAnimationRunning: Boolean
get() = isZooming || isPanning || isRotating
internal open fun updateBounds(lowerBound: Offset?, upperBound: Offset?) {
animatablePanX.updateBounds(lowerBound?.x, upperBound?.x)
animatablePanY.updateBounds(lowerBound?.y, upperBound?.y)
}
/**
* Update centroid, pan, zoom and rotation of this state when transform gestures are
* invoked with one or multiple pointers
*/
internal open suspend fun updateTransformState(
centroid: Offset,
panChange: Offset,
zoomChange: Float,
rotationChange: Float = 1f,
) {
val newZoom = (this.zoom * zoomChange).coerceIn(zoomMin, zoomMax)
snapZoomTo(newZoom)
val newRotation = if (rotatable) {
this.rotation + rotationChange
} else {
0f
}
snapRotationTo(newRotation)
if (pannable) {
val newPan = this.pan + panChange.times(this.zoom / newZoom)
snapPanXto(newPan.x)
snapPanYto(newPan.y)
}
}
/**
* Reset [pan], [zoom] and [rotation] with animation.
*/
internal suspend fun resetWithAnimation(
pan: Offset = Offset.Zero,
zoom: Float = 1f,
rotation: Float = 0f,
animationSpec: AnimationSpec<Float> = tween(250)
) = coroutineScope {
launch { animatePanXto(pan.x, animationSpec) }
launch { animatePanYto(pan.y, animationSpec) }
launch { animateZoomTo(zoom, animationSpec) }
launch { animateRotationTo(rotation, animationSpec) }
}
internal suspend fun animatePanXto(
panX: Float,
animationSpec: AnimationSpec<Float> = tween(250)
) {
if (pannable && pan.x != panX) {
animatablePanX.animateTo(panX, animationSpec)
}
}
internal suspend fun animatePanYto(
panY: Float,
animationSpec: AnimationSpec<Float> = tween(250)
) {
if (pannable && pan.y != panY) {
animatablePanY.animateTo(panY, animationSpec)
}
}
internal suspend fun animateZoomTo(
zoom: Float,
animationSpec: AnimationSpec<Float> = tween(250)
) {
if (zoomable && this.zoom != zoom) {
val newZoom = zoom.coerceIn(zoomMin, zoomMax)
animatableZoom.animateTo(newZoom, animationSpec)
}
}
suspend fun animateRotationTo(
rotation: Float,
animationSpec: AnimationSpec<Float> = tween(250)
) {
if (rotatable && this.rotation != rotation) {
animatableRotation.animateTo(rotation, animationSpec)
}
}
internal suspend fun snapPanXto(panX: Float) {
if (pannable) {
animatablePanX.snapTo(panX)
}
}
internal suspend fun snapPanYto(panY: Float) {
if (pannable) {
animatablePanY.snapTo(panY)
}
}
internal suspend fun snapZoomTo(zoom: Float) {
if (zoomable) {
animatableZoom.snapTo(zoom.coerceIn(zoomMin, zoomMax))
}
}
internal suspend fun snapRotationTo(rotation: Float) {
if (rotatable) {
animatableRotation.snapTo(rotation)
}
}
/*
Fling gesture
*/
internal fun addPosition(timeMillis: Long, position: Offset) {
velocityTracker.addPosition(
timeMillis = timeMillis,
position = position
)
}
/**
* Create a fling gesture when user removes finger from scree to have continuous movement
* until [velocityTracker] speed reached to lower bound
*/
internal suspend fun fling(onFlingStart: () -> Unit) = coroutineScope {
val velocityTracker = velocityTracker.calculateVelocity()
val velocity = Offset(velocityTracker.x, velocityTracker.y)
var flingStarted = false
launch {
animatablePanX.animateDecay(
velocity.x,
exponentialDecay(absVelocityThreshold = 20f),
block = {
// This callback returns target value of fling gesture initially
if (!flingStarted) {
onFlingStart()
flingStarted = true
}
}
)
}
launch {
animatablePanY.animateDecay(
velocity.y,
exponentialDecay(absVelocityThreshold = 20f),
block = {
// This callback returns target value of fling gesture initially
if (!flingStarted) {
onFlingStart()
flingStarted = true
}
}
)
}
}
internal fun resetTracking() {
velocityTracker.resetTracking()
}
}
@@ -0,0 +1,61 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.util
/**
* [Linear Interpolation](https://en.wikipedia.org/wiki/Linear_interpolation) function that moves
* amount from it's current position to start and amount
* @param start of interval
* @param stop of interval
* @param fraction closed unit interval [0, 1]
*/
fun lerp(start: Float, stop: Float, fraction: Float): Float {
return (1 - fraction) * start + fraction * stop
}
/**
* Scale x1 from start1..end1 range to start2..end2 range
*/
fun scale(start1: Float, end1: Float, pos: Float, start2: Float, end2: Float) =
lerp(start2, end2, calculateFraction(start1, end1, pos))
/**
* Scale x.start, x.endInclusive from a1..b1 range to a2..b2 range
*/
fun scale(
start1: Float,
end1: Float,
range: ClosedFloatingPointRange<Float>,
start2: Float,
end2: Float
) =
scale(start1, end1, range.start, start2, end2)..scale(
start1,
end1,
range.endInclusive,
start2,
end2
)
/**
* Calculate fraction for value between a range [end] and [start] coerced into 0f-1f range
*/
fun calculateFraction(start: Float, end: Float, pos: Float) =
(if (end - start == 0f) 0f else (pos - start) / (end - start)).coerceIn(0f, 1f)
@@ -0,0 +1,67 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.util
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
/**
* Draw grid that is divided by 2 vertical and 2 horizontal lines for overlay
*/
fun DrawScope.drawGrid(rect: Rect, strokeWidth: Float, color: Color) {
val width = rect.width
val height = rect.height
val gridWidth = width / 3
val gridHeight = height / 3
// Horizontal lines
for (i in 1..2) {
drawLine(
color = color,
start = Offset(rect.left, rect.top + i * gridHeight),
end = Offset(rect.right, rect.top + i * gridHeight),
strokeWidth = strokeWidth
)
}
// Vertical lines
for (i in 1..2) {
drawLine(
color,
start = Offset(rect.left + i * gridWidth, rect.top),
end = Offset(rect.left + i * gridWidth, rect.bottom),
strokeWidth = strokeWidth
)
}
}
/**
* Draw with layer to use [BlendMode]s
*/
fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
@@ -0,0 +1,105 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.util
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
/**
* Get Rectangle of [ImageBitmap] with [bitmapWidth] and [bitmapHeight] that is drawn inside
* Canvas with [imageWidth] and [imageHeight]. [boxWidth] and [boxHeight] belong
* to [BoxWithConstraints] that contains Canvas.
* @param boxWidth width of the parent container
* @param boxHeight height of the parent container
* @param imageWidth width of the [Canvas] that draws [ImageBitmap]
* @param imageHeight height of the [Canvas] that draws [ImageBitmap]
* @param bitmapWidth intrinsic width of the [ImageBitmap]
* @param bitmapHeight intrinsic height of the [ImageBitmap]
* @return [IntRect] that covers [ImageBitmap] bounds. When image [ContentScale] is crop
* this rectangle might return smaller rectangle than actual [ImageBitmap] and left or top
* of the rectangle might be bigger than zero.
*/
internal fun getScaledBitmapRect(
boxWidth: Int,
boxHeight: Int,
imageWidth: Float,
imageHeight: Float,
bitmapWidth: Int,
bitmapHeight: Int
): IntRect {
// Get scale of box to width of the image
// We need a rect that contains Bitmap bounds to pass if any child requires it
// For a image with 100x100 px with 300x400 px container and image with crop 400x400px
// So we need to pass top left as 0,50 and size
val scaledBitmapX = boxWidth / imageWidth
val scaledBitmapY = boxHeight / imageHeight
val topLeft = IntOffset(
x = (bitmapWidth * (imageWidth - boxWidth) / imageWidth / 2)
.coerceAtLeast(0f).toInt(),
y = (bitmapHeight * (imageHeight - boxHeight) / imageHeight / 2)
.coerceAtLeast(0f).toInt()
)
val size = IntSize(
width = (bitmapWidth * scaledBitmapX).toInt().coerceAtMost(bitmapWidth),
height = (bitmapHeight * scaledBitmapY).toInt().coerceAtMost(bitmapHeight)
)
return IntRect(offset = topLeft, size = size)
}
/**
* Get [IntSize] of the parent or container that contains [Canvas] that draws [ImageBitmap]
* @param bitmapWidth intrinsic width of the [ImageBitmap]
* @param bitmapHeight intrinsic height of the [ImageBitmap]
* @return size of parent Composable. When Modifier is assigned with fixed or finite size
* they are used, but when any dimension is set to infinity intrinsic dimensions of
* [ImageBitmap] are returned
*/
internal fun BoxWithConstraintsScope.getParentSize(
bitmapWidth: Int,
bitmapHeight: Int
): IntSize {
// Check if Composable has fixed size dimensions
val hasBoundedDimens = constraints.hasBoundedWidth && constraints.hasBoundedHeight
// Check if Composable has infinite dimensions
val hasFixedDimens = constraints.hasFixedWidth && constraints.hasFixedHeight
// Box is the parent(BoxWithConstraints) that contains Canvas under the hood
// Canvas aspect ratio or size might not match parent but it's upper bounds are
// what are passed from parent. Canvas cannot be bigger or taller than BoxWithConstraints
val boxWidth: Int = if (hasBoundedDimens || hasFixedDimens) {
constraints.maxWidth
} else {
constraints.minWidth.coerceAtLeast(bitmapWidth)
}
val boxHeight: Int = if (hasBoundedDimens || hasFixedDimens) {
constraints.maxHeight
} else {
constraints.minHeight.coerceAtLeast(bitmapHeight)
}
return IntSize(boxWidth, boxHeight)
}
@@ -0,0 +1,29 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.util
import androidx.compose.ui.geometry.Offset
/**
* Coerce an [Offset] x value in [horizontalRange] and y value in [verticalRange]
*/
fun Offset.coerceIn(
horizontalRange: ClosedRange<Float>,
verticalRange: ClosedRange<Float>
) = Offset(this.x.coerceIn(horizontalRange), this.y.coerceIn(verticalRange))
@@ -0,0 +1,189 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.util
import android.graphics.Matrix
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import com.t8rin.cropper.model.AspectRatio
import kotlin.math.cos
import kotlin.math.sin
/**
* Creates a polygon with number of [sides] centered at ([cx],[cy]) with [radius].
* ```
* To generate regular polygons (i.e. where each interior angle is the same),
* polar coordinates are extremely useful. You can calculate the angle necessary
* to produce the desired number of sides (as the interior angles total 360º)
* and then use multiples of this angle with the same radius to describe each point.
* val x = radius * Math.cos(angle);
* val y = radius * Math.sin(angle);
*
* For instance to draw triangle loop thrice with angle
* 0, 120, 240 degrees in radians and draw lines from each coordinate.
* ```
*/
fun createPolygonPath(cx: Float, cy: Float, sides: Int, radius: Float): Path {
val angle = 2.0 * Math.PI / sides
return Path().apply {
moveTo(
cx + (radius * cos(0.0)).toFloat(),
cy + (radius * sin(0.0)).toFloat()
)
for (i in 1 until sides) {
lineTo(
cx + (radius * cos(angle * i)).toFloat(),
cy + (radius * sin(angle * i)).toFloat()
)
}
close()
}
}
/**
* Create a polygon shape
*/
fun createPolygonShape(sides: Int, degrees: Float = 0f): GenericShape {
return GenericShape { size: Size, _: LayoutDirection ->
val radius = size.width.coerceAtMost(size.height) / 2
addPath(
createPolygonPath(
cx = size.width / 2,
cy = size.height / 2,
sides = sides,
radius = radius
)
)
val matrix = Matrix()
matrix.postRotate(degrees, size.width / 2, size.height / 2)
this.asAndroidPath().transform(matrix)
}
}
/**
* Creates a [Rect] shape with given aspect ratio.
*/
fun createRectShape(aspectRatio: AspectRatio): GenericShape {
return GenericShape { size: Size, _: LayoutDirection ->
val value = aspectRatio.value
val width = size.width
val height = size.height
val shapeSize =
if (aspectRatio == AspectRatio.Original) Size(width, height)
else if (value > 1) Size(width = width, height = width / value)
else Size(width = height * value, height = height)
addRect(Rect(offset = Offset.Zero, size = shapeSize))
}
}
/**
* Scales this path to [width] and [height] from [Path.getBounds] and translates
* as difference between scaled path and original path
*/
fun Path.scaleAndTranslatePath(
width: Float,
height: Float,
) {
val pathSize = getBounds().size
val matrix = Matrix()
matrix.postScale(
width / pathSize.width,
height / pathSize.height
)
this.asAndroidPath().transform(matrix)
val left = getBounds().left
val top = getBounds().top
translate(Offset(-left, -top))
}
/**
* Build an outline from a shape using aspect ratio, shape and coefficient to scale
*
* @return [Triple] that contains left, top offset and [Outline]
*/
fun buildOutline(
aspectRatio: AspectRatio,
coefficient: Float,
shape: Shape,
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Pair<Offset, Outline> {
val (shapeSize, offset) = calculateSizeAndOffsetFromAspectRatio(aspectRatio, coefficient, size)
val outline = shape.createOutline(
size = shapeSize,
layoutDirection = layoutDirection,
density = density
)
return Pair(offset, outline)
}
/**
* Calculate new size and offset based on [size], [coefficient] and [aspectRatio]
*
* For 4/3f aspect ratio with 1000px width, 1000px height with coefficient 1f
* it returns Size(1000f, 750f), Offset(0f, 125f).
*/
fun calculateSizeAndOffsetFromAspectRatio(
aspectRatio: AspectRatio,
coefficient: Float,
size: Size,
): Pair<Size, Offset> {
val width = size.width
val height = size.height
val value = aspectRatio.value
val newSize = if (aspectRatio == AspectRatio.Original) {
Size(width * coefficient, height * coefficient)
} else if (value > 1) {
Size(
width = coefficient * width,
height = coefficient * width / value
)
} else {
Size(width = coefficient * height * value, height = coefficient * height)
}
val left = (width - newSize.width) / 2
val top = (height - newSize.height) / 2
return Pair(newSize, Offset(left, top))
}
@@ -0,0 +1,25 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.util
/**
* Enum class for zoom levels
*/
enum class ZoomLevel {
Min, Mid, Max
}
@@ -0,0 +1,88 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.util
import androidx.compose.ui.graphics.GraphicsLayerScope
import com.t8rin.cropper.state.TransformState
/**
* Calculate zoom level and zoom value when user double taps
*/
internal fun calculateZoom(
zoomLevel: ZoomLevel,
initial: Float,
min: Float,
max: Float
): Pair<ZoomLevel, Float> {
val newZoomLevel: ZoomLevel
val newZoom: Float
when (zoomLevel) {
ZoomLevel.Mid -> {
newZoomLevel = ZoomLevel.Max
newZoom = max.coerceAtMost(3f)
}
ZoomLevel.Max -> {
newZoomLevel = ZoomLevel.Min
newZoom = if (min == initial) initial else min
}
else -> {
newZoomLevel = ZoomLevel.Mid
newZoom = if (min == initial) (min + max.coerceAtMost(3f)) / 2 else initial
}
}
return Pair(newZoomLevel, newZoom)
}
internal fun getNextZoomLevel(zoomLevel: ZoomLevel): ZoomLevel = when (zoomLevel) {
ZoomLevel.Mid -> {
ZoomLevel.Max
}
ZoomLevel.Max -> {
ZoomLevel.Min
}
else -> {
ZoomLevel.Mid
}
}
/**
* Update graphic layer with [transformState]
*/
internal fun GraphicsLayerScope.update(transformState: TransformState) {
// Set zoom
val zoom = transformState.zoom
this.scaleX = zoom
this.scaleY = zoom
// Set pan
val pan = transformState.pan
val translationX = pan.x
val translationY = pan.y
this.translationX = translationX
this.translationY = translationY
// Set rotation
this.rotationZ = transformState.rotation
}
@@ -0,0 +1,109 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.widget
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.t8rin.cropper.model.CropAspectRatio
@Composable
fun AspectRatioSelectionCard(
modifier: Modifier = Modifier,
contentColor: Color = MaterialTheme.colorScheme.surface,
color: Color,
cropAspectRatio: CropAspectRatio,
onClick: ((List<Int>) -> Unit)? = null
) {
Box(
modifier = modifier
.background(contentColor)
.padding(4.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
Box(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.aspectRatio(1f)
.drawWithCache {
val outline = cropAspectRatio.shape.createOutline(
size = size, layoutDirection = layoutDirection, density = density
)
val width = size.width
val height = size.height
val outlineWidth = outline.bounds.width
val outlineHeight = outline.bounds.height
onDrawWithContent {
translate(
left = (width - outlineWidth) / 2,
top = (height - outlineHeight) / 2
) {
drawOutline(
outline = outline, color = color, style = Stroke(3.dp.toPx())
)
}
drawContent()
}
},
contentAlignment = Alignment.Center
) {
GridImageLayout(
modifier = Modifier
.matchParentSize()
.padding(5.dp),
thumbnails = cropAspectRatio.icons,
onClick = onClick
)
}
if (cropAspectRatio.title.isNotEmpty()) {
Text(
text = cropAspectRatio.title,
color = color,
fontSize = 14.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
}
}
}
@@ -0,0 +1,196 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.widget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.t8rin.cropper.model.CropOutline
import com.t8rin.cropper.model.CropPath
import com.t8rin.cropper.model.CropShape
import com.t8rin.cropper.settings.Paths
import com.t8rin.imagetoolbox.core.resources.icons.Favorite
import com.t8rin.imagetoolbox.core.resources.icons.Image
import com.t8rin.imagetoolbox.core.resources.icons.Star
@Composable
fun CropFrameDisplayCard(
modifier: Modifier = Modifier,
scale: Float,
outlineColor: Color,
fontSize: TextUnit = 12.sp,
title: String,
cropOutline: CropOutline,
) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Column(
Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
},
horizontalAlignment = Alignment.CenterHorizontally
) {
CropFrameDisplay(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.aspectRatio(1f),
cropOutline = cropOutline,
color = outlineColor,
content = {}
)
if (title.isNotEmpty()) {
Text(
text = title,
color = outlineColor,
fontSize = fontSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun CropFrameDisplay(
modifier: Modifier,
cropOutline: CropOutline,
color: Color,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
when (cropOutline) {
is CropShape -> {
val shape = remember { cropOutline.shape }
Box(
modifier.drawWithCache {
val outline = shape.createOutline(
size = size,
layoutDirection = layoutDirection,
density = density
)
onDrawWithContent {
val width = size.width
val height = size.height
val outlineWidth = outline.bounds.width
val outlineHeight = outline.bounds.height
translate(
left = (width - outlineWidth) / 2,
top = (height - outlineHeight) / 2
) {
drawOutline(
outline = outline,
color = color,
style = Stroke(6.dp.toPx())
)
}
drawContent()
}
},
contentAlignment = Alignment.TopEnd
) {
content()
}
}
is CropPath -> {
Box(
modifier = modifier,
contentAlignment = Alignment.TopEnd
) {
if (cropOutline.path == Paths.Star) {
Icon(
modifier = Modifier
.matchParentSize()
.scale(1.3f),
imageVector = Icons.Outlined.Star,
tint = color,
contentDescription = "Crop with Path"
)
} else {
Icon(
modifier = Modifier
.matchParentSize()
.scale(1.3f),
imageVector = Icons.Outlined.Favorite,
tint = color,
contentDescription = "Crop with Path"
)
}
content()
}
}
else -> {
Box(
modifier = modifier,
contentAlignment = Alignment.TopEnd
) {
Icon(
modifier = Modifier
.matchParentSize()
.scale(1.3f),
imageVector = Icons.Outlined.Image,
tint = color,
contentDescription = "Crop with Image Mask"
)
content()
}
}
}
}
@@ -0,0 +1,186 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.cropper.widget
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Composable that positions and displays [Image]s based on count as one element,
* horizontal two images, 2 images as quarter of parent and one has lower hals, 4 quarter images
* 3 images and [Text] that displays number of images that are not visible.
* @param thumbnails images or icons to be displayed with resource ids
* @param divider divider space between items when number of items is bigger than 1
* @param onClick callback for returning images when this composable is clicked
*/
@Composable
fun GridImageLayout(
modifier: Modifier = Modifier,
thumbnails: List<Int>,
divider: Dp = 2.dp,
onClick: ((List<Int>) -> Unit)? = null
) {
if (thumbnails.isNotEmpty()) {
ImageDrawLayout(
modifier = modifier
.clickable {
onClick?.invoke(thumbnails)
},
divider = divider,
itemCount = thumbnails.size
) {
val size = thumbnails.size
if (size < 5) {
thumbnails.forEach {
Image(
painter = painterResource(id = it),
contentDescription = "Icon",
contentScale = ContentScale.Crop,
)
}
} else {
thumbnails.take(3).forEach {
Image(
painter = painterResource(id = it),
contentDescription = "Icon",
contentScale = ContentScale.Crop,
)
}
Box(
contentAlignment = Alignment.Center
) {
val carry = size - 3
Text(text = "+$carry", fontSize = 20.sp)
}
}
}
}
}
@Composable
private fun ImageDrawLayout(
modifier: Modifier = Modifier,
itemCount: Int,
divider: Dp,
content: @Composable () -> Unit
) {
val spacePx = LocalDensity.current.run { (divider).roundToPx() }
val measurePolicy = remember(itemCount, spacePx) {
MeasurePolicy { measurables, constraints ->
val newConstraints = when (itemCount) {
1 -> constraints
2 -> Constraints.fixed(
width = constraints.maxWidth / 2 - spacePx / 2,
height = constraints.maxHeight
)
else -> Constraints.fixed(
width = constraints.maxWidth / 2 - spacePx / 2,
height = constraints.maxHeight / 2 - spacePx / 2
)
}
val placeables: List<Placeable> = if (measurables.size != 3) {
measurables.map { measurable: Measurable ->
measurable.measure(constraints = newConstraints)
}
} else {
measurables
.take(2)
.map { measurable: Measurable ->
measurable.measure(constraints = newConstraints)
} +
measurables
.last()
.measure(
constraints = Constraints.fixed(
constraints.maxWidth,
constraints.maxHeight / 2 - spacePx
)
)
}
layout(constraints.maxWidth, constraints.maxHeight) {
when (itemCount) {
1 -> {
placeables.forEach { placeable: Placeable ->
placeable.placeRelative(0, 0)
}
}
2 -> {
var xPos = 0
placeables.forEach { placeable: Placeable ->
placeable.placeRelative(xPos, 0)
xPos += placeable.width + spacePx
}
}
else -> {
var xPos = 0
var yPos = 0
placeables.forEachIndexed { index: Int, placeable: Placeable ->
placeable.placeRelative(xPos, yPos)
if (index % 2 == 0) {
xPos += placeable.width + spacePx
} else {
xPos = 0
}
if (index % 2 == 1) {
yPos += placeable.height + spacePx
}
}
}
}
}
}
}
Layout(
modifier = modifier,
content = content,
measurePolicy = measurePolicy
)
}