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