fix(photo-editor): 修复裁剪 tab 流程与裁剪框拖动

两个问题:
1. 切到裁剪 tab 后还要再点裁剪 icon 才进入裁剪——改为切 tab 即自动进入,
   底部按钮由"开关裁剪"改为"✓ 确认裁剪",点击烘焙裁剪并保留在 tab。
2. 裁剪框无法移动——重写 CropOverlay 拖动逻辑:
   - 加 BODY 整体平移(拖框内区域平移整框)
   - onDrag 每帧从 state 读实时裁剪值叠加增量,修复闭包快照导致的抖动
   - dragHandle 内聚到组件内部,消除跨重组延迟
   - 抽出 hitHandle/moveCrop,四角与整体均带边界约束防交叉越界

ViewModel 新增 enterCrop/exitCrop/applyCrop,删除 toggleCrop。
This commit is contained in:
xfy 2026-07-22 14:34:42 +08:00
parent cec8debff0
commit b8509381d9
2 changed files with 169 additions and 48 deletions

View File

@ -80,14 +80,43 @@ class PhotoEditorViewModel(
update { it.copy(rotationDegrees = it.rotationDegrees + delta) }
}
/** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */
fun toggleCrop() {
/** 进入裁剪模式:启用裁剪框并重置为默认居中区域。 */
fun enterCrop() {
update {
if (it.cropEnabled) {
it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f)
} else {
it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f)
}
it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f)
}
}
/** 退出裁剪模式:清空裁剪框(不应用裁剪)。 */
fun exitCrop() {
update {
it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f)
}
}
/**
* 确认裁剪将当前裁剪框烘焙到源图用裁剪结果替换 [PhotoEditorState.sourceBitmap]
* 重置旋转与裁剪框清空手写笔触坐标系已变化
*/
fun applyCrop() {
update {
if (!it.cropEnabled) return@update it
val cropped = PhotoProcessor.crop(
bitmap = it.rotatedBitmap,
left = it.cropLeft!!,
top = it.cropTop,
right = it.cropRight!!,
bottom = it.cropBottom
)
it.copy(
sourceBitmap = cropped,
rotationDegrees = 0,
cropLeft = null,
cropTop = 0f,
cropRight = null,
cropBottom = 1f,
strokes = emptyList()
)
}
}

View File

@ -20,7 +20,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Crop
import androidx.compose.material.icons.filled.CropFree
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.RotateLeft
import androidx.compose.material.icons.filled.RotateRight
@ -45,6 +44,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import kotlin.math.absoluteValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@ -95,6 +95,14 @@ fun PhotoEditorScreen(
var activeTab by remember { mutableStateOf(EditTab.ROTATE) }
val savedPath = state.savedPath
// 切到裁剪 tab 自动进入裁剪模式;切走则退出(不应用裁剪)
LaunchedEffect(activeTab, state.editorState) {
when (activeTab) {
EditTab.CROP -> viewModel.enterCrop()
else -> viewModel.exitCrop()
}
}
if (savedPath != null) {
LaunchedEffect(savedPath) { onSaved(savedPath) }
}
@ -157,7 +165,7 @@ fun PhotoEditorScreen(
activeTab = activeTab,
onTabChange = { activeTab = it },
onRotate = viewModel::rotate,
onCropToggle = viewModel::toggleCrop,
onApplyCrop = viewModel::applyCrop,
onCropChange = viewModel::updateCrop,
onAddPoint = viewModel::addStrokePoint,
onEndStroke = viewModel::endStroke,
@ -181,7 +189,7 @@ private fun EditorBody(
activeTab: EditTab,
onTabChange: (EditTab) -> Unit,
onRotate: (Int) -> Unit,
onCropToggle: () -> Unit,
onApplyCrop: () -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit,
onAddPoint: (Offset) -> Unit,
onEndStroke: () -> Unit,
@ -245,7 +253,7 @@ private fun EditorBody(
mode = activeTab,
state = state,
onRotate = onRotate,
onCropToggle = onCropToggle,
onApplyCrop = onApplyCrop,
onUndoStroke = onUndoStroke,
onStrokeColorChange = onStrokeColorChange,
modifier = Modifier
@ -265,8 +273,6 @@ private fun EditableImage(
onDisplaySizeChange: (Float, Float) -> Unit
) {
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
// 裁剪框拖动手柄CROP 模式下使用)
var dragHandle by remember { mutableStateOf<CropHandle?>(null) }
Box(
modifier = Modifier
@ -299,8 +305,6 @@ private fun EditableImage(
if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay(
state = state,
dragHandle = dragHandle,
onHandleChange = { dragHandle = it },
onCropChange = onCropChange,
modifier = Modifier.fillMaxSize()
)
@ -324,7 +328,7 @@ private fun EditableImage(
}
}
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, NONE }
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, BODY, NONE }
/** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
private data class CropRect(
@ -334,16 +338,110 @@ private data class CropRect(
val bottom: Float
)
/**
* 判断触摸点落在裁剪框的哪个区域用于决定拖动行为
*
* - 落在四角阈值范围内 对应角手柄缩放裁剪框
* - 落在框内 整体平移
* - 落在框外 [CropHandle.NONE]不响应拖动
*
* @param ox 触摸点 x 比例坐标 [0,1]
* @param oy 触摸点 y 比例坐标 [0,1]
* @param l/t/r/b 当前裁剪框四边比例
*/
private fun hitHandle(
ox: Float,
oy: Float,
l: Float,
t: Float,
r: Float,
b: Float
): CropHandle {
val corner = 0.1f
val atTopLeft = (ox - l).absoluteValue < corner && (oy - t).absoluteValue < corner
val atTopRight = (ox - r).absoluteValue < corner && (oy - t).absoluteValue < corner
val atBottomLeft = (ox - l).absoluteValue < corner && (oy - b).absoluteValue < corner
val atBottomRight = (ox - r).absoluteValue < corner && (oy - b).absoluteValue < corner
return when {
atTopLeft -> CropHandle.TOP_LEFT
atTopRight -> CropHandle.TOP_RIGHT
atBottomLeft -> CropHandle.BOTTOM_LEFT
atBottomRight -> CropHandle.BOTTOM_RIGHT
ox in l..r && oy in t..b -> CropHandle.BODY
else -> CropHandle.NONE
}
}
/**
* 将单次拖动增量应用到当前裁剪框返回受边界约束的新四元组
*
* - 角手柄只移动对应角并用 [minSize] 约束避免左右/上下交叉
* - [CropHandle.BODY]整体平移 [0,1] 边界约束
*
* @param dx x 方向增量比例已归一化
* @param dy y 方向增量比例已归一化
* @param l/t/r/b 当前裁剪框四边比例
* @param handle 本次拖动的手柄
* @param minSize 裁剪框最小宽/高比例防止四角交叉
*/
private fun moveCrop(
dx: Float,
dy: Float,
l: Float,
t: Float,
r: Float,
b: Float,
handle: CropHandle,
minSize: Float = 0.1f
): CropRect {
val left = l.coerceIn(0f, 1f)
val top = t.coerceIn(0f, 1f)
val right = r.coerceIn(0f, 1f)
val bottom = b.coerceIn(0f, 1f)
return when (handle) {
CropHandle.TOP_LEFT -> {
val nl = (left + dx).coerceIn(0f, right - minSize)
val nt = (top + dy).coerceIn(0f, bottom - minSize)
CropRect(nl, nt, right, bottom)
}
CropHandle.TOP_RIGHT -> {
val nr = (right + dx).coerceIn(left + minSize, 1f)
val nt = (top + dy).coerceIn(0f, bottom - minSize)
CropRect(left, nt, nr, bottom)
}
CropHandle.BOTTOM_LEFT -> {
val nl = (left + dx).coerceIn(0f, right - minSize)
val nb = (bottom + dy).coerceIn(top + minSize, 1f)
CropRect(nl, top, right, nb)
}
CropHandle.BOTTOM_RIGHT -> {
val nr = (right + dx).coerceIn(left + minSize, 1f)
val nb = (bottom + dy).coerceIn(top + minSize, 1f)
CropRect(left, top, nr, nb)
}
CropHandle.BODY -> {
val w = right - left
val h = bottom - top
val nl = (left + dx).coerceIn(0f, 1f - w)
val nt = (top + dy).coerceIn(0f, 1f - h)
CropRect(nl, nt, nl + w, nt + h)
}
CropHandle.NONE -> CropRect(left, top, right, bottom)
}
}
@Composable
private fun CropOverlay(
state: PhotoEditorState,
dragHandle: CropHandle?,
onHandleChange: (CropHandle?) -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
// 拖动手柄内聚在此组件,避免跨重组延迟
var dragHandle by remember { mutableStateOf(CropHandle.NONE) }
val left = state.cropLeft ?: 0.1f
val top = state.cropTop
val right = state.cropRight ?: 0.9f
val bottom = state.cropBottom
Canvas(
modifier = modifier.pointerInput(Unit) {
detectDragGestures(
@ -351,45 +449,42 @@ private fun CropOverlay(
val size = this.size
val ox = offset.x / size.width
val oy = offset.y / size.height
onHandleChange(
when {
ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT
ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT
ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT
ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT
else -> CropHandle.NONE
}
dragHandle = hitHandle(
ox, oy,
state.cropLeft ?: 0.1f,
state.cropTop,
state.cropRight ?: 0.9f,
state.cropBottom
)
},
onDrag = { change, drag ->
if (dragHandle == CropHandle.NONE) return@detectDragGestures
change.consume()
val size = this.size
val dx = drag.x / size.width
val dy = drag.y / size.height
val (nL, nT, nR, nB) = when (dragHandle) {
CropHandle.TOP_LEFT ->
CropRect(left + dx, state.cropTop + dy, right, state.cropBottom)
CropHandle.TOP_RIGHT ->
CropRect(left, state.cropTop + dy, right + dx, state.cropBottom)
CropHandle.BOTTOM_LEFT ->
CropRect(left + dx, state.cropTop, right, state.cropBottom + dy)
CropHandle.BOTTOM_RIGHT ->
CropRect(left, state.cropTop, right + dx, state.cropBottom + dy)
else -> return@detectDragGestures
}
onCropChange(nL, nT, nR, nB)
// 每帧从 state 读最新值,叠加增量
val cur = moveCrop(
dx, dy,
state.cropLeft ?: 0.1f,
state.cropTop,
state.cropRight ?: 0.9f,
state.cropBottom,
dragHandle
)
onCropChange(cur.left, cur.top, cur.right, cur.bottom)
},
onDragEnd = { onHandleChange(null) },
onDragCancel = { onHandleChange(null) }
onDragEnd = { dragHandle = CropHandle.NONE },
onDragCancel = { dragHandle = CropHandle.NONE }
)
}
) {
val w = size.width
val h = size.height
val l = left * w
val t = state.cropTop * h
val t = top * h
val r = right * w
val b = state.cropBottom * h
val b = bottom * h
// 四周遮罩
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, 0f), size = Size(l, h))
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(r, 0f), size = Size(w - r, h))
@ -410,7 +505,7 @@ private fun ToolBar(
mode: EditTab,
state: PhotoEditorState,
onRotate: (Int) -> Unit,
onCropToggle: () -> Unit,
onApplyCrop: () -> Unit,
onUndoStroke: () -> Unit,
onStrokeColorChange: (Color) -> Unit,
modifier: Modifier = Modifier
@ -430,11 +525,8 @@ private fun ToolBar(
}
}
EditTab.CROP -> {
FilledIconButton(onClick = onCropToggle) {
Icon(
if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop,
contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪"
)
FilledIconButton(onClick = onApplyCrop) {
Icon(Icons.Filled.Done, contentDescription = "确认裁剪")
}
}
EditTab.HANDWRITE -> {