refactor: replace zoom/pan image cropping with fixed-image draggable frame UI in ImageCropBottomSheet

- Remove transform gestures (zoom/pan) in favor of fixed ContentScale.Fit background image
- Implement draggable crop frame with corner handles for resize and center drag for repositioning
- Add `FrameHandle` composable with circular white/blue indicators at frame corners
- Replace complex scale/offset calculations with direct frame coordinate manipulation
- Enforce minimum frame size (48dp) and container
This commit is contained in:
Bruno Charest 2026-04-27 07:05:46 -04:00
parent c8dff8df40
commit 76ad44a7ca

View File

@ -4,7 +4,7 @@ import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -13,9 +13,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
@ -31,26 +31,30 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import java.io.File
import kotlin.math.max
import kotlin.math.min
private val HandleSize = 28.dp
private val MinFrameSize = 48.dp
/**
* Bottom sheet permettant de recadrer une image avec zoom/pan.
* Affiche un cadre blanc centré (type scanner) et un overlay sombre.
* Bottom sheet permettant de recadrer une image.
* La photo reste fixe en arrière-plan. L'utilisateur déplace et redimensionne
* le cadre de recadrage (overlay sombre + cadre blanc avec poignées aux coins).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -61,59 +65,42 @@ fun ImageCropBottomSheet(
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
val context = LocalContext.current
val density = LocalDensity.current
var containerSize by remember { mutableStateOf(IntSize.Zero) }
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
val bmpW = bitmap.width.toFloat()
val bmpH = bitmap.height.toFloat()
val containerW = containerSize.width.toFloat().coerceAtLeast(1f)
val containerH = containerSize.height.toFloat().coerceAtLeast(1f)
val fitScale = remember(containerW, containerH, bmpW, bmpH) {
min(containerW / bmpW, containerH / bmpH)
}
val bmpW = bitmap.width.toFloat()
val bmpH = bitmap.height.toFloat()
val bmpRatio = bmpW / bmpH
val containerRatio = containerW / containerH.coerceAtLeast(1f)
val initialOffsetX = (containerW - bmpW * fitScale) / 2f
val initialOffsetY = (containerH - bmpH * fitScale) / 2f
val displayedW: Float
val displayedH: Float
val displayedOffsetX: Float
val displayedOffsetY: Float
val totalScale = fitScale * scale
val displayW = bmpW * totalScale
val displayH = bmpH * totalScale
val frameSize = min(containerW, containerH) * 0.8f
val frameLeft = (containerW - frameSize) / 2f
val frameTop = (containerH - frameSize) / 2f
val frameRight = frameLeft + frameSize
val frameBottom = frameTop + frameSize
// Contraintes de pan pour garder le cadre rempli
val minOffsetX = if (displayW > frameSize) {
frameLeft - initialOffsetX - displayW
if (bmpRatio > containerRatio) {
displayedW = containerW
displayedH = containerW / bmpRatio
displayedOffsetX = 0f
displayedOffsetY = (containerH - displayedH) / 2f
} else {
(containerW - displayW) / 2f - initialOffsetX
}
val maxOffsetX = if (displayW > frameSize) {
frameRight - initialOffsetX
} else {
(containerW - displayW) / 2f - initialOffsetX
}
val minOffsetY = if (displayH > frameSize) {
frameTop - initialOffsetY - displayH
} else {
(containerH - displayH) / 2f - initialOffsetY
}
val maxOffsetY = if (displayH > frameSize) {
frameBottom - initialOffsetY
} else {
(containerH - displayH) / 2f - initialOffsetY
displayedH = containerH
displayedW = containerH * bmpRatio
displayedOffsetX = (containerW - displayedW) / 2f
displayedOffsetY = 0f
}
val totalOffsetX = initialOffsetX + offsetX
val totalOffsetY = initialOffsetY + offsetY
val scaleToBmp = displayedW / bmpW
val halfHandlePx = with(density) { (HandleSize / 2).toPx() }
val minPx = with(density) { MinFrameSize.toPx() }
var frameLeft by remember { mutableFloatStateOf(containerW * 0.15f) }
var frameTop by remember { mutableFloatStateOf(containerH * 0.15f) }
var frameRight by remember { mutableFloatStateOf(containerW * 0.85f) }
var frameBottom by remember { mutableFloatStateOf(containerH * 0.85f) }
ModalBottomSheet(
onDismissRequest = onDismiss,
@ -123,13 +110,13 @@ fun ImageCropBottomSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.height(520.dp)
.height(560.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = "Ajuster le cadrage",
style = MaterialTheme.typography.titleLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
@ -138,135 +125,123 @@ fun ImageCropBottomSheet(
.weight(1f)
.fillMaxWidth()
.onSizeChanged { containerSize = it }
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(1f, 5f)
val scaleChange = newScale / scale
scale = newScale
// Ajuste l'offset pour zoomer autour du centre du cadre
val focusX = containerW / 2f
val focusY = containerH / 2f
val relFocusX = focusX - totalOffsetX
val relFocusY = focusY - totalOffsetY
offsetX = (offsetX + pan.x - relFocusX * (scaleChange - 1f))
.coerceIn(minOffsetX, maxOffsetX)
offsetY = (offsetY + pan.y - relFocusY * (scaleChange - 1f))
.coerceIn(minOffsetY, maxOffsetY)
}
}
) {
// Image zoomable/pannable
// Photo fixe en arrière-plan (ContentScale.Fit)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.None,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
this.scaleX = totalScale
this.scaleY = totalScale
this.translationX = totalOffsetX
this.translationY = totalOffsetY
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0f)
}
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
)
// Overlay sombre + cadre blanc
Canvas(modifier = Modifier.fillMaxSize()) {
val fSize = min(size.width, size.height) * 0.8f
val fLeft = (size.width - fSize) / 2f
val fTop = (size.height - fSize) / 2f
val fRight = fLeft + fSize
val fBottom = fTop + fSize
val fL = frameLeft
val fT = frameTop
val fR = frameRight
val fB = frameBottom
// Top
// Zone sombre
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(0f, 0f),
size = Size(size.width, fTop)
size = Size(size.width, fT)
)
// Bottom
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(0f, fBottom),
size = Size(size.width, size.height - fBottom)
topLeft = Offset(0f, fB),
size = Size(size.width, size.height - fB)
)
// Left
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(0f, fTop),
size = Size(fLeft, fSize)
topLeft = Offset(0f, fT),
size = Size(fL, fB - fT)
)
// Right
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(fRight, fTop),
size = Size(size.width - fRight, fSize)
topLeft = Offset(fR, fT),
size = Size(size.width - fR, fB - fT)
)
// Coins du cadre (type scanner)
val cornerLength = fSize * 0.15f
val strokeWidth = 3f
val cornerColor = Color.White
// Top-left
drawLine(
cornerColor,
Offset(fLeft, fTop),
Offset(fLeft + cornerLength, fTop),
strokeWidth = strokeWidth
)
drawLine(
cornerColor,
Offset(fLeft, fTop),
Offset(fLeft, fTop + cornerLength),
strokeWidth = strokeWidth
)
// Top-right
drawLine(
cornerColor,
Offset(fRight, fTop),
Offset(fRight - cornerLength, fTop),
strokeWidth = strokeWidth
)
drawLine(
cornerColor,
Offset(fRight, fTop),
Offset(fRight, fTop + cornerLength),
strokeWidth = strokeWidth
)
// Bottom-left
drawLine(
cornerColor,
Offset(fLeft, fBottom),
Offset(fLeft + cornerLength, fBottom),
strokeWidth = strokeWidth
)
drawLine(
cornerColor,
Offset(fLeft, fBottom),
Offset(fLeft, fBottom - cornerLength),
strokeWidth = strokeWidth
)
// Bottom-right
drawLine(
cornerColor,
Offset(fRight, fBottom),
Offset(fRight - cornerLength, fBottom),
strokeWidth = strokeWidth
)
drawLine(
cornerColor,
Offset(fRight, fBottom),
Offset(fRight, fBottom - cornerLength),
strokeWidth = strokeWidth
// Bordure blanche
drawRect(
color = Color.White,
topLeft = Offset(fL, fT),
size = Size(fR - fL, fB - fT),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
)
}
// Zone centrale draggable pour déplacer le cadre entier
Box(
modifier = Modifier
.offset(
x = with(density) { frameLeft.toDp() },
y = with(density) { frameTop.toDp() }
)
.size(
width = with(density) { (frameRight - frameLeft).toDp() },
height = with(density) { (frameBottom - frameTop).toDp() }
)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
val dx = dragAmount.x
val dy = dragAmount.y
val w = frameRight - frameLeft
val h = frameBottom - frameTop
frameLeft = (frameLeft + dx).coerceIn(0f, containerW - w)
frameTop = (frameTop + dy).coerceIn(0f, containerH - h)
frameRight = frameLeft + w
frameBottom = frameTop + h
}
}
.zIndex(1f)
)
// Poignée coin haut-gauche
FrameHandle(
x = frameLeft,
y = frameTop,
halfHandlePx = halfHandlePx,
onDrag = { dx, dy ->
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
}
)
// Poignée coin haut-droit
FrameHandle(
x = frameRight,
y = frameTop,
halfHandlePx = halfHandlePx,
onDrag = { dx, dy ->
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
}
)
// Poignée coin bas-gauche
FrameHandle(
x = frameLeft,
y = frameBottom,
halfHandlePx = halfHandlePx,
onDrag = { dx, dy ->
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
}
)
// Poignée coin bas-droit
FrameHandle(
x = frameRight,
y = frameBottom,
halfHandlePx = halfHandlePx,
onDrag = { dx, dy ->
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
}
)
}
Spacer(modifier = Modifier.height(12.dp))
@ -286,14 +261,31 @@ fun ImageCropBottomSheet(
PrimaryButton(
text = "OK",
onClick = {
val cropped = cropAndSaveBitmap(
bitmap,
containerW, containerH,
frameLeft, frameTop, frameSize,
totalOffsetX, totalOffsetY, totalScale,
context.cacheDir
)
onCropComplete(cropped)
val cropLeft = ((frameLeft - displayedOffsetX) / scaleToBmp).toInt().coerceIn(0, bitmap.width)
val cropTop = ((frameTop - displayedOffsetY) / scaleToBmp).toInt().coerceIn(0, bitmap.height)
val cropRight = ((frameRight - displayedOffsetX) / scaleToBmp).toInt().coerceIn(cropLeft + 1, bitmap.width)
val cropBottom = ((frameBottom - displayedOffsetY) / scaleToBmp).toInt().coerceIn(cropTop + 1, bitmap.height)
val cropW = cropRight - cropLeft
val cropH = cropBottom - cropTop
val cropped = Bitmap.createBitmap(bitmap, cropLeft, cropTop, cropW, cropH)
val maxSize = 512
val out = if (cropW > maxSize || cropH > maxSize) {
val ratio = maxSize.toFloat() / max(cropW, cropH)
val newW = (cropW * ratio).toInt()
val newH = (cropH * ratio).toInt()
Bitmap.createScaledBitmap(cropped, newW, newH, true).also {
if (it != cropped) cropped.recycle()
}
} else cropped
val file = File(context.cacheDir, "item_${System.currentTimeMillis()}.jpg")
file.outputStream().use { fos ->
out.compress(Bitmap.CompressFormat.JPEG, 85, fos)
}
if (out != cropped && out != bitmap) out.recycle()
onCropComplete(Uri.fromFile(file).toString())
},
modifier = Modifier.weight(1f),
large = true
@ -304,42 +296,47 @@ fun ImageCropBottomSheet(
}
}
private fun cropAndSaveBitmap(
bitmap: Bitmap,
containerW: Float,
containerH: Float,
frameLeft: Float,
frameTop: Float,
frameSize: Float,
totalOffsetX: Float,
totalOffsetY: Float,
totalScale: Float,
cacheDir: File
): String? {
return try {
val cropX = ((frameLeft - totalOffsetX) / totalScale).toInt().coerceIn(0, bitmap.width)
val cropY = ((frameTop - totalOffsetY) / totalScale).toInt().coerceIn(0, bitmap.height)
val cropW = (frameSize / totalScale).toInt().coerceIn(1, bitmap.width - cropX)
val cropH = (frameSize / totalScale).toInt().coerceIn(1, bitmap.height - cropY)
val cropped = Bitmap.createBitmap(bitmap, cropX, cropY, cropW, cropH)
val maxSize = 512
val scaled = if (cropW > maxSize || cropH > maxSize) {
val ratio = maxSize.toFloat() / maxOf(cropW, cropH)
val newW = (cropW * ratio).toInt()
val newH = (cropH * ratio).toInt()
Bitmap.createScaledBitmap(cropped, newW, newH, true).also {
if (it != cropped) cropped.recycle()
}
} else cropped
val file = File(cacheDir, "item_${System.currentTimeMillis()}.jpg")
file.outputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
if (scaled != cropped && scaled != bitmap) scaled.recycle()
Uri.fromFile(file).toString()
} catch (_: Exception) {
null
@Composable
private fun FrameHandle(
x: Float,
y: Float,
halfHandlePx: Float,
onDrag: (dx: Float, dy: Float) -> Unit
) {
val density = LocalDensity.current
Box(
modifier = Modifier
.offset(
x = with(density) { (x - halfHandlePx).toDp() },
y = with(density) { (y - halfHandlePx).toDp() }
)
.size(HandleSize)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount.x, dragAmount.y)
}
}
.zIndex(2f),
contentAlignment = Alignment.Center
) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.size(12.dp)
.zIndex(2f)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color.White,
radius = size.minDimension / 2f,
center = Offset(size.width / 2f, size.height / 2f)
)
drawCircle(
color = Color(0xFF1976D2),
radius = size.minDimension / 2f - 2f,
center = Offset(size.width / 2f, size.height / 2f)
)
}
}
}
}