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