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,15 +80,44 @@ class PhotoEditorViewModel(
update { it.copy(rotationDegrees = it.rotationDegrees + delta) } update { it.copy(rotationDegrees = it.rotationDegrees + delta) }
} }
/** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */ /** 进入裁剪模式:启用裁剪框并重置为默认居中区域。 */
fun toggleCrop() { fun enterCrop() {
update { 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.Brush
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Crop 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.Done
import androidx.compose.material.icons.filled.RotateLeft import androidx.compose.material.icons.filled.RotateLeft
import androidx.compose.material.icons.filled.RotateRight 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.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import kotlin.math.absoluteValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@ -95,6 +95,14 @@ fun PhotoEditorScreen(
var activeTab by remember { mutableStateOf(EditTab.ROTATE) } var activeTab by remember { mutableStateOf(EditTab.ROTATE) }
val savedPath = state.savedPath val savedPath = state.savedPath
// 切到裁剪 tab 自动进入裁剪模式;切走则退出(不应用裁剪)
LaunchedEffect(activeTab, state.editorState) {
when (activeTab) {
EditTab.CROP -> viewModel.enterCrop()
else -> viewModel.exitCrop()
}
}
if (savedPath != null) { if (savedPath != null) {
LaunchedEffect(savedPath) { onSaved(savedPath) } LaunchedEffect(savedPath) { onSaved(savedPath) }
} }
@ -157,7 +165,7 @@ fun PhotoEditorScreen(
activeTab = activeTab, activeTab = activeTab,
onTabChange = { activeTab = it }, onTabChange = { activeTab = it },
onRotate = viewModel::rotate, onRotate = viewModel::rotate,
onCropToggle = viewModel::toggleCrop, onApplyCrop = viewModel::applyCrop,
onCropChange = viewModel::updateCrop, onCropChange = viewModel::updateCrop,
onAddPoint = viewModel::addStrokePoint, onAddPoint = viewModel::addStrokePoint,
onEndStroke = viewModel::endStroke, onEndStroke = viewModel::endStroke,
@ -181,7 +189,7 @@ private fun EditorBody(
activeTab: EditTab, activeTab: EditTab,
onTabChange: (EditTab) -> Unit, onTabChange: (EditTab) -> Unit,
onRotate: (Int) -> Unit, onRotate: (Int) -> Unit,
onCropToggle: () -> Unit, onApplyCrop: () -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit,
onAddPoint: (Offset) -> Unit, onAddPoint: (Offset) -> Unit,
onEndStroke: () -> Unit, onEndStroke: () -> Unit,
@ -245,7 +253,7 @@ private fun EditorBody(
mode = activeTab, mode = activeTab,
state = state, state = state,
onRotate = onRotate, onRotate = onRotate,
onCropToggle = onCropToggle, onApplyCrop = onApplyCrop,
onUndoStroke = onUndoStroke, onUndoStroke = onUndoStroke,
onStrokeColorChange = onStrokeColorChange, onStrokeColorChange = onStrokeColorChange,
modifier = Modifier modifier = Modifier
@ -265,8 +273,6 @@ private fun EditableImage(
onDisplaySizeChange: (Float, Float) -> Unit onDisplaySizeChange: (Float, Float) -> Unit
) { ) {
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap } val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
// 裁剪框拖动手柄CROP 模式下使用)
var dragHandle by remember { mutableStateOf<CropHandle?>(null) }
Box( Box(
modifier = Modifier modifier = Modifier
@ -299,8 +305,6 @@ private fun EditableImage(
if (mode == EditTab.CROP && state.cropEnabled) { if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay( CropOverlay(
state = state, state = state,
dragHandle = dragHandle,
onHandleChange = { dragHandle = it },
onCropChange = onCropChange, onCropChange = onCropChange,
modifier = Modifier.fillMaxSize() 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 回传。 */ /** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
private data class CropRect( private data class CropRect(
@ -334,16 +338,110 @@ private data class CropRect(
val bottom: Float 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 @Composable
private fun CropOverlay( private fun CropOverlay(
state: PhotoEditorState, state: PhotoEditorState,
dragHandle: CropHandle?,
onHandleChange: (CropHandle?) -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// 拖动手柄内聚在此组件,避免跨重组延迟
var dragHandle by remember { mutableStateOf(CropHandle.NONE) }
val left = state.cropLeft ?: 0.1f val left = state.cropLeft ?: 0.1f
val top = state.cropTop
val right = state.cropRight ?: 0.9f val right = state.cropRight ?: 0.9f
val bottom = state.cropBottom
Canvas( Canvas(
modifier = modifier.pointerInput(Unit) { modifier = modifier.pointerInput(Unit) {
detectDragGestures( detectDragGestures(
@ -351,45 +449,42 @@ private fun CropOverlay(
val size = this.size val size = this.size
val ox = offset.x / size.width val ox = offset.x / size.width
val oy = offset.y / size.height val oy = offset.y / size.height
onHandleChange( dragHandle = hitHandle(
when { ox, oy,
ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT state.cropLeft ?: 0.1f,
ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT state.cropTop,
ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT state.cropRight ?: 0.9f,
ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT state.cropBottom
else -> CropHandle.NONE
}
) )
}, },
onDrag = { change, drag -> onDrag = { change, drag ->
if (dragHandle == CropHandle.NONE) return@detectDragGestures
change.consume() change.consume()
val size = this.size val size = this.size
val dx = drag.x / size.width val dx = drag.x / size.width
val dy = drag.y / size.height val dy = drag.y / size.height
val (nL, nT, nR, nB) = when (dragHandle) { // 每帧从 state 读最新值,叠加增量
CropHandle.TOP_LEFT -> val cur = moveCrop(
CropRect(left + dx, state.cropTop + dy, right, state.cropBottom) dx, dy,
CropHandle.TOP_RIGHT -> state.cropLeft ?: 0.1f,
CropRect(left, state.cropTop + dy, right + dx, state.cropBottom) state.cropTop,
CropHandle.BOTTOM_LEFT -> state.cropRight ?: 0.9f,
CropRect(left + dx, state.cropTop, right, state.cropBottom + dy) state.cropBottom,
CropHandle.BOTTOM_RIGHT -> dragHandle
CropRect(left, state.cropTop, right + dx, state.cropBottom + dy) )
else -> return@detectDragGestures onCropChange(cur.left, cur.top, cur.right, cur.bottom)
}
onCropChange(nL, nT, nR, nB)
}, },
onDragEnd = { onHandleChange(null) }, onDragEnd = { dragHandle = CropHandle.NONE },
onDragCancel = { onHandleChange(null) } onDragCancel = { dragHandle = CropHandle.NONE }
) )
} }
) { ) {
val w = size.width val w = size.width
val h = size.height val h = size.height
val l = left * w val l = left * w
val t = state.cropTop * h val t = top * h
val r = right * w 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(0f, 0f), size = Size(l, h))
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(r, 0f), size = Size(w - r, 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, mode: EditTab,
state: PhotoEditorState, state: PhotoEditorState,
onRotate: (Int) -> Unit, onRotate: (Int) -> Unit,
onCropToggle: () -> Unit, onApplyCrop: () -> Unit,
onUndoStroke: () -> Unit, onUndoStroke: () -> Unit,
onStrokeColorChange: (Color) -> Unit, onStrokeColorChange: (Color) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -430,11 +525,8 @@ private fun ToolBar(
} }
} }
EditTab.CROP -> { EditTab.CROP -> {
FilledIconButton(onClick = onCropToggle) { FilledIconButton(onClick = onApplyCrop) {
Icon( Icon(Icons.Filled.Done, contentDescription = "确认裁剪")
if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop,
contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪"
)
} }
} }
EditTab.HANDWRITE -> { EditTab.HANDWRITE -> {