chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user