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:
parent
c8dff8df40
commit
76ad44a7ca
@ -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()
|
||||
@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)
|
||||
)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user