Opt the image editing dialog box, add full-screen zoom function, improve the panning limit to ensure the security of image transformation.

This commit is contained in:
ShirkNeko
2025-04-27 14:02:16 +08:00
parent c442f43090
commit 7769a23f59
4 changed files with 127 additions and 25 deletions

View File

@@ -1,6 +1,7 @@
package com.sukisu.ultra.ui.component
import android.net.Uri
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
@@ -8,6 +9,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -18,6 +20,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -27,6 +30,12 @@ import coil.request.ImageRequest
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.BackgroundTransformation
import com.sukisu.ultra.ui.util.saveTransformedBackground
import kotlinx.coroutines.launch
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onSizeChanged
import kotlin.math.max
@Composable
fun ImageEditorDialog(
@@ -38,6 +47,48 @@ fun ImageEditorDialog(
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var lastScale by remember { mutableFloatStateOf(1f) }
var lastOffsetX by remember { mutableFloatStateOf(0f) }
var lastOffsetY by remember { mutableFloatStateOf(0f) }
var imageSize by remember { mutableStateOf(Size.Zero) }
var screenSize by remember { mutableStateOf(Size.Zero) }
val animatedScale by animateFloatAsState(
targetValue = scale,
label = "ScaleAnimation"
)
val animatedOffsetX by animateFloatAsState(
targetValue = offsetX,
label = "OffsetXAnimation"
)
val animatedOffsetY by animateFloatAsState(
targetValue = offsetY,
label = "OffsetYAnimation"
)
val updateTransformation = remember {
{ newScale: Float, newOffsetX: Float, newOffsetY: Float ->
val scaleDiff = kotlin.math.abs(newScale - lastScale)
val offsetXDiff = kotlin.math.abs(newOffsetX - lastOffsetX)
val offsetYDiff = kotlin.math.abs(newOffsetY - lastOffsetY)
if (scaleDiff > 0.01f || offsetXDiff > 1f || offsetYDiff > 1f) {
scale = newScale
offsetX = newOffsetX
offsetY = newOffsetY
lastScale = newScale
lastOffsetX = newOffsetX
lastOffsetY = newOffsetY
}
}
}
val scaleToFullScreen = remember {
{
if (imageSize.height > 0 && screenSize.height > 0) {
val newScale = screenSize.height / imageSize.height
updateTransformation(newScale, 0f, 0f)
}
}
}
Dialog(
onDismissRequest = onDismiss,
@@ -51,8 +102,10 @@ fun ImageEditorDialog(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f))
.onSizeChanged { size ->
screenSize = Size(size.width.toFloat(), size.height.toFloat())
}
) {
// 主图片区域
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUri)
@@ -63,24 +116,39 @@ fun ImageEditorDialog(
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
scaleX = animatedScale,
scaleY = animatedScale,
translationX = animatedOffsetX,
translationY = animatedOffsetY
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 3f)
// 限制平移范围,防止图片完全移出屏幕
val maxOffset = size.width * (scale - 1) / 2
offsetX = (offsetX + pan.x).coerceIn(-maxOffset, maxOffset)
offsetY = (offsetY + pan.y).coerceIn(-maxOffset, maxOffset)
scope.launch {
try {
val newScale = (scale * zoom).coerceIn(0.5f, 3f)
val maxOffsetX = max(0f, size.width * (newScale - 1) / 2)
val maxOffsetY = max(0f, size.height * (newScale - 1) / 2)
val newOffsetX = if (maxOffsetX > 0) {
(offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
} else {
0f
}
val newOffsetY = if (maxOffsetY > 0) {
(offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
} else {
0f
}
updateTransformation(newScale, newOffsetX, newOffsetY)
} catch (e: Exception) {
updateTransformation(lastScale, lastOffsetX, lastOffsetY)
}
}
}
}
.onSizeChanged { size ->
imageSize = Size(size.width.toFloat(), size.height.toFloat())
}
)
// 顶部工具栏
Row(
modifier = Modifier
.fillMaxWidth()
@@ -100,12 +168,29 @@ fun ImageEditorDialog(
tint = Color.White
)
}
IconButton(
onClick = { scaleToFullScreen() },
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.6f))
) {
Icon(
imageVector = Icons.Default.Fullscreen,
contentDescription = stringResource(R.string.reprovision),
tint = Color.White
)
}
IconButton(
onClick = {
scope.launch {
try {
val transformation = BackgroundTransformation(scale, offsetX, offsetY)
val savedUri = context.saveTransformedBackground(imageUri, transformation)
savedUri?.let { onConfirm(it) }
} catch (e: Exception) {
""
}
}
},
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -119,7 +204,6 @@ fun ImageEditorDialog(
}
}
// 底部提示
Box(
modifier = Modifier
.fillMaxWidth()

View File

@@ -59,17 +59,33 @@ fun Context.applyTransformationToBitmap(bitmap: Bitmap, transformation: Backgrou
val matrix = Matrix()
matrix.postScale(transformation.scale, transformation.scale)
// 确保缩放值有效
val safeScale = maxOf(0.1f, transformation.scale)
matrix.postScale(safeScale, safeScale)
// 计算中心点
val centerX = targetWidth / 2f
val centerY = targetHeight / 2f
// 缩放围绕中心点
matrix.postTranslate(
-((bitmap.width * transformation.scale - targetWidth) / 2) + transformation.offsetX,
-((bitmap.height * transformation.scale - targetHeight) / 2) + transformation.offsetY
)
// 计算偏移量,确保不会出现负最大值的问题
val widthDiff = (bitmap.width * safeScale - targetWidth)
val heightDiff = (bitmap.height * safeScale - targetHeight)
// 安全计算偏移量边界
val maxOffsetX = maxOf(0f, widthDiff / 2)
val maxOffsetY = maxOf(0f, heightDiff / 2)
// 限制偏移范围
val safeOffsetX = if (maxOffsetX > 0)
transformation.offsetX.coerceIn(-maxOffsetX, maxOffsetX) else 0f
val safeOffsetY = if (maxOffsetY > 0)
transformation.offsetY.coerceIn(-maxOffsetY, maxOffsetY) else 0f
// 应用偏移量到矩阵
val translationX = -widthDiff / 2 + safeOffsetX
val translationY = -heightDiff / 2 + safeOffsetY
matrix.postTranslate(translationX, translationY)
// 将原始位图绘制到新位图上
canvas.drawBitmap(bitmap, matrix, null)
@@ -92,7 +108,7 @@ fun Context.saveTransformedBackground(uri: Uri, transformation: BackgroundTransf
return Uri.fromFile(file)
} catch (e: Exception) {
Log.e("BackgroundUtils", "Failed to save transformed image: ${e.message}")
Log.e("BackgroundUtils", "Failed to save transformed image: ${e.message}", e)
return null
}
}

View File

@@ -265,4 +265,5 @@
<string name="image_editor_title">调整背景图片</string>
<string name="image_editor_hint">使用双指缩放图片,单指拖动调整位置</string>
<string name="background_image_error">无法加载图片</string>
<string name="reprovision">重置</string>
</resources>

View File

@@ -269,4 +269,5 @@
<string name="image_editor_title">Adjust background image</string>
<string name="image_editor_hint">Use two fingers to zoom the image, and one finger to drag it to adjust the position</string>
<string name="background_image_error">Could not load image</string>
<string name="reprovision">Reprovision</string>
</resources>